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 (sku, amount, currency)"
    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 (adapter Adyen)"
    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 page d’accueil demo-shop/app/page.tsx affiche un produit unique défini en dur dans le composant (constante PRODUCT : « Lampe artisan », priceCents: 14900, currency: 'EUR'). Il n’y a pas de catalogue ni de fichier lib/products.ts : le « panier » est un simple formulaire HTML POST avec trois champs cachés (sku, amount, currency) qui pointe vers /api/checkout.

<form action="/api/checkout" method="POST">
<input type="hidden" name="sku" value="sku_lampe_artisan" />
<input type="hidden" name="amount" value="14900" />
<input type="hidden" name="currency" value="EUR" />
<button type="submit">Payer 149,00 €</button>
</form>

Le handler demo-shop/app/api/checkout/route.ts (runtime = 'nodejs', dynamic = 'force-dynamic') reçoit le POST du formulaire, valide les entrées, puis enchaîne deux appels serveur via demo-shop/lib/titreo.ts :

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

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: amount, currency, reference: `${sku}-${Date.now()}` })
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, 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 de carte, la tokenisation Adyen, 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 })
}
// TODO: update local order status by event.data.sessionId

Le commentaire // TODO indique l’état réel : 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 Page produit unique + 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

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).
  • Un seul produit en dur dans page.tsx ; pas de catalogue, pas de lib/products.ts.
  • Pas de persistance : aucune table de commandes ; le webhook journalise mais ne met rien à jour (// TODO).
  • 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.