/** * @qbirr/sdk — Node.js client for the qbirr payment verification API. * * import { Qbirr } from '@qbirr/sdk'; * const qb = new Qbirr({ apiKey: process.env.QBIRR_API_KEY! }); * const r = await qb.verify({ * provider: 'cbe', * ref: 'FT23001234ABC', * amount: 500, * receiverName: 'YOUR NAME', * receiverAccount: '1000017692643', * }); * console.log(r.verified, r.payer); * * Works on Node 18+ (uses the built-in fetch). No dependencies. */ export type Provider = | 'cbe' // Commercial Bank of Ethiopia (needs receiverAccount for legacy FT refs) | 'telebirr' // Telebirr | 'abyssinia' // Bank of Abyssinia (needs receiverAccount) | 'dashen' // Dashen Bank | 'mpesa' // Safaricom M-Pesa Ethiopia | 'awash' // Awash Bank (ref = SMS URL or token) | 'ebirr' // eBirr (COOPay/KAAFI/Nib/Wegagen/Ahadu — ref = "tenant/token" or full URL) | 'zamzam'; // ZamZam Bank (ref = SMS URL or bare id) export interface VerifyRequest { provider: Provider; /** Transaction reference (or, for Awash/eBirr, the SMS URL / URL token). */ ref: string; /** Expected payment amount in ETB. */ amount: number; /** Exact receiver name (case-insensitive, whitespace-normalised match). */ receiverName: string; /** Required for CBE and Bank of Abyssinia. Ignored for other providers. */ receiverAccount?: string; } export interface VerifyResult { /** True if the receipt is real, the amount matches, and the receiver matches. */ verified: boolean; /** Payer name as extracted from the receipt. */ payer: string; /** Amount that was actually paid (per the receipt). */ amount: number; /** Human-readable failure reason, empty on success. */ error: string; } export interface UsageStatus { used: number; limit: number; remaining: number; plan: string; /** ISO date — first instant of the current monthly window. */ period_start: string; /** ISO date — first instant of the next monthly window. */ period_end: string; } export interface QbirrOptions { /** Your API key from the qbirr dashboard. */ apiKey: string; /** Override base URL. Defaults to https://verify.qbirr.com */ baseUrl?: string; /** Per-request timeout (ms). Defaults to 30 000. */ timeoutMs?: number; /** Optional fetch override (for tests). */ fetch?: typeof fetch; } /** Thrown for network failures, HTTP errors, or non-JSON responses. */ export class QbirrError extends Error { constructor( message: string, public readonly status?: number, public readonly body?: unknown, ) { super(message); this.name = 'QbirrError'; } } const DEFAULT_BASE_URL = 'https://verify.qbirr.com'; export class Qbirr { private readonly apiKey: string; private readonly baseUrl: string; private readonly timeoutMs: number; private readonly fetchFn: typeof fetch; constructor(opts: QbirrOptions) { if (!opts.apiKey) throw new QbirrError('apiKey is required'); this.apiKey = opts.apiKey; this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); this.timeoutMs = opts.timeoutMs ?? 30_000; this.fetchFn = opts.fetch ?? globalThis.fetch; if (!this.fetchFn) throw new QbirrError('fetch is not available — pass options.fetch on Node < 18'); } /** * Verify a single transaction. Returns `{ verified: false, error: '...' }` * on a real verification failure (wrong amount, wrong receiver, expired * receipt). Throws QbirrError for network/auth problems. */ async verify(req: VerifyRequest): Promise { const body: Record = { provider: req.provider, ref: req.ref, amount: req.amount, receiver_name: req.receiverName, }; if (req.receiverAccount) body.receiver_account = req.receiverAccount; const data = await this.request('POST', '/api/v1/verify', body); return data; } /** * Verify by uploading the receipt itself — PDF or an image (PNG/JPEG). * Images with a QR code (most bank receipts have one) are decoded and * routed through the URL-based flow automatically. * * const buf = await fs.readFile('receipt.pdf'); * await qb.verifyUpload({ * provider: 'cbe', * amount: 999, * receiverName: 'YOUR NAME', * receiverAccount: '1000017692643', * file: buf, * filename: 'receipt.pdf', * contentType: 'application/pdf', * }); */ async verifyUpload(opts: { provider: Provider; amount: number; receiverName: string; receiverAccount?: string; file: Buffer | Uint8Array | Blob; filename?: string; contentType?: string; }): Promise { const form = new FormData(); form.append('provider', opts.provider); form.append('amount', String(opts.amount)); form.append('receiver_name', opts.receiverName); if (opts.receiverAccount) form.append('receiver_account', opts.receiverAccount); const blob = opts.file instanceof Blob ? opts.file : new Blob([opts.file as unknown as BlobPart], { type: opts.contentType || 'application/octet-stream' }); form.append('file', blob, opts.filename || 'upload.pdf'); return this.requestForm('POST', '/api/v1/verify/upload', form); } /** Returns the current month's quota usage for this API key. */ async usage(): Promise { return this.request('GET', '/api/v1/usage'); } private async requestForm(method: 'POST', path: string, form: FormData): Promise { const url = `${this.baseUrl}${path}`; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), this.timeoutMs); try { const resp = await this.fetchFn(url, { method, headers: { 'X-API-Key': this.apiKey, 'User-Agent': '@qbirr/sdk-node/1.0.0' }, body: form, signal: ctrl.signal, }); const text = await resp.text(); let parsed: any; try { parsed = text ? JSON.parse(text) : {}; } catch { throw new QbirrError(`Non-JSON response from qbirr (HTTP ${resp.status})`, resp.status, text); } if (!resp.ok) throw new QbirrError((parsed as any)?.error || `HTTP ${resp.status}`, resp.status, parsed); return parsed as T; } catch (e: any) { if (e instanceof QbirrError) throw e; if (e?.name === 'AbortError') throw new QbirrError(`Upload timed out after ${this.timeoutMs}ms`); throw new QbirrError(`Network error: ${e.message}`); } finally { clearTimeout(timer); } } private async request(method: 'GET' | 'POST', path: string, body?: unknown): Promise { const url = `${this.baseUrl}${path}`; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), this.timeoutMs); try { const resp = await this.fetchFn(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': '@qbirr/sdk-node/1.0.0', }, body: body ? JSON.stringify(body) : undefined, signal: ctrl.signal, }); let parsed: unknown; const text = await resp.text(); try { parsed = text ? JSON.parse(text) : {}; } catch { throw new QbirrError(`Non-JSON response from qbirr (HTTP ${resp.status})`, resp.status, text); } if (!resp.ok) { const msg = (parsed as any)?.error || `HTTP ${resp.status}`; throw new QbirrError(msg, resp.status, parsed); } return parsed as T; } catch (e: any) { if (e instanceof QbirrError) throw e; if (e?.name === 'AbortError') throw new QbirrError(`Request timed out after ${this.timeoutMs}ms`); throw new QbirrError(`Network error: ${e.message}`); } finally { clearTimeout(timer); } } } export default Qbirr;