npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@xkorienta/payment-sdk

v1.0.0

Published

Generic payment SDK — multi-provider, multi-currency, hexagonal architecture

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.

Node.js TypeScript License: MIT


✨ 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 zod

Prérequis : Node.js ≥ 18 (utilise fetch et crypto natifs).


🚀 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érification findDuplicateTransaction en 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 exponentiel

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