From e3c13f924f5936735cde1432081702fe0dfbbc67 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 12:50:52 -0600 Subject: [PATCH] feat(ramps): add NeoBankService Pix and quote client methods Expose messenger-backed neo-bank proxy helpers for Pix send and autoramp quotes under the live `/neobank` prefix, and align getAutoramp with that path. --- packages/ramps-controller/CHANGELOG.md | 5 + .../src/NeoBankService-method-action-types.ts | 90 +++- .../src/NeoBankService.test.ts | 449 +++++++++++++++++- .../ramps-controller/src/NeoBankService.ts | 229 ++++++++- packages/ramps-controller/src/index.ts | 13 +- 5 files changed, 732 insertions(+), 54 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c3573e3e5c..557119c690 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,8 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) +### Changed + +- Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. + ## [20.0.0] ### Changed diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts index 48dcc1839b..3343ed3ba8 100644 --- a/packages/ramps-controller/src/NeoBankService-method-action-types.ts +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -6,8 +6,9 @@ import type { NeoBankService } from './NeoBankService.js'; /** - * Fetches an autoramp account via the Ramp API proxy of - * MoonPay `GET /api/autoramps/{autoramp_id}`. + * Fetches an autoramp account via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}` (MoonPay + * `GET /api/autoramps/{autoramp_id}`). * * @param autorampId - MoonPay / Ramp API autoramp id. * @returns Remote snapshot for controller apply/refresh. @@ -17,7 +18,90 @@ export type NeoBankServiceGetAutorampAction = { handler: NeoBankService['getAutoramp']; }; +/** + * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. + * Body is forwarded as opaque JSON (MoonPay address schema). + * + * @param body - Pix address registration payload. + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceRegisterPixAddressAction = { + type: `NeoBankService:registerPixAddress`; + handler: NeoBankService['registerPixAddress']; +}; + +/** + * Fetches an autoramp quote via neobank-proxy `GET /neobank/autoramps/quote`. + * + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceGetAutorampQuoteAction = { + type: `NeoBankService:getAutorampQuote`; + handler: NeoBankService['getAutorampQuote']; +}; + +/** + * Creates an autoramp from a signed quote via neobank-proxy + * `POST /neobank/autoramps` (MoonPay `POST /api/autoramps`). + * + * @param body - CreateAutoramp / signed-quote payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Remote snapshot for controller apply/refresh. + */ +export type NeoBankServiceCreateAutorampAction = { + type: `NeoBankService:createAutoramp`; + handler: NeoBankService['createAutoramp']; +}; + +/** + * Fetches a quote for an existing autoramp via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}/quote`. + * + * @param autorampId - Autoramp id. + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceGetAutorampQuoteForAutorampAction = { + type: `NeoBankService:getAutorampQuoteForAutoramp`; + handler: NeoBankService['getAutorampQuoteForAutoramp']; +}; + +/** + * Attaches a signed quote to an autoramp via neobank-proxy + * `POST /neobank/autoramps/{autoramp_id}/quotes`. + * + * @param autorampId - Autoramp id. + * @param body - Quote attachment payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceAttachAutorampQuoteAction = { + type: `NeoBankService:attachAutorampQuote`; + handler: NeoBankService['attachAutorampQuote']; +}; + +/** + * Fetches a customer by partner external id via neobank-proxy + * `GET /neobank/customers/{external_id}/external`. + * + * @param externalId - Partner-assigned external customer id. + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceGetCustomerByExternalIdAction = { + type: `NeoBankService:getCustomerByExternalId`; + handler: NeoBankService['getCustomerByExternalId']; +}; + /** * Union of all NeoBankService action types. */ -export type NeoBankServiceMethodActions = NeoBankServiceGetAutorampAction; +export type NeoBankServiceMethodActions = + | NeoBankServiceGetAutorampAction + | NeoBankServiceRegisterPixAddressAction + | NeoBankServiceGetAutorampQuoteAction + | NeoBankServiceCreateAutorampAction + | NeoBankServiceGetAutorampQuoteForAutorampAction + | NeoBankServiceAttachAutorampQuoteAction + | NeoBankServiceGetCustomerByExternalIdAction; diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index a0765b0002..c2f40d582a 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -9,7 +9,61 @@ import { RampsEnvironment } from './RampsService.js'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; +const STAGING_BASE = 'https://on-ramp.uat-api.cx.metamask.io'; + +/** + * Builds a NeoBankService with AuthenticationController bearer auth stubbed. + * + * @param options - Optional constructor overrides. Pass `omitDefaults: true` to + * exercise constructor defaulted parameters (`environment`, `policyOptions`). + * @returns Service instance for the test. + */ +function createService(options?: { + environment?: RampsEnvironment; + baseUrlOverride?: string; + omitDefaults?: boolean; +}): NeoBankService { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => 'test-token', + ); + + const messenger = new Messenger({ + namespace: 'NeoBankService', + parent: rootMessenger, + }) as unknown as NeoBankServiceMessenger; + rootMessenger.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + }); + + if (options?.omitDefaults) { + return new NeoBankService({ + messenger, + context: 'test', + fetch: globalThis.fetch.bind(globalThis), + baseUrlOverride: options.baseUrlOverride, + }); + } + + return new NeoBankService({ + messenger, + environment: options?.environment ?? RampsEnvironment.Staging, + context: 'test', + fetch: globalThis.fetch.bind(globalThis), + policyOptions: { maxRetries: 0 }, + baseUrlOverride: options?.baseUrlOverride, + }); +} + describe('NeoBankService', () => { + afterEach(() => { + nock.cleanAll(); + }); + describe('mapNeoBankAutorampToRemoteSnapshot', () => { it('maps MoonPay-shaped fields into a remote snapshot', () => { expect( @@ -28,29 +82,38 @@ describe('NeoBankService', () => { depositRailsSummary: { ready: true }, }); }); - }); - describe('getAutoramp', () => { - it('GETs the proxied autoramp endpoint with bearer auth', async () => { - const rootMessenger = new Messenger({ - namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace, + it('falls back to recipient_account.address when wallet_address is absent', () => { + expect( + mapNeoBankAutorampToRemoteSnapshot({ + id: 'ar-1', + customer_id: 'cust-1', + status: 'Pending', + recipient_account: { address: '0xfrom-recipient' }, + }), + ).toMatchObject({ + walletAddress: '0xfrom-recipient', + depositRailsSummary: undefined, }); - rootMessenger.registerActionHandler( - 'AuthenticationController:getBearerToken', - async () => 'test-token', - ); + }); - const messenger = new Messenger({ - namespace: 'NeoBankService', - parent: rootMessenger, - }) as unknown as NeoBankServiceMessenger; - rootMessenger.delegate({ - messenger, - actions: ['AuthenticationController:getBearerToken'], + it('marks deposit rails not ready when Approved without rails', () => { + expect( + mapNeoBankAutorampToRemoteSnapshot({ + id: 'ar-1', + customer_id: 'cust-1', + status: 'Approved', + }), + ).toMatchObject({ + depositRailsSummary: { ready: false }, }); + }); + }); - const scope = nock('https://on-ramp.uat-api.cx.metamask.io') - .get(/\/api\/v2\/autoramps\/ar-1/u) + describe('getAutoramp', () => { + it('GETs /neobank/autoramps/{id} with bearer auth', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) .matchHeader('Authorization', 'Bearer test-token') .reply(200, { id: 'ar-1', @@ -59,13 +122,7 @@ describe('NeoBankService', () => { wallet_address: '0xabc', }); - const service = new NeoBankService({ - messenger, - environment: RampsEnvironment.Staging, - context: 'test', - fetch: globalThis.fetch.bind(globalThis), - }); - + const service = createService(); const snapshot = await service.getAutoramp('ar-1'); expect(scope.isDone()).toBe(true); @@ -76,5 +133,347 @@ describe('NeoBankService', () => { walletAddress: '0xabc', }); }); + + it('throws HttpError when the proxy returns a non-2xx status', async () => { + nock(STAGING_BASE).get(/\/neobank\/autoramps\/missing/u).reply(404); + + const service = createService(); + await expect(service.getAutoramp('missing')).rejects.toThrow( + /failed with status '404'/u, + ); + }); + + it('throws when the response body is malformed', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { status: 'Authorized' }); + + const service = createService(); + await expect(service.getAutoramp('ar-1')).rejects.toThrow( + 'Malformed response received from neo-bank autoramp API', + ); + }); + }); + + describe('registerPixAddress', () => { + it('POSTs /neobank/addresses/pix with JSON body and bearer auth', async () => { + const body = { + type: 'Pix', + pix_key: 'user@example.com', + customer_id: 'cust-1', + }; + + const scope = nock(STAGING_BASE) + .post('/neobank/addresses/pix', body) + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .matchHeader('Content-Type', 'application/json') + .reply(200, { id: 'addr-1', ...body }); + + const service = createService(); + const result = await service.registerPixAddress(body); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ id: 'addr-1' }); + }); + + it('forwards Idempotency-Key when provided', async () => { + const scope = nock(STAGING_BASE) + .post('/neobank/addresses/pix', { pix_key: 'k' }) + .query(true) + .matchHeader('Idempotency-Key', 'idem-1') + .reply(200, { id: 'addr-1' }); + + const service = createService(); + await service.registerPixAddress( + { pix_key: 'k' }, + { idempotencyKey: 'idem-1' }, + ); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe('getAutorampQuote', () => { + it('GETs /neobank/autoramps/quote with query params', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps/quote') + .query((query) => { + return ( + query.amount === '100' && + query.currency === 'BRL' && + typeof query.sdk === 'string' && + typeof query.controller === 'string' && + query.context === 'test' + ); + }) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { quote_id: 'q-1', amount: '100' }); + + const service = createService(); + const result = await service.getAutorampQuote({ + amount: '100', + currency: 'BRL', + }); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ quote_id: 'q-1' }); + }); + }); + + describe('createAutoramp', () => { + it('POSTs /neobank/autoramps and maps the Autoramp response', async () => { + const body = { + signed_quote: 'sig', + customer_id: 'cust-1', + }; + + const scope = nock(STAGING_BASE) + .post('/neobank/autoramps', body) + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .matchHeader('Content-Type', 'application/json') + .reply(201, { + id: 'ar-new', + customer_id: 'cust-1', + status: 'Pending', + wallet_address: '0xdef', + }); + + const service = createService(); + const snapshot = await service.createAutoramp(body); + + expect(scope.isDone()).toBe(true); + expect(snapshot).toMatchObject({ + id: 'ar-new', + customerId: 'cust-1', + status: 'Pending', + walletAddress: '0xdef', + }); + }); + + it('forwards Idempotency-Key when provided', async () => { + const scope = nock(STAGING_BASE) + .post('/neobank/autoramps', { signed_quote: 'sig' }) + .query(true) + .matchHeader('Idempotency-Key', 'create-idem') + .reply(201, { + id: 'ar-2', + customer_id: 'cust-1', + status: 'Pending', + }); + + const service = createService(); + await service.createAutoramp( + { signed_quote: 'sig' }, + { idempotencyKey: 'create-idem' }, + ); + + expect(scope.isDone()).toBe(true); + }); + + it('throws when the response body is malformed', async () => { + nock(STAGING_BASE) + .post('/neobank/autoramps') + .query(true) + .reply(201, { status: 'Pending' }); + + const service = createService(); + await expect( + service.createAutoramp({ signed_quote: 'sig' }), + ).rejects.toThrow( + 'Malformed response received from neo-bank autoramp API', + ); + }); + }); + + describe('getAutorampQuoteForAutoramp', () => { + it('GETs /neobank/autoramps/{id}/quote with query params', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps/ar-1/quote') + .query((query) => { + return query.amount === '50' && query.context === 'test'; + }) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { quote_id: 'q-2' }); + + const service = createService(); + const result = await service.getAutorampQuoteForAutoramp('ar-1', { + amount: '50', + }); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ quote_id: 'q-2' }); + }); + }); + + describe('attachAutorampQuote', () => { + it('POSTs /neobank/autoramps/{id}/quotes with JSON body', async () => { + const body = { signed_quote: 'attach-sig' }; + + const scope = nock(STAGING_BASE) + .post('/neobank/autoramps/ar-1/quotes', body) + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .matchHeader('Content-Type', 'application/json') + .reply(200, { quote_id: 'q-attached' }); + + const service = createService(); + const result = await service.attachAutorampQuote('ar-1', body); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ quote_id: 'q-attached' }); + }); + }); + + describe('getCustomerByExternalId', () => { + it('GETs /neobank/customers/{external_id}/external', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/customers/ext-1/external') + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { id: 'cust-1', external_id: 'ext-1' }); + + const service = createService(); + const result = await service.getCustomerByExternalId('ext-1'); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ id: 'cust-1', external_id: 'ext-1' }); + }); + }); + + describe('environments and policy hooks', () => { + it.each([ + [RampsEnvironment.Production, 'https://on-ramp.api.cx.metamask.io'], + [RampsEnvironment.Development, 'https://on-ramp.dev-api.cx.metamask.io'], + [RampsEnvironment.Local, 'http://localhost:3000'], + ] as const)( + 'uses the %s host for getAutoramp', + async (environment, host) => { + const scope = nock(host) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ environment }); + await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + }, + ); + + it('uses constructor defaults for environment and policyOptions', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ omitDefaults: true }); + await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + }); + + it('calls getAutorampQuote and getAutorampQuoteForAutoramp without query', async () => { + const quoteScope = nock(STAGING_BASE) + .get('/neobank/autoramps/quote') + .query(true) + .reply(200, { quote_id: 'q-default' }); + const forAutorampScope = nock(STAGING_BASE) + .get('/neobank/autoramps/ar-1/quote') + .query(true) + .reply(200, { quote_id: 'q-for-ar' }); + + const service = createService(); + await service.getAutorampQuote(); + await service.getAutorampQuoteForAutoramp('ar-1'); + + expect(quoteScope.isDone()).toBe(true); + expect(forAutorampScope.isDone()).toBe(true); + }); + + it('uses baseUrlOverride when provided', async () => { + const scope = nock('http://custom-neobank.test') + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ + baseUrlOverride: 'http://custom-neobank.test', + }); + await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + }); + + it('throws for an invalid environment', async () => { + await expect( + createService({ + environment: 'bogus' as RampsEnvironment, + }).getAutoramp('ar-1'), + ).rejects.toThrow(/Invalid environment/u); + }); + + it('throws HttpError on non-2xx POST responses', async () => { + nock(STAGING_BASE) + .post('/neobank/addresses/pix') + .query(true) + .reply(422, { error: 'bad' }); + + const service = createService(); + await expect( + service.registerPixAddress({ pix_key: 'k' }), + ).rejects.toThrow(/failed with status '422'/u); + }); + + it('omits nullish query values when building quote URLs', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps/quote') + .query((query) => { + return ( + query.amount === '10' && + query.currency === undefined && + query.optional === undefined + ); + }) + .reply(200, { quote_id: 'q-nullish' }); + + const service = createService(); + await service.getAutorampQuote({ + amount: '10', + currency: undefined, + optional: null, + }); + + expect(scope.isDone()).toBe(true); + }); + + it('registers onRetry, onBreak, and onDegraded listeners', () => { + const service = createService(); + const onRetry = jest.fn(); + const onBreak = jest.fn(); + const onDegraded = jest.fn(); + + const retrySub = service.onRetry(onRetry); + const breakSub = service.onBreak(onBreak); + const degradedSub = service.onDegraded(onDegraded); + + expect(typeof retrySub.dispose).toBe('function'); + expect(typeof breakSub.dispose).toBe('function'); + expect(typeof degradedSub.dispose).toBe('function'); + + retrySub.dispose(); + breakSub.dispose(); + degradedSub.dispose(); + }); }); }); diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index ca8f5dd7b0..e13d8b1ef1 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -40,7 +40,34 @@ export type NeoBankAutorampResponse = { deposit_rails?: unknown[]; }; -const MESSENGER_EXPOSED_METHODS = ['getAutoramp'] as const; +/** + * Optional headers for neo-bank mutating requests. + */ +export type NeoBankRequestOptions = { + /** + * Forwarded as `Idempotency-Key` when set (MoonPay requires it on some POSTs; + * neobank-proxy generates one when omitted). + */ + idempotencyKey?: string; +}; + +/** + * Query string values accepted by neo-bank GET helpers. + */ +export type NeoBankQueryParams = Record< + string, + string | number | boolean | undefined | null +>; + +const MESSENGER_EXPOSED_METHODS = [ + 'getAutoramp', + 'registerPixAddress', + 'getAutorampQuote', + 'createAutoramp', + 'getAutorampQuoteForAutoramp', + 'attachAutorampQuote', + 'getCustomerByExternalId', +] as const; /** * Actions that {@link NeoBankService} exposes to other consumers. @@ -65,14 +92,17 @@ export type NeoBankServiceMessenger = Messenger< >; /** - * Builds an `/api/v2/...` path for the Ramp API neo-bank proxy. + * Builds a path under the neobank-proxy global prefix. * - * @param path - Path under the versioned API root (no leading slash). - * @param version - API version segment. - * @returns Versioned API path. + * Live neobank-proxy (#1124) mounts routes at `/neobank` on the on-ramp.api + * host (ALB path routing, no rewrite). Prefer this over `/api/v2/...` so Core + * matches the proxy that ships. + * + * @param path - Path under `/neobank` (no leading slash). + * @returns Absolute path segment for URL join against the Ramp API host. */ -function getApiPath(path: string, version: string = 'v2'): string { - return `api/${version}/${path.replace(/^\//u, '')}`; +function getNeoBankPath(path: string): string { + return `neobank/${path.replace(/^\//u, '')}`; } /** @@ -128,8 +158,10 @@ export function mapNeoBankAutorampToRemoteSnapshot( * Client for MetaMask Ramp API neo-bank endpoints (MoonPay Enterprise proxy). * * Lives alongside {@link RampsService} and {@link TransakService}. Authentication - * and MoonPay partner headers are handled by the Ramp API — this service only + * and MoonPay partner headers are handled by the Ramp API; this service only * attaches the MetaMask user bearer token. + * + * Paths use the neobank-proxy `/neobank` prefix on the on-ramp.api host. */ export class NeoBankService { readonly name: typeof serviceName; @@ -182,32 +214,42 @@ export class NeoBankService { return getBaseUrl(this.#environment); } - async #getRequestHeaders(): Promise> { + async #getRequestHeaders( + options: NeoBankRequestOptions = {}, + ): Promise> { const bearerToken = await this.#messenger.call( 'AuthenticationController:getBearerToken', ); - return { + const headers: Record = { Authorization: `Bearer ${bearerToken}`, }; + if (options.idempotencyKey) { + headers['Idempotency-Key'] = options.idempotencyKey; + } + return headers; } - /** - * Fetches an autoramp account via the Ramp API proxy of - * MoonPay `GET /api/autoramps/{autoramp_id}`. - * - * @param autorampId - MoonPay / Ramp API autoramp id. - * @returns Remote snapshot for controller apply/refresh. - */ - async getAutoramp(autorampId: string): Promise { - const url = new URL( - getApiPath(`autoramps/${encodeURIComponent(autorampId)}`), - this.#getBaseUrl(), - ); + #buildUrl(path: string, query?: NeoBankQueryParams): URL { + const url = new URL(getNeoBankPath(path), this.#getBaseUrl()); url.searchParams.set('sdk', RAMPS_SDK_VERSION); url.searchParams.set('controller', packageJson.version); url.searchParams.set('context', this.#context); + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + } + return url; + } - const response = await this.#policy.execute(async () => { + async #getJson( + path: string, + query?: NeoBankQueryParams, + ): Promise { + const url = this.#buildUrl(path, query); + return this.#policy.execute(async () => { const headers = await this.#getRequestHeaders(); const fetchResponse = await this.#fetch(url, { headers }); if (!fetchResponse.ok) { @@ -216,16 +258,153 @@ export class NeoBankService { `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, ); } - return fetchResponse.json() as Promise; + return fetchResponse.json() as Promise; + }); + } + + async #postJson( + path: string, + body: Record, + options: NeoBankRequestOptions, + ): Promise { + const url = this.#buildUrl(path); + return this.#policy.execute(async () => { + const headers = await this.#getRequestHeaders(options); + headers['Content-Type'] = 'application/json'; + const fetchResponse = await this.#fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + return fetchResponse.json() as Promise; }); + } + #mapAutorampResponse(response: NeoBankAutorampResponse): AutorampRemoteSnapshot { if (!response || typeof response !== 'object' || !response.id) { throw new Error('Malformed response received from neo-bank autoramp API'); } - return mapNeoBankAutorampToRemoteSnapshot(response); } + /** + * Fetches an autoramp account via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}` (MoonPay + * `GET /api/autoramps/{autoramp_id}`). + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Remote snapshot for controller apply/refresh. + */ + async getAutoramp(autorampId: string): Promise { + const response = await this.#getJson( + `autoramps/${encodeURIComponent(autorampId)}`, + ); + return this.#mapAutorampResponse(response); + } + + /** + * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. + * Body is forwarded as opaque JSON (MoonPay address schema). + * + * @param body - Pix address registration payload. + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ + async registerPixAddress( + body: Record, + options: NeoBankRequestOptions = {}, + ): Promise { + return this.#postJson('addresses/pix', body, options); + } + + /** + * Fetches an autoramp quote via neobank-proxy `GET /neobank/autoramps/quote`. + * + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ + async getAutorampQuote(query: NeoBankQueryParams = {}): Promise { + return this.#getJson('autoramps/quote', query); + } + + /** + * Creates an autoramp from a signed quote via neobank-proxy + * `POST /neobank/autoramps` (MoonPay `POST /api/autoramps`). + * + * @param body - CreateAutoramp / signed-quote payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Remote snapshot for controller apply/refresh. + */ + async createAutoramp( + body: Record, + options: NeoBankRequestOptions = {}, + ): Promise { + const response = await this.#postJson( + 'autoramps', + body, + options, + ); + return this.#mapAutorampResponse(response); + } + + /** + * Fetches a quote for an existing autoramp via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}/quote`. + * + * @param autorampId - Autoramp id. + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ + async getAutorampQuoteForAutoramp( + autorampId: string, + query: NeoBankQueryParams = {}, + ): Promise { + return this.#getJson( + `autoramps/${encodeURIComponent(autorampId)}/quote`, + query, + ); + } + + /** + * Attaches a signed quote to an autoramp via neobank-proxy + * `POST /neobank/autoramps/{autoramp_id}/quotes`. + * + * @param autorampId - Autoramp id. + * @param body - Quote attachment payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ + async attachAutorampQuote( + autorampId: string, + body: Record, + options: NeoBankRequestOptions = {}, + ): Promise { + return this.#postJson( + `autoramps/${encodeURIComponent(autorampId)}/quotes`, + body, + options, + ); + } + + /** + * Fetches a customer by partner external id via neobank-proxy + * `GET /neobank/customers/{external_id}/external`. + * + * @param externalId - Partner-assigned external customer id. + * @returns Parsed proxy JSON response. + */ + async getCustomerByExternalId(externalId: string): Promise { + return this.#getJson( + `customers/${encodeURIComponent(externalId)}/external`, + ); + } + onRetry( listener: Parameters[0], ): ReturnType { diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index eb84a47d86..5dbe85e45e 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -211,8 +211,19 @@ export type { NeoBankServiceEvents, NeoBankServiceMessenger, NeoBankAutorampResponse, + NeoBankRequestOptions, + NeoBankQueryParams, } from './NeoBankService.js'; -export type { NeoBankServiceGetAutorampAction } from './NeoBankService-method-action-types.js'; +export type { + NeoBankServiceGetAutorampAction, + NeoBankServiceRegisterPixAddressAction, + NeoBankServiceGetAutorampQuoteAction, + NeoBankServiceCreateAutorampAction, + NeoBankServiceGetAutorampQuoteForAutorampAction, + NeoBankServiceAttachAutorampQuoteAction, + NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceMethodActions, +} from './NeoBankService-method-action-types.js'; export { NeoBankService, serviceName as neoBankServiceName,