checkout-sdk (Web Components Lit)
À quoi sert cette brique
Section intitulée « À quoi sert cette brique »Le checkout-sdk est l’interface de paiement que voit l’acheteur. C’est un composant web autonome (<titreo-epay-checkout>) que le marchand pose sur sa page de checkout, quel que soit son stack (vanilla, Next.js, Remix, Nuxt, SvelteKit, Astro, headless). Il affiche un seul widget qui gère tout le parcours :
- saisie d’une ou plusieurs cartes cadeaux (gift) avec choix du montant à imputer ;
- split-tender automatique : le reste à payer bascule sur la carte bancaire ;
- capture CB déléguée à un adapter PSP (Stripe, PayPlug, etc.) chargé dynamiquement ;
- soumission de la session puis affichage de l’état terminal (succès, refus, 3DS, expiration).
Point clé d’architecture : le shell ne connaît aucun PSP nominalement. Il lit dans un manifest signé le type de PSP à utiliser, charge le module adapter correspondant depuis le CDN, le monte dans un slot, lui transmet les theme tokens Titreo, et lui délègue la capture carte + le 3DS. Le branding et toute l’UX restent 100 % Titreo ; la captation de la carte reste dans l’iframe du PSP. Résultat : la carte ne touche jamais l’infra Titreo, et le secret PSP du marchand ne descend jamais dans le navigateur.
Le contrat exact entre shell et adapters est décrit dans Contrats inter-briques ; le modèle de menaces et la chaîne de confiance dans Sécurité. Le backend qui sert le manifest et les sessions est documenté côté Backend. Les modules adapters côté client sont décrits dans Adapters clients.
Cycle de vie du composant <titreo-epay-checkout>
Section intitulée « Cycle de vie du composant <titreo-epay-checkout> »Le composant est un LitElement enregistré sous la balise titreo-epay-checkout (checkout-sdk/src/components/checkout.ts). Particularité : createRenderRoot() retourne this — le composant rend en light DOM (pas de Shadow DOM), et injecte ses styles globalement une fois via injectStyles() (les sélecteurs :host sont réécrits en titreo-epay-checkout). Cela permet aux adapters PSP montés dans un slot d’hériter du CSS et facilite l’intégration SSR/React.
Attributs HTML
Section intitulée « Attributs HTML »| Attribut | Propriété | Rôle |
|---|---|---|
session-id |
sessionId |
ID de la session de paiement (obligatoire) |
api-base-url |
apiBaseUrl |
Base URL de l’API Titreo E-Pay (obligatoire) |
api-token |
apiToken |
Token client session-scoped tep_pub_… (obligatoire) |
locale |
locale |
fr (défaut) ou en |
palette |
palette |
warm (défaut) / ink / aubergine |
theme |
theme |
light (défaut) / dark |
Si l’un des trois attributs obligatoires manque, le composant passe en status = 'failed' avec le message session-id, api-base-url, api-token requis.
Séquence de démarrage
Section intitulée « Séquence de démarrage »sequenceDiagram
participant DOM as "Page marchand"
participant C as "<titreo-epay-checkout>"
participant API as "API Titreo E-Pay"
participant REG as "window.TitreoEpay (registry)"
participant PSP as "Adapter PSP (iframe)"
DOM->>C: "connectedCallback()"
C->>API: "GET /v1/sessions/:id/bootstrap (Bearer)"
API-->>C: "BootstrapManifest (signé Ed25519)"
C->>C: "applyMerchantThemeTokens()"
C->>API: "GET /v1/sessions/:id (refreshSession)"
API-->>C: "totalAmount, remainingAmount, legs"
C->>REG: "ensureRegistry()"
C->>C: "status = 'ready' (render checkout)"
Note over C,PSP: "si remainingAmount > 0 et psp_adapter présent"
C->>PSP: "adapter.mount(slot, MountOptions + themeTokens)"
connectedCallback() est async : il appelle bootstrap(), qui enchaîne le fetch du manifest, l’application des theme tokens marchand, un premier refreshSession(), l’init du registry, puis status = 'ready'. L’étape initiale (currentStep) est 'gift' si le manifest expose un gift_card_provider, sinon 'pay'.
États internes (status) et écrans rendus
Section intitulée « États internes (status) et écrans rendus »render() route vers un sous-rendu selon le champ status (type interne, distinct de la state-machine UI partagée) :
status |
Écran |
|---|---|
loading |
renderLoading() — anneau pulsant |
ready / submitting |
renderCheckout() — wizard gift → pay → review |
processing |
renderProcessing() |
completed |
renderSuccess() |
refused / failed |
renderRefused() (via le catalogue i18n) |
requires_action |
réservé 3DS (Phase 2) |
Montage et synchronisation de l’adapter PSP
Section intitulée « Montage et synchronisation de l’adapter PSP »Dans updated(), dès que status === 'ready', qu’un psp_adapter est présent et que remainingAmount > 0 :
- premier passage →
mountPspAdapter(): récupère l’adapter danswindow.TitreoEpay.psp.get(type), le monte dans le slot[data-slot="psp-card"]avec lesMountOptions(sessionId,amount,currency,config=psp_adapter.client_config,locale,themeTokens) ; - si le montant restant change (ajout/retrait d’un gift) →
syncPspAmount(): appelleadapter.updateAmount()si disponible, sinondestroy()+ remontage complet du slot.
Les theme tokens transmis sont calculés par themeTokens() à partir du getComputedStyle du composant (--accent, --bg-widget, --text, --border-strong, --radius-sm, --font-sans), fusionnés avec d’éventuels theme_tokens du manifest.
Actions utilisateur → appels API
Section intitulée « Actions utilisateur → appels API »Tous les appels portent le Authorization: Bearer <api-token> ; les mutations ajoutent une Idempotency-Key: crypto.randomUUID().
| Action | Méthode interne | Appel API |
|---|---|---|
| Vérifier solde d’une carte cadeau | onApplyGift() |
POST /v1/sessions/:id/gift-cards/check-balance |
| Confirmer le montant gift à imputer | onConfirmGiftAmount() |
POST /v1/sessions/:id/legs/gift |
| Retirer une carte cadeau (optimiste) | onRemoveGift(legId) |
DELETE /v1/sessions/:id/legs/:legId |
| Soumettre (capture PSP + submit) | onSubmit() |
POST /v1/sessions/:id/legs/psp puis POST /v1/sessions/:id/submit |
| Rafraîchir l’état de session | refreshSession() |
GET /v1/sessions/:id |
onSubmit() : si remainingAmount > 0, appelle adapter.capture() (retour CapturePayload opaque), sérialise capturePayload.data dans le champ paymentMethod, le POST sur …/legs/psp, puis POST …/submit. Selon result.status (PROCESSING / COMPLETED → succès, sinon refus) il bascule l’état et émet l’événement DOM correspondant.
Événements DOM émis
Section intitulée « Événements DOM émis »Les CustomEvent sont bubbles: true et composed: true (capturables hors arbre).
| Événement | detail |
|---|---|
titreo-epay:completed |
{ sessionId, result } |
titreo-epay:failed |
{ reason } |
<titreo-epay-checkout session-id="sess_xxx" api-base-url="https://api.titreo-epay.io" api-token="tep_pub_xxx" locale="fr"></titreo-epay-checkout>
<script> const el = document.querySelector('titreo-epay-checkout') el.addEventListener('titreo-epay:completed', (e) => { window.location = '/order/success?session=' + e.detail.sessionId }) el.addEventListener('titreo-epay:failed', (e) => console.error(e.detail.reason))</script>Cartographie des fichiers
Section intitulée « Cartographie des fichiers »Fichier (sous checkout-sdk/src/) |
Rôle | Exports principaux |
|---|---|---|
index.ts |
Point d’entrée . : enregistre le composant, ré-exporte loader/UI/poll/i18n/types |
TitreoEpayCheckout + ré-exports |
components/checkout.ts |
Le composant Lit principal, tout le wizard et l’orchestration côté client | TitreoEpayCheckout |
components/tokens.ts |
Design tokens Titreo en CSS custom properties (css Lit) |
designTokens |
components/helpers.ts |
Formatage monétaire, Luhn, thèmes couleur par émetteur gift | fmtMoney, luhnValid, giftTheme, type GiftThemeColors |
loader/index.ts |
API publique du sous-export ./loader |
loadFromManifest, ensureRegistry, canonicalize, verifyManifestSignature, parseEd25519PubKeyPem, base64ToBytes, loadModuleWithIntegrity, type LoaderConfig |
loader/types.ts |
Contrats partagés (source : ecosystem/CONTRACTS.md) |
ClientPspAdapter, ClientGiftAdapter, BootstrapManifest, ManifestAdapterEntry, MountOptions, GiftMountOptions, GiftLookupResult, CapturePayload, ThemeTokens, AdapterRegistry |
loader/registry.ts |
Crée/récupère window.TitreoEpay (idempotent) |
ensureRegistry |
loader/verify.ts |
Vérification de signature Ed25519 du manifest + utils PEM/base64 | verifyManifestSignature, parseEd25519PubKeyPem, base64ToBytes, bytesToBase64 |
loader/canonicalize.ts |
Canonicalisation JSON déterministe (tri récursif des clés) | canonicalize |
loader/load.ts |
Chargement dynamique d’un module ESM avec vérification SRI | loadModuleWithIntegrity, DEV_SRI_SKIP_TOKEN, base64ToBytes |
ui/state-machine.ts |
State-machine UI pure (sans dépendance framework) | deriveUiState, canTransition, categoryOf, types UiState, UiCategory, SessionStatus, LegStatus, LegInput, DeriveInput |
ui/screens.ts |
Écrans transverses autonomes (Web Components réutilisables) | ProcessingScreen, PollingScreen, RequiresActionScreen, LaunchingScreen, SuccessScreen, RefusedScreen, ExpiredScreen, InsufficientScreen |
poll/session-poller.ts |
Polling GET /v1/sessions/:id jusqu’à un état terminal |
pollSession, types PollOptions, PollResult, SessionSnapshot, TerminalStatus |
i18n/errors.ts |
Catalogue d’erreurs canoniques → message + CTA, FR/EN | ErrorCode, ERROR_CATALOG, describeError, isRetriable, isUserActionable + types |
adapters/mock-psp.ts |
Adapter PSP factice pour démo/tests (auto-enregistré) | MockPspAdapter |
Le composant index.ts initialise le registry au chargement si window existe (ensureRegistry()), puis enregistre <titreo-epay-checkout> et ré-exporte tout l’outillage UI pour les intégrations custom.
Le loader : manifest signé + adapters vérifiés depuis le CDN
Section intitulée « Le loader : manifest signé + adapters vérifiés depuis le CDN »C’est le cœur de sécurité de la brique. Le shell ne télécharge pas du code arbitraire : il ne charge que des adapters listés dans un manifest signé Ed25519 par le backend, et vérifie l’intégrité SRI de chaque module avant de l’exécuter. Voir Sécurité pour le modèle complet, et Contrats inter-briques pour la forme du manifest.
Le registry window.TitreoEpay
Section intitulée « Le registry window.TitreoEpay »ensureRegistry() (loader/registry.ts) crée idempotemment l’objet global. Il lève une erreur hors navigateur (pas de window).
// Forme de window.TitreoEpay (AdapterRegistry)type AdapterRegistry = { psp: Map<string, ClientPspAdapter> gift: Map<string, ClientGiftAdapter> registerPsp(adapter: ClientPspAdapter): void registerGift(adapter: ClientGiftAdapter): void}Les modules adapters, une fois chargés, s’auto-enregistrent à l’import en appelant registerPsp / registerGift (clé = adapter.type). Le shell les retrouve ensuite par window.TitreoEpay.psp.get(manifest.psp_adapter.type).
Forme du BootstrapManifest
Section intitulée « Forme du BootstrapManifest »Champs principaux (loader/types.ts) : schema_version, session_id, expires_at, psp_adapter ({ type, url, integrity, client_config }), gift_card_provider ({ url, integrity, client_config }), theme_tokens, key_id, et signature (format ed25519:<base64>). Le type autorise des champs additionnels ([k: string]: unknown).
loadFromManifest() — le flux complet
Section intitulée « loadFromManifest() — le flux complet »flowchart TD
A["loadFromManifest(manifest, { pubKey })"] --> B{"skipVerify ?"}
B -- non --> C["verifyManifestSignature(manifest, pubKey)"]
C --> D{"signature valide ?"}
D -- non --> E["throw 'manifest signature invalid'"]
D -- oui --> F["ensureRegistry()"]
B -- oui (DEV) --> F
F --> G["pour psp_adapter et gift_card_provider"]
G --> H["loadModuleWithIntegrity(url, integrity)"]
H --> I["fetch module → verifyIntegrity(SRI sha256/384/512)"]
I --> J{"hash OK ?"}
J -- non --> K["throw 'integrity mismatch'"]
J -- oui --> L["import via Blob URL → auto-register dans le registry"]
verifyManifestSignature() (loader/verify.ts) : retire signature et key_id du manifest, canonicalise le reste (canonicalize() — tri récursif des clés, identique au signer serveur), encode en UTF-8, et vérifie avec crypto.subtle (algorithme Ed25519 natif, clé publique raw 32 octets importée en 'raw'). parseEd25519PubKeyPem() extrait ces 32 octets d’une clé PEM SPKI (les 32 derniers octets après l’en-tête).
loadModuleWithIntegrity() (loader/load.ts) : import() ne supporte pas nativement l’attribut integrity. Le loader fetch donc le module en arrayBuffer, recalcule le digest (crypto.subtle.digest en SHA-256/384/512 selon le préfixe shaXXX-), compare en temps constant au hash attendu, puis crée une Blob URL dont il fait import() avant de la révoquer. Un sentinel DEV_SRI_SKIP_TOKEN = 'sha384-DEV_SKIP' permet, en dev uniquement, d’importer l’URL directement sans vérif SRI (injecté par le backend seulement quand NODE_ENV=development).
Usage bas niveau (./loader)
Section intitulée « Usage bas niveau (./loader) »import { ensureRegistry, loadFromManifest, parseEd25519PubKeyPem, type BootstrapManifest,} from '@titreo-epay/checkout/loader'
const pubKey = parseEd25519PubKeyPem(EMBEDDED_TITREO_PUBKEY_PEM)const manifest: BootstrapManifest = await fetch(bootstrapUrl).then((r) => r.json())await loadFromManifest(manifest, { pubKey })const adapter = window.TitreoEpay?.psp.get('stripe')await adapter?.mount(myElement, { sessionId, amount, currency, config: {} })La state-machine UI
Section intitulée « La state-machine UI »ui/state-machine.ts est un module pur, sans dépendance framework, réutilisable par tout shell (Lit, React, Vue) ou un checkout custom marchand. Il mappe l’état serveur + l’état des legs + des drapeaux UI vers un UiState unique.
Les 14 états : initial, card, gift, split, multi, insufficient, invalid (pré-submit) ; processing, launching, success, refused (submit/post-submit) ; requires_action, polling, expired (transverses).
deriveUiState(input: DeriveInput): UiState applique des règles ordonnées par priorité (premier match gagne) :
flowchart TD
S["deriveUiState(input)"] --> X1{"sessionStatus EXPIRED ?"} -->|oui| EXP["expired"]
X1 -->|non| X2{"COMPLETED / FULLY_REFUNDED ?"} -->|oui| OK["launching ou success"]
X2 -->|non| X3{"FAILED / CANCELLED ?"} -->|oui| KO["refused"]
X3 -->|non| X4{"actionUrl présent ?"} -->|oui| RA["requires_action"]
X4 -->|non| X5{"PROCESSING / awaitingTerminal ?"} -->|oui| TR["processing / polling / launching"]
X5 -->|non| X6{"inputInvalid ?"} -->|oui| INV["invalid"]
X6 -->|non| X7{"giftInsufficient ?"} -->|oui| INS["insufficient"]
X7 -->|non| X8{"gifts et reste ?"} --> RES["multi / gift / split / card"]
categoryOf(state)range chaque état dans une catégorie UX (collect,transit,terminal_ok,terminal_ko,recoverable) — utile pour décider du rendu (CTA retry vs change-method vs aucune action).canTransition(from, to)valide une transition contre la tableALLOWED(utilitaire de debug/tests, non imposé strictement).
Note : le composant checkout.ts pilote son propre champ status interne pour le rendu, tandis que deriveUiState est la machine canonique partagée exposée aux intégrateurs et aux écrans transverses.
Le session-poller
Section intitulée « Le session-poller »poll/session-poller.ts — pollSession(options) interroge GET /v1/sessions/:id jusqu’à un statut terminal (COMPLETED, FAILED, EXPIRED, CANCELLED, FULLY_REFUNDED) ou un timeout. Pas de dépendance Lit/DOM → testable en Node, et réutilisable par les pages retour des plugins (Presta/Woo/Shopify) et l’écran <titreo-epay-screen-polling>.
Comportement :
- intervalle par défaut
2500 ms, timeout15 min, backoff exponentiel plafonné (backoffMaxMs, défaut10 s) sur erreur réseau ; signal?: AbortSignalpour cleanup propre côté composant ;fetchImplinjectable pour tests ; hookonStateà chaque snapshot non terminal ;- sur statut terminal, extrait le premier
errorCoded’un leg en échec (extractFirstErrorCode).
const result = await pollSession({ apiBaseUrl, sessionId, token, // Bearer client, TTL court onState: (snap) => render(snap.status), signal: abortController.signal,})// result.kind : 'terminal' | 'timeout' | 'aborted'i18n des erreurs
Section intitulée « i18n des erreurs »i18n/errors.ts est la source de vérité UX du checkout pour les erreurs. Il réplique en TypeScript le catalogue d’erreurs canonique du backend (api/src/domain/shared/error-catalog.ts) et la table PHP des plugins (plugins/prestashop/.../error-codes.php). Tout nouveau code doit être ajouté ici ET côté plugins.
ErrorCode: ~22 codes canoniques (provider_unavailable,card_declined,card_expired,insufficient_funds,requires_action,session_expired,compensation_failed,idempotency_replay,webhook_signature_invalid,unknown, …).ERROR_CATALOG: pour chaque code, unecategory(transient/permanent/user_actionable), unecta(retry/change_method/resume_3ds/restart/contact_support) et des libelléstitle/body/ctaLabelenfreten.describeError(code, locale)retourne{ title, body, ctaLabel, cta, category }(fallbackUNKNOWNsi code inconnu).isRetriable(code)(catégorietransient) etisUserActionable(code)(catégorieuser_actionable) servent à brancher la logique de retry/3DS.
const desc = describeError('card_declined', 'fr')// { title: 'Carte refusée', body: '…', ctaLabel: 'Changer de moyen', cta: 'change_method', category: 'permanent' }<titreo-epay-screen-refused> consomme directement describeError pour afficher titre, message et bouton CTA traduits.
Écrans transverses réutilisables
Section intitulée « Écrans transverses réutilisables »ui/screens.ts expose 8 Web Components autonomes (balises titreo-epay-screen-*), montables hors du shell principal — par exemple une page retour returnpayment.tpl de Presta peut poser <titreo-epay-screen-polling>. Tous prennent locale="fr|en" et suivent les CSS variables Titreo :
titreo-epay-screen-processing, -polling (attr remaining-ms), -requires-action (attr action-url), -launching, -success, -refused (attr error-code, émet titreo-epay:refused-cta), -expired (émet titreo-epay:restart), -insufficient (attrs available/expected, émet titreo-epay:insufficient-continue / …-change).
Adapter mock & démo
Section intitulée « Adapter mock & démo »adapters/mock-psp.ts fournit MockPspAdapter (type = 'mock') : il rend de simples champs CB stylés via themeTokens, valide localement (longueur numéro, format MM/AA, CVC) et retourne un CapturePayload factice { kind: 'mock_token', data: { token } }. Il s’auto-enregistre à l’import (via queueMicrotask si le registry n’existe pas encore). À ne jamais charger en prod — un vrai adapter Stripe/PayPlug le remplace.
Le dossier demo/index.html (pnpm demo → vite serve demo) monte <titreo-epay-checkout> pointant sur http://localhost:3000, importe src/index.ts + src/adapters/mock-psp.ts, et logge les événements DOM. Voir Environnement de dev local pour lancer le backend en regard.
Scripts npm/pnpm
Section intitulée « Scripts npm/pnpm »| Script | Commande | Rôle |
|---|---|---|
build |
tsup |
Bundle ESM + types (entrées index.ts et loader/index.ts) |
typecheck |
tsc --noEmit |
Vérification de types stricte |
lint / lint:fix |
biome check . |
Lint/format Biome |
test / test:watch |
vitest run / vitest |
Tests (happy-dom) |
demo |
vite serve demo |
Sert la démo locale |
Tests présents : tests/canonicalize.test.ts, tests/verify.test.ts (signature Ed25519, avec @noble/ed25519 comme oracle), tests/load.test.ts (SRI), src/ui/state-machine.test.ts, src/i18n/errors.test.ts, src/poll/session-poller.test.ts.