@xkorienta/payment-sdk
v1.0.0
Published
Generic payment SDK — multi-provider, multi-currency, hexagonal architecture
Maintainers
Readme
@xkorienta/payment-sdk 🚀
Un SDK de paiement générique, modulaire et sécurisé conçu pour être intégré dans n'importe quel projet Node.js (Next.js, Express, NestJS…).
Basé sur une Architecture Hexagonale (Ports & Adapters), il abstrait toute la complexité des paiements (providers, devises, webhooks, résilience) et vous laisse le contrôle total sur la persistence et les notifications.
✨ Fonctionnalités
| Fonctionnalité | Description |
|---|---|
| 💳 Multi-providers | NotchPay natif, Stripe et providers custom via register() |
| 🌍 Multi-devises | Conversion automatique (live API → cache → fallback statique) |
| 🛡️ Sécurité | HMAC anti-timing attacks (timingSafeEqual), validation Zod runtime |
| 🔑 Idempotence | V-Shape pattern (Stripe-style) — safe retries, jamais de double débit |
| 🔄 Résilience | Circuit Breaker + Retry avec backoff exponentiel |
| 📢 Event-Driven | PaymentEventBus pour réagir aux paiements (Observer pattern) |
| ⚡ Performance | Zéro dépendance runtime, fetch natif, hooks asynchrones non-bloquants |
| 🏗️ Hexagonal | Découplé de toute BDD, ORM ou service tiers |
📦 Installation
npm install @xkorienta/payment-sdk zodPrérequis : Node.js ≥ 18 (utilise
fetchetcryptonatifs).
🚀 Démarrage rapide
Étape 1 — Implémenter les adaptateurs (ports)
Le SDK ne touche jamais votre base de données directement. Vous implementez ses interfaces selon votre stack.
MongoStorageAdapter.ts
import type { IStorageAdapter, TransactionData, TransactionStatus, IdempotencyRecord } from '@xkorienta/payment-sdk'
export class MongoStorageAdapter implements IStorageAdapter {
// ── Transactions ──
async createTransaction(data: Omit<TransactionData, 'id' | 'createdAt' | 'updatedAt'>): Promise<TransactionData> {
const tx = await TransactionModel.create(data)
return tx.toObject()
}
async findTransactionByReference(reference: string): Promise<TransactionData | null> {
return TransactionModel.findOne({ paymentReference: reference }).lean()
}
async findTransactionsByUser(userId: string, page: number, limit: number) {
const skip = (page - 1) * limit
const [transactions, total] = await Promise.all([
TransactionModel.find({ userId }).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
TransactionModel.countDocuments({ userId }),
])
return { transactions, total, page, totalPages: Math.ceil(total / limit) }
}
// Retourne le tx mis à jour (évite une double requête — fix performance P2)
async updateTransactionStatus(reference: string, status: TransactionStatus, extra?: any): Promise<TransactionData> {
return TransactionModel.findOneAndUpdate(
{ paymentReference: reference },
{ $set: { status, ...extra }, $push: { statusHistory: { status, at: new Date() } } },
{ new: true }
).lean()
}
async findDuplicateTransaction(userId: string, productId: string, type: string): Promise<TransactionData | null> {
return TransactionModel.findOne({ userId, productId, type, status: 'COMPLETED' }).lean()
}
async expireStaleTransactions(ttlMinutes: number): Promise<number> {
const cutoff = new Date(Date.now() - ttlMinutes * 60 * 1000)
const result = await TransactionModel.updateMany(
{ status: 'PENDING', createdAt: { $lt: cutoff } },
{ $set: { status: 'EXPIRED' } }
)
return result.modifiedCount
}
// ── Idempotency (optionnel mais recommandé) ──
// IMPORTANT : L'upsert doit être ATOMIQUE pour garantir la sécurité concurrente.
async acquireIdempotencyLock(record: Omit<IdempotencyRecord, 'responsePayload' | 'errorPayload'>) {
const existing = await IdempotencyModel.findOne({ key: record.key })
if (existing) {
return { created: false, record: existing.toObject() }
}
// $setOnInsert garantit que si deux requêtes arrivent simultanément,
// une seule crée le document (la BDD rejette l'autre via l'index unique sur `key`).
const created = await IdempotencyModel.findOneAndUpdate(
{ key: record.key },
{ $setOnInsert: record },
{ upsert: true, new: true }
)
return { created: true, record: created.toObject() }
}
async completeIdempotencyRecord(key: string, responsePayload: Record<string, unknown>): Promise<void> {
await IdempotencyModel.updateOne({ key }, { $set: { status: 'COMPLETED', responsePayload } })
}
async failIdempotencyRecord(key: string, error: { code: string; message: string }): Promise<void> {
await IdempotencyModel.updateOne({ key }, { $set: { status: 'FAILED', errorPayload: error } })
}
// ── Cache des taux de change (optionnel) ──
async getCachedRate(from: string, to: string): Promise<{ rate: number } | null> {
return ExchangeRateModel.findOne({ from, to, expiresAt: { $gt: new Date() } }).lean()
}
async setCachedRate(from: string, to: string, rate: number, expiresAt: Date): Promise<void> {
await ExchangeRateModel.findOneAndUpdate({ from, to }, { rate, expiresAt }, { upsert: true })
}
}Index MongoDB requis pour la sécurité de l'idempotence :
idempotencySchema.index({ key: 1 }, { unique: true }) // Contrainte d'unicité idempotencySchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }) // TTL auto-cleanup
Étape 2 — Initialiser le SDK
// lib/payment.ts
import { PaymentSDK } from '@xkorienta/payment-sdk'
import { MongoStorageAdapter } from './adapters/MongoStorageAdapter'
import { PusherNotificationAdapter } from './adapters/PusherNotificationAdapter'
import { NodemailerAdapter } from './adapters/NodemailerAdapter'
export const paymentSDK = new PaymentSDK({
defaultProvider: 'notchpay',
defaultCurrency: 'XAF',
supportedCurrencies: ['XAF', 'EUR', 'USD'],
commissionRate: 5, // 5% de commission plateforme
transactionTTLMinutes: 30,
exchangeRateApiKey: process.env.EXCHANGE_RATE_API_KEY,
providers: {
notchpay: {
publicKey: process.env.NOTCHPAY_PUBLIC_KEY!,
secretKey: process.env.NOTCHPAY_SECRET_KEY!,
webhookHash: process.env.NOTCHPAY_HASH!,
},
},
storage: new MongoStorageAdapter(),
notification: new PusherNotificationAdapter(),
email: new NodemailerAdapter(),
})
// ── Abonnements aux événements (un handler par responsabilité) ──
paymentSDK.events.on('payment.completed', async (e) => {
if (e.type === 'BOOK_PURCHASE') await bookRepository.grantAccess(e.userId, e.productId)
})
paymentSDK.events.on('payment.completed', async (e) => {
if (e.sellerId) await walletService.credit(e.sellerId, e.sellerAmount, e.currency)
})
paymentSDK.events.on('payment.completed', async (e) => {
if (e.type === 'SUBSCRIPTION') await subscriptionService.activate(e.reference)
})
paymentSDK.events.on('payment.completed', async (e) => {
await invoiceService.generate(e)
})🛠️ Utilisation
Initier un paiement
import { randomUUID } from 'crypto'
// ⚠️ Générer la clé UNE FOIS côté client, avant l'appel réseau.
// La stocker (localStorage) pour pouvoir la réutiliser si le réseau coupe.
const idempotencyKey = randomUUID()
const result = await paymentSDK.payments.initiatePayment({
userId: 'user_123',
userEmail: '[email protected]',
amount: 5000,
originalCurrency: 'XAF',
paymentCurrency: 'EUR', // Conversion automatique XAF → EUR
type: 'BOOK_PURCHASE', // Libre — le SDK ne l'interprète pas
productId: 'book_999',
productType: 'Book',
description: 'Achat du livre "Architecture Hexagonale"',
callbackUrl: 'https://mon-site.com/merci',
discountPercent: 10, // Optionnel : 10% de réduction
sellerId: 'author_456', // Optionnel : paiement avec commission
idempotencyKey, // ✅ Protège contre les doubles débits
})
// Rediriger l'utilisateur vers la page de paiement NotchPay
redirect(result.paymentUrl)Gérer les webhooks
// app/api/webhooks/notchpay/route.ts (Next.js App Router)
import { paymentSDK } from '@/lib/payment'
export async function POST(req: Request) {
const signature = req.headers.get('x-notchpay-signature') ?? ''
const payload = await req.json()
// handleWebhook est idempotent : si l'événement arrive deux fois, le second est ignoré.
// Les hooks (EventBus) s'exécutent en arrière-plan — réponse 200 immédiate.
await paymentSDK.payments.handleWebhook('notchpay', payload, signature)
return Response.json({ received: true })
}Vérifier le statut d'un paiement
// Utile pour la page de confirmation ou un polling de statut
const status = await paymentSDK.payments.verifyPayment(reference)
// ou
const tx = await paymentSDK.payments.getTransactionStatus(reference)Historique des transactions d'un utilisateur
const history = await paymentSDK.payments.getUserTransactions(userId, page, limit)
// { transactions, total, page, totalPages }Conversion de devises
const result = await paymentSDK.currency.convert(5000, 'XAF', 'EUR')
// { convertedAmount: 7.62, exchangeRate: 0.001524, source: 'live' }
// Forcer le rafraîchissement du cache
await paymentSDK.currency.refreshRates('XAF')🔑 Idempotence — Garantie anti-double débit
L'idempotence répond à ce scénario fréquent :
L'utilisateur clique deux fois sur "Payer", ou le réseau coupe après l'appel et le client réessaie.
Comment ça fonctionne (V-Shape Pattern — Stripe-style)
Client SDK NotchPay
│ │ │
│─── initiatePayment ─────►│ │
│ (idempotencyKey: X) │─ Verifier clé X en BDD ──► │
│ │ Clé absente → INSERT X │
│ │ (status: PROCESSING) │
│ │─── call provider ──────────►│
│ │◄── paymentUrl ─────────────│
│ │ UPDATE X → COMPLETED │
│◄─── paymentUrl ──────────│ (stocke le résultat) │
│ │ │
│─── initiatePayment ─────►│ │
│ (même clé X) │ Clé X → COMPLETED │
│ │ REPLAY résultat stocké ───►│ ✅ Provider jamais appelé
│◄─── même paymentUrl ─────│ │Comportements selon le statut de la clé
| Statut | Comportement |
|---|---|
| Clé absente | Crée le verrou, exécute le paiement, stocke le résultat |
| PROCESSING | Lance IdempotencyConflictError — une requête est déjà en cours |
| COMPLETED | Rejoue le résultat stocké sans appeler le provider |
| FAILED | Réacquiert le verrou et réessaie (la clé peut être réutilisée) |
| Fingerprint différent | Lance IdempotencyFingerprintMismatchError — générer une nouvelle clé |
Gestion des erreurs d'idempotence
import { IdempotencyConflictError, IdempotencyFingerprintMismatchError } from '@xkorienta/payment-sdk'
try {
const result = await paymentSDK.payments.initiatePayment({ ...params, idempotencyKey })
} catch (err) {
if (err instanceof IdempotencyConflictError) {
// Une requête est déjà en cours — attendre et réessayer dans 1-2s
return { status: 202, message: 'Paiement en cours, veuillez patienter...' }
}
if (err instanceof IdempotencyFingerprintMismatchError) {
// Bug client : même clé, paramètres différents — générer une nouvelle clé
return { status: 422, message: 'Clé invalide — générez une nouvelle clé pour ce paiement' }
}
throw err
}Sans
idempotencyKey: Le SDK reste sûr via la vérificationfindDuplicateTransactionen BDD (empêche les doublons sur les produits déjà achetés), mais sans protection contre les double-clics rapides.
🏗️ Architecture
@xkorienta/payment-sdk/
├── src/
│ ├── PaymentSDK.ts # Façade — point d'entrée unique
│ ├── types/ # Interfaces TypeScript pures
│ │ ├── transaction.ts # TransactionData, InitiatePaymentInput
│ │ ├── provider.ts # IPaymentProvider (contrat Strategy)
│ │ ├── events.ts # PaymentCompletedEvent (sanitisé)
│ │ ├── idempotency.ts # IdempotencyRecord, IdempotencyStatus
│ │ └── config.ts # PaymentSDKConfig
│ ├── adapters/ # Ports (interfaces à implémenter)
│ │ ├── IStorageAdapter.ts # Persistence (Mongo, Postgres, Redis…)
│ │ ├── INotificationAdapter.ts# Temps réel (Pusher, Socket.io…)
│ │ ├── IEmailAdapter.ts # Emails (Nodemailer, SendGrid…)
│ │ └── ILoggerAdapter.ts # Logging (Winston, Pino, console)
│ ├── core/ # Logique métier pure
│ │ ├── PaymentEngine.ts # Orchestrateur principal
│ │ ├── CurrencyEngine.ts # Conversion multi-devises
│ │ ├── PaymentEventBus.ts # Observer pattern (multi-listeners)
│ │ ├── CircuitBreaker.ts # Résilience provider
│ │ ├── CommissionCalculator.ts# Calcul commissions
│ │ └── ReferenceGenerator.ts # Génération références uniques
│ ├── providers/ # Implémentations Strategy
│ │ ├── NotchPayProvider.ts # NotchPay (sécurisé)
│ │ └── ProviderFactory.ts # Registre dynamique de providers
│ └── utils/
│ ├── errors.ts # Erreurs typées
│ └── retry.ts # Retry + backoff exponentielFlux de données
Projet consommateur
│
▼
PaymentSDK (façade)
│
┌───┴───────────────┐
│ │
PaymentEngine CurrencyEngine
│
├── IStorageAdapter ← [Votre MongoAdapter / PostgresAdapter]
├── IPaymentProvider ← [NotchPayProvider / StripeProvider]
├── PaymentEventBus ← [Vos handlers métier]
├── CircuitBreaker ← [Protection provider down]
└── withRetry ← [Résilience réseau]🛡️ Sécurité
| Vecteur | Protection |
|---|---|
| Timing attack sur HMAC | crypto.timingSafeEqual() — comparaison en temps constant |
| Payload webhook malformé | Validation Zod à l'exécution (pas de cast as) |
| Double débit (clic rapide) | Idempotency key + contrainte unique BDD |
| Double débit (webhook dupliqué) | Vérification idempotente du statut COMPLETED |
| Provider en panne | Circuit Breaker — fail-fast après 5 échecs |
| Erreur réseau transitoire | Retry avec backoff exponentiel + jitter |
| Secrets exposés | Clés API injectées via constructeur, jamais via process.env dans le SDK |
🔌 Ajouter un provider custom
import type { IPaymentProvider } from '@xkorienta/payment-sdk'
class MyCustomProvider implements IPaymentProvider {
readonly providerName = 'my-provider'
async initiatePayment(params) { /* ... */ }
async verifyPayment(reference) { /* ... */ }
async handleWebhook(payload, signature) { /* ... */ }
async transfer(params) { /* ... */ }
}
// Enregistrement
paymentSDK.providers.register('my-provider', new MyCustomProvider())📋 Variables d'environnement
# NotchPay
NOTCHPAY_PUBLIC_KEY=pk_...
NOTCHPAY_SECRET_KEY=sk_...
NOTCHPAY_HASH=your_webhook_hash
# Taux de change (optionnel — utilise fallback statique si absent)
EXCHANGE_RATE_API_KEY=your_key # https://www.exchangerate-api.com📊 Codes d'erreur
| Code | Classe | Description |
|---|---|---|
| ALREADY_PURCHASED | DuplicateTransactionError | L'utilisateur a déjà acheté ce produit |
| TRANSACTION_NOT_FOUND | TransactionNotFoundError | Référence inconnue |
| INVALID_SIGNATURE | InvalidSignatureError | Signature webhook invalide |
| PROVIDER_UNAVAILABLE | ProviderUnavailableError | Circuit Breaker ouvert |
| CURRENCY_NOT_SUPPORTED | CurrencyNotSupportedError | Devise non supportée |
| IDEMPOTENCY_CONFLICT | IdempotencyConflictError | Requête déjà en cours — réessayer dans 1-2s |
| IDEMPOTENCY_FINGERPRINT_MISMATCH | IdempotencyFingerprintMismatchError | Même clé, paramètres différents — nouvelle clé requise |
📝 Licence
MIT — Développé par l'équipe Xkorienta.
