diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index 75e1baf6..0fa5652d 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -137,8 +137,8 @@ export const CATEGORIES: readonly AdapterCategory[] = [ { id: 'payments', pkgPrefix: '@profullstack/sh1pt-payment', - description: 'Payment providers — CoinPay default, Stripe/PayPal/WorldRemit', - adapters: ['coinpay', 'paypal', 'stripe', 'worldremit'], + description: 'Payment providers — CoinPay default, Stripe/PayPal, TransFi/WorldRemit payouts', + adapters: ['coinpay', 'paypal', 'stripe', 'transfi', 'worldremit'], }, { id: 'promo', diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index fc52bde1..e58dc561 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -66,7 +66,7 @@ paymentsCmd paymentsCmd .command('add ') - .description('Enable a provider (e.g. payment-coinpay, payment-stripe, payment-paypal, payment-worldremit)') + .description('Enable a provider (e.g. payment-coinpay, payment-stripe, payment-paypal, payment-transfi, payment-worldremit)') .option('--default', 'also set as defaultProvider') .action((provider: string, opts: { default?: boolean }) => { console.log(kleur.cyan(`[stub] config payments add ${provider}${opts.default ? ' (as default)' : ''}`)); diff --git a/packages/payments/transfi/README.md b/packages/payments/transfi/README.md new file mode 100644 index 00000000..f3df1c2a --- /dev/null +++ b/packages/payments/transfi/README.md @@ -0,0 +1,43 @@ +# TransFi (cross-border payouts) + +Provides the TransFi (cross-border 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-transfi` +- Path: `packages/payments/transfi` +- Adapter ID: `payment-transfi` +- 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-transfi +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-payment-transfi typecheck +``` + +Run tests from the repository root when this module includes a test file: + +```bash +pnpm vitest run packages/payments/transfi/src/index.test.ts +``` + + diff --git a/packages/payments/transfi/package.json b/packages/payments/transfi/package.json new file mode 100644 index 00000000..a5f36f5f --- /dev/null +++ b/packages/payments/transfi/package.json @@ -0,0 +1,37 @@ +{ + "name": "@profullstack/sh1pt-payment-transfi", + "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/transfi" + }, + "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/transfi/src/index.test.ts b/packages/payments/transfi/src/index.test.ts new file mode 100644 index 00000000..7d1ddfeb --- /dev/null +++ b/packages/payments/transfi/src/index.test.ts @@ -0,0 +1,216 @@ +import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import adapter, { + authHeader, + normalizeTransfiWebhook, + verifyTransfiSignature, + TRANSFI_SIGNATURE_HEADER, +} from './index.js'; + +// transfi is a payouts adapter, not a checkout provider — supports[] is +// intentionally empty, same as worldremit. requireSupports skipped. +smokeTest(adapter, { idPrefix: 'payment', requireSupports: false }); + +const SECRET = 'whsec_test'; +const sign = (body: string, secret = SECRET) => + createHmac('sha256', secret).update(body, 'utf8').digest('hex'); + +describe('payment-transfi auth', () => { + it('builds Basic auth, not a bearer token', () => { + // The sibling implementation in coinpayportal sent `Bearer ` and would + // have 401'd on every request. Pinned here so this one cannot drift. + expect(authHeader('user', 'pass')).toBe(`Basic ${Buffer.from('user:pass').toString('base64')}`); + expect(authHeader('user', 'pass')).not.toContain('Bearer'); + }); + + it('encodes a colon in the password without splitting the pair', () => { + const decoded = Buffer.from(authHeader('user', 'pa:ss').replace('Basic ', ''), 'base64').toString(); + + expect(decoded).toBe('user:pa:ss'); + }); +}); + +describe('verifyTransfiSignature', () => { + const body = '{"eventId":"EV-1","status":"fund_settled"}'; + + it('accepts a correctly signed body', () => { + expect(() => verifyTransfiSignature(body, sign(body), SECRET)).not.toThrow(); + }); + + it('rejects a signature made with the wrong secret', () => { + expect(() => verifyTransfiSignature(body, sign(body, 'nope'), SECRET)).toThrow( + 'Invalid TransFi webhook signature' + ); + }); + + it('rejects a body that changed after signing', () => { + const signature = sign(body); + + expect(() => verifyTransfiSignature(`${body} `, signature, SECRET)).toThrow( + 'Invalid TransFi webhook signature' + ); + }); + + it('says so when the header is absent rather than failing obscurely', () => { + expect(() => verifyTransfiSignature(body, '', SECRET)).toThrow('signature header is missing'); + }); + + it('rejects a malformed signature instead of throwing from the comparison', () => { + // timingSafeEqual throws on a length mismatch, which would read as a crash + // rather than a rejected webhook. + expect(() => verifyTransfiSignature(body, 'zzzz', SECRET)).toThrow( + 'Invalid TransFi webhook signature' + ); + expect(() => verifyTransfiSignature(body, 'ab', SECRET)).toThrow( + 'Invalid TransFi webhook signature' + ); + }); + + it('tolerates surrounding whitespace on the header value', () => { + expect(() => verifyTransfiSignature(body, ` ${sign(body)} `, SECRET)).not.toThrow(); + }); + + it('names the header in lowercase, because Node lowercases them', () => { + // TransFi's own sample reads req.headers['X-Transfi-Hmac-Hash'], which is + // always undefined in Node. + expect(TRANSFI_SIGNATURE_HEADER).toBe('x-transfi-hmac-hash'); + }); +}); + +describe('normalizeTransfiWebhook', () => { + // Payload shapes taken verbatim from TransFi's payout event docs. + const settled = { + eventId: 'EV-260824094702778', + entityId: 'OR-260824094625490386520298764', + status: 'fund_settled', + order: { + orderId: 'OR-260824094625490386520298764', + depositCurrency: 'IDR', + depositAmount: 214721, + withdrawCurrency: 'USDTPOLYGON', + withdrawAmount: 12, + }, + }; + + it('maps a settled fiat payout to succeeded', () => { + const hook = normalizeTransfiWebhook(settled); + + expect(hook.status).toBe('succeeded'); + expect(hook.paymentId).toBe('OR-260824094625490386520298764'); + // The withdraw side: what left TransFi toward the recipient. + expect(hook.amount).toBe(12); + expect(hook.currency).toBe('USDTPOLYGON'); + }); + + it('maps a settled crypto payout to succeeded', () => { + expect(normalizeTransfiWebhook({ ...settled, status: 'asset_deposited' }).status).toBe('succeeded'); + }); + + it('treats a scheduled payout as pending, not delivered', () => { + // The order is booked; the recipient has nothing yet. + expect(normalizeTransfiWebhook({ ...settled, status: 'fund_scheduled' }).status).toBe('pending'); + expect(normalizeTransfiWebhook({ ...settled, status: 'initiated' }).status).toBe('pending'); + }); + + it('maps both failure vocabularies', () => { + expect(normalizeTransfiWebhook({ ...settled, status: 'fund_failed' }).status).toBe('failed'); + expect(normalizeTransfiWebhook({ ...settled, status: 'fund_deposit_failed' }).status).toBe('failed'); + }); + + it('maps an unknown status to pending, never to a confident answer', () => { + // Both confident answers are harmful: one reports a payout as delivered, + // the other tells someone their money bounced when it did not. + expect(normalizeTransfiWebhook({ ...settled, status: 'some_new_status' }).status).toBe('pending'); + }); + + it('is case-insensitive about the status', () => { + expect(normalizeTransfiWebhook({ ...settled, status: 'FUND_SETTLED' }).status).toBe('succeeded'); + }); + + it('falls back to entityId when the order has no orderId', () => { + const hook = normalizeTransfiWebhook({ entityId: 'OR-9', status: 'initiated' }); + + expect(hook.paymentId).toBe('OR-9'); + expect(hook.amount).toBeUndefined(); + }); + + it('keeps the raw payload so the deposit side is not lost', () => { + // TransFi's samples label deposit/withdraw in a way that is easy to read + // backwards, so callers that care must be able to check the raw fields. + expect((normalizeTransfiWebhook(settled).payload as typeof settled).order.depositAmount).toBe(214721); + }); +}); + +describe('payment-transfi verifyWebhook', () => { + const ctx = (secrets: Record) => ({ secret: (k: string) => secrets[k] }); + + it('refuses to verify without a secret rather than accepting anything', async () => { + await expect( + adapter.verifyWebhook(ctx({}), '{}', sign('{}'), {}) + ).rejects.toThrow('TRANSFI_WEBHOOK_SECRET not in vault'); + }); + + it('verifies and normalizes end to end', async () => { + const body = JSON.stringify({ status: 'fund_settled', order: { orderId: 'OR-1', withdrawAmount: 5, withdrawCurrency: 'NGN' } }); + const hook = await adapter.verifyWebhook(ctx({ TRANSFI_WEBHOOK_SECRET: SECRET }), body, sign(body), {}); + + expect(hook.status).toBe('succeeded'); + expect(hook.paymentId).toBe('OR-1'); + expect(hook.currency).toBe('NGN'); + }); + + it('takes the secret from config ahead of the vault', async () => { + const body = '{"status":"initiated"}'; + const hook = await adapter.verifyWebhook(ctx({}), body, sign(body, 'cfg'), { webhookSecret: 'cfg' }); + + expect(hook.status).toBe('pending'); + }); + + it('rejects a forged delivery', async () => { + await expect( + adapter.verifyWebhook(ctx({ TRANSFI_WEBHOOK_SECRET: SECRET }), '{"status":"fund_settled"}', 'deadbeef', {}) + ).rejects.toThrow('Invalid TransFi webhook signature'); + }); +}); + +describe('payment-transfi checkout', () => { + it('refuses buyer-facing checkout and says what to use instead', async () => { + await expect( + adapter.createCheckout({ secret: () => undefined, log: () => {} }, {} as never, {}) + ).rejects.toThrow('does not support buyer-facing checkout'); + }); +}); + +describe('payment-transfi payout validation', () => { + it('rejects missing payout recipients', async () => { + await expect(adapter.payout!(' ', 1000, 'USD', {})).rejects.toThrow('recipient accountId is required'); + }); + + it('rejects invalid payout amounts', async () => { + await expect(adapter.payout!('recipient-1', 0, 'USD', {})).rejects.toThrow('positive finite number'); + await expect(adapter.payout!('recipient-1', Number.NaN, 'USD', {})).rejects.toThrow('positive finite number'); + }); + + it('rejects malformed payout currencies', async () => { + await expect(adapter.payout!('recipient-1', 1000, 'US', {})).rejects.toThrow('3-letter ISO code'); + }); + + it('refuses rather than fabricating a transfer id', async () => { + await expect(adapter.payout!('recipient-1', 1000, 'USD', {})).rejects.toThrow('not implemented yet'); + }); +}); + +describe('payment-transfi connect', () => { + const ctx = (secrets: Record) => ({ + secret: (k: string) => secrets[k], + log: () => {}, + }); + + it('names the missing half of the credential pair', async () => { + await expect(adapter.connect(ctx({}), {})).rejects.toThrow('TRANSFI_API_KEY not in vault'); + await expect(adapter.connect(ctx({ TRANSFI_API_KEY: 'u' }), {})).rejects.toThrow( + 'TRANSFI_API_SECRET not in vault' + ); + }); +}); diff --git a/packages/payments/transfi/src/index.ts b/packages/payments/transfi/src/index.ts new file mode 100644 index 00000000..04a8d840 --- /dev/null +++ b/packages/payments/transfi/src/index.ts @@ -0,0 +1,262 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { definePayment, tokenSetup, type Webhook } from '@profullstack/sh1pt-core'; + +// TransFi — cross-border payouts in ~100 countries, funded from stablecoin or +// fiat. Like worldremit this is a SENDING rail, not a checkout provider: use it +// to pay contractors, creators or marketplace sellers into their local bank or +// wallet. TransFi also sells a Collections product for taking money in; that is +// not wired here. +// +// Unlike worldremit, onboarding is self-serve — sign up, clear KYB, and the +// dashboard issues sandbox and production credentials without a sales call. +// That is the whole reason this adapter exists: `sh1pt config payments` can +// walk the operator through it and verify the result, which is not possible +// for a provider that only issues credentials over email. +// +// VERIFIED against TransFi's published docs: Basic auth over +// `username:password`; the sandbox and production base URLs; that +// `GET /v3/balance` returns 200 on good credentials and 401 on bad; the webhook +// signature scheme; and the payout event statuses mapped below. +// +// NOT VERIFIED: the payout *request* shape. payout() validates its arguments +// and then refuses rather than inventing a transfer id — a fabricated id would +// report money as sent when nothing left the account. + +interface Config { + environment?: 'sandbox' | 'production'; + webhookSecret?: string; // read from the vault when not set here +} + +const BASE_URL = { + sandbox: 'https://api-sandbox.transfi.com', + production: 'https://api.transfi.com', +} as const; + +/** + * The header TransFi puts its signature in. + * + * Exported because it is easy to get wrong: TransFi's own documented sample + * reads `req.headers['X-Transfi-Hmac-Hash']`, which is always `undefined` in + * Node — it lowercases incoming header names. Anything routing webhooks to this + * adapter should look the header up case-insensitively. + */ +export const TRANSFI_SIGNATURE_HEADER = 'x-transfi-hmac-hash'; + +function baseUrl(config: Config): string { + return BASE_URL[config.environment ?? 'production']; +} + +/** + * TransFi's Basic auth header. + * + * Base64 of `username:password`, NOT a bearer token. Worth stating loudly: a + * sibling implementation of this same API in coinpayportal sent + * `Authorization: Bearer ` and would have failed every request the moment + * a real credential was configured. The 401 that produces looks identical to an + * expired key, so it is the kind of mistake that survives a long time. + */ +export function authHeader(username: string, password: string): string { + return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; +} + +/** + * Verify a TransFi webhook signature. + * + * HMAC-SHA256 over the raw request body, keyed with the dedicated webhook + * secret, hex-encoded, compared against `X-Transfi-Hmac-Hash`. + * + * Two deliberate departures from TransFi's documented sample. It compares with + * {@link timingSafeEqual} rather than `===`, because a byte-by-byte string + * compare leaks how much of a forged signature was correct. And it takes the + * body as the exact bytes that arrived: any re-serialisation — parsing to JSON + * and stringifying again — changes whitespace or key order and the hash stops + * matching, which is the single most common way this check gets wrongly + * reported as broken. + */ +export function verifyTransfiSignature(rawBody: string, signature: string, secret: string): void { + if (!signature) throw new Error('TransFi webhook signature header is missing'); + + const expected = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); + const expectedBuffer = Buffer.from(expected, 'hex'); + + let actualBuffer: Buffer; + try { + actualBuffer = Buffer.from(signature.trim(), 'hex'); + } catch { + throw new Error('Invalid TransFi webhook signature'); + } + + // Length check first: timingSafeEqual throws on a mismatch rather than + // returning false, and that throw would read as a crash instead of a + // rejected webhook. + if (actualBuffer.length !== expectedBuffer.length || !timingSafeEqual(actualBuffer, expectedBuffer)) { + throw new Error('Invalid TransFi webhook signature'); + } +} + +/** + * TransFi payout statuses, mapped onto sh1pt's normalized set. + * + * Fiat and crypto payouts have different vocabularies for the same three + * moments, so both appear here. `fund_scheduled` is pending rather than + * succeeded: the order is booked, the recipient has nothing yet. + */ +const PAYOUT_STATUS: Record = { + initiated: 'pending', + fund_scheduled: 'pending', + fund_settled: 'succeeded', + asset_deposited: 'succeeded', + fund_failed: 'failed', + fund_deposit_failed: 'failed', +}; + +interface TransfiWebhookPayload { + eventId?: string; + entityId?: string; + entityType?: string; + status?: string; + order?: { + orderId?: string; + depositCurrency?: string; + depositAmount?: number | string; + withdrawCurrency?: string; + withdrawAmount?: number | string; + }; + [key: string]: unknown; +} + +function toFiniteNumber(value: unknown): number | undefined { + const n = typeof value === 'string' ? Number(value) : value; + return typeof n === 'number' && Number.isFinite(n) ? n : undefined; +} + +/** + * Map a TransFi webhook onto sh1pt's normalized shape. + * + * The amount reported is the **withdraw** side — what leaves TransFi toward the + * recipient — because that is the number a payout is about. The deposit side is + * what we funded it with. Both survive untouched in `payload`, which matters: + * TransFi's own sample payloads label these in a way that is easy to read + * backwards, so anything depending on the distinction should check the raw + * fields rather than trust this one. + * + * An unrecognised status becomes `pending`, never `succeeded` or `failed`. + * TransFi can add statuses without asking us, and both of the confident answers + * are harmful: one would report a payout as delivered, the other would tell + * someone their money bounced when it had not. + */ +export function normalizeTransfiWebhook(payload: TransfiWebhookPayload): Webhook { + const status = payload.status ? PAYOUT_STATUS[payload.status.toLowerCase()] ?? 'pending' : undefined; + + return { + type: payload.status ?? payload.entityType ?? 'unknown', + payload, + paymentId: payload.order?.orderId ?? payload.entityId, + status, + amount: toFiniteNumber(payload.order?.withdrawAmount), + currency: payload.order?.withdrawCurrency, + }; +} + +export default definePayment({ + id: 'payment-transfi', + label: 'TransFi (cross-border payouts)', + supports: [], // sending rail, not a buyer-facing checkout + + async connect(ctx, config) { + const username = ctx.secret('TRANSFI_API_KEY'); + const password = ctx.secret('TRANSFI_API_SECRET'); + if (!username) throw new Error('TRANSFI_API_KEY not in vault'); + if (!password) throw new Error('TRANSFI_API_SECRET not in vault'); + + // A real check, not a presence check. Credentials that are present but + // wrong are the failure this catches, and they are indistinguishable from + // correct ones until something tries to move money. + const response = await fetch(`${baseUrl(config)}/v3/balance`, { + headers: { Authorization: authHeader(username, password) }, + }); + + if (response.status === 401) { + throw new Error( + 'TransFi rejected these credentials (401). Check they are the right environment — sandbox and production keys are separate.' + ); + } + if (!response.ok) { + throw new Error(`TransFi API error ${response.status} while verifying credentials`); + } + + ctx.log(`transfi connected · ${config.environment ?? 'production'}`); + return { accountId: 'transfi' }; + }, + + async createCheckout() { + throw new Error( + 'payment-transfi does not support buyer-facing checkout — use payout(), or TransFi Collections which is not wired here' + ); + }, + + async verifyWebhook(ctx, rawBody, signature, config): Promise { + const secret = config.webhookSecret ?? ctx.secret('TRANSFI_WEBHOOK_SECRET'); + if (!secret) throw new Error('TRANSFI_WEBHOOK_SECRET not in vault'); + + verifyTransfiSignature(rawBody, signature, secret); + + return normalizeTransfiWebhook(JSON.parse(rawBody) as TransfiWebhookPayload); + }, + + async payout(accountId, amount, currency) { + const recipient = accountId.trim(); + if (!recipient) throw new Error('TransFi payout recipient accountId is required'); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('TransFi payout amount must be a positive finite number'); + } + if (!/^[a-z]{3}$/i.test(currency)) { + throw new Error('TransFi payout currency must be a 3-letter ISO code'); + } + + // Deliberately not implemented against a guessed request shape. Returning a + // fabricated id here would report a payout as sent when nothing left the + // account, which is worse than refusing. + throw new Error( + 'TransFi payout is not implemented yet — the request shape is unverified. Run a sandbox payout first, then wire it here.' + ); + }, + + setup: tokenSetup({ + secretKey: 'TRANSFI_API_KEY', + label: 'TransFi', + // Opens the signup page. Self-serve: no sales call, unlike WorldRemit. + vendorDocUrl: 'https://www.transfi.com/signup', + steps: [ + 'Sign up for a TransFi business account (self-serve — no sales call)', + 'Complete KYB: company details, regulatory info, beneficial ownership', + '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', + 'The username is the API key and the password is the API secret; both are needed', + '', + 'The webhook secret is available BEFORE KYB clears:', + 'Settings → Webhooks → create a listener and copy its dedicated secret', + 'Point the listener at your handler; TransFi signs each delivery with it', + ], + fields: [ + { + key: 'TRANSFI_API_SECRET', + message: 'TransFi API secret (the password half of the credential pair)', + secret: true, + required: true, + }, + { + key: 'TRANSFI_WEBHOOK_SECRET', + message: 'TransFi webhook secret (Settings → Webhooks — available before KYB)', + secret: true, + required: false, + }, + { + key: 'environment', + message: 'Environment — sandbox or production', + required: false, + }, + ], + }), +}); diff --git a/packages/payments/transfi/tsconfig.json b/packages/payments/transfi/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/payments/transfi/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/packages/social/ugig/README.md b/packages/social/ugig/README.md index 2cce9613..b431b678 100644 --- a/packages/social/ugig/README.md +++ b/packages/social/ugig/README.md @@ -1,6 +1,6 @@ -# uGig (Prompts Marketplace) +# uGig (AI Gig Marketplace) -Provides the uGig (Prompts Marketplace) social platform adapter for sh1pt promotion workflows. +Provides the uGig (AI Gig Marketplace) social platform adapter for sh1pt promotion workflows. ## What it does diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0d98d87..41a710eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1247,6 +1247,12 @@ importers: specifier: workspace:* version: link:../../core + packages/payments/transfi: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/payments/worldremit: dependencies: '@profullstack/sh1pt-core':