@maxnate/payments-core
v1.0.0
Published
Framework-agnostic payment provider abstraction. Define adapters, register providers, handle webhooks — zero runtime dependencies.
Maintainers
Readme
@maxnate/payments-core
Zero-dependency TypeScript library for building payment provider plugins. Define adapters, register providers, handle webhooks — framework-agnostic, works in Node.js, Edge, Deno, and Bun.
npm install @maxnate/payments-coreWhy payments-core?
| Concern | payments-core handles | You handle |
|---|---|---|
| Provider interface | PaymentProviderAdapter with lifecycle hooks | Implementing per provider |
| Registry | createPaymentProviderRegistry() — register, configure, route | Creating and wiring the registry |
| Webhook orchestration | handlePaymentWebhook() — verify, dedup, update | Providing minimal DB callbacks |
| Crypto | HMAC-SHA256, timing-safe compare — Web Crypto API | Nothing |
| Idempotency | In-memory key dedup with configurable TTL | Nothing |
| Validation | Amount, currency, min/max config checks | Nothing |
Available Providers
| Package | Provider | Currencies | Countries | Payment Methods |
|---|---|---|---|---|
| @maxnate/provider-snippe | Snippe.sh | TZS | TZ | Mobile Money, Card, Dynamic QR |
| @maxnate/provider-clickpesa | ClickPesa | TZS, USD | TZ | Mobile Money, Card, BillPay, Checkout Link |
| @maxnate/provider-selcom | Selcom | TZS | TZ | Mobile Money, Card, Utility Payments, Wallet Pull, Selcom Pesa |
Quick Start
import { createPaymentProviderRegistry } from '@maxnate/payments-core'
import { SnippeProvider } from '@maxnate/provider-snippe'
const registry = createPaymentProviderRegistry()
registry.register(new SnippeProvider())
await registry.setConfig({
id: 'snippe', name: 'Snippe', type: 'mobile_money', enabled: true,
credentials: { apiKey: process.env.SNIPPE_API_KEY! },
currencies: ['TZS'], countries: ['TZ']
})
const result = await registry.createPaymentIntent('snippe', {
orderId: 'ORD-001', amount: 5000, currency: 'TZS',
customerEmail: '[email protected]'
})Architecture
@maxnate/payments-core ← This package (interface + registry + webhooks)
@maxnate/provider-snippe ← Adapter implementing PaymentProviderAdapter
@maxnate/provider-clickpesa ← Adapter
@maxnate/provider-selcom ← Adapter
@maxnate/provider-* ← Community adaptersResponsibility Boundary
payments-core is the generic engine and contract layer. It does not own your storefront UI, admin UI, or provider dashboard.
Shared Terminology
- Customer UI: the checkout or payment experience inside your own application.
- Admin UI: the tenant/operator-facing configuration and operations screens.
- Provider-hosted checkout: a payment page hosted by the provider, not by your app.
- Provider dashboard: the provider's own back office (for example Snippe dashboard).
What payments-core Is Responsible For
- defining the provider contract (
PaymentProviderAdapter) - registry-based provider discovery and dispatch
- tenant-aware configuration loading and routing
- webhook verification/orchestration primitives
- normalized payment, review, and capability types
- cross-provider helper concepts like manual-review flows and provider capability discovery
What payments-core Is Not Responsible For
- rendering customer checkout pages
- rendering admin gateway setup pages
- storing your database records by itself
- replacing provider dashboards like Snippe dashboard
- building payout/disbursement flows in this package
How The Layers Fit Together
payments-coredefines the contracts and orchestration primitives.- Provider packages like
@maxnate/provider-snippeimplement real provider-specific APIs. - Admin/backend packages like
@maxnate/plugin-paymentsexpose tenant configuration, review queues, and operational workflows. - Your project or industry plugin renders the customer-facing checkout UI and calls the payment module / registry.
UI Boundary
- Customer UI: build in your own app, website, or industry plugin.
- Admin UI: build on top of capability discovery and plugin/backend helpers.
- Provider-hosted UI: some adapters may hand off to a provider-hosted page (for example Snippe Sessions), but that UI still belongs to the provider.
Recommended Mental Model
- use
payments-corewhen you need a reusable payment backend contract - use a provider package when you need a real integration
- use a plugin/admin package when you need tenant configuration and operations
- use your own application UI for the business-specific checkout experience
Key Concepts
PaymentProviderAdapter
interface PaymentProviderAdapter {
providerId: string
providerName: string
initialize(config: PaymentProviderConfig): void | Promise<void>
createPaymentIntent(input: CreatePaymentIntentInput): Promise<PaymentResult>
getPaymentIntent(intentId: string): Promise<PaymentIntent>
cancelPayment(intentId: string): Promise<PaymentResult>
refundPayment(intentId: string, amount?: number, reason?: string): Promise<RefundResult>
verifyWebhook?(payload, signature, headers?): Promise<boolean>
handleWebhook?(payload, signature?, headers?): Promise<WebhookEvent>
submitProof?(input): Promise<PaymentResult>
listPendingReviews?(tenantId, opts?): Promise<PaymentIntent[]>
reviewPayment?(input): Promise<PaymentResult>
getInstructionSets?(tenantId): Promise<PaymentInstructionSet[]>
supportsFeature?(feature: keyof PaymentProviderFeature): boolean
}Manual Payments
payments-core now ships a ManualPaymentProvider reference adapter for bank transfer, cash, and phone/till workflows where the customer pays offline and uploads proof for admin review.
import { ManualPaymentProvider } from '@maxnate/payments-core'
const provider = new ManualPaymentProvider({
providerId: 'manual-bank-tz',
providerName: 'Bank Transfer (TZ)',
currencies: ['TZS'],
getInstructionSets: async tenantId => [
{
id: 'crdb-tzs',
providerId: 'manual-bank-tz',
tenantId,
label: 'CRDB Bank - TZS',
kind: 'bank',
currencies: ['TZS'],
enabled: true,
fields: [
{ key: 'account_name', label: 'Account Name', value: 'Maxnate Ltd' },
{ key: 'account_number', label: 'Account Number', value: '0123456789', copyable: true }
]
}
]
})Manual intents use review-oriented statuses like awaiting_proof and awaiting_review, and webhook dispatch now rejects external transitions from those states straight to succeeded.
Registry
registry.register(provider) // Add a provider
registry.unregister(providerId) // Remove a provider
registry.get(providerId) // Get by ID
registry.discoverProviders() // Capability catalog for admin UIs
registry.getEnabled() // All enabled providers
registry.getByCurrency('TZS') // Filter by currency
registry.getByType('mobile_money') // Filter by type
registry.setConfig(config) // Configure a provider (async — awaits provider.initialize)
registry.createPaymentIntent(...) // Validate + route to providerProvider Discovery And Self-Test
Providers can expose a structured ProviderCapabilities descriptor plus an optional side-effect-free selfTest(config) method. This gives admin UIs a generic way to render forms, list available providers, and validate credentials without creating a real payment.
Reconciliation
Providers may expose fetchProviderLedger(...) so hosts can compare the provider's authoritative ledger against local PaymentIntent records. This powers reconciliation reports, settlement recording, and admin dashboard drift alerts.
Webhook Handler
const result = await handlePaymentWebhook(
rawBody, // Raw body as string
signature, // Signature header value
providerId, // e.g. 'snippe'
registry, // Provider registry
{
findPaymentIntent: (id) => db.payments.findById(id),
updatePaymentIntent: (id, data) => db.payments.update(id, data),
idempotencyStore, // Replica-safe; system-core's IdempotencyStore satisfies the duck-typed shape
onPaymentStatusChange: (intentId, status) => emit('payment.status', { intentId, status })
},
headers // Optional — forwarded to provider verifyWebhook
)
// Returns: { success, event?, error? }API
Core Functions
| Function | Returns | Description |
|---|---|---|
| createPaymentProviderRegistry() | PaymentProviderRegistry | Create a new registry instance |
| handlePaymentWebhook(payload, sig, id, registry, deps, headers?) | WebhookResult | Process incoming webhook |
Crypto Utilities
import { hmacSha256Hex, timingSafeEqualHex, verifyWebhookSignature, toBase64 } from '@maxnate/payments-core'All functions use the Web Crypto API — no Node.js dependencies.
Creating a Provider
import { PaymentProviderAdapter, PaymentProviderConfig, PaymentResult } from '@maxnate/payments-core'
export class MyProvider implements PaymentProviderAdapter {
providerId = 'my-provider'
providerName = 'My Provider'
// ... implement all methods
}See src/provider-template.ts for a base class with defaults.
Documentation
| Guide | File |
|---|---|
| Architecture | docs/ARCHITECTURE.md |
| E-Commerce Integration | docs/ECOMMERCE-INTEGRATION.md |
| Membership / Subscriptions | docs/MEMBERSHIP-INTEGRATION.md |
| Donations | docs/DONATION-INTEGRATION.md |
| Booking / Deposit | docs/BOOKING-DEPOSIT-INTEGRATION.md |
| Unified Payment Pattern | docs/UNIFIED-PATTERN.md |
Requirements
- Node.js >= 18 (or any runtime with Web Crypto API)
- TypeScript 5.x recommended
- Zero runtime dependencies
License
MIT
