Aller au contenu

checkout-sdk (Web Components Lit)

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 :

  1. saisie d’une ou plusieurs cartes cadeaux (gift) avec choix du montant à imputer ;
  2. split-tender automatique : le reste à payer bascule sur la carte bancaire ;
  3. capture CB déléguée à un adapter PSP (Stripe, PayPlug, etc.) chargé dynamiquement ;
  4. 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.

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.

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.

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'.

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)

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 dans window.TitreoEpay.psp.get(type), le monte dans le slot [data-slot="psp-card"] avec les MountOptions (sessionId, amount, currency, config = psp_adapter.client_config, locale, themeTokens) ;
  • si le montant restant change (ajout/retrait d’un gift) → syncPspAmount() : appelle adapter.updateAmount() si disponible, sinon destroy() + 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.

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.

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>
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.

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).

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).

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).

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: {} })

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 table ALLOWED (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.

poll/session-poller.tspollSession(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, timeout 15 min, backoff exponentiel plafonné (backoffMaxMs, défaut 10 s) sur erreur réseau ;
  • signal?: AbortSignal pour cleanup propre côté composant ; fetchImpl injectable pour tests ; hook onState à chaque snapshot non terminal ;
  • sur statut terminal, extrait le premier errorCode d’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/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, une category (transient / permanent / user_actionable), une cta (retry / change_method / resume_3ds / restart / contact_support) et des libellés title / body / ctaLabel en fr et en.
  • describeError(code, locale) retourne { title, body, ctaLabel, cta, category } (fallback UNKNOWN si code inconnu). isRetriable(code) (catégorie transient) et isUserActionable(code) (catégorie user_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.

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).

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 demovite 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.

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.