# @mostajs/payment — fiche LLM > Module de paiement multi-provider (Stripe, Satim/CIB, Chargily, PayPal, Manuel), multi-méthode, multi-devise. - Version: 2.3.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI - Chemin: mostajs/mosta-commerce-stack/mosta-payment · Statut audit: complet (dist/) ## ⚠️ RUPTURE 2.0.0 — LES URL DE RETOUR SONT REQUISES ET ABSOLUES Sur le canal `en-ligne`, `encaisser()` **exige** `urlSucces` et `urlAnnulation` en URL absolue. Le repli `'/'` est supprimé : relatif, il était rejeté par le fournisseur sous « Not a valid URL », un message qui ne désigne ni le champ ni sa cause — on cherchait chez sa banque un défaut qui était ici. Avec le dialecte `manual`, qui n'appelle aucune banque, ce chemin fonctionnait : d'où un MAJEUR et non un mineur. ```diff - await encaisser({ moyen: 'carte', canal: 'en-ligne', montant, monnaie, reference }) + await encaisser({ moyen: 'carte', canal: 'en-ligne', montant, monnaie, reference, + urlSucces: 'https://mon-site.fr/retour', urlAnnulation: 'https://mon-site.fr/panier' }) ``` ⚠️ **N'ALLEZ PAS CHERCHER CETTE ADRESSE DANS L'EN-TÊTE `Host`** — il est fourni par le client. Qui le falsifie fait renvoyer le payeur vers SON site, avec le numéro de commande. Déclarez-la (`PUBLIC_BASE_URL`) : c'est ce que font `race-event` et `octonet-cloud`. `booking-baloon` prend son `returnUrl` dans le corps de la requête — c'est le contre-exemple. ## ⚠️ AVANT TOUT : LE SCHÉMA DOIT ÊTRE ENREGISTRÉ Ce module **persiste une ligne `Payment`** par session bancaire — c'est elle que le webhook retrouve par `orderId` pour clore la vente. Si son schéma n'est pas collecté, **la table n'existe pas**, et tout fonctionne jusqu'au **premier paiement réel** : la banque ouvre la session, puis l'écriture échoue sur « no such table: payments ». L'échec survient donc APRÈS que le client a engagé son paiement. Défaut vécu en production le 19/08/2026. ```js import { register as registerPayment } from '@mostajs/payment/register' // 1.3.0+ registerPayment(registry) // registry = ModuleRegistry de @mostajs/socle ``` ⚠️ **`paymentModuleRegistration` n'est PAS ce contrat** : il a une forme propre au module (`{name, label, description, version, priority, getSchemas}`) et le socle attend `{manifest, permissions, schemas}`. Le lui passer échoue sur « Cannot read properties of undefined (reading 'name') ». Il reste exporté pour ses consommateurs. ⚠️ **Ne recopiez pas `PaymentSchema` dans votre application.** Une liste tenue à la main ne sait pas qu'un module a gagné une entité : la table n'est jamais créée, et rien ne le signale. ## RÔLE Abstraction unifiée du paiement pour l'écosystème `@mostajs/*`. Un contrat `PaymentProvider` commun couvre **11 dialectes** : DZ — Satim, Chargily, SlickPay, Guiddini ; international — Stripe, PayPal, Mollie, Paystack ; crypto — Coinbase Commerce, NOWPayments ; + Manuel. Fournit checkout, webhooks normalisés (**avec leur route, agnostique du cadre**), billing/abonnements (Stripe), point d'entrée omnicanal `encaisser()` et schéma ORM `Payment`. **Aucune interface** : `PaymentPage` et les handlers de routes Next.js vivent dans `@mostajs/pay-panel-ui` (règle « moteur et UI séparés »). Sélection automatique du provider par devise (DZD → Chargily par défaut, autres → Stripe ; cibler SATIM/SlickPay/Guiddini via `providerName`). ## VOCABULAIRE COMMUN (v0.9.0) `@mostajs/paiement-contrat` fournit MOYENS · CANAUX · VERDICTS. Adopté **PAR AJOUT** : `supportedMethods` est CONSERVÉ, `moyenNormalise(provider)` le traduit à côté. - `PaymentChannel = 'en-presence' | 'en-ligne' | 'hors-ligne'` — le canal, distinct du moyen. - `canauxDuDialecte(provider)` — `ManualProvider` est reconnu `en-presence` par son `tpe`. - `PaymentStatus` gagne **`indetermine`** (additionnel) : la réponse est PERDUE, ≠ `pending` où elle peut encore arriver. Le passage de l'un à l'autre n'est PAS automatique (délai + tentative d'interrogation — étape 4). ⚠️ `tpe` figurait parmi les MOYENS de `manual` : c'est un CANAL. ## ⚠️ « DIALECTE » DÉSIGNE DEUX CHOSES DANS CE MODULE Le mot est employé ici pour **deux interlocuteurs différents**, et les confondre est le défaut le plus coûteux de cette fiche : - **dialecte de PAIEMENT** = une banque / un PSP (Stripe, Chargily, SATIM…). C'est le sens du § ci-dessous, et de `registerProvider`, `getProviderForCurrency`, `providerName`. - **`dialect: IDialect`** dans les signatures = le **dialecte d'ORM**, celui qui PERSISTE la ligne `Payment`. Rien à voir avec la banque. Ainsi `createPaymentCheckout({ dialect, providerName })` reçoit les DEUX : `dialect` est la base, `providerName` est la banque. Passer `null` au premier crée le paiement chez la banque **sans en garder trace** — l'écart n'apparaît qu'au rapprochement. ## MODÈLE DIALECTE (v0.6) — calqué sur @mostajs/orm Chaque **fournisseur = un dialecte de paiement**, exactement comme un dialecte ORM : - `PaymentProvider` (contrat) ≡ `IDialect`. - `AbstractPaymentProvider` (base, v0.6) ≡ `AbstractSqlDialect` : méthodes *template* publiques `createCheckout`/`verifyWebhook` (validation + garantie `metadata.orderId` + garde de devise) qui délèguent aux *primitives* `protected abstract doCheckout`/`doVerifyWebhook`. Les 5 providers l'étendent. - `payment-engine` (register/get/forCurrency) ≡ registre des dialectes. - **Orchestration** `createPaymentCheckout` / `settlePaymentFromWebhook` (v0.6) ≡ `BaseRepository`/`createConnection` : *utilise* un dialecte + persiste la ligne `Payment` (data-plug), sans réimplémenter la logique fournisseur. ## INSTALLATION npm i @mostajs/payment stripe ## EXPORTS ⚠️ **Déménagés vers `@mostajs/pay-panel-ui`** (règle « moteur et UI séparés ») : `PaymentPage` (→ `@mostajs/pay-panel-ui/react`), `createCheckoutHandler` et `createPaymentHandlers` (→ `@mostajs/pay-panel-ui/routes`). Ils ne sont PLUS exportés ici — un import depuis `@mostajs/payment` rend `undefined`. Entrée `.` (client + serveur léger) : - Schémas: `PaymentSchema`, `createPaymentSchema` - Constante: `moduleInfo` - Types: `PaymentConfig`, `PaymentStatus`, `PaymentMethodType`, `LineItem`, `CheckoutRequest`, `CheckoutResult`, `OrderSummary`, `PaymentDTO`, `WebhookHandlers` ## EXPORTS PAR SOUS-CHEMIN Entrée `@mostajs/payment/server` (serveur uniquement) : - Engine: `registerProvider`, `setDefaultProvider`, `getProvider`, `listProviders`, `getProviderForCurrency`, `resetProviders` - Dialecte base (v0.6): `AbstractPaymentProvider` (classe abstraite — étendue par les 5 providers) - Orchestration (v0.6): `createPaymentCheckout(input)`, `settlePaymentFromWebhook(input)` + types `CreateCheckoutInput`/`CreateCheckoutOutput`/`SettleWebhookInput`/`SettleWebhookOutput` - Auto-enregistrement (v0.5.4+): `registerProvidersFromEnv(opts?)`, `ensureProvidersFromEnv(opts?)`, `resetEnsuredProviders()` + type `AutoRegisterOptions` - Providers/dialectes (classes + factories env): `StripeProvider`/`createStripeProvider`, `SatimProvider`/`createSatimProvider`, `ChargilyProvider`/`createChargilyProvider`, `PayPalProvider`/`createPayPalProvider`, `ManualProvider`/`createManualProvider`, **`SlickPayProvider`/`createSlickPayProvider`**, **`GuiddiniProvider`/`createGuiddiniProvider`** (+ configs `SlickPayConfig`/`GuiddiniConfig`). SATIM expose `getOrderStatusExtended` + statique `mapOrderStatus(code)` ; SlickPay/Guiddini exposent `mapStatus(raw)`. - Stripe bas niveau: `createStripeClient`, `createCheckoutSession`, `handleWebhook`, `createBillingSession`, `createPortalSession`, `handleBillingWebhook` - Webhook helpers: `handleProviderWebhook`, `pickSignatureHeader`, `getProviderByName`, `isPaidEvent`, `isFailedEvent`, `isRefundedEvent`, `extractOrderId`, `pickProviderByCurrency`, `isKnownProvider`, constantes `KNOWN_PROVIDERS`, `CURRENCY_TO_PROVIDER` - Repo: `getPaymentRepo`, `resetPaymentRepo` - **Omnicanal (1.1.0, ⚠️ rupture 2.0.0)**: `encaisser(params, ports)` — sur `en-ligne`, `urlSucces` et `urlAnnulation` sont **REQUISES et ABSOLUES** (le repli relatif `'/'` est supprimé) ; `urlWebhook` facultative, transmise en `webhookUrl` (requise par Chargily), `rapprocherEnAttente(paiements, opts)`, `enUnitesDeBase(montantMineur, uniteMineure)` - **Événements (1.4.0)**: `isDisputedEvent` + statut `conteste` ; `async_payment_succeeded/failed`, `payment_intent.canceled`, `refund.created`, `charge.refund.updated` reconnus - **Enregistrement (1.3.0)**: `@mostajs/payment/register` → `register(registry)` au format du socle (`{manifest, permissions, schemas}`). ⚠️ `paymentModuleRegistration` a une forme PROPRE au module et n'est PAS acceptée par `registry.register()` du socle. - **Route webhook (1.2.0)**: `createWebhookHandler(opts)`, `depuisCorpsBrut(opts, body, headers)`, `detecterFournisseur(headers)` - Module: `getSchemas`, `moduleInfo`, `paymentModuleRegistration` - Types: `PaymentProvider`, `CheckoutParams`, `CheckoutResult`, `CustomerParams`, `SubscriptionParams`, `SubscriptionResult`, `RefundParams`, `RefundResult`, `InvoiceResult`, `WebhookEvent`, `ProviderName`, `HandleProviderWebhookArgs`, `HandleProviderWebhookResult`, configs `StripeConfig`/`SatimConfig`/`ChargilyConfig`/`PayPalConfig`/`ManualConfig`, `SmtpDriverConfig` n/a ## API — SIGNATURES - `encaisser({ moyen, canal, montant, monnaie, reference, acteur?, dialecte?, urlSucces, urlAnnulation, urlWebhook?, meta? }, { enLigne?, dialecteTpe? })` · `montant` = **ENTIER de la plus petite unité** (1380 = 13,80 €) — c'est le module qui convertit, une seule fois, et qui REFUSE une monnaie à trois décimales ; · `acteur` **EXIGÉ** sur tout constat pris sur parole (espèces, terminal autonome) : un règlement déclaré sur parole doit nommer qui a parlé ; · `urlSucces` / `urlAnnulation` **REQUISES et ABSOLUES** sur `en-ligne` (⚠️ rupture 2.0.0) ; · `urlWebhook` facultative → `webhookUrl`. Sans effet pour Stripe, qui lit la sienne au tableau de bord ; **requise par Chargily**, qui l'attend par session — l'omettre y prive de tout webhook, et la vente n'est alors close que si le client revient ; · rend `en-attente` sur `en-ligne`, **jamais** `accepte` : la réponse peut encore arriver. - `createPaymentSchema(options?: { currency?; relationTarget?; relationRequired?; subscriptionTarget?; invoiceTarget? }): EntitySchema` - `getPaymentRepo(dialect: IDialect): BaseRepository` — cache WeakMap par identité du dialect - `registerProvider(p: PaymentProvider): void` · `setDefaultProvider(name): void` · `getProvider(name?): PaymentProvider` · `getProviderForCurrency(currency): PaymentProvider | null` - `registerProvidersFromEnv(opts?: { providers?: ProviderName[]; setDefault?: ProviderName; force?: boolean }): string[]` — enregistre depuis l'env (cascade `MOSTA_ENV`) les providers configurés ; ordre = résolution devise (Chargily/Satim avant Stripe). `manual` exclu sauf si listé. - `ensureProvidersFromEnv(opts?): string[]` — variante idempotente « une fois par process » (à appeler en tête de route handler) · `resetEnsuredProviders(): void` (tests) - `pickProviderByCurrency(currency, fallback?: ProviderName): { name; provider }` - `createPaymentCheckout(input: { dialect: IDialect; orderId; amount; currency; successUrl; cancelUrl; webhookUrl?; description?; method?: PaymentMethodType; metadata?; providerName? }): Promise<{ payment: PaymentDTO; checkout: CheckoutResult; provider: string }>` — sélectionne le dialecte (explicite > registre/devise > mapping), crée le checkout ET persiste la `Payment` (status 'pending', provider, transactionRef=sessionId, orderId, metadata) - `settlePaymentFromWebhook(input: { dialect: IDialect; providerName: ProviderName; body: string; headers }): Promise<{ ok; reason?; event?; orderId?; status?: PaymentStatus; payment?: PaymentDTO|null }>` — vérifie le webhook, mappe l'event (paid/failed/refunded), retrouve la `Payment` par `orderId` et la met à jour (paidAt si paid). Le métier post-paiement reste au caller. - `handleProviderWebhook(args: HandleProviderWebhookArgs): Promise` - `isPaidEvent/isFailedEvent/isRefundedEvent/isDisputedEvent(event: WebhookEvent): boolean` ⚠️ `isPaidEvent` inspecte **`data.payment_status`** : `checkout.session.completed` porteur de `unpaid` n'est PAS un encaissement (règlement différé — SEPA, virement). Ne contredit que sur aveu explicite : les fournisseurs sans ce champ sont inchangés. · `extractOrderId(event): string | null` - Stripe: `createStripeClient(config)`, `createCheckoutSession(stripe, request, config)`, `handleWebhook(stripe, body, sig, secret)`, `createBillingSession(stripe, {...})`, `createPortalSession(stripe, customerId, returnUrl)`, `handleBillingWebhook(stripe, body, sig, secret, handlers)` Interface `PaymentProvider` : `name`, `supportedCurrencies`, `supportedMethods`, `createCheckout(params)`, `verifyWebhook(body, sig)` requis ; `createCustomer/getCustomer/updateCustomer/createSubscription/getSubscription/cancelSubscription/changeSubscription/pauseSubscription/resumeSubscription/createRefund/listInvoices/getUpcomingInvoice/createPortal` optionnels. ## TYPES CLÉS - `PaymentStatus = 'pending'|'paid'|'refunded'|'failed'|'indetermine'|'conteste'` — `conteste` (1.4.0) : un TIERS a repris l'argent, ce n'est ni `failed` (le client a payé et consommé) ni `refunded` (on n'a pas remboursé de son plein gré, et il y a des frais) - `PaymentMethodType = 'card'|'transfer'|'cash'|'tpe'` - `ProviderName = 'chargily'|'stripe'|'satim'|'paypal'|'manual'|'slickpay'|'guiddini'|'coinbase'|'nowpayments'|'paystack'|'mollie'` - `PaymentConfig { currency: string; defaultCurrency?; stripeSecretKey?; stripePublicKey?; stripeWebhookSecret?; successUrlTemplate: string; cancelUrlTemplate: string; methods?: PaymentMethodType[]; bankInfo?: { rib; bankName; holder } }` — templates avec `{orderId}` substitué - `LineItem { name; description?; unitAmount: number; quantity: number }` — unitAmount en unités de base (PAS en centimes) - `CheckoutRequest { orderId; lineItems: LineItem[]; currency?; returnUrl?; metadata? }` - `CheckoutResult { url: string | null; sessionId: string }` - `CheckoutParams { orderId; amount; currency?; … }` — ⚠️ **`amount` est en UNITÉS DE BASE** (13.80 pour 13,80 €), **jamais en centimes**. Chaque dialecte fait `Math.round(amount * 100)` en interne. Passer 1380 facture **1 380,00 €** — et rien ne lève, ni ici, ni chez le fournisseur, qui n'a aucune raison de trouver la somme suspecte. Utiliser `enUnitesDeBase(montantMineur, 2)`. - `WebhookEvent { type: string; data: Record; raw? }` - `HandleProviderWebhookResult = { ok: true; event: WebhookEvent } | { ok: false; reason: 'bad_signature'|'malformed'|'unknown_provider'; error: string }` - `PaymentDTO { id; amount; currency; method; status: PaymentStatus; transactionRef?; paidAt?; orderId?; provider?; metadata? }` — schéma `Payment` = 9 champs + index sur `orderId`. ⚠️ L'énumération `status` du schéma admet les SIX valeurs depuis la 1.4.0. Avant, elle en admettait quatre alors que le type en déclarait cinq : écrire `indetermine` **violait le schéma**, donc échouait à la persistance — précisément quand la réponse de la banque est perdue, le cas le plus coûteux de tout l'encaissement. - `WebhookHandlers { onCheckoutCompleted?; onInvoicePaid?; onSubscriptionUpdated?; onSubscriptionDeleted?; onPaymentFailed? }` ## PATTERN ```ts // ── LE PATRON CANONIQUE — celui d'ASSO-SEL et de race-event. QUATRE temps. ── // 0. le SCHÉMA (sinon « no such table: payments » au premier paiement réel) import { register as registerPayment } from '@mostajs/payment/register' registerPayment(registry) import { ensureProvidersFromEnv, createPaymentCheckout, createWebhookHandler } from '@mostajs/payment/server' // 1. les BANQUES, depuis .env — idempotent, ordonné du spécifique au joker (DZD → Chargily) ensureProvidersFromEnv() // 2. le CHECKOUT — `dialect` = l'ORM (l'OBJET, pas son nom) ; URL ABSOLUES const { checkout } = await createPaymentCheckout({ dialect, orderId, amount, currency, successUrl: `${PUBLIC_BASE_URL}/retour`, cancelUrl: `${PUBLIC_BASE_URL}/panier`, }) // 3. le WEBHOOK — sans lui, la vente n'est close que si le client REVIENT export const POST = createWebhookHandler({ dialect, onSettled: (r) => { if (r.status === 'paid') … } }) // Webhook provider-agnostic import { handleProviderWebhook, isPaidEvent, extractOrderId } from '@mostajs/payment/server' const r = await handleProviderWebhook({ body: await req.text(), headers: req.headers, providerName: 'chargily' }) if (!r.ok) return Response.json({ error: r.error }, { status: 400 }) if (isPaidEvent(r.event)) { /* MAJ DB métier via extractOrderId(r.event) */ } // Orchestration v0.6 — checkout + persistance en un appel import { ensureProvidersFromEnv, createPaymentCheckout, settlePaymentFromWebhook } from '@mostajs/payment/server' ensureProvidersFromEnv() // dialectes depuis .env (cascade MOSTA_ENV) const { checkout } = await createPaymentCheckout({ dialect, orderId, amount: 50000, currency: 'DZD', // → Chargily successUrl, cancelUrl, webhookUrl, metadata: { campaignId }, }) // route webhook : const s = await settlePaymentFromWebhook({ dialect, providerName: 'chargily', body: await req.text(), headers: req.headers }) if (s.status === 'paid') { /* activer la campagne sponsor (métier) */ } ``` ## LE WEBHOOK — LA ROUTE **Sans webhook, une vente n'est close que si le client REVIENT** sur votre page de confirmation. Un client qui paie sur son téléphone et ferme l'onglet ne revient jamais : la banque a encaissé, la commande reste impayée, et l'écart n'apparaît qu'au rapprochement. C'est l'état dans lequel se trouve `booking-baloon`, qui n'a aucune route webhook. ### Monter la route — ne l'écrivez pas à la main ```ts // Next.js / Bun / Deno / Node ≥ 18 — `Request → Response`, aucun cadre importé export const POST = createWebhookHandler({ dialect, // l'OBJET dialecte d'ORM, pas son nom onSettled: async (r) => { if (r.status === 'paid') await libererLaCommande(r.orderId) }, }) // Fastify / Express / http natif — pas de `Request` du Web const { statut, corps } = await depuisCorpsBrut({ dialect }, req.rawBody, req.headers) ``` ### ⚠️ LE CORPS DOIT RESTER BRUT La signature est calculée sur les **octets exacts**. Un `JSON.parse` suivi d'un `JSON.stringify` réordonne les clés et invalide une signature pourtant authentique. C'est le défaut le plus fréquent des routes webhook, et il ne se voit qu'en production. Lisez `req.text()` / `rawBody`, jamais un corps déjà analysé par un middleware. ### ⚠️ LES CODES DE RETOUR SONT UNE DÉCISION, PAS UNE FORMALITÉ La banque les lit pour savoir si elle doit **réessayer**. Un 200 à tort perd le paiement en silence — il n'y aura pas de seconde chance. Un 500 à tort fait pilonner la route jusqu'à ce que la banque désactive le webhook. | situation | code | pourquoi | |---|---|---| | signature invalide / corps malformé | **400** | le réessai reproduirait le refus, et un 200 masquerait une clé mal configurée — la cause la plus fréquente | | signature valide, `orderId` inconnu | **200** | webhook authentique et bien traité ; la commande vient d'ailleurs (autre environnement, base réinitialisée) — réessayer ne la fera pas apparaître | | `onSettled` lève | **500** | là, le réessai est exactement ce qu'on veut : la banque a encaissé, notre base ne le sait pas encore | ### ⚠️ LA MÊME NOTIFICATION ARRIVE PLUSIEURS FOIS C'est le mécanisme de réessai de la banque, pas une anomalie. Le règlement de la `Payment` est idempotent (mise à jour par `orderId`) ; **votre `onSettled` ne l'est pas forcément**. Une action non idempotente enverra deux reçus, déduira deux fois le stock, comptera la vente en double chez le comptable. Gardez sur un fait DÉJÀ ÉCRIT (« un règlement existe-t-il pour cette référence ? »), pas sur l'état de la commande. ### LA BANQUE VIENT DE L'URL (2.1.0) — une seule route pour toutes La détection par en-tête échoue pour **cinq banques sur onze** (Chargily et SATIM partagent `signature` nu ; Mollie, SlickPay et Guiddini ne signent pas). Le **dernier segment du chemin** tranche donc ce que l'en-tête ne peut pas — sans qu'aucun nom de banque n'entre dans le code d'une application : ```js route('POST', /^\/paiement\/webhook(?:\/[a-z0-9-]+)?$/, gestionnaireWebhook) ``` ``` …/paiement/webhook/stripe …/paiement/webhook/chargily …/paiement/webhook/satim ``` **Résolution** : `providerName` explicite > segment d'URL connu > en-tête > **400**. `createWebhookHandler` lit le chemin depuis `req.url` ; `depuisCorpsBrut` le reçoit par `opts.chemin`. ⚠️ **Le segment choisit le VÉRIFICATEUR, il n'autorise rien** : poster sur `…/webhook/stripe` fait vérifier par Stripe, et rejeter faute de signature valable. La signature reste la seule autorité. ⚠️ **Un segment fautif ne fait pas perdre un webhook authentique** : `…/webhook/strpie` avec un `stripe-signature` valide est traité — refuser perdrait un paiement réel pour une coquille d'URL. Quand rien n'aboutit, le refus nomme la coquille et propose la correction (« vouliez-vous *stripe* ? »), mais seulement si le segment ressemble à une banque. ### ⚠️ PAYPAL SE VÉRIFIE AUTREMENT (2.3.0) Stripe, Chargily et Paystack signent en HMAC, recalculable localement. **PayPal, non** : il envoie **cinq en-têtes** (`paypal-transmission-id`, `-time`, `-sig`, `-cert-url`, `paypal-auth-algo`) et c'est **son API** qui tranche. D'où le troisième paramètre `verifyWebhook(body, signature, headers)` — facultatif, ignoré par les autres dialectes. `PAYPAL_WEBHOOK_ID` est **indispensable** : ce n'est pas un secret, c'est l'identifiant sans lequel PayPal ne sait pas quelle configuration vérifier. **Sans lui, le module LÈVE** — accepter faute de pouvoir vérifier rétablirait le trou. ⚠️ Notre référence voyage en **`custom_id`** (échoé par les événements de capture), jamais en `resource.id`, qui est l'identifiant de PayPal et ne rapproche rien. ### La banque est déduite de son en-tête — ou exigée `detecterFournisseur(headers)` rend le nom du fournisseur, ou **`null`**. Il rend `null` sur un en-tête `signature` NU : Chargily **et** SATIM l'emploient, et deviner ferait vérifier la signature avec le secret de l'autre banque — donc **rejeter un paiement authentique en accusant la banque**. Mollie, SlickPay et Guiddini ne signent pas du tout. Dans ces cas, nommez `providerName` : une route dédiée à une banque doit toujours le faire. ### Déclarer l'adresse chez la banque Stripe lit la sienne au **tableau de bord** ; Chargily l'attend **par session** (`urlWebhook` / `webhookUrl`). Tant qu'elle n'est pas déclarée, seul le retour du client ferme la vente. **Jeton `{provider}` (2.2.0)** — la banque est résolue par la DEVISE, à l'intérieur du module : l'application ne la connaît pas quand elle compose ses URL. Écrivez-la une fois, le module y met le nom réel : ```js webhookUrl: 'https://site.fr/paiement/webhook/{provider}' // → …/webhook/chargily ``` ## ⚠️ LES ÉVÉNEMENTS DE WEBHOOK — CE QUI EST TRAITÉ, ET CE QUI NE L'EST PAS | famille | événements reconnus | |---|---| | **payé** | `payment.success` · `checkout.paid` · `checkout.session.completed` · `checkout.session.async_payment_succeeded` · `payment_intent.succeeded` · `PAYMENT.CAPTURE.COMPLETED` | | **échec** | `payment.failed` · `payment.canceled` · `checkout.failed` · `checkout.session.expired` · `checkout.session.async_payment_failed` · `payment_intent.payment_failed` · `payment_intent.canceled` · `PAYMENT.CAPTURE.DENIED` | | **remboursé** | `payment.refunded` · `charge.refunded` · `refund.created` · `charge.refund.updated` · `PAYMENT.CAPTURE.REFUNDED` | | **contesté** | `charge.dispute.created` · `charge.dispute.funds_withdrawn` · `payment.disputed` | **Tout autre événement traverse sans effet** : la `Payment` reste `pending` et la route acquitte en **200**. C'est voulu — faire réessayer la banque pour un événement qu'on ignore volontairement la ferait pilonner la route. ### ⚠️ « TUNNEL TERMINÉ » N'EST PAS « ARGENT ARRIVÉ » `isPaidEvent` inspecte **`data.payment_status`**. `checkout.session.completed` signifie « le client a terminé le tunnel », **pas** « les fonds sont là » : pour un règlement différé (SEPA, virement), Stripe l'envoie avec `payment_status: 'unpaid'`, puis `async_payment_succeeded` quand l'argent arrive — parfois des jours plus tard. Se fier au seul type fait **servir avant d'être payé**. **Règle : ne contredire que sur aveu explicite.** On ne refuse que si le payload dit lui-même que le paiement n'est pas acquis. Chargily, SATIM et PayPal n'émettent pas ce champ et sont inchangés — exiger un champ qu'ils n'envoient pas rejetterait des paiements authentiques. `no_payment_required` (total nul, bon de réduction couvrant tout) compte comme acquis. ⚠️ **Le contrôle porte sur les DONNÉES, pas sur le nom** : le fournisseur Stripe renomme `checkout.session.completed` en `payment.success` avant d'arriver ici. Un contrôle par nom serait contourné par ce renommage, sans que rien ne le signale. ### `conteste` N'EST NI UN ÉCHEC NI UN REMBOURSEMENT Une contestation est évaluée **en premier** : elle survient APRÈS un paiement acquis et doit l'emporter sur toute autre lecture du même dossier. La ranger ailleurs coûte des deux côtés — sous `failed` on croirait que le client n'a jamais payé, alors qu'il a payé, consommé, puis réclamé ; sous `refunded` on croirait avoir remboursé de son plein gré, alors qu'un TIERS a repris l'argent, avec des frais et un délai pour se défendre. ## DÉPEND DE - `@mostajs/config` (dependency) — résolution env avec cascade `MOSTA_ENV` (`TEST_*`/`DEV_*`/`PROD_*`). - `@mostajs/data-plug` (peer dep `^1.2.4`) — façade ORM : `BaseRepository`, `IDialect`, `EntitySchema`. Le module n'importe jamais `@mostajs/orm` directement. - `stripe` (dependency) — SDK Stripe. - `@mostajs/paiement-tpe` (peer dep OPTIONNELLE `>=0.2.0`) — chargée paresseusement par `encaisser()` sur le canal `en-presence`. Absente, seul le canal en ligne fonctionne. - ⚠️ `next` et `react` ne sont PLUS des pairs ici : l'interface et les routes vivent dans `@mostajs/pay-panel-ui` (`/react`, `/routes`). ## PIÈGES > Le webhook a sa propre section, plus haut : route, corps brut, codes de retour, idempotence. - **L'UNITÉ DU MONTANT est le piège le plus cher du module.** `LineItem.unitAmount` ET `CheckoutParams.amount` sont en **unités de base**, pas en centimes ; la conversion `× 100` est interne à chaque dialecte. Relevé dans le parc : `octonet-cloud` écrit `plan.price / 100`, `race-event` `Number(plan.price)`, `qr-pro-paywall` la valeur brute — trois conventions pour un seul contrat. Une base qui stocke des centimes DOIT passer par `enUnitesDeBase()`, qui refuse en prime les monnaies à trois décimales (le dinar tunisien serait facturé au dixième). - Le sous-chemin `/server` contient toute la logique sensible (clés, providers) — ne jamais l'importer côté client. `PaymentSchema` est sur `.` ; `PaymentPage` a DÉMÉNAGÉ vers `@mostajs/pay-panel-ui/react`. - Sélection devise : `CURRENCY_TO_PROVIDER` mappe DZD → Chargily, le reste → Stripe ; pour CIB-only Algérie sans Chargily, passer `fallback` à `pickProviderByCurrency`. - Les factories `create*Provider` lisent l'env en fallback (cascade `@mostajs/config`) ; defaults testMode : Chargily test-on (`!== 'false'`), Satim test-off (`=== 'true'`), PayPal test-on. - `ChargilyProvider` : préférer `CHARGILY_SECRET_KEY` ; `CHARGILY_API_KEY` est un alias legacy. - **Dialectes DZ (v0.7)** : `SatimProvider` (acquéreur direct, codes BPC OrderStatus, matching par `OrderNumber`), `SlickPayProvider` (env `SLICKPAY_PUBLIC_KEY` + base/chemins `SLICKPAY_BASE_URL`/`_INVOICE_PATH`), `GuiddiniProvider` (env `GUIDDINI_APP_KEY`/`_APP_SECRET` + `GUIDDINI_BASE_URL`/`_INITIATE_PATH`/`_STATUS_PATH`). DZD partagé : par défaut → Chargily ; cibler SATIM/SlickPay/Guiddini via `providerName` (ou ordre/`setDefault`). Confirmation par retour+statut (pas de HMAC) ; chemins SlickPay/Guiddini provisoires, pilotés par env, à confirmer en certification GIE. - **Dialectes vague 2 (v0.8)** : `CoinbaseProvider` (crypto, HMAC-SHA256 `X-CC-Webhook-Signature`, env `COINBASE_COMMERCE_API_KEY`/`_WEBHOOK_SECRET`, **opt-in**), `NowPaymentsProvider` (crypto, IPN HMAC-SHA512 sur **JSON aux clés triées** `x-nowpayments-sig`, env `NOWPAYMENTS_API_KEY`/`_IPN_SECRET`, **opt-in**), `PaystackProvider` (Afrique NGN/GHS/ZAR/KES, HMAC-SHA512 `x-paystack-signature`, `reference`=orderId, env `PAYSTACK_SECRET_KEY`), `MollieProvider` (EUR/GBP iDEAL/SEPA/cartes/Apple-Google Pay, webhook **non signé → re-fetch** `getPayment`, env `MOLLIE_API_KEY`). Crypto = joker devise → **exclus du routage auto** (opt-in via `providers:[…]`/`providerName`). - `getPaymentRepo` met en cache via WeakMap par identité de dialect — un changement de dialect reconstruit le repo ; `resetPaymentRepo` est un no-op de rétro-compat. - Méthodes optionnelles de `PaymentProvider` (subscriptions, refunds…) : seul Stripe les implémente toutes ; vérifier la présence avant appel sur Satim/Chargily/Manual. ## RÉFÉRENCES README.md · docs/