Aller au contenu

demo-shop (storefront)

demo-shop est la boutique e-commerce de démonstration de référence de l’écosystème. Elle n’a pas de valeur métier propre : son rôle est de montrer, dans un vrai contexte de marchand, comment un site e-commerce intègre Titreo E-Pay côté serveur, et de servir de cible pour les tests de bout en bout (E2E) du parcours de paiement.

C’est l’exemple canonique du rôle « marchand » dans l’écosystème : un commerçant qui possède une TITREO_API_KEY, crée une session de paiement depuis son backend, puis délègue tout l’encaissement à la checkout-page hébergée par Titreo. Le marchand ne touche jamais les données de carte ; il ne voit que des identifiants de session.

Concrètement, demo-shop permet de répondre à la question « si je suis un marchand, qu’est-ce que je dois écrire pour brancher Titreo E-Pay ? » — la réponse tient dans deux fichiers : demo-shop/lib/titreo.ts (appels serveur vers l’API) et demo-shop/app/api/checkout/route.ts (le handler de paiement).

Élément Valeur
Dossier demo-shop/
Package @titreo-epay/demo-shop (privé, non publié)
Framework Next.js 15 (App Router), React 19
Runtime des routes API nodejs (runtime = 'nodejs')
Dépendance interne @titreo-epay/sdk (workspace)
Lint / format Biome
Port de dev 3200

Scripts npm réels (demo-shop/package.json) :

Script Commande Rôle
dev next dev -p 3200 Serveur de dev sur le port 3200
build next build Build de production
start next start -p 3200 Serveur de production
typecheck tsc --noEmit Vérification TypeScript
lint biome check . Lint
lint:fix biome check --write . Lint + correction auto

Le parcours suit le découplage strict de l’écosystème : le marchand fait l’orchestration côté serveur, puis redirige vers la checkout-page ; il ne rend lui-même aucun champ de paiement.

sequenceDiagram
    participant U as "Navigateur (client)"
    participant S as "demo-shop (serveur Next.js :3200)"
    participant API as "Backend Titreo E-Pay (:3000)"
    participant CP as "checkout-page (:3100)"

    U->>S: "POST /api/checkout (slug)"
    S->>API: "POST /v1/sessions (createSession)"
    API-->>S: "{ id: sess_xxx, ... }"
    S->>API: "POST /v1/sessions/{id}/client-token (issueClientToken)"
    API-->>S: "{ token: tepc_yyy, ... }"
    S-->>U: "303 redirect vers checkout-page (avec t, success, cancel)"
    U->>CP: "GET /fr/c/sess_xxx?t=tepc_yyy&success=...&cancel=..."
    CP->>U: "Rend le widget de paiement (cartes cadeaux, adapter PSP)"
    Note over U,CP: "Saisie carte test, 3DS, capture côté backend"
    CP-->>U: "Redirect vers successUrl ou cancelUrl"
    U->>S: "GET /order/success (ou /order/cancel)"
    API-->>S: "POST /api/webhooks/titreo (notification asynchrone, HMAC)"

La boutique s’appelle « Maison 4242 ». Son catalogue est défini en dur dans demo-shop/lib/products.ts (PRODUCTS) : six produits, chacun calibré sur un scénario de test. La page d’accueil demo-shop/app/page.tsx liste la collection ; la fiche demo-shop/app/p/[slug]/page.tsx dit quelle carte utiliser et ce qu’on va observer.

Produit (slug) Prix Scénario
bougie-transaction-sereine 49,00 € Paiement accepté
lampe-3d-secure 101,00 € Challenge 3DS
affiche-solde-exact 48,00 € Carte cadeau seule
tote-split-tender 149,00 € Split cadeau + CB
carnet-seconde-chance 42,00 € Refus puis nouvel essai
tasse-idempotente 24,00 € Paiement accepté, double clic sans double débit

Le formulaire de paiement n’envoie que le slug. Le prix vient du catalogue côté serveur, jamais du formulaire : un champ caché amount serait librement modifiable par le navigateur.

Le tiroir « Mode démo » (demo-shop/components/demo.tsx) et les fiches produit rappellent les cartes à utiliser.

Moyen Carte
Carte bancaire Cartes de test Stripe, numéros publiés : 4242 4242 4242 4242 (accepté), 4000 0000 0000 0002 (refusé), 4000 0025 0000 3155 (3DS). Expiration 12/30, CVC 123
Carte cadeau Vraie carte de test Titréo, remise à l’enseigne. Numéro et code figurent sur la carte

Le handler demo-shop/app/api/checkout/route.ts (runtime = 'nodejs', dynamic = 'force-dynamic') reçoit le POST du formulaire, retrouve le produit par son slug (400 s’il est inconnu), puis enchaîne deux appels serveur via demo-shop/lib/titreo.ts :

  1. createSession({ totalAmount, currency, reference, metadata }) → POST /v1/sessions
  2. issueClientToken(session.id) → POST /v1/sessions/{id}/client-token

metadata porte un numéro de commande (order: { id, number }, de la forme M42-…) et le nom du marchand. La boutique de démonstration numérote sa commande avant le paiement ; une vraie boutique la crée après, et la rattache par PATCH /v1/sessions/:id/metadata.

Il construit ensuite l’URL de la checkout-page avec le token client (t), les URLs de retour (success, cancel) et le nom du marchand, puis renvoie un 303 redirect vers la checkout-page.

const session = await createSession({
totalAmount: product.priceCents,
currency: product.currency,
reference: `${product.slug}-${Date.now()}`,
metadata: { order: { id: orderNumber, number: orderNumber }, merchant: { name: env.merchantName } },
})
const tk = await issueClientToken(session.id)
const checkoutUrl = new URL(`${env.checkoutPageUrl}/fr/c/${session.id}`)
checkoutUrl.searchParams.set('t', tk.token)
checkoutUrl.searchParams.set('success', successUrl)
checkoutUrl.searchParams.set('cancel', cancelUrl)
checkoutUrl.searchParams.set('merchant', env.merchantName)
return NextResponse.redirect(checkoutUrl.toString(), { status: 303 })

Tous les appels API passent par le client maison demo-shop/lib/titreo.ts : authentification Authorization: Bearer <apiKey>, en-tête Idempotency-Key (UUID) sur chaque opération, en-tête X-Correlation-Id (un identifiant par appel), cache: 'no-store'. Le fichier expose aussi setAdyenPspLeg(sessionId, amount) (POST /v1/sessions/{id}/legs/psp), mais le handler actuel ne l’appelle pas : c’est la checkout-page / le backend qui résolvent le leg PSP. Cette fonction est donc présente mais non câblée dans le flux de démo.

Étape 3 — Encaissement délégué (hors demo-shop)

Section intitulée « Étape 3 — Encaissement délégué (hors demo-shop) »

La saisie des cartes cadeaux et de la carte bancaire, la tokenisation par le PSP, le 3DS et la capture se passent entièrement sur la checkout-page et dans le backend. demo-shop n’a aucun code de paiement : c’est précisément l’intérêt du modèle (le marchand reste hors périmètre PCI).

À la fin, la checkout-page redirige le navigateur vers l’une des deux URLs fournies par demo-shop :

Page Fichier Affichage
Succès demo-shop/app/order/success/page.tsx « Merci », montant formaté, identifiant de session tronqué
Annulation demo-shop/app/order/cancel/page.tsx « Paiement annulé », rien débité

Ces deux pages sont des Server Components qui lisent leurs searchParams (session, amount, currency). Elles sont purement informatives : elles ne re-vérifient pas le statut auprès du backend et ne font pas de polling. Le montant affiché provient des paramètres d’URL passés par le marchand, pas d’une lecture serveur.

Étape 5 — Webhook (source de vérité asynchrone)

Section intitulée « Étape 5 — Webhook (source de vérité asynchrone) »

En parallèle du retour navigateur, le backend notifie le marchand via demo-shop/app/api/webhooks/titreo/route.ts. Ce handler :

  1. lit le corps brut de la requête,
  2. lit l’en-tête x-titreo-signature,
  3. vérifie la signature HMAC avec verifyMerchantWebhook(body, signature, secret) importé de @titreo-epay/sdk/webhooks (comparaison timing-safe),
  4. répond 401 si invalide, sinon parse l’événement et le logge (console.info).
import { verifyMerchantWebhook } from '@titreo-epay/sdk/webhooks'
// ...
const result = verifyMerchantWebhook(body, signatureHeader, env.webhookSecret)
if (!result.valid) {
return NextResponse.json({ error: result.reason }, { status: 401 })
}
const event = JSON.parse(body) // { event, session_id, merchant_id, status, total_amount, currency, reference, occurred_at }
console.info('[demo-shop] webhook', event.event, event.session_id, event.status, event.reference)

La commande n’est pas persistée : le webhook se contente de vérifier et journaliser. Pour une vraie boutique, c’est ici qu’on mettrait à jour le statut d’une commande en base.

Méthode + chemin Fichier Rôle
GET / app/page.tsx La collection
GET /p/:slug app/p/[slug]/page.tsx Fiche produit, scénario et cartes à utiliser, formulaire de paiement
POST /api/checkout app/api/checkout/route.ts Crée la session, émet le token client, redirige (303) vers la checkout-page
GET /order/success app/order/success/page.tsx Confirmation (paramètres session, amount, currency)
GET /order/cancel app/order/cancel/page.tsx Annulation (paramètre session)
POST /api/webhooks/titreo app/api/webhooks/titreo/route.ts Réception + vérification HMAC des webhooks

La configuration est centralisée dans demo-shop/lib/config.ts. Aucune variable n’est préfixée NEXT_PUBLIC_ : tout reste côté serveur, la clé d’API ne fuit jamais vers le navigateur (voir sécurité).

Variable Défaut Rôle
TITREO_API_BASE_URL http://localhost:3000 Base de l’API Titreo E-Pay (serveur)
TITREO_API_KEY (vide, requise) Clé d’API marchand — server-only, jamais envoyée au client
TITREO_WEBHOOK_SECRET (vide) Secret HMAC pour vérifier les webhooks
CHECKOUT_PAGE_URL http://localhost:3100 Base de la checkout-page (cible du redirect)
PUBLIC_SHOP_URL http://localhost:3200 Base de la boutique (construit les URLs de retour)
MERCHANT_NAME Maison Générique Nom affiché du marchand

TITREO_API_KEY est ici la clé d’API E-Pay du marchand (tep_…), lue par demo-shop seulement. Elle n’a rien à voir avec l’ancienne variable du même nom de l’API, qui portait la clé Titréo de la plateforme et a été supprimée le 28/09/2026.

requireServerEnv() lève une erreur explicite (Missing env: TITREO_API_KEY) si la clé d’API est absente au moment d’un appel. Le secret webhook, lui, est vérifié à la réception : sans secret, le handler répond 500.

Fenêtre de terminal
# Mise en place
cp demo-shop/.env.example demo-shop/.env.local
# renseigner TITREO_API_KEY et TITREO_WEBHOOK_SECRET
pnpm install
pnpm --filter @titreo-epay/demo-shop dev # http://localhost:3200
  • Clé d’API server-only : TITREO_API_KEY n’est lue que dans lib/config.ts / lib/titreo.ts, côté serveur ; jamais de préfixe NEXT_PUBLIC_.
  • Token client à durée courte : seul le token tepc_ (TTL court) transite par l’URL vers le navigateur ; il suffit à bootstrapper et soumettre la session courante, pas plus.
  • Webhook signé : vérification HMAC SHA-256 timing-safe via le SDK avant tout traitement.
  • Indexation désactivée : metadata.robots = { index: false, follow: false } dans app/layout.tsx.
  • Idempotence : chaque appel d’écriture vers l’API porte un Idempotency-Key (UUID).
  • Catalogue en dur dans lib/products.ts ; pas de base, pas de panier à plusieurs articles.
  • Pas de persistance : aucune table de commandes ; le webhook journalise mais ne met rien à jour.
  • Cartes cadeaux : il faut une carte de test Titréo physique ou communiquée par Titréo ; la boutique n’en publie aucune.
  • Pages de retour passives : order/success et order/cancel affichent les searchParams sans relire le statut côté serveur (pas de polling, pas de vérification).
  • setAdyenPspLeg est présent dans lib/titreo.ts mais non appelé par le flux actuel.
  • Aucun test automatisé dans la brique.