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?, webhooks? })




┌─────────────────────────────────┐
│          PesaInstance           │
│                                 │
│  .createOrder(payload)          │
│  .getPaymentStatus(orderId)     │
│  .disburse(payload)             │
│  .handleWebhook(body, headers)  │
│  .on(event, handler)            │
│  .mount                         │
└─────────────────────────────────┘

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

Seven optional methods cover previews, refunds, cancellations, name lookups, credential validation, and order listing. Providers override what they support; the rest throw 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 0..3:
  │   ├─ provider.createOrder()
  │   └─ afterResponse hooks             ← retry decision, logging
  └─ returns result

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 SQLite (better-sqlite3) — zero configuration, works in any Node.js environment. Swap for Turso ("libSQL"), PostgreSQL, Prisma, or Drizzle via the db config field.

const pesa = createPesa({
  provider: new ClickPesaProvider({ ... }),
  db: new LibSQLAdapter({ url: process.env.TURSO_DATABASE_URL! }),
});

HTTP mount

Every pesa instance exposes a generic (Request) => Response handler. Framework adapters are thin wrappers around this handler — they add idiomatic route mounting but contain no business logic.

              ┌──────────────┐
Bun.serve() ──┤              │
Next.js    ──┤   pesa.mount  ├── POST /order
Express    ──┤ (fetch-like)  ├── GET  /status/:orderId
Elysia     ──┤              ├── POST /webhook
              └──────────────┘

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 @borapesa/client package contains zero provider credentials. All provider API calls happen server-side.
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