From 137045b1a6537170e003c631417d66a9651af9fa Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 17 Jul 2026 16:42:18 +0200 Subject: [PATCH] Initial release: @qbirr/sdk v1.0.0 --- README.md | 78 +++++++++++++++++ package.json | 26 ++++++ src/index.ts | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 15 ++++ 4 files changed, 345 insertions(+) create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..8878ab7 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# @qbirr/sdk + +Verify Ethiopian bank transfer receipts in real time — CBE, Telebirr, Awash, Dashen, M-Pesa, Bank of Abyssinia, and eBirr. + +## Install + +```bash +npm install @qbirr/sdk +``` + +Requires Node.js 18+ (uses built-in `fetch`). Zero dependencies. + +## Quick start + +```js +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 FULL NAME', + receiverAccount: '1000017692643', +}); + +if (r.verified) { + console.log(`✓ Paid by ${r.payer}, amount: ${r.amount}`); +} else { + console.log(`✗ ${r.error}`); +} +``` + +## API + +### `new Qbirr(options)` + +| Option | Required | Default | Description | +|---|---|---|---| +| `apiKey` | ✓ | — | Your key from the qbirr dashboard | +| `baseUrl` | | `https://verify.qbirr.com` | Override (e.g. for self-hosted) | +| `timeoutMs` | | `30000` | Per-request timeout | + +### `qb.verify(req)` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `provider` | string | ✓ | `cbe` · `telebirr` · `abyssinia` · `dashen` · `mpesa` · `awash` · `ebirr` | +| `ref` | string | ✓ | Transaction reference. For **Awash**, paste the SMS URL or its token. For **eBirr**, paste the URL or `"tenant/token"`. | +| `amount` | number | ✓ | Expected payment amount in ETB | +| `receiverName` | string | ✓ | Exact name on your receiving account | +| `receiverAccount` | string | for CBE & Abyssinia | Your account number | + +Returns `{ verified, payer, amount, error }`. + +### `qb.usage()` + +Returns the current month's `{ used, limit, remaining, plan, period_start, period_end }`. + +## Errors + +`QbirrError` is thrown for network failures, HTTP 4xx/5xx, or non-JSON responses. **Verification failures** (wrong amount, wrong receiver, expired receipt) are NOT thrown — they're returned as `{ verified: false, error: '...' }`. + +```js +import { Qbirr, QbirrError } from '@qbirr/sdk'; +try { + const r = await qb.verify({ ... }); +} catch (e) { + if (e instanceof QbirrError) { + console.error(`API error ${e.status}: ${e.message}`); + } +} +``` + +## License + +MIT diff --git a/package.json b/package.json new file mode 100644 index 0000000..38da323 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "@qbirr/sdk", + "version": "1.0.0", + "description": "qbirr.com — Ethiopian bank verification SDK for Node.js (CBE, Telebirr, Awash, Dashen, M-Pesa, Abyssinia, eBirr)", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist", "README.md"], + "engines": { "node": ">=18" }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.6.3", + "@types/node": "^22.7.5" + }, + "keywords": ["qbirr", "ethiopia", "cbe", "telebirr", "awash", "dashen", "mpesa", "abyssinia", "ebirr", "payment", "verification"] +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..deb8215 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,226 @@ +/** + * @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], { 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; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0e93c7e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +}