From 19eaa841f8d83c52c1a5b5f34a60723f53b7b48b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 14:02:58 +0000 Subject: [PATCH 1/2] payments: TransFi adapter, with setup you can actually automate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @profullstack/sh1pt-payment-transfi — cross-border payouts into local banks and wallets in ~100 countries, funded from stablecoin or fiat. A sending rail like worldremit, not a buyer-facing checkout. The point of it is the setup flow. WorldRemit issues credentials only after a conversation with their business team, so `sh1pt config payments` can do no more than print instructions. TransFi onboarding is self-serve: sign up, clear KYB, and displai.transfi.com issues sandbox and production credential pairs. That is automatable end to end, so this adapter opens the signup page, walks the operator through it, prompts for both halves of the pair, and stores them in the vault. connect() then verifies them for real. It calls GET /v3/balance and reports a 401 as bad credentials with a note that sandbox and production keys are separate, rather than only asserting a secret exists. Credentials that are present but wrong are indistinguishable from correct ones until something tries to move money, which is late. Authentication is HTTP Basic over `username:password`, verified from TransFi's published docs and pinned by a test. Worth stating plainly because a sibling implementation of this same API in coinpayportal sends `Authorization: Bearer` and would have failed every request the moment a real credential was set — the resulting 401 looks exactly like an expired key, so the mistake survives a long time. That one needs fixing separately. payout() validates its arguments and then refuses, because the request shape is unverified. Returning a fabricated transfer id would report money as sent when nothing left the account, which is worse than an error. It gets implemented against a sandbox payout, not against documentation. packages/social/ugig/README.md moves too: it is generated output that had drifted from its source label, and running the repo's own generator to create this package's README regenerates it. Note for anyone running these tests: packages/core must be built first. Otherwise resolution falls through to a stray copy under ~/node_modules and every payments adapter test fails with a confusing "Cannot find package 'vitest'". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KcjnyR2gpRBzWYGobUvuHT --- packages/cli/src/adapter-registry.ts | 4 +- packages/cli/src/commands/config.ts | 2 +- packages/payments/transfi/README.md | 43 ++++++ packages/payments/transfi/package.json | 37 ++++++ packages/payments/transfi/src/index.test.ts | 68 ++++++++++ packages/payments/transfi/src/index.ts | 138 ++++++++++++++++++++ packages/payments/transfi/tsconfig.json | 5 + packages/social/ugig/README.md | 4 +- pnpm-lock.yaml | 6 + 9 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 packages/payments/transfi/README.md create mode 100644 packages/payments/transfi/package.json create mode 100644 packages/payments/transfi/src/index.test.ts create mode 100644 packages/payments/transfi/src/index.ts create mode 100644 packages/payments/transfi/tsconfig.json 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..7bf77627 --- /dev/null +++ b/packages/payments/transfi/src/index.test.ts @@ -0,0 +1,68 @@ +import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { describe, expect, it } from 'vitest'; +import adapter, { authHeader } 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 }); + +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 the + // same way. + 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', () => { + // Base64 of the whole string; only the FIRST colon separates the two + // halves, so a password containing one must survive intact. + const header = authHeader('user', 'pa:ss'); + const decoded = Buffer.from(header.replace('Basic ', ''), 'base64').toString(); + + expect(decoded).toBe('user:pa:ss'); + }); +}); + +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 () => { + // A made-up id would report money as sent when nothing left the account. + 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..7fa6063b --- /dev/null +++ b/packages/payments/transfi/src/index.ts @@ -0,0 +1,138 @@ +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 API docs: Basic auth over +// `username:password`, the sandbox and production base URLs, and that +// `GET /v3/balance` returns 200 on good credentials and 401 on bad. connect() +// uses that endpoint as a real credential check rather than only asserting the +// secret is present in the vault. +// +// NOT VERIFIED: the payout request and response shapes. payout() validates its +// arguments and refuses to invent a transfer id, which is the same failure mode +// worldremit chose — a bad mapping surfaces as an error, never as a payment +// silently going nowhere. + +interface Config { + environment?: 'sandbox' | 'production'; +} + +const BASE_URL = { + sandbox: 'https://api-sandbox.transfi.com', + production: 'https://api.transfi.com', +} as const; + +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')}`; +} + +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): Promise { + return { type: 'unknown', payload: JSON.parse(rawBody) }; + }, + + 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', + ], + fields: [ + { + key: 'TRANSFI_API_SECRET', + message: 'TransFi API secret (the password half of the credential pair)', + secret: true, + required: true, + }, + { + 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': From cb3e4aec8e687870955e825bb4a0b9d6b2cb6170 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 14:30:01 +0000 Subject: [PATCH 2/2] payments: verify TransFi webhooks, and map payout events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhook secret is the one credential TransFi hands over before KYB clears, so it is the part of this integration that can be built and tested now. Wires it up end to end. Signature verification is HMAC-SHA256 over the raw request body, hex, compared against X-Transfi-Hmac-Hash — verified from TransFi's published docs. Two deliberate departures from their documented sample. It compares with timingSafeEqual rather than `===`, because a byte-by-byte string compare leaks how much of a forged signature was correct. And the header constant is lowercase: their sample reads req.headers['X-Transfi-Hmac-Hash'], which is always undefined in Node, since Node lowercases incoming header names. Payout events are mapped for both the fiat and crypto vocabularies, which name the same three moments differently. fund_scheduled is pending rather than succeeded — the order is booked and the recipient has nothing yet. An unrecognised status becomes pending, never a confident answer: TransFi can add statuses without asking us, and both confident answers cause harm, one reporting a payout as delivered and the other telling someone their money bounced when it did not. The normalized amount is the withdraw side, what left TransFi toward the recipient. TransFi's sample payloads label deposit and withdraw in a way that is easy to read backwards, so the raw payload is preserved intact for anything that depends on the distinction. Setup now captures TRANSFI_WEBHOOK_SECRET too, and the steps say plainly that it is available before KYB — which is what makes this worth doing first. 29 tests on this adapter, 94 across packages/payments. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KcjnyR2gpRBzWYGobUvuHT --- packages/payments/transfi/src/index.test.ts | 170 ++++++++++++++++++-- packages/payments/transfi/src/index.ts | 146 +++++++++++++++-- 2 files changed, 294 insertions(+), 22 deletions(-) diff --git a/packages/payments/transfi/src/index.test.ts b/packages/payments/transfi/src/index.test.ts index 7bf77627..7d1ddfeb 100644 --- a/packages/payments/transfi/src/index.test.ts +++ b/packages/payments/transfi/src/index.test.ts @@ -1,35 +1,184 @@ import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { createHmac } from 'node:crypto'; import { describe, expect, it } from 'vitest'; -import adapter, { authHeader } from './index.js'; +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 the - // same way. + // 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', () => { - // Base64 of the whole string; only the FIRST colon separates the two - // halves, so a password containing one must survive intact. - const header = authHeader('user', 'pa:ss'); - const decoded = Buffer.from(header.replace('Basic ', ''), 'base64').toString(); + 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' - ); + await expect( + adapter.createCheckout({ secret: () => undefined, log: () => {} }, {} as never, {}) + ).rejects.toThrow('does not support buyer-facing checkout'); }); }); @@ -48,7 +197,6 @@ describe('payment-transfi payout validation', () => { }); it('refuses rather than fabricating a transfer id', async () => { - // A made-up id would report money as sent when nothing left the account. await expect(adapter.payout!('recipient-1', 1000, 'USD', {})).rejects.toThrow('not implemented yet'); }); }); diff --git a/packages/payments/transfi/src/index.ts b/packages/payments/transfi/src/index.ts index 7fa6063b..04a8d840 100644 --- a/packages/payments/transfi/src/index.ts +++ b/packages/payments/transfi/src/index.ts @@ -1,3 +1,4 @@ +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 @@ -12,19 +13,18 @@ import { definePayment, tokenSetup, type Webhook } from '@profullstack/sh1pt-cor // 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 API docs: Basic auth over -// `username:password`, the sandbox and production base URLs, and that -// `GET /v3/balance` returns 200 on good credentials and 401 on bad. connect() -// uses that endpoint as a real credential check rather than only asserting the -// secret is present in the vault. +// 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 and response shapes. payout() validates its -// arguments and refuses to invent a transfer id, which is the same failure mode -// worldremit chose — a bad mapping surfaces as an error, never as a payment -// silently going nowhere. +// 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 = { @@ -32,6 +32,16 @@ const BASE_URL = { 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']; } @@ -49,6 +59,105 @@ 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)', @@ -86,8 +195,13 @@ export default definePayment({ ); }, - async verifyWebhook(_ctx, rawBody): Promise { - return { type: 'unknown', payload: JSON.parse(rawBody) }; + 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) { @@ -120,6 +234,10 @@ export default definePayment({ '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: [ { @@ -128,6 +246,12 @@ export default definePayment({ 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',