demo-shop (storefront)
À quoi ça sert
Section intitulée « À quoi ça sert »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).
Stack et emplacement
Section intitulée « Stack et emplacement »| É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 flux complet
Section intitulée « Le flux complet »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)"
Étape 1 — Page produit et panier
Section intitulée « Étape 1 — Page produit et panier »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>Étape 2 — Création de session côté serveur
Section intitulée « Étape 2 — Création de session côté serveur »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 :
createSession({ totalAmount, currency, reference })→POST /v1/sessionsissueClientToken(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).
Étape 4 — Retour utilisateur
Section intitulée « Étape 4 — Retour utilisateur »À 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 :
- lit le corps brut de la requête,
- lit l’en-tête
x-titreo-signature, - vérifie la signature HMAC avec
verifyMerchantWebhook(body, signature, secret)importé de@titreo-epay/sdk/webhooks(comparaison timing-safe), - répond
401si 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.sessionIdLe 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.
Routes exposées
Section intitulée « Routes exposées »| 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 |
Configuration (environnement)
Section intitulée « Configuration (environnement) »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.
# Mise en placecp demo-shop/.env.example demo-shop/.env.local# renseigner TITREO_API_KEY et TITREO_WEBHOOK_SECRETpnpm installpnpm --filter @titreo-epay/demo-shop dev # http://localhost:3200Aspects sécurité notables
Section intitulée « Aspects sécurité notables »- Clé d’API server-only :
TITREO_API_KEYn’est lue que danslib/config.ts/lib/titreo.ts, côté serveur ; jamais de préfixeNEXT_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 }dansapp/layout.tsx. - Idempotence : chaque appel d’écriture vers l’API porte un
Idempotency-Key(UUID).
Limites actuelles (état prototype)
Section intitulée « Limites actuelles (état prototype) »- Un seul produit en dur dans
page.tsx; pas de catalogue, pas delib/products.ts. - Pas de persistance : aucune table de commandes ; le webhook journalise mais ne met rien à jour (
// TODO). - Pages de retour passives :
order/successetorder/cancelaffichent lessearchParamssans relire le statut côté serveur (pas de polling, pas de vérification). setAdyenPspLegest présent danslib/titreo.tsmais non appelé par le flux actuel.- Aucun test automatisé dans la brique.