Aller au contenu

giftcard-feasibility (POC Shopify)

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"]

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.

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) exporte createGiftCard() (mutation giftCardCreate) et runCreateTest().
  • Test 2 (src/test-2-split-flow.ts) exporte pickVariant(), 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.ts enchaîne runCreateTest() puis runSplitFlowTest().

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.

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.

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 + worker drainOutbox. Claim atomique, backoff exponentiel, idempotence par idempotencyKey (une clé déjà en file n’est jamais ré-enfilée). En test, une LogicalClock « saute » au prochain retry → backoff fidèle sans sleep, exécution déterministe et instantanée.
  • Compensation ordre-inverse, best-effort : si emit/apply épuise ses maxAttempts, la saga déclenche DISABLE_MIRROR puis RELEASE_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 (RESERVINGRESERVEDMIRROREDAPPLIEDCOMPLETED, avec COMPENSATING/CANCELED/REVERSED en sortie), sinon elle renvoie COMPLETED sans rien faire. État du leg/session dans src/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.

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âble FakeIssuer (carte externe ; codes démo DEMO-50 / DEMO-25 / WEDOO-TEST, à swapper par l’adapter Wedoogift) + ShopifyStoreCreditGateway (avoir réel sur le sandbox). Sert la page et l’endpoint POST /api/redeem. Variables : PORT (def. 3000), STORE_CREDIT_CURRENCY (def. USD).
  • consumer/redeem-service.ts — la chaîne RedeemService : checkBalanceresolveCustomerreserve (débit carte externe) → credit (store credit). Idempotent (cache par cardCode + identité, pas de double-crédit) et compensé (release si le crédit échoue). Deux chemins : redeem() (par email, maquette standalone) et redeemForCustomer() (client déjà identifié, le vrai parcours).
  • consumer/page.tsrenderPage(), la page de saisie (HTML/CSS/JS inline).
Fenêtre de terminal
npm run consumer # http://localhost:3000 — email + code démo, puis checkout connecté
PORT=3010 npm run consumer # autre port

README de la brique : giftcard-feasibility/README.md. Notes de la surface native : giftcard-feasibility/native/README.md.