Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Export `getTransakApiMessage` and `isTransakPhoneRegisteredError` for consumers handling `TransakApiError`, and centralize known Transak API error codes in `transakErrorCodes.ts` ([#9135](https://github.com/MetaMask/core/pull/9135))

### Changed

- Bump `@metamask/profile-sync-controller` from `^28.1.1` to `^28.2.0` ([#9119](https://github.com/MetaMask/core/pull/9119))

### Fixed

- Compare internal order codes (from canonical order `id`) instead of provider-native `providerOrderId` when merging orders in `RampsController.addOrder` and `RampsController.getOrder` ([#9159](https://github.com/MetaMask/core/pull/9159))

## [14.2.0]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export type RampsControllerGetQuotesAction = {

/**
* Adds or updates a V2 order in controller state.
* If an order with the same providerOrderId already exists, the incoming
* If an order with the same internal order code already exists, the incoming
* fields are merged on top of the existing order so that fields not present
* in the update (e.g. paymentDetails from the Transak API) are preserved.
*
Expand Down
71 changes: 70 additions & 1 deletion packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7100,12 +7100,39 @@ describe('RampsController', () => {
rootMessenger.call('RampsController:addOrder', mockOrder);
rootMessenger.call('RampsController:addOrder', {
...mockOrder,
id: '/providers/transak-staging/orders/def-456',
providerOrderId: 'def-456',
});

expect(controller.state.orders).toHaveLength(2);
});
});

it('merges orders using internal order id when providerOrderId differs', async () => {
await withController(({ controller, rootMessenger }) => {
const precreatedOrder = createMockOrder({
id: '/providers/paypal/orders/internal-order-123',
providerOrderId: 'internal-order-123',
status: RampsOrderStatus.Precreated,
});
rootMessenger.call('RampsController:addOrder', precreatedOrder);

const apiOrder = createMockOrder({
id: '/providers/paypal/orders/internal-order-123',
providerOrderId: 'provider-native-order-456',
status: RampsOrderStatus.Pending,
});
rootMessenger.call('RampsController:addOrder', apiOrder);

expect(controller.state.orders).toHaveLength(1);
expect(controller.state.orders[0]?.status).toBe(
RampsOrderStatus.Pending,
);
expect(controller.state.orders[0]?.providerOrderId).toBe(
'internal-order-123',
);
});
});
});

describe('removeOrder', () => {
Expand Down Expand Up @@ -7334,6 +7361,42 @@ describe('RampsController', () => {
});
});

it('merges precreated order when API returns a different providerOrderId', async () => {
await withController(async ({ controller, rootMessenger }) => {
const precreatedOrder = createMockOrder({
id: '/providers/paypal/orders/internal-order-123',
providerOrderId: 'internal-order-123',
status: RampsOrderStatus.Precreated,
});
rootMessenger.call('RampsController:addOrder', precreatedOrder);

const apiOrder = createMockOrder({
id: '/providers/paypal/orders/internal-order-123',
providerOrderId: 'provider-native-order-456',
status: RampsOrderStatus.Pending,
});
rootMessenger.registerActionHandler(
'RampsService:getOrder',
async () => apiOrder,
);

await rootMessenger.call(
'RampsController:getOrder',
'paypal',
'internal-order-123',
'0xabc',
);

expect(controller.state.orders).toHaveLength(1);
expect(controller.state.orders[0]?.status).toBe(
RampsOrderStatus.Pending,
);
expect(controller.state.orders[0]?.providerOrderId).toBe(
'internal-order-123',
);
});
});

it('uses wallet param when updating existing order and API omits walletAddress', async () => {
await withController(async ({ controller, rootMessenger }) => {
const existingOrder = createMockOrder({
Expand Down Expand Up @@ -7509,6 +7572,7 @@ describe('RampsController', () => {
it('publishes orderStatusChanged when order status transitions', async () => {
await withController(async ({ rootMessenger, messenger }) => {
const pendingOrder = createMockOrder({
id: '/providers/transak/orders/status-change-1',
providerOrderId: 'status-change-1',
status: RampsOrderStatus.Pending,
provider: createMockProvider({
Expand Down Expand Up @@ -7538,7 +7602,10 @@ describe('RampsController', () => {
await jest.advanceTimersByTimeAsync(0);

expect(statusChangedListener).toHaveBeenCalledWith({
order: updatedOrder,
order: {
...updatedOrder,
providerOrderId: 'status-change-1',
},
previousStatus: RampsOrderStatus.Pending,
});

Expand Down Expand Up @@ -7701,6 +7768,7 @@ describe('RampsController', () => {
it('skips orders without providerOrderId', async () => {
await withController(async ({ rootMessenger }) => {
const orderNoId = createMockOrder({
id: undefined,
providerOrderId: '',
status: RampsOrderStatus.Pending,
provider: createMockProvider({
Expand Down Expand Up @@ -7751,6 +7819,7 @@ describe('RampsController', () => {
it('passes provider id through to service without stripping prefix', async () => {
await withController(async ({ rootMessenger }) => {
const order = createMockOrder({
id: '/providers/transak/orders/strip-prefix-1',
providerOrderId: 'strip-prefix-1',
status: RampsOrderStatus.Pending,
provider: createMockProvider({
Expand Down
52 changes: 43 additions & 9 deletions packages/ramps-controller/src/RampsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,31 @@ export function normalizeProviderCode(providerCode: string): string {
return providerCode.replace(/^\/providers\//u, '');
}

/**
* Returns the internal MetaMask order code used for state lookups and polling.
* Prefers the code embedded in the canonical order `id` path over `providerOrderId`,
* which may contain the provider's native order identifier.
*
* @param orderOrId - Order fields or a full order id / order code string.
* @returns The internal order code.
*/
export function getInternalOrderCode(
orderOrId: Pick<RampsOrder, 'id' | 'providerOrderId'> | string,
): string {
if (typeof orderOrId === 'string') {
return orderOrId.includes('/orders/')
? orderOrId.split('/orders/')[1]
: orderOrId;
}

const { id, providerOrderId } = orderOrId;
if (id?.includes('/orders/')) {
return id.split('/orders/')[1];
}

return providerOrderId;
}

// === ORDER POLLING CONSTANTS ===

const TERMINAL_ORDER_STATUSES = new Set<RampsOrderStatus>([
Expand Down Expand Up @@ -2088,23 +2113,29 @@ export class RampsController extends BaseController<

/**
* Adds or updates a V2 order in controller state.
* If an order with the same providerOrderId already exists, the incoming
* If an order with the same internal order code already exists, the incoming
* fields are merged on top of the existing order so that fields not present
* in the update (e.g. paymentDetails from the Transak API) are preserved.
*
* @param order - The RampsOrder to add or update.
*/
addOrder(order: RampsOrder): void {
const internalOrderCode = getInternalOrderCode(order);
const healedOrder = {
...order,
providerOrderId: internalOrderCode,
};

this.update((state) => {
const idx = state.orders.findIndex(
(existing) => existing.providerOrderId === order.providerOrderId,
(existing) => getInternalOrderCode(existing) === internalOrderCode,
);
if (idx === -1) {
state.orders.push(order as Draft<RampsOrder>);
state.orders.push(healedOrder as Draft<RampsOrder>);
} else {
state.orders[idx] = {
...state.orders[idx],
...order,
...healedOrder,
} as Draft<RampsOrder>;
}
});
Expand Down Expand Up @@ -2306,9 +2337,7 @@ export class RampsController extends BaseController<
}): void {
const { orderId, providerCode, walletAddress, chainId } = params;

const orderCode = orderId.includes('/orders/')
? orderId.split('/orders/')[1]
: orderId;
const orderCode = getInternalOrderCode(orderId);
if (!orderCode?.trim()) {
return;
}
Expand Down Expand Up @@ -2368,15 +2397,20 @@ export class RampsController extends BaseController<
);

const healedWalletAddress = order.walletAddress || wallet;
const internalOrderCode = getInternalOrderCode({
id: order.id,
providerOrderId: orderCode,
});
const healedOrder = {
...order,
walletAddress: healedWalletAddress,
providerOrderId: orderCode,
providerOrderId: internalOrderCode,
};

this.update((state) => {
const idx = state.orders.findIndex(
(existing: RampsOrder) => existing.providerOrderId === orderCode,
(existing: RampsOrder) =>
getInternalOrderCode(existing) === internalOrderCode,
);
if (idx === -1) {
state.orders.push(healedOrder as Draft<RampsOrder>);
Expand Down
5 changes: 2 additions & 3 deletions packages/ramps-controller/src/TransakService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
import { createServicePolicy, HttpError } from '@metamask/controller-utils';
import type { Messenger } from '@metamask/messenger';

import { TRANSAK_ERROR_CODES } from './transakErrorCodes';
import type { TransakServiceMethodActions } from './TransakService-method-action-types';

// === TYPES ===
Expand Down Expand Up @@ -429,8 +430,6 @@ function getPaymentWidgetBaseUrl(environment: TransakEnvironment): string {

// === TRANSAK API ERROR ===

const TRANSAK_ORDER_EXISTS_CODE = '4005';

export class TransakApiError extends HttpError {
readonly errorCode: string | undefined;

Expand Down Expand Up @@ -915,7 +914,7 @@ export class TransakService {
if (
error instanceof TransakApiError &&
error.httpStatus === 409 &&
error.errorCode === TRANSAK_ORDER_EXISTS_CODE
error.errorCode === TRANSAK_ERROR_CODES.ORDER_EXISTS
) {
await this.cancelAllActiveOrders();
await new Promise((resolve) =>
Expand Down
5 changes: 5 additions & 0 deletions packages/ramps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export type {
export {
RampsController,
getDefaultRampsControllerState,
getInternalOrderCode,
normalizeProviderCode,
RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS,
} from './RampsController';
Expand Down Expand Up @@ -172,6 +173,10 @@ export {
TransakEnvironment,
TransakOrderIdTransformer,
} from './TransakService';
export {
getTransakApiMessage,
isTransakPhoneRegisteredError,
} from './transakApiErrorUtils';
export type {
TransakServiceMethodActions,
TransakServiceSendUserOtpAction,
Expand Down
27 changes: 27 additions & 0 deletions packages/ramps-controller/src/transakApiErrorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
getTransakApiMessage,
isTransakPhoneRegisteredError,
} from './transakApiErrorUtils';
import { TRANSAK_ERROR_CODES } from './transakErrorCodes';
import { TransakApiError } from './TransakService';

describe('transakApiErrorUtils', () => {
const phoneRegisteredError = new TransakApiError(
400,
"Fetching 'https://api.transak.com/user' failed with status '400'",
TRANSAK_ERROR_CODES.PHONE_ALREADY_REGISTERED,
'Phone registered with t***@test.com',
);

it('reads apiMessage from TransakApiError', () => {
expect(getTransakApiMessage(phoneRegisteredError)).toBe(
'Phone registered with t***@test.com',
);
expect(getTransakApiMessage(new Error('generic'))).toBeUndefined();
});

it('detects phone already registered errors', () => {
expect(isTransakPhoneRegisteredError(phoneRegisteredError)).toBe(true);
expect(isTransakPhoneRegisteredError(new Error('generic'))).toBe(false);
});
});
13 changes: 13 additions & 0 deletions packages/ramps-controller/src/transakApiErrorUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { TRANSAK_ERROR_CODES } from './transakErrorCodes';
import { TransakApiError } from './TransakService';

export function getTransakApiMessage(error: unknown): string | undefined {
return error instanceof TransakApiError ? error.apiMessage : undefined;
}

export function isTransakPhoneRegisteredError(error: unknown): boolean {
return (
error instanceof TransakApiError &&
error.errorCode === TRANSAK_ERROR_CODES.PHONE_ALREADY_REGISTERED
);
}
13 changes: 13 additions & 0 deletions packages/ramps-controller/src/transakErrorCodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Known Transak API error codes surfaced by {@link TransakApiError}.
*
* Values are normalized to strings because TransakService stringifies numeric
* codes when parsing API responses.
*/
export const TRANSAK_ERROR_CODES = {
ORDER_EXISTS: '4005',
PHONE_ALREADY_REGISTERED: '2020',
} as const;

export type TransakErrorCode =
(typeof TRANSAK_ERROR_CODES)[keyof typeof TRANSAK_ERROR_CODES];
Loading