diff --git a/apps/backend/scripts/probe-nomba-adapter.cjs b/apps/backend/scripts/probe-nomba-adapter.cjs new file mode 100644 index 00000000..3aa94ae4 --- /dev/null +++ b/apps/backend/scripts/probe-nomba-adapter.cjs @@ -0,0 +1,35 @@ +/** Credentialless sandbox contract probe. Never proves settlement; no raw identities logged. */ +require('ts-node').register({ transpileOnly: true, compilerOptions: { module: 'CommonJS', moduleResolution: 'node' } }); +const { randomUUID } = require('node:crypto'); +const { NombaAdapter } = require('../src/fiat/banking/nomba.adapter.ts'); +const observations = []; +let observedReference; +// Capture opaque transaction ID only for the dependent requery; do not print provider identities. +const transport = async (url, init) => { + const response = await fetch(url, init); + if (url.endsWith('/v2/transfers/bank')) { + const payload = await response.clone().json(); + if (typeof payload.data?.id === 'string') observedReference = payload.data.id; + } + return response; +}; +const adapter = new NombaAdapter({ senderName: 'Xend Sandbox Test' }, transport); +const reference = `xend_${randomUUID().replaceAll('-', '').slice(0, 20)}`; +async function probe(name, operation) { + try { + const result = await operation(); + observations.push({ name, acceptedByAdapter: true, evidence: result.evidence ?? 'fixture', status: result.status, currency: result.currency }); + return result; + } catch (error) { + observations.push({ name, acceptedByAdapter: false, code: typeof error.code === 'string' ? error.code : 'PROBE_FAILED' }); + } +} +(async () => { + await probe('createAccount', () => adapter.createAccount({ reference, accountReference: reference, firstName: 'Xend', lastName: 'Sandbox', email: 'sandbox@example.com' })); + const payout = { reference, amountMinor: '350000', recipient: { bankCode: '058', accountNumber: '0000000000', accountName: 'Xend Sandbox Test' }, narration: 'Sandbox contract probe' }; + await probe('submitPayout', () => adapter.submitPayout(payout)); + if (observedReference) await probe('getPayout', () => adapter.getPayout({ ...payout, providerReference: observedReference })); + else observations.push({ name: 'getPayout', skipped: 'No provider reference returned' }); + await probe('getPayout nonexistent control', () => adapter.getPayout({ ...payout, providerReference: `${reference}_nonexistent` })); + console.log(JSON.stringify({ checkedAt: new Date().toISOString(), environment: 'public sandbox fixture', observations }, null, 2)); +})().catch(() => { console.error('Probe failed'); process.exitCode = 1; }); diff --git a/apps/backend/src/fiat/banking/banking-provider.interface.ts b/apps/backend/src/fiat/banking/banking-provider.interface.ts index 0329cf4e..686a13db 100644 --- a/apps/backend/src/fiat/banking/banking-provider.interface.ts +++ b/apps/backend/src/fiat/banking/banking-provider.interface.ts @@ -40,6 +40,13 @@ export interface BankAccountProvider { bvn?: string; }): Promise; } +/** Authenticated read by the stable reference assigned before account creation. */ +export interface BankAccountReader { + retrieveAccount( + accountReference: string, + requestReference: string, + ): Promise; +} /** A customer-scoped balance observation, not an authorization to spend it. */ export interface BankBalanceReader { getBalance( @@ -47,6 +54,21 @@ export interface BankBalanceReader { reference: string, ): Promise<{ amountMinor: string; currency: 'NGN'; observedAt: string }>; } +/** Authenticated provider lookup. Observation alone is not customer settlement. */ +export interface BankTransactionObservation { + transactionId: string; + merchantId: string | null; + type: string; + status: string; + amountMinor: string; + feeMinor: string | null; + createdAt: string; + source: string; + evidence: 'authenticated_sandbox'; +} +export interface BankTransactionReader { + getTransaction(transactionId: string): Promise; +} /** Read-only indicative valuation. This never authorizes an exchange or payout. */ export interface BankUsdValuationReader { quoteNgnUsd(amountMinor: string): Promise<{ diff --git a/apps/backend/src/fiat/banking/nomba-auth.spec.ts b/apps/backend/src/fiat/banking/nomba-auth.spec.ts new file mode 100644 index 00000000..20238ce0 --- /dev/null +++ b/apps/backend/src/fiat/banking/nomba-auth.spec.ts @@ -0,0 +1,197 @@ +import { NombaSandboxAuth } from './nomba-auth'; +import { NombaAdapter } from './nomba.adapter'; + +const credentials = { + clientId: 'fixture-client', + clientSecret: 'fixture-secret', + accountId: 'fixture-account', +}; +const start = Date.parse('2026-09-09T00:00:00Z'); +function response(accessToken = 'first-token', at = start, overrides = {}) { + return new Response( + JSON.stringify({ + code: '00', + data: { + access_token: accessToken, + refresh_token: `${accessToken}-refresh`, + expiresAt: new Date(at + 1_800_000).toISOString(), + ...overrides, + }, + }), + ); +} + +describe('Nomba sandbox server authentication', () => { + it('issues once for concurrent requests, caches, and refreshes before expiry without resending the client secret', async () => { + let now = start; + const transport = jest + .fn, [string, RequestInit]>() + .mockResolvedValueOnce(response()) + .mockImplementationOnce(() => + Promise.resolve(response('second-token', now)), + ); + const auth = new NombaSandboxAuth(credentials, transport, () => now); + expect( + await Promise.all([auth.getAccessToken(), auth.getAccessToken()]), + ).toEqual(['first-token', 'first-token']); + expect(await auth.getAccessToken()).toBe('first-token'); + expect(transport).toHaveBeenCalledTimes(1); + expect(transport.mock.calls[0][0]).toBe( + 'https://sandbox.nomba.com/v1/auth/token/issue', + ); + expect(transport.mock.calls[0][1]).toMatchObject({ + redirect: 'error', + headers: { accountId: credentials.accountId }, + }); + expect(JSON.parse(transport.mock.calls[0][1].body as string)).toEqual({ + grant_type: 'client_credentials', + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + }); + now += 1_500_000; + expect( + await Promise.all([auth.getAccessToken(), auth.getAccessToken()]), + ).toEqual(['second-token', 'second-token']); + expect(transport).toHaveBeenCalledTimes(2); + expect(transport.mock.calls[1][0]).toBe( + 'https://sandbox.nomba.com/v1/auth/token/refresh', + ); + expect( + new Headers(transport.mock.calls[1][1].headers).get('Authorization'), + ).toBe('Bearer first-token'); + expect(JSON.parse(transport.mock.calls[1][1].body as string)).toEqual({ + grant_type: 'refresh_token', + refresh_token: 'first-token-refresh', + }); + expect(transport.mock.calls[1][1].body).not.toContain( + credentials.clientSecret, + ); + auth.invalidate('first-token'); + expect(await auth.getAccessToken()).toBe('second-token'); + expect(transport).toHaveBeenCalledTimes(2); + }); + + it.each([401, 403, 429, 500])( + 'sanitizes auth rejection %s without returning the response body', + async (status) => { + const transport = jest + .fn, [string, RequestInit]>() + .mockResolvedValue(new Response(credentials.clientSecret, { status })); + const auth = new NombaSandboxAuth(credentials, transport, () => start); + await expect(auth.getAccessToken()).rejects.toThrow( + status >= 429 ? 'NOMBA_AUTH_UNAVAILABLE' : 'NOMBA_AUTH_REJECTED', + ); + expect(transport).toHaveBeenCalledTimes(1); + }, + ); + + it('does not keep a rejected pending promise and sanitizes transport errors', async () => { + const transport = jest + .fn, [string, RequestInit]>() + .mockRejectedValueOnce(new Error(credentials.clientSecret)) + .mockResolvedValueOnce(response()); + const auth = new NombaSandboxAuth(credentials, transport, () => start); + await expect(auth.getAccessToken()).rejects.toThrow( + 'NOMBA_AUTH_UNAVAILABLE', + ); + expect(await auth.getAccessToken()).toBe('first-token'); + }); + + it.each([ + { access_token: '' }, + { access_token: 'bad\ntoken' }, + { refresh_token: '' }, + { expiresAt: 'invalid' }, + { expiresAt: new Date(start).toISOString() }, + ])('rejects unusable token response %j', async (overrides) => { + const transport = jest + .fn, [string, RequestInit]>() + .mockResolvedValue(response('first-token', start, overrides)); + const auth = new NombaSandboxAuth(credentials, transport, () => start); + await expect(auth.getAccessToken()).rejects.toThrow( + 'NOMBA_AUTH_INVALID_RESPONSE', + ); + }); + + it('clears rejected refresh credentials and only reissues on a subsequent operation', async () => { + let now = start; + const transport = jest + .fn, [string, RequestInit]>() + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce(new Response('{}', { status: 401 })) + .mockImplementationOnce(() => + Promise.resolve(response('new-token', now)), + ); + const auth = new NombaSandboxAuth(credentials, transport, () => now); + await auth.getAccessToken(); + now += 1_500_000; + await expect(auth.getAccessToken()).rejects.toThrow('NOMBA_AUTH_REJECTED'); + expect(transport).toHaveBeenCalledTimes(2); + expect(await auth.getAccessToken()).toBe('new-token'); + expect(transport.mock.calls[2][0]).toContain('/issue'); + }); + + it('authenticates before a bank request and never submits when authentication fails', async () => { + const authTransport = jest + .fn, [string, RequestInit]>() + .mockRejectedValue(new Error('network failure')); + const bankTransport = jest.fn, [string, RequestInit]>(); + const auth = new NombaSandboxAuth(credentials, authTransport, () => start); + const adapter = new NombaAdapter( + { + senderName: 'Xend', + accountId: credentials.accountId, + accessToken: () => auth.getAccessToken(), + }, + bankTransport, + ); + await expect( + adapter.createAccount({ + reference: 'request', + accountReference: 'account', + firstName: 'Test', + lastName: 'Person', + email: 'test@example.com', + }), + ).rejects.toThrow('NOMBA_AUTH_UNAVAILABLE'); + expect(bankTransport).not.toHaveBeenCalled(); + }); + + it('invalidates a rejected token without replaying a bank mutation', async () => { + const authTransport = jest + .fn, [string, RequestInit]>() + .mockResolvedValueOnce(response()) + .mockResolvedValueOnce(response('second-token')); + const bankTransport = jest + .fn, [string, RequestInit]>() + .mockResolvedValueOnce(new Response('{}', { status: 401 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ code: '00', data: [] })), + ); + const auth = new NombaSandboxAuth(credentials, authTransport, () => start); + const adapter = new NombaAdapter( + { + senderName: 'Xend', + accountId: credentials.accountId, + accessToken: () => auth.getAccessToken(), + onUnauthorized: (token) => auth.invalidate(token), + }, + bankTransport, + ); + await expect( + adapter.createAccount({ + reference: 'request', + accountReference: 'account', + firstName: 'Test', + lastName: 'Person', + email: 'test@example.com', + }), + ).rejects.toThrow('NOMBA_REQUEST_REJECTED'); + expect(bankTransport).toHaveBeenCalledTimes(1); + expect(authTransport).toHaveBeenCalledTimes(1); + expect(await adapter.banks()).toEqual([]); + expect( + new Headers(bankTransport.mock.calls[1][1].headers).get('Authorization'), + ).toBe('Bearer second-token'); + }); +}); diff --git a/apps/backend/src/fiat/banking/nomba-auth.ts b/apps/backend/src/fiat/banking/nomba-auth.ts new file mode 100644 index 00000000..89f74ae9 --- /dev/null +++ b/apps/backend/src/fiat/banking/nomba-auth.ts @@ -0,0 +1,133 @@ +import { NombaError, type NombaTransport } from './nomba.adapter'; + +export interface NombaSandboxCredentials { + clientId: string; + clientSecret: string; + accountId: string; +} +interface TokenSet { + accessToken: string; + refreshToken: string; + expiresAt: number; +} + +/** Server-only OAuth cache. No credentials, provider bodies or tokens enter errors. + * https://developer.nomba.com/docs/getting-started/authentication + * https://developer.nomba.com/docs/products/accept-payment/sandbox-testing + * Authentication is not evidence of money settlement in Nomba's sandbox. + */ +export class NombaSandboxAuth { + private token?: TokenSet; + private pending?: Promise; + + constructor( + private readonly credentials: NombaSandboxCredentials, + private readonly transport: NombaTransport = fetch, + private readonly now: () => number = Date.now, + ) { + if ( + [ + credentials.clientId, + credentials.clientSecret, + credentials.accountId, + ].some( + (value) => + typeof value !== 'string' || !value.trim() || /\s/.test(value), + ) + ) + throw new NombaError('NOMBA_INCOMPLETE_AUTH'); + } + + getAccessToken(): Promise { + if (this.pending) return this.pending; + if (this.token && this.token.expiresAt - this.now() > 300_000) + return Promise.resolve(this.token.accessToken); + this.pending = this.exchange().finally(() => { + this.pending = undefined; + }); + return this.pending; + } + + /** A late 401 for an old token must not discard a newer token. */ + invalidate(accessToken: string): void { + if (this.token?.accessToken === accessToken) this.token = undefined; + } + + private async exchange(): Promise { + const previous = this.token; + let response: Response; + try { + response = await this.transport( + `https://sandbox.nomba.com/v1/auth/token/${previous ? 'refresh' : 'issue'}`, + { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(15_000), + headers: { + 'Content-Type': 'application/json', + accountId: this.credentials.accountId, + ...(previous + ? { Authorization: `Bearer ${previous.accessToken}` } + : {}), + }, + body: JSON.stringify( + previous + ? { + grant_type: 'refresh_token', + refresh_token: previous.refreshToken, + } + : { + grant_type: 'client_credentials', + client_id: this.credentials.clientId, + client_secret: this.credentials.clientSecret, + }, + ), + }, + ); + } catch { + throw new NombaError('NOMBA_AUTH_UNAVAILABLE'); + } + if (!response.ok) { + if (response.status === 401 || response.status === 403) + this.token = undefined; + throw new NombaError( + response.status >= 500 || response.status === 429 + ? 'NOMBA_AUTH_UNAVAILABLE' + : 'NOMBA_AUTH_REJECTED', + ); + } + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new NombaError('NOMBA_AUTH_INVALID_RESPONSE'); + } + if (!payload || typeof payload !== 'object') + throw new NombaError('NOMBA_AUTH_INVALID_RESPONSE'); + const row = payload as Record; + if (row.code !== '00') { + if (row.code === '401' || row.code === '403') this.token = undefined; + throw new NombaError('NOMBA_AUTH_REJECTED'); + } + const data = row.data as Record | undefined; + if ( + !data || + typeof data.access_token !== 'string' || + !data.access_token || + /\s/.test(data.access_token) || + typeof data.refresh_token !== 'string' || + !data.refresh_token || + /\s/.test(data.refresh_token) || + typeof data.expiresAt !== 'string' || + !Number.isFinite(Date.parse(data.expiresAt)) || + Date.parse(data.expiresAt) <= this.now() + 15_000 + ) + throw new NombaError('NOMBA_AUTH_INVALID_RESPONSE'); + this.token = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.parse(data.expiresAt), + }; + return this.token.accessToken; + } +} diff --git a/apps/backend/src/fiat/banking/nomba-transaction.spec.ts b/apps/backend/src/fiat/banking/nomba-transaction.spec.ts new file mode 100644 index 00000000..ccadb24c --- /dev/null +++ b/apps/backend/src/fiat/banking/nomba-transaction.spec.ts @@ -0,0 +1,77 @@ +import { NombaAdapter } from './nomba.adapter'; + +const transaction = { + id: 'deposit/1', + userId: 'merchant', + type: 'vact_transfer', + status: 'SUCCESS', + amount: '100.00', + fixedCharge: '10.00', + timeCreated: '2026-09-14T12:51:20Z', + source: 'api', +}; +function setup(data: unknown = transaction, authenticated = true) { + const transport = jest.fn, [string, RequestInit]>(() => + Promise.resolve(new Response(JSON.stringify({ code: '00', data }))), + ); + return { + transport, + adapter: new NombaAdapter( + { + senderName: 'Xend', + ...(authenticated + ? { accessToken: 'test-token', accountId: 'merchant' } + : {}), + }, + transport, + ), + }; +} +describe('Nomba notification transaction lookup', () => { + it('uses authenticated GET with an encoded ID and exact minor units', async () => { + const { adapter, transport } = setup(); + expect(await adapter.getTransaction('deposit/1')).toMatchObject({ + transactionId: 'deposit/1', + merchantId: 'merchant', + amountMinor: '10000', + feeMinor: '1000', + evidence: 'authenticated_sandbox', + }); + const [url, init] = transport.mock.calls[0]; + expect(url).toBe( + 'https://sandbox.nomba.com/v1/transactions/accounts/single?transactionRef=deposit%2F1', + ); + expect(init.method).toBe('GET'); + expect(init.headers).toMatchObject({ + Authorization: 'Bearer test-token', + accountId: 'merchant', + }); + }); + it('does not call the public fixture API as authenticated evidence', async () => { + const { adapter, transport } = setup(transaction, false); + await expect(adapter.getTransaction('deposit/1')).rejects.toThrow( + 'NOMBA_AUTH_REQUIRED', + ); + expect(transport).not.toHaveBeenCalled(); + }); + it.each([ + { id: 'other' }, + { amount: '-1' }, + { amount: '100.001' }, + { fixedCharge: '-1' }, + { timeCreated: 'invalid' }, + ])('rejects malformed evidence %j', async (changes) => { + await expect( + setup({ ...transaction, ...changes }).adapter.getTransaction('deposit/1'), + ).rejects.toThrow(); + }); + it('preserves missing merchant and fee as unknown', async () => { + expect( + await setup({ + ...transaction, + userId: undefined, + fixedCharge: undefined, + }).adapter.getTransaction('deposit/1'), + ).toMatchObject({ merchantId: null, feeMinor: null }); + }); +}); diff --git a/apps/backend/src/fiat/banking/nomba-valuation.spec.ts b/apps/backend/src/fiat/banking/nomba-valuation.spec.ts new file mode 100644 index 00000000..bfc6141e --- /dev/null +++ b/apps/backend/src/fiat/banking/nomba-valuation.spec.ts @@ -0,0 +1,113 @@ +import { NombaAdapter } from './nomba.adapter'; + +const now = Date.parse('2026-09-09T22:00:00.000Z'); +const rate = { + currencyPairName: 'NGN/USD', + createdAt: new Date(now).toISOString(), +}; +const quote = { + fromAmount: 1500, + fromCurrency: 'NGN', + toAmount: 1, + toCurrency: 'USD', + exchangeRateId: 'rate-1', +}; +function setup(rateResponse: unknown = rate, quoteResponse: unknown = quote) { + const transport = jest + .fn, [string, RequestInit]>() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ code: '00', data: { rates: [rateResponse] } }), + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ code: '00', data: quoteResponse })), + ); + const adapter = new NombaAdapter( + { + senderName: 'Xend', + accountId: 'sandbox-account', + accessToken: 'sandbox-token', + }, + transport, + ); + return { adapter, transport }; +} +describe('Nomba indicative USD fiat valuation', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(now); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('binds explicit amounts and authenticates both quote-only requests without authorizing settlement', async () => { + const { adapter, transport } = setup(); + expect(await adapter.quoteNgnUsd('150000')).toEqual({ + debitNgnMinor: '150000', + creditUsdMinor: '100', + environment: 'sandbox', + evidence: 'fixture', + observedAt: new Date(now).toISOString(), + expiresAt: new Date(now + 60_000).toISOString(), + }); + expect(transport.mock.calls.map(([url]) => url)).toEqual([ + 'https://sandbox.nomba.com/v1/global-payout/exchange-rates?from=NGN&to=USD', + 'https://sandbox.nomba.com/v1/global-payout/money/convert', + ]); + expect(transport.mock.calls[1][1].headers).toMatchObject({ + Authorization: 'Bearer sandbox-token', + accountId: 'sandbox-account', + }); + expect(JSON.parse(transport.mock.calls[1][1].body as string)).toEqual({ + amount: 1500, + currency: 'NGN', + destinationCurrency: 'USD', + transactionType: 'EXCHANGE', + }); + }); + it('refuses anonymous fixtures for balance valuation', async () => { + const transport = jest.fn(); + await expect( + new NombaAdapter({ senderName: 'Xend' }, transport).quoteNgnUsd('150000'), + ).rejects.toThrow('NOMBA_AUTH_REQUIRED'); + expect(transport).not.toHaveBeenCalled(); + }); + it.each([ + { ...rate, currencyPairName: 'EUR/USD' }, + { ...rate, createdAt: 'invalid' }, + { ...rate, createdAt: new Date(now - 300_000).toISOString() }, + { ...rate, createdAt: new Date(now + 5_001).toISOString() }, + ])( + 'rejects mismatched, invalid, stale or future rate evidence', + async (rateResponse) => { + const { adapter, transport } = setup(rateResponse); + await expect(adapter.quoteNgnUsd('150000')).rejects.toThrow(); + expect(transport).toHaveBeenCalledTimes(1); + }, + ); + it.each([ + { ...quote, fromAmount: 1499 }, + { ...quote, fromCurrency: 'USD' }, + { ...quote, toCurrency: 'USDC' }, + { ...quote, toAmount: 0 }, + { ...quote, toAmount: -1 }, + { ...quote, toAmount: '1.001' }, + ])( + 'rejects mismatched or invalid conversion amounts', + async (quoteResponse) => { + await expect( + setup(rate, quoteResponse).adapter.quoteNgnUsd('150000'), + ).rejects.toThrow(); + }, + ); + it('rechecks rate expiry after the quote response', async () => { + const { adapter } = setup(); + jest + .spyOn(Date, 'now') + .mockReturnValueOnce(now) + .mockReturnValueOnce(now + 300_000); + await expect(adapter.quoteNgnUsd('150000')).rejects.toThrow( + 'NOMBA_STALE_RATE', + ); + }); +}); diff --git a/apps/backend/src/fiat/banking/nomba.adapter.spec.ts b/apps/backend/src/fiat/banking/nomba.adapter.spec.ts new file mode 100644 index 00000000..44f1830b --- /dev/null +++ b/apps/backend/src/fiat/banking/nomba.adapter.spec.ts @@ -0,0 +1,378 @@ +import { createHmac } from 'node:crypto'; +import { NombaAdapter, verifyNombaWebhook } from './nomba.adapter'; + +const recipient = { + bankCode: '058', + accountNumber: '0000000000', + accountName: 'Sandbox Recipient', +}; +const payout = { + reference: 'xend-test', + amountMinor: '10001', + recipient, + narration: 'Test', +}; +const record = { + id: 'provider-1', + amount: '100.01', + type: 'transfer', + status: 'SUCCESS', + meta: { merchantTxRef: payout.reference, currency: 'NGN', ...recipient }, +}; +function setup(data: unknown = record) { + const transport = jest.fn(() => + Promise.resolve( + new Response(JSON.stringify({ code: '00', data }), { status: 200 }), + ), + ); + return { + transport, + adapter: new NombaAdapter({ senderName: 'Xend' }, transport), + }; +} +describe('Nomba sandbox adapter', () => { + it('rejects production URLs and partial authentication', () => { + expect( + () => + new NombaAdapter({ + senderName: 'Xend', + baseUrl: 'https://api.nomba.com', + }), + ).toThrow('NOMBA_PRODUCTION_DISABLED'); + expect( + () => new NombaAdapter({ senderName: 'Xend', accessToken: 'test' }), + ).toThrow('NOMBA_INCOMPLETE_AUTH'); + }); + it('sends exact minor units and never claims verified settlement', async () => { + const { adapter, transport } = setup(); + expect(await adapter.submitPayout(payout)).toMatchObject({ + amountMinor: '10001', + status: 'succeeded', + evidence: 'fixture', + }); + const call = transport.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(call[1].body as string)).toMatchObject({ + amount: 100.01, + merchantTxRef: payout.reference, + }); + expect(call[1].redirect).toBe('error'); + }); + it.each(['0', '-1', '1.2', '01', '9007199254740992'])( + 'rejects invalid or unsafe amount %s', + async (amountMinor) => { + const { adapter, transport } = setup(); + await expect( + adapter.submitPayout({ ...payout, amountMinor }), + ).rejects.toThrow('NOMBA_INVALID_AMOUNT'); + expect(transport).not.toHaveBeenCalled(); + }, + ); + it('does not retry an uncertain write', async () => { + const { adapter, transport } = setup(); + transport.mockRejectedValueOnce(new Error('timeout')); + await expect(adapter.submitPayout(payout)).rejects.toThrow( + 'NOMBA_SUBMISSION_UNCERTAIN', + ); + expect(transport).toHaveBeenCalledTimes(1); + }); + it('identifies an acceptance without transaction ID as uncertain', async () => { + await expect( + setup({ status: 'PENDING_BILLING' }).adapter.submitPayout(payout), + ).rejects.toThrow('NOMBA_SUBMISSION_UNCERTAIN'); + }); + it.each([ + { ...record, amount: '100.02' }, + { ...record, type: 'withdrawal' }, + { ...record, meta: { ...record.meta, merchantTxRef: 'foreign' } }, + { ...record, meta: { ...record.meta, currency: 'USD' } }, + { ...record, meta: { ...record.meta, accountNumber: '1111111111' } }, + { ...record, meta: { ...record.meta, bankCode: undefined } }, + ])('rejects missing or mismatched transaction evidence', async (data) => { + await expect( + setup(data).adapter.getPayout({ + ...payout, + providerReference: record.id, + }), + ).rejects.toThrow('NOMBA_TRANSACTION_MISMATCH'); + }); + it('rejects a changed ID even when merchant reference is equal', async () => { + await expect( + setup({ ...record, id: 'retry-created-another-id' }).adapter.getPayout({ + ...payout, + providerReference: record.id, + }), + ).rejects.toThrow('NOMBA_TRANSACTION_MISMATCH'); + }); + it('keeps same-ref provider retries distinct, without claiming provider idempotency', async () => { + const { adapter, transport } = setup(); + await adapter.submitPayout(payout); + transport.mockResolvedValueOnce( + new Response( + JSON.stringify({ code: '00', data: { ...record, id: 'provider-2' } }), + ), + ); + expect((await adapter.submitPayout(payout)).providerReference).toBe( + 'provider-2', + ); + // Cross-request dedupe must live in the durable Xend orchestrator, never this HTTP adapter. + }); + it.each([ + ['PENDING_BILLING', 'pending'], + ['REFUND', 'refunded'], + ['FAILED', 'failed'], + ['NEW_STATUS', 'unknown'], + ])('maps %s without guessing finality', async (status, expected) => { + expect( + ( + await setup({ ...record, status }).adapter.getPayout({ + ...payout, + providerReference: record.id, + }) + ).status, + ).toBe(expected); + }); + it('attaches server authentication while retaining fixture evidence', async () => { + const { transport } = setup(); + const adapter = new NombaAdapter( + { + senderName: 'Xend', + accessToken: 'test-token', + accountId: 'test-account', + }, + transport, + ); + expect( + (await adapter.getPayout({ ...payout, providerReference: record.id })) + .evidence, + ).toBe('fixture'); + const call = transport.mock.calls[0] as unknown as [string, RequestInit]; + expect(call[1].headers).toMatchObject({ + Authorization: 'Bearer test-token', + accountId: 'test-account', + }); + expect(call[0]).toContain('merchantTxRef=xend-test'); + }); + it('creates a static account with matching reference and pooled custody', async () => { + const { adapter, transport } = setup({ + accountRef: 'consumer-ref', + currency: 'NGN', + bankAccountNumber: '0000000000', + bankAccountName: 'Test Name', + bankName: 'Test Bank', + }); + const result = await adapter.createAccount({ + reference: 'idempotency-ref', + accountReference: 'consumer-ref', + firstName: 'Test', + lastName: 'Name', + email: 'test@example.com', + }); + expect(result.custody).toBe('pooled'); + const call = transport.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(call[1].body as string)).not.toHaveProperty('expiryDate'); + }); + it('rejects account reference mismatch', async () => { + await expect( + setup({ accountRef: 'wrong' }).adapter.createAccount({ + reference: 'request', + accountReference: 'consumer', + firstName: 'A', + lastName: 'B', + email: 'a@example.com', + }), + ).rejects.toThrow('NOMBA_ACCOUNT_MISMATCH'); + }); + it('lists banks and checks lookup identity', async () => { + const banks = setup([{ code: '058', name: 'Test Bank' }]); + expect(await banks.adapter.banks()).toEqual([ + { code: '058', name: 'Test Bank' }, + ]); + expect(banks.transport).toHaveBeenCalledWith( + 'https://sandbox.nomba.com/v1/transfers/banks', + expect.objectContaining({ method: 'GET' }), + ); + expect( + await setup(recipient).adapter.resolveRecipient('058', '0000000000'), + ).toEqual(recipient); + await expect( + setup({ + ...recipient, + accountNumber: '1111111111', + }).adapter.resolveRecipient('058', '0000000000'), + ).rejects.toThrow('NOMBA_RECIPIENT_MISMATCH'); + }); +}); + +describe('Nomba account recovery', () => { + const account = { + accountRef: 'xna-owned-account-reference', + accountHolderId: 'sandbox-parent', + currency: 'NGN', + expired: false, + bankAccountNumber: '0123456789', + bankAccountName: 'Xend Sandbox', + bankName: 'Nomba', + }; + function authenticated(data: unknown) { + const { transport } = setup(data); + return { + transport, + adapter: new NombaAdapter( + { + senderName: 'Xend', + accessToken: 'sandbox-token', + accountId: 'sandbox-parent', + }, + transport, + ), + }; + } + it('recovers only the matching active account under the configured parent using a read', async () => { + const { adapter, transport } = authenticated(account); + await expect( + adapter.retrieveAccount(account.accountRef, 'request'), + ).resolves.toEqual({ + provider: 'nomba', + reference: account.accountRef, + accountNumber: account.bankAccountNumber, + accountName: account.bankAccountName, + bankName: account.bankName, + currency: 'NGN', + custody: 'pooled', + }); + expect(transport).toHaveBeenCalledTimes(1); + expect(transport).toHaveBeenCalledWith( + `https://sandbox.nomba.com/v1/accounts/virtual/${account.accountRef}`, + expect.objectContaining({ + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer sandbox-token', + accountId: 'sandbox-parent', + }, + }), + ); + }); + it.each([ + { accountRef: 'other-reference' }, + { accountHolderId: 'other-parent' }, + { accountHolderId: undefined }, + { currency: 'USD' }, + { expired: true }, + { expired: undefined }, + { bankAccountNumber: '123' }, + ])( + 'refuses mismatched or incomplete recovery evidence: %j', + async (overrides) => { + await expect( + authenticated({ ...account, ...overrides }).adapter.retrieveAccount( + account.accountRef, + 'request', + ), + ).rejects.toThrow('NOMBA_ACCOUNT_MISMATCH'); + }, + ); + it('does not recover an account from anonymous sandbox responses', async () => { + const { adapter, transport } = setup(account); + await expect( + adapter.retrieveAccount(account.accountRef, 'request'), + ).rejects.toThrow('NOMBA_AUTH_REQUIRED'); + expect(transport).not.toHaveBeenCalled(); + }); + it.each([ + { accountHolderId: 'other-parent' }, + { accountHolderId: undefined }, + { expired: undefined }, + ])( + 'does not activate an authenticated create response with incomplete parent ownership: %j', + async (overrides) => { + await expect( + authenticated({ ...account, ...overrides }).adapter.createAccount({ + reference: 'request', + accountReference: account.accountRef, + firstName: 'Xend', + lastName: 'Sandbox', + email: 'sandbox@example.com', + }), + ).rejects.toThrow('NOMBA_ACCOUNT_MISMATCH'); + }, + ); +}); + +describe('Nomba webhook notification verification', () => { + const timestamp = '2026-09-09T12:00:00Z'; + const payload = { + event_type: 'payment_success', + requestId: 'event', + data: { + merchant: { userId: 'merchant', walletId: 'wallet' }, + transaction: { + transactionId: 'txn', + type: 'vact_transfer', + time: timestamp, + responseCode: '', + transactionAmount: 10, + }, + }, + }; + const signature = createHmac('sha256', 'test-secret') + .update( + `payment_success:event:merchant:wallet:txn:vact_transfer:${timestamp}::${timestamp}`, + ) + .digest('base64'); + const headers = { + 'nomba-signature': signature, + 'nomba-signature-algorithm': 'HmacSHA256', + 'nomba-signature-version': '1.0.0', + 'nomba-timestamp': timestamp, + }; + it('validates signed fields but demonstrates unsigned amounts are not money authority', () => { + expect( + verifyNombaWebhook( + payload, + headers, + 'test-secret', + Date.parse(timestamp), + ), + ).toBe(true); + const changed = structuredClone(payload); + changed.data.transaction.transactionAmount = 999999; + expect( + verifyNombaWebhook( + changed, + headers, + 'test-secret', + Date.parse(timestamp), + ), + ).toBe(true); + }); + it('rejects changed signed identity, case-corrupted signature, stale or malformed notifications', () => { + expect( + verifyNombaWebhook( + { ...payload, requestId: 'tampered' }, + headers, + 'test-secret', + Date.parse(timestamp), + ), + ).toBe(false); + expect( + verifyNombaWebhook( + payload, + { ...headers, 'nomba-signature': signature.toLowerCase() }, + 'test-secret', + Date.parse(timestamp), + ), + ).toBe(false); + expect( + verifyNombaWebhook( + payload, + headers, + 'test-secret', + Date.parse(timestamp) + 300001, + ), + ).toBe(false); + expect( + verifyNombaWebhook({}, headers, 'test-secret', Date.parse(timestamp)), + ).toBe(false); + }); +}); diff --git a/apps/backend/src/fiat/banking/nomba.adapter.ts b/apps/backend/src/fiat/banking/nomba.adapter.ts new file mode 100644 index 00000000..84bcfe5c --- /dev/null +++ b/apps/backend/src/fiat/banking/nomba.adapter.ts @@ -0,0 +1,463 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { + BankAccount, + BankAccountProvider, + BankAccountReader, + BankOperation, + BankPayoutProvider, + BankRecipient, + BankTransactionReader, + BankTransactionObservation, +} from './banking-provider.interface'; + +export interface NombaConfig { + baseUrl?: string; + accessToken?: string | (() => Promise); + onUnauthorized?: (token: string) => void; + accountId?: string; + senderName: string; +} +export type NombaTransport = ( + url: string, + init: RequestInit, +) => Promise; +export interface NombaIndicativeUsdQuote { + debitNgnMinor: string; + creditUsdMinor: string; + observedAt: string; + expiresAt: string; + environment: 'sandbox'; + evidence: 'fixture'; +} +export class NombaError extends Error { + constructor(readonly code: string) { + super(code); + } +} +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new NombaError('NOMBA_INVALID_RESPONSE'); + return value as Record; +} +function text(value: unknown): string { + if (typeof value !== 'string' || !value.trim()) + throw new NombaError('NOMBA_MISSING_FIELD'); + return value; +} +function minor(value: unknown): string { + const input = + typeof value === 'number' && Number.isFinite(value) ? String(value) : value; + if (typeof input !== 'string' || !/^\d+(\.\d{1,2})?$/.test(input)) + throw new NombaError('NOMBA_INVALID_AMOUNT'); + const [whole, fraction = ''] = input.split('.'); + const amount = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')); + if (amount > BigInt(Number.MAX_SAFE_INTEGER)) + throw new NombaError('NOMBA_INVALID_AMOUNT'); + return amount.toString(); +} +function major(amount: string): number { + if (!/^[1-9]\d*$/.test(amount)) throw new NombaError('NOMBA_INVALID_AMOUNT'); + const result = Number( + `${BigInt(amount) / 100n}.${(BigInt(amount) % 100n).toString().padStart(2, '0')}`, + ); + if (minor(result) !== amount) throw new NombaError('NOMBA_INVALID_AMOUNT'); + return result; +} +function recipient(value: BankRecipient): void { + if ( + !/^\d{10}$/.test(value.accountNumber) || + !/^\d{3,6}$/.test(value.bankCode) + ) + throw new NombaError('NOMBA_INVALID_RECIPIENT'); + text(value.accountName); +} + +/** Sandbox contract adapter only. Even authenticated sandbox responses remain fixtures. + * Durable idempotency/reservations belong in Xend's orchestrator. This adapter never retries writes. + */ +export class NombaAdapter + implements + BankAccountProvider, + BankAccountReader, + BankPayoutProvider, + BankTransactionReader +{ + readonly name = 'nomba'; + private readonly baseUrl: string; + constructor( + private readonly config: NombaConfig, + private readonly transport: NombaTransport = fetch, + ) { + this.baseUrl = config.baseUrl ?? 'https://sandbox.nomba.com'; + if (this.baseUrl !== 'https://sandbox.nomba.com') + throw new NombaError('NOMBA_PRODUCTION_DISABLED'); + if (Boolean(config.accessToken) !== Boolean(config.accountId)) + throw new NombaError('NOMBA_INCOMPLETE_AUTH'); + text(config.senderName); + } + private async request(path: string, body?: unknown): Promise { + // Authenticate before entering the submission boundary. An auth failure cannot + // make a bank write uncertain because no bank request has been sent yet. + const accessToken = + typeof this.config.accessToken === 'function' + ? await this.config.accessToken() + : this.config.accessToken; + if (this.config.accessToken && (!accessToken || /\s/.test(accessToken))) + throw new NombaError('NOMBA_AUTH_INVALID_RESPONSE'); + let response: Response; + try { + response = await this.transport(`${this.baseUrl}${path}`, { + method: body === undefined ? 'GET' : 'POST', + redirect: 'error', + signal: AbortSignal.timeout(15000), + headers: { + 'Content-Type': 'application/json', + ...(accessToken + ? { + Authorization: `Bearer ${accessToken}`, + accountId: this.config.accountId!, + } + : {}), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch { + throw new NombaError( + body === undefined ? 'NOMBA_UNAVAILABLE' : 'NOMBA_SUBMISSION_UNCERTAIN', + ); + } + if (response.status === 401 && accessToken) + this.config.onUnauthorized?.(accessToken); + // Never refresh-and-retry a money mutation; its durable caller decides recovery. + if (!response.ok) + throw new NombaError( + response.status >= 500 && body !== undefined + ? 'NOMBA_SUBMISSION_UNCERTAIN' + : 'NOMBA_REQUEST_REJECTED', + ); + let payload: Record; + try { + payload = object(await response.json()); + } catch { + throw new NombaError( + body === undefined + ? 'NOMBA_INVALID_RESPONSE' + : 'NOMBA_SUBMISSION_UNCERTAIN', + ); + } + if (String(payload.code) === '401' && accessToken) + this.config.onUnauthorized?.(accessToken); + if (!['00', '200', '201'].includes(String(payload.code))) + throw new NombaError('NOMBA_REQUEST_REJECTED'); + return payload.data; + } + /** Quote-only API: https://developer.nomba.com/nomba-api-reference/global-payout/convert-money + * This calculates USD fiat value; it neither authorizes an exchange nor quotes USDC settlement. + * Sandbox values remain fixtures, including when authenticated. + */ + async quoteNgnUsd(amountMinor: string): Promise { + if (!this.config.accessToken || !this.config.accountId) + throw new NombaError('NOMBA_AUTH_REQUIRED'); + const amount = major(amountMinor); + const rateData = object( + await this.request('/v1/global-payout/exchange-rates?from=NGN&to=USD'), + ); + if (!Array.isArray(rateData.rates)) + throw new NombaError('NOMBA_INVALID_RATE'); + const rates = rateData.rates + .map(object) + .filter((rate) => rate.currencyPairName === 'NGN/USD'); + if (rates.length !== 1) throw new NombaError('NOMBA_INVALID_RATE'); + const rate = rates[0]; + const rateTime = Date.parse(text(rate.updatedAt || rate.createdAt)); + const rateExpiry = rateTime + 300_000; + const now = Date.now(); + if ( + !Number.isFinite(rateTime) || + rateTime > now + 5_000 || + rateExpiry <= now + ) + throw new NombaError('NOMBA_STALE_RATE'); + // A displayed currencyPairName alone does not establish numeric orientation. + // Use explicit conversion amounts rather than interpreting the midpoint. + const data = object( + await this.request('/v1/global-payout/money/convert', { + amount, + currency: 'NGN', + destinationCurrency: 'USD', + transactionType: 'EXCHANGE', + }), + ); + if ( + data.fromCurrency !== 'NGN' || + data.toCurrency !== 'USD' || + minor(data.fromAmount) !== amountMinor + ) + throw new NombaError('NOMBA_QUOTE_MISMATCH'); + const creditUsdMinor = minor(data.toAmount); + if (BigInt(creditUsdMinor) <= 0n) + throw new NombaError('NOMBA_INVALID_AMOUNT'); + text(data.exchangeRateId); + const observed = Date.now(); + if (rateExpiry <= observed) throw new NombaError('NOMBA_STALE_RATE'); + return { + debitNgnMinor: amountMinor, + creditUsdMinor, + observedAt: new Date(observed).toISOString(), + expiresAt: new Date( + Math.min(observed + 60_000, rateExpiry), + ).toISOString(), + environment: 'sandbox', + evidence: 'fixture', + }; + } + async createAccount( + input: Parameters[0], + ): Promise { + text(input.reference); + text(input.accountReference); + if (input.bvn !== undefined && !/^\d{11}$/.test(input.bvn)) + throw new NombaError('NOMBA_INVALID_BVN'); + const data = object( + await this.request('/v1/accounts/virtual', { + accountRef: input.accountReference, + accountName: `${text(input.firstName)} ${text(input.lastName)}`, + currency: 'NGN', + ...(input.bvn ? { bvn: input.bvn } : {}), + }), + ); + if ( + data.accountRef !== input.accountReference || + data.currency !== 'NGN' || + data.expired === true || + (this.config.accountId && + (data.accountHolderId !== this.config.accountId || + data.expired !== false)) + ) + throw new NombaError('NOMBA_ACCOUNT_MISMATCH'); + const accountNumber = text(data.bankAccountNumber); + if (!/^\d{10}$/.test(accountNumber)) + throw new NombaError('NOMBA_ACCOUNT_MISMATCH'); + return { + provider: this.name, + reference: input.accountReference, + accountNumber, + accountName: text(data.bankAccountName), + bankName: text(data.bankName), + currency: 'NGN', + custody: 'pooled', + }; + } + async banks(): Promise<{ code: string; name: string }[]> { + const data = await this.request('/v1/transfers/banks'); + if (!Array.isArray(data)) throw new NombaError('NOMBA_INVALID_RESPONSE'); + return data.map((item: unknown) => { + const row = object(item); + return { code: text(row.code), name: text(row.name) }; + }); + } + /** Requery the signed notification's ID, never its unsigned monetary fields. + * The sandbox can return canned successes, so consumers must match identities + * and independently establish customer attribution before treating this as cash. + */ + async getTransaction( + transactionId: string, + ): Promise { + if (!this.config.accessToken || !this.config.accountId) + throw new NombaError('NOMBA_AUTH_REQUIRED'); + text(transactionId); + const data = object( + await this.request( + `/v1/transactions/accounts/single?${new URLSearchParams({ transactionRef: transactionId })}`, + ), + ); + if (data.id !== transactionId) + throw new NombaError('NOMBA_TRANSACTION_MISMATCH'); + const createdAt = text(data.timeCreated); + if (!Number.isFinite(Date.parse(createdAt))) + throw new NombaError('NOMBA_INVALID_RESPONSE'); + return { + transactionId, + merchantId: data.userId == null ? null : text(data.userId), + type: text(data.type), + status: text(data.status), + amountMinor: minor(data.amount), + feeMinor: data.fixedCharge == null ? null : minor(data.fixedCharge), + createdAt, + source: text(data.source), + evidence: 'authenticated_sandbox', + }; + } + /** Recover a provisioned account by Xend's persisted reference, never create again. */ + async retrieveAccount( + accountReference: string, + requestReference: string, + ): Promise { + if (!this.config.accessToken || !this.config.accountId) + throw new NombaError('NOMBA_AUTH_REQUIRED'); + text(accountReference); + text(requestReference); + const data = object( + await this.request( + `/v1/accounts/virtual/${encodeURIComponent(accountReference)}`, + ), + ); + if ( + data.accountRef !== accountReference || + data.accountHolderId !== this.config.accountId || + data.currency !== 'NGN' || + data.expired !== false || + typeof data.bankAccountNumber !== 'string' || + !/^\d{10}$/.test(data.bankAccountNumber) + ) + throw new NombaError('NOMBA_ACCOUNT_MISMATCH'); + return { + provider: this.name, + reference: accountReference, + accountNumber: data.bankAccountNumber, + accountName: text(data.bankAccountName), + bankName: text(data.bankName), + currency: 'NGN', + custody: 'pooled', + }; + } + async resolveRecipient( + bankCode: string, + accountNumber: string, + ): Promise { + recipient({ bankCode, accountNumber, accountName: 'lookup' }); + const data = object( + await this.request('/v1/transfers/bank/lookup', { + bankCode, + accountNumber, + }), + ); + if (data.accountNumber !== accountNumber) + throw new NombaError('NOMBA_RECIPIENT_MISMATCH'); + return { bankCode, accountNumber, accountName: text(data.accountName) }; + } + async submitPayout( + input: Parameters[0], + ): Promise { + recipient(input.recipient); + text(input.reference); + const data = object( + await this.request('/v2/transfers/bank', { + amount: major(input.amountMinor), + ...input.recipient, + merchantTxRef: input.reference, + senderName: this.config.senderName, + narration: input.narration, + }), + ); + // Acceptance without an ID cannot be safely retried: requery the durable merchant reference. + if (!data.id) throw new NombaError('NOMBA_SUBMISSION_UNCERTAIN'); + return this.operation(data, input); + } + async getPayout( + input: Parameters[0], + ): Promise { + recipient(input.recipient); + text(input.reference); + text(input.providerReference); + major(input.amountMinor); + const query = new URLSearchParams({ + transactionRef: input.providerReference, + merchantTxRef: input.reference, + }); + const data = object( + await this.request(`/v1/transactions/accounts/single?${query}`), + ); + if (data.id !== input.providerReference) + throw new NombaError('NOMBA_TRANSACTION_MISMATCH'); + return this.operation(data, input); + } + private operation( + data: Record, + input: { reference: string; amountMinor: string; recipient: BankRecipient }, + ): BankOperation { + const meta = data.meta === undefined ? {} : object(data.meta); + if ( + (data.merchantTxRef ?? meta.merchantTxRef) !== input.reference || + minor(data.amount) !== input.amountMinor || + data.type !== 'transfer' + ) + throw new NombaError('NOMBA_TRANSACTION_MISMATCH'); + // These fields are documented on transfers. Missing fields cannot prove matching settlement. + if ( + (data.currency ?? meta.currency) !== 'NGN' || + (meta.accountNumber ?? data.customerBillerId) !== + input.recipient.accountNumber || + (meta.bankCode ?? data.productId) !== input.recipient.bankCode + ) + throw new NombaError('NOMBA_TRANSACTION_MISMATCH'); + const statuses: Record = { + SUCCESS: 'succeeded', + PENDING_BILLING: 'pending', + PENDING: 'pending', + FAILED: 'failed', + REFUND: 'refunded', + }; + return { + provider: this.name, + reference: input.reference, + providerReference: text(data.id), + status: statuses[String(data.status)] ?? 'unknown', + amountMinor: input.amountMinor, + currency: 'NGN', + evidence: 'fixture', + }; + } +} + +/** A verified notification only schedules authenticated requery; amount and beneficiary are NOT signed. */ +export function verifyNombaWebhook( + payload: unknown, + headers: Record, + secret: string, + now = Date.now(), +): boolean { + try { + if ( + !secret || + headers['nomba-signature-algorithm'] !== 'HmacSHA256' || + headers['nomba-signature-version'] !== '1.0.0' + ) + return false; + const timestamp = text(headers['nomba-timestamp']); + const instant = Date.parse(timestamp); + if (!Number.isFinite(instant) || Math.abs(now - instant) > 300000) + return false; + const row = object(payload), + data = object(row.data), + merchant = object(data.merchant), + transaction = object(data.transaction); + const responseCode = + transaction.responseCode == null || + transaction.responseCode === 'null' || + transaction.responseCode === '' + ? '' + : text(transaction.responseCode); + const signed = [ + text(row.event_type), + text(row.requestId), + text(merchant.userId), + text(merchant.walletId), + text(transaction.transactionId), + text(transaction.type), + text(transaction.time), + responseCode, + timestamp, + ].join(':'); + const expected = createHmac('sha256', secret) + .update(signed) + .digest('base64'); + const actual = text(headers['nomba-signature']); + return ( + actual.length === expected.length && + timingSafeEqual(Buffer.from(actual), Buffer.from(expected)) + ); + } catch { + return false; + } +}