Bora PesaBora Pesa

Architecture

How Bora Pesa is designed — factory pattern, provider abstraction, plugin pipeline, and event store.

Added in v0.1.0

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:

MethodPurpose
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 fire

Plugins 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:

DecisionRationale
TZS only, whole integersTZS has no subunits in digital payments. 15000 = TZS 15,000. Floats are a bug surface.
MSISDN phone format255XXXXXXXXX. Local 07XX rejected at validation. Matches every Tanzanian payment API.
Auth out of scopePesa is payment infrastructure. Use better-auth or your own auth system.
Secrets never reach the clientThe SDK is server-only — all provider API calls happen server-side. No credentials are ever exposed to the browser.
AMBIGUOUS is a first-class statusSelcom returns this when the transaction outcome is unknown. Normalizing it to PENDING or FAILED would lose information. Applications should poll.

On this page