Architecture
How Bora Pesa is designed — factory pattern, provider abstraction, plugin pipeline, and event store.
Bora Pesa is architecturally inspired by better-auth. A single factory function wires together a payment provider, plugins, and an event store into a fully configured instance.
The factory
createPesa() is the only entry point. It takes a config object and returns a PesaInstance:
createPesa({ provider, plugins?, db?, basePath? })
│
▼
┌─────────────────────────────────┐
│ PesaInstance │
│ │
│ .createOrder(payload) │
│ .getPaymentStatus(orderId) │
│ .disburse(payload) │
│ .handleWebhook(body, headers) │
│ .on(event, handler) │
│ .mountWebhook │
└─────────────────────────────────┘Provider abstraction
Every payment provider implements BasePaymentProvider — an abstract class with four required methods:
| Method | Purpose |
|---|---|
createOrder(payload) | Initiate a checkout / USSD push / redirect |
getPaymentStatus(orderId) | Poll or fetch current status |
handleWebhook(body, headers) | Parse + verify an incoming webhook |
disburse(payload) | B2C / wallet-out disbursement |
Eight optional methods cover previews, refunds, cancellations, balance queries, name lookups, credential validation, and order listing. Providers override what they support; the rest throw PesaUnsupportedError. On the returned PesaInstance, unsupported operations are undefined — use feature detection (if (pesa.refund)) rather than catching PesaUnsupportedError.
Application code never imports provider-specific logic. Swap Selcom for ClickPesa with a one-line config change. The SDK calls only BasePaymentProvider methods — no provider internals ever leak.
Plugin pipeline
Plugins intercept provider requests and webhook events at specific lifecycle points:
app calls pesa.createOrder(payload)
├─ 1. validateCreateOrderPayload() ← SDK-level input validation
├─ 2. beforeRequest hooks (once) ← idempotency, logging
├─ 3. for attempt in retryPlugin maxAttempts:
│ ├─ provider.createOrder()
│ └─ afterResponse hooks ← retry decision, logging
└─ returns result (or throws after safety cap: 100)
webhook arrives:
├─ 1. Provider verifies signature
├─ 2. SDK assigns UUID
├─ 3. Plugin onPaymentEvent hooks ← webhook verification, logging
├─ 4. Event persisted to store
└─ 5. pesa.on() handlers firePlugins are composed in order in the plugins array. Order matters — place idempotencyPlugin before retryPlugin.
Event store
Every verified webhook is normalized into a PaymentEvent and persisted. This is the source of truth for all payment activity.
The default adapter is in-memory (lost on restart). Swap to a persistent adapter for production:
import { SQLiteAdapter } from '@borapesa/sqlite';
const pesa = createPesa({
provider: new ClickPesaProvider({ ... }),
db: new SQLiteAdapter('./pesa.db'),
});For other storage backends, implement the PesaDatabaseAdapter interface — four methods (saveEvent, getEvent, getEventsByReference, getEventsByOrderId) that work with any database. See the @borapesa/sqlite source for the reference implementation.
HTTP mount
pesa.mountWebhook is a standard fetch handler — a (Request) => Promise<Response> function. Mount it on any fetch-compatible server with no adapter needed:
┌──────────────────────┐
│ │
Bun.serve() ──┤ pesa.mountWebhook ├── POST /pesa/webhook
│ (webhook only) │
│ │
└──────────────────────┘// Bun, Deno, Cloudflare Workers, Node 18+
Bun.serve({ fetch: pesa.mountWebhook });
// Elysia
new Elysia().mount(pesa.mountWebhook);
// Hono
new Hono().mount('/pesa', pesa.mountWebhook);
// Next.js App Router
export const POST = pesa.mountWebhook;The route prefix defaults to /pesa and is configurable via basePath in PesaConfig.
Design decisions
These are locked for v1.0 and should not change without an RFC:
| Decision | Rationale |
|---|---|
| TZS only, whole integers | TZS has no subunits in digital payments. 15000 = TZS 15,000. Floats are a bug surface. |
| MSISDN phone format | 255XXXXXXXXX. Local 07XX rejected at validation. Matches every Tanzanian payment API. |
| Auth out of scope | Pesa is payment infrastructure. Use better-auth or your own auth system. |
| Secrets never reach the client | The SDK is server-only — all provider API calls happen server-side. No credentials are ever exposed to the browser. |
| AMBIGUOUS is a first-class status | Selcom returns this when the transaction outcome is unknown. Normalizing it to PENDING or FAILED would lose information. Applications should poll. |