Aller au contenu

Contrats inter-briques

C’est ce qui rend Titreo E-Pay extensible sans toucher au cœur. Le core API ne connaît aucun PSP nominalement : il opère sur des contrats abstraits, résolus à l’exécution par des registries.

Source de vérité : ecosystem/CONTRACTS.md. Cette page la résume et la relie au code réel.

Chaque brique PSP ou émetteur gift est une paire :

flowchart TB
  subgraph nav["Navigateur (non fiable)"]
    CC["client adapter<br/>implémente ClientPspAdapter / ClientGiftAdapter"]
  end
  subgraph srv["Serveur (de confiance)"]
    SS["server adapter<br/>implémente PaymentProvider / GiftCardProvider"]
  end
  CC -->|"capturePayload opaque { kind, data }"| API["core API"]
  API -->|"resolve via Registry"| SS
  • Le client adapter monte l’UI (Stripe Elements, Adyen Web…), tokenize côté navigateur, et renvoie un payload opaque. Il est chargé dynamiquement via le manifest signé.
  • Le server adapter sait décoder ce payload et parle au PSP avec les credentials du marchand. Il est résolu par un registry.

L’interface que tout adapter PSP serveur implémente. Forme documentée dans CONTRACTS.md :

export type PaymentProvider = {
readonly type: string // 'stripe' | 'adyen' | …
authorize(params): Promise<AuthorizeResult> // → HELD | REQUIRES_ACTION | FAILED
capture(params): Promise<CaptureResult>
void(params): Promise<VoidResult>
refund(params): Promise<RefundResult>
// …vérification de webhook
}

AuthorizeResult est une union discriminée :

type AuthorizeResult =
| { status: 'HELD'; authId; providerRef; raw }
| { status: 'REQUIRES_ACTION'; authId; actionUrl; raw } // 3DS / redirection
| { status: 'FAILED'; errorCode; errorMessage; raw }
export type GiftCardProvider = {
readonly type: string // 'titreo' | 'edenred' | …
lookupBalance(input): Promise<{ balance; cardLast4; emitter }>
debit(input): Promise<DebitResult>
cancel(input): Promise<CancelResult>
refund(input): Promise<RefundResult>
}

Implémentations : TitreoGiftCardAdapter (réel) et MockGiftCardAdapter (dev/tests), dans api/src/infrastructure/titreo/ et api/src/infrastructure/gift/.

Le cœur ne fait jamais new StripeAdapter(). Il demande au registry :

  • PspRegistry.get(providerType, merchantId): Promise<PaymentProvider> — implémenté par ConfigBackedPspRegistry, qui charge la config marchand chiffrée depuis la base puis appelle la factory du PSP. api/src/infrastructure/psp/in-memory-psp-registry.ts.
  • GiftCardRegistry.get(emitterType): GiftCardProvider.
export type ClientPspAdapter = {
readonly type: string
mount(container: HTMLElement, options: MountOptions): Promise<void>
capture(): Promise<CapturePayload> // { kind, data } opaque
handleAction?(actionUrl: string): Promise<{ status: 'COMPLETED' | 'FAILED' }> // 3DS
destroy(): void
}

MountOptions transmet sessionId, amount, currency, la config publique issue du manifest (ex. clé publishable Stripe), la locale et des themeTokens (variables CSS du thème marchand, passées aux iframes).

export type ClientGiftAdapter = {
readonly type: string
mount(container, options): Promise<void>
lookupBalance(): Promise<{ cardToken; cardLast4; emitter; availableBalance }>
destroy(): void
}

Le numéro de carte ne quitte jamais HTTPS : la brique cliente envoie carte + PIN à l’API Titreo (POST /v1/gift-cards/lookup), qui forwarde à l’émetteur. Le cœur ne voit qu’un cardToken opaque.

Le shell checkout-sdk expose un registre global ; chaque module d’adapter s’y auto-enregistre après chargement :

window.TitreoEpay = {
adapters: { psp: Map<string, ClientPspAdapter>, gift: Map<string, ClientGiftAdapter> },
registerPsp(adapter): void,
registerGift(adapter): void,
}
sequenceDiagram
  participant L as Loader (checkout-sdk)
  participant API as Core API
  participant CA as ClientPspAdapter
  participant SA as PaymentProvider (server)
  participant PSP as PSP réel

  L->>API: GET /v1/sessions/:id/bootstrap (manifest signé)
  L->>L: vérifie Ed25519 + SRI, charge le module
  CA-->>L: window.TitreoEpay.registerPsp(adapter)
  L->>CA: mount(container, options)
  CA->>CA: capture() → { kind, data }
  L->>API: POST /v1/sessions/:id/legs/psp/charge { providerType, capturePayload }
  API->>SA: PspRegistry.get(type, merchantId).authorize(...)
  SA->>PSP: appel REST avec credentials marchand
  PSP-->>SA: HELD → outbox CAPTURE_PSP

Le cœur n’a aucun code spécifique à un PSP donné. Ajouter PayPlug = écrire une nouvelle paire client+serveur conforme à ces contrats, zéro modification du cœur.

  • if (leg.providerType === 'stripe') dans domain/ ou application/ — toute logique spécifique va dans l’adapter.
  • import 'stripe' hors de infrastructure/psp/.
  • ❌ Énumérer les kind de CapturePayload dans le cœur.
  • ❌ Un plugin CMS qui parlerait directement à la base Titreo (un plugin n’est qu’un consumer HTTP /v1/*).
  • ❌ Instancier un adapter en bypass du registry.

Sources de vérité : ecosystem/CONTRACTS.md, ecosystem/adapters/ADAPTER_CONTRACT.md, ecosystem/plugins/PLUGIN_CONTRACT.md, et doc/site/content/providers.mdx.