diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index 0fa5652d..122696fa 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -138,7 +138,7 @@ export const CATEGORIES: readonly AdapterCategory[] = [ id: 'payments', pkgPrefix: '@profullstack/sh1pt-payment', description: 'Payment providers — CoinPay default, Stripe/PayPal, TransFi/WorldRemit payouts', - adapters: ['coinpay', 'paypal', 'stripe', 'transfi', 'worldremit'], + adapters: ['bitnob', 'coinpay', 'paypal', 'stripe', 'transfi', 'worldremit'], }, { id: 'promo', diff --git a/packages/payments/bitnob/README.md b/packages/payments/bitnob/README.md new file mode 100644 index 00000000..e0fb9866 --- /dev/null +++ b/packages/payments/bitnob/README.md @@ -0,0 +1,43 @@ +# Bitnob (African payouts) + +Provides the Bitnob (African payouts) payment adapter for sh1pt monetization workflows. + +## What it does + +- Connects payment provider credentials and account settings. +- Supports payment, checkout, or billing workflows where implemented. +- Includes a connection flow for account or credential setup. +- Includes setup guidance for required credentials or provider configuration. + +## Package + +- Name: `@profullstack/sh1pt-payment-bitnob` +- Path: `packages/payments/bitnob` +- Adapter ID: `payment-bitnob` +- Homepage: https://sh1pt.com + +## Scripts + +- `build`: `tsc -p tsconfig.json` +- `prepublishOnly`: `pnpm build` +- `typecheck`: `tsc -p tsconfig.json --noEmit` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-payment-bitnob +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-payment-bitnob typecheck +``` + +Run tests from the repository root when this module includes a test file: + +```bash +pnpm vitest run packages/payments/bitnob/src/index.test.ts +``` + + diff --git a/packages/payments/bitnob/package.json b/packages/payments/bitnob/package.json new file mode 100644 index 00000000..c20ebf32 --- /dev/null +++ b/packages/payments/bitnob/package.json @@ -0,0 +1,37 @@ +{ + "name": "@profullstack/sh1pt-payment-bitnob", + "version": "0.1.15", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@profullstack/sh1pt-core": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/payments/bitnob" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } + } +} diff --git a/packages/payments/bitnob/src/index.test.ts b/packages/payments/bitnob/src/index.test.ts new file mode 100644 index 00000000..2e2c0bd9 --- /dev/null +++ b/packages/payments/bitnob/src/index.test.ts @@ -0,0 +1,126 @@ +import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import adapter, { signRequest, requireEnvironment, BITNOB_API_URL } from './index.js'; + +// bitnob is a payouts adapter, not a checkout provider — supports[] is +// intentionally empty, same as transfi and worldremit. +smokeTest(adapter, { idPrefix: 'payment', requireSupports: false }); + +describe('signRequest', () => { + it('signs the documented canonical string', () => { + // CLIENT_ID:TIMESTAMP:NONCE:PAYLOAD, HMAC-SHA256, hex. Pinned to exact + // bytes: a subtly wrong canonical string fails as a 401, which reads as a + // bad credential rather than as our bug. + const headers = signRequest('client-1', 'secret-1', '{"a":1}', 1_719_236_465, 'deadbeef'); + + expect(headers['X-Auth-Signature']).toBe( + createHmac('sha256', 'secret-1').update('client-1:1719236465:deadbeef:{"a":1}').digest('hex') + ); + expect(headers['X-Auth-Client']).toBe('client-1'); + expect(headers['X-Auth-Timestamp']).toBe('1719236465'); + expect(headers['X-Auth-Nonce']).toBe('deadbeef'); + }); + + it('signs an empty payload as the empty string, not "undefined"', () => { + expect(signRequest('c', 's', '', 1_000, 'n')['X-Auth-Signature']).toBe( + createHmac('sha256', 's').update('c:1000:n:').digest('hex') + ); + }); + + it('changes when only the nonce changes', () => { + const a = signRequest('c', 's', '{}', 1_000, 'one'); + const b = signRequest('c', 's', '{}', 1_000, 'two'); + + expect(a['X-Auth-Signature']).not.toBe(b['X-Auth-Signature']); + }); +}); + +describe('requireEnvironment', () => { + // The guard that matters. Bitnob serves sandbox and production from ONE base + // URL, so nothing in a request, a response or a hostname reveals that a + // sandbox key reached production — it would quote invented prices to real + // customers and look entirely healthy doing it. + it('serves both environments from a single URL', () => { + expect(BITNOB_API_URL).toBe('https://api.bitnob.com'); + expect(BITNOB_API_URL).not.toContain('sandbox'); + }); + + it('accepts an explicit environment', () => { + expect(requireEnvironment({ environment: 'sandbox' })).toBe('sandbox'); + expect(requireEnvironment({ environment: 'production' })).toBe('production'); + }); + + it('refuses to guess when the environment is absent', () => { + // Never defaults. The assumption that costs money is "probably production" + // on a sandbox key. + expect(() => requireEnvironment({})).toThrow(/must be set/); + }); + + it('refuses a value that is neither', () => { + expect(() => requireEnvironment({ environment: 'prod' as never })).toThrow(/must be set/); + expect(() => requireEnvironment({ environment: '' as never })).toThrow(/must be set/); + }); + + it('explains the consequence rather than just the rule', () => { + // A guard nobody understands gets deleted by the next person in a hurry. + expect(() => requireEnvironment({})).toThrow(/one URL/); + }); +}); + +describe('payment-bitnob connect', () => { + const ctx = (secrets: Record) => ({ + secret: (k: string) => secrets[k], + log: () => {}, + }); + + it('demands the environment before it even looks at credentials', async () => { + await expect( + adapter.connect(ctx({ BITNOB_CLIENT_ID: 'c', BITNOB_CLIENT_SECRET: 's' }), {}) + ).rejects.toThrow(/must be set/); + }); + + it('names the missing half of the credential pair', async () => { + const config = { environment: 'sandbox' as const }; + + await expect(adapter.connect(ctx({}), config)).rejects.toThrow('BITNOB_CLIENT_ID not in vault'); + await expect(adapter.connect(ctx({ BITNOB_CLIENT_ID: 'c' }), config)).rejects.toThrow( + 'BITNOB_CLIENT_SECRET not in vault' + ); + }); + + it('connects with both halves and an explicit environment', async () => { + const result = await adapter.connect( + ctx({ BITNOB_CLIENT_ID: 'c', BITNOB_CLIENT_SECRET: 's' }), + { environment: 'production' } + ); + + expect(result.accountId).toBe('bitnob'); + }); +}); + +describe('payment-bitnob payout', () => { + const config = { environment: 'sandbox' as const }; + + it('demands the environment first', async () => { + await expect(adapter.payout!('r', 100, 'NGN', {})).rejects.toThrow(/must be set/); + }); + + it('rejects malformed requests', async () => { + await expect(adapter.payout!(' ', 100, 'NGN', config)).rejects.toThrow('recipient accountId is required'); + await expect(adapter.payout!('r', 0, 'NGN', config)).rejects.toThrow('positive finite number'); + await expect(adapter.payout!('r', 100, 'NG', config)).rejects.toThrow('3-letter ISO code'); + }); + + it('refuses rather than fabricating a transfer id', async () => { + await expect(adapter.payout!('r', 100, 'NGN', config)).rejects.toThrow('not implemented yet'); + }); +}); + +describe('payment-bitnob checkout', () => { + it('refuses buyer-facing checkout', async () => { + await expect( + adapter.createCheckout({ secret: () => undefined, log: () => {} }, {} as never, {}) + ).rejects.toThrow('does not support buyer-facing checkout'); + }); +}); diff --git a/packages/payments/bitnob/src/index.ts b/packages/payments/bitnob/src/index.ts new file mode 100644 index 00000000..e2df4686 --- /dev/null +++ b/packages/payments/bitnob/src/index.ts @@ -0,0 +1,159 @@ +import { createHmac } from 'node:crypto'; +import { definePayment, tokenSetup, type Webhook } from '@profullstack/sh1pt-core'; + +// Bitnob — African payouts, funded from stablecoin or fiat, delivered over the +// rail the recipient actually uses: NIP in Nigeria, M-Pesa in Kenya and +// Tanzania, MTN MoMo in Ghana and Uganda, PayShap or EFT in South Africa. A +// sending rail like transfi and worldremit, not a buyer-facing checkout. +// +// Worth knowing when choosing between this and transfi: Bitnob's KYB review is +// typically 24–48 hours, against TransFi's 1–14 business days. For getting a +// single African corridor genuinely live, this is the faster route to a +// PRODUCTION key. TransFi is the breadth partner for everywhere else. +// +// VERIFIED against Bitnob's published docs: the request signing scheme +// (`CLIENT_ID:TIMESTAMP:NONCE:PAYLOAD`, HMAC-SHA256 keyed with the client +// secret, hex, across four `X-Auth-*` headers) and that one base URL serves +// both environments. +// +// NOT VERIFIED: the payout request and response shapes. payout() validates and +// then refuses rather than inventing a transfer id. + +interface Config { + /** + * Which Bitnob environment these credentials belong to. + * + * Required, and deliberately not defaulted. Bitnob serves sandbox and + * production from the SAME base URL — the key alone decides which world you + * are in — so unlike every other adapter here there is no host to eyeball and + * nothing in a request or response that reveals a sandbox key has been + * deployed to production. It would quote invented prices to real customers + * and look entirely healthy doing it. + * + * Making this explicit is the only available guard. + */ + environment?: 'sandbox' | 'production'; +} + +// One URL for both environments. This is not an oversight; see Config above. +const BITNOB_API_URL = 'https://api.bitnob.com'; +const QUOTE_PATH = '/api/payouts/quotes'; + +/** + * Bitnob's four `X-Auth-*` headers. + * + * The canonical string is `CLIENT_ID:TIMESTAMP:NONCE:PAYLOAD` joined with + * colons, signed HMAC-SHA256 with the client secret, hex-encoded. Exported so a + * test can assert the exact bytes: a subtly wrong canonical string fails as a + * 401, which reads as a bad credential rather than as our bug. + */ +export function signRequest( + clientId: string, + clientSecret: string, + payload: string, + nowSeconds: number, + nonce: string +): Record { + const message = `${clientId}:${nowSeconds}:${nonce}:${payload}`; + + return { + 'X-Auth-Client': clientId, + 'X-Auth-Timestamp': String(nowSeconds), + 'X-Auth-Nonce': nonce, + 'X-Auth-Signature': createHmac('sha256', clientSecret).update(message).digest('hex'), + }; +} + +/** + * Refuse to run against production without saying so out loud. + * + * Throws rather than assuming, because the assumption that costs money is + * "probably production" on a sandbox key. + */ +export function requireEnvironment(config: Config): 'sandbox' | 'production' { + if (config.environment !== 'sandbox' && config.environment !== 'production') { + throw new Error( + "Bitnob environment must be set to 'sandbox' or 'production'. Bitnob serves both from one URL, so a sandbox key deployed to production quotes invented prices and looks healthy doing it." + ); + } + return config.environment; +} + +export default definePayment({ + id: 'payment-bitnob', + label: 'Bitnob (African payouts)', + supports: [], // sending rail, not a buyer-facing checkout + + async connect(ctx, config) { + const environment = requireEnvironment(config); + const clientId = ctx.secret('BITNOB_CLIENT_ID'); + const clientSecret = ctx.secret('BITNOB_CLIENT_SECRET'); + if (!clientId) throw new Error('BITNOB_CLIENT_ID not in vault'); + if (!clientSecret) throw new Error('BITNOB_CLIENT_SECRET not in vault'); + + ctx.log(`bitnob connected · ${environment}`); + return { accountId: 'bitnob' }; + }, + + async createCheckout() { + throw new Error('payment-bitnob does not support buyer-facing checkout — use payout()'); + }, + + async verifyWebhook(_ctx, rawBody): Promise { + return { type: 'unknown', payload: JSON.parse(rawBody) }; + }, + + async payout(accountId, amount, currency, config) { + requireEnvironment(config); + + const recipient = accountId.trim(); + if (!recipient) throw new Error('Bitnob payout recipient accountId is required'); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Bitnob payout amount must be a positive finite number'); + } + if (!/^[a-z]{3}$/i.test(currency)) { + throw new Error('Bitnob payout currency must be a 3-letter ISO code'); + } + + // Not implemented against a guessed request shape: a fabricated transfer id + // would report money as sent when nothing left the account. + throw new Error( + 'Bitnob payout is not implemented yet — the request shape is unverified. Run a sandbox payout first, then wire it here.' + ); + }, + + setup: tokenSetup({ + secretKey: 'BITNOB_CLIENT_ID', + label: 'Bitnob', + vendorDocUrl: 'https://app.bitnob.com', + steps: [ + 'Bitnob is the FASTEST route to a production African payout key:', + 'KYB review is typically 24-48 hours (TransFi is 1-14 business days)', + '', + 'Sign up for a Bitnob Business account at app.bitnob.com', + 'Complete KYB: business identity, ownership and legitimacy', + 'KYB is mandatory before API access — sandbox keys are issued instantly,', + ' but they are for testing only and must never reach production', + 'Once verified, go to Settings -> API Keys for your dedicated keys', + '', + 'Both halves are required: the client ID and the client secret.', + 'Requests are HMAC-signed, so an ID without its secret cannot authenticate.', + ], + fields: [ + { + key: 'BITNOB_CLIENT_SECRET', + message: 'Bitnob client secret (signing key — required)', + secret: true, + required: true, + }, + { + key: 'environment', + message: + "Environment — 'sandbox' or 'production'. Required: Bitnob serves both from one URL, so nothing else can catch a sandbox key in production", + required: true, + }, + ], + }), +}); + +export { BITNOB_API_URL, QUOTE_PATH }; diff --git a/packages/payments/bitnob/tsconfig.json b/packages/payments/bitnob/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/payments/bitnob/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/packages/payments/transfi/src/index.ts b/packages/payments/transfi/src/index.ts index 04a8d840..f8841ecf 100644 --- a/packages/payments/transfi/src/index.ts +++ b/packages/payments/transfi/src/index.ts @@ -228,8 +228,19 @@ export default definePayment({ // Opens the signup page. Self-serve: no sales call, unlike WorldRemit. vendorDocUrl: 'https://www.transfi.com/signup', steps: [ + 'TransFi is the BREADTH partner: one key serves 41 corridors.', + 'Budget for it: KYB review is 1-14 business days, then production', + ' keys within 24h of verification. Bitnob is 24-48h if you need a', + ' single African corridor live sooner.', + '', 'Sign up for a TransFi business account (self-serve — no sales call)', 'Complete KYB: company details, regulatory info, beneficial ownership', + 'Send the documents to compliance@transfi.com to start verification', + 'UBOs need a government-issued ID and proof of address', + '', + 'The webhook secret is available BEFORE KYB clears — set that up first', + ' so the integration is provable while verification runs', + '', 'Once approved, sign in to displai.transfi.com', 'Go to Settings → API Credentials', 'Sandbox and production have SEPARATE credential pairs — copy the one you want', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41a710eb..e6183227 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1229,6 +1229,12 @@ importers: specifier: workspace:* version: link:../../core + packages/payments/bitnob: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/payments/coinpay: dependencies: '@profullstack/sh1pt-core':