Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,62 @@ describe('ProviderSelection', () => {
});
});

it('shows Previously used tag when a canonical provider id matches a legacy prefixed order id', async () => {
const canonicalTransak: Provider = { ...transakProvider, id: 'transak' };

const { getByText } = renderWithProvider([canonicalTransak], null, {
ordersProviders: ['/providers/transak'],
quotes: {
success: [createMockQuote('transak', 'Transak')],
sorted: [],
error: [],
customActions: [],
},
});

await waitFor(() => {
expect(getByText('Previously used')).toBeOnTheScreen();
});
});

it('shows Previously used tag when a legacy prefixed provider id matches a canonical order id', async () => {
const { getByText } = renderWithProvider(mockProviders, null, {
ordersProviders: ['transak'],
quotes: {
success: [createMockQuote('/providers/transak', 'Transak')],
sorted: [],
error: [],
customActions: [],
},
});

await waitFor(() => {
expect(getByText('Previously used')).toBeOnTheScreen();
});
});

it('does not show Previously used tag when a similar id does not canonically match', async () => {
const canonicalTransakNative: Provider = {
...transakProvider,
id: 'transak-native',
};

const { queryByText } = renderWithProvider([canonicalTransakNative], null, {
ordersProviders: ['/providers/transak'],
quotes: {
success: [createMockQuote('transak-native', 'Transak')],
sorted: [],
error: [],
customActions: [],
},
});

await waitFor(() => {
expect(queryByText('Transak')).toBeOnTheScreen();
});
expect(queryByText('Previously used')).toBeNull();
});

it('shows provider error subtitle and prevents selection when provider has no matched quote', async () => {
const onProviderSelect = jest.fn();
jest.mocked(useRampsController).mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { BannerAlertSeverity } from '../../../../../../component-library/compone
import { useTheme } from '../../../../../../util/theme';
import { providerSupportsAsset } from '../../../utils/providerSupportsAsset';
import { getProviderLimitMessage } from '../../../utils/getProviderLimitMessage';
import { rampIdInList } from '../../../utils/rampIdMatch';

const SKELETON_ROW_COUNT = 5;
const SKELETON_NAME_WIDTH = 120;
Expand Down Expand Up @@ -136,7 +137,7 @@ function getProviderTag(
matchedQuote: Quote | null,
ordersProviders: string[],
): string | null {
if (ordersProviders.includes(providerId)) {
if (rampIdInList(ordersProviders, providerId)) {
return strings('fiat_on_ramp.previously_used');
}
if (matchedQuote?.metadata?.tags?.isMostReliable) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,43 @@ describe('SettingsModal', () => {
expect(logoutButton).toBeOnTheScreen();
});

it('displays logout option for the canonical (non-prefixed) Transak native id', async () => {
mockSelectedProvider = createMockTransakProvider({
id: 'transak-native',
});

const { findByText } = renderWithProvider(SettingsModal);

const logoutButton = await findByText('Log out of Transak');

expect(logoutButton).toBeOnTheScreen();
});

it('displays logout option for the canonical (non-prefixed) Transak staging id', async () => {
mockSelectedProvider = createMockTransakStagingProvider({
id: 'transak-native-staging',
});

const { findByText } = renderWithProvider(SettingsModal);

const logoutButton = await findByText('Log out of Transak');

expect(logoutButton).toBeOnTheScreen();
});

it('hides logout option for a canonical non-Transak provider even when authenticated', async () => {
mockSelectedProvider = createMockProvider({
id: 'moonpay',
name: 'MoonPay',
});

const { queryByText } = renderWithProvider(SettingsModal);

await waitFor(() => {
expect(queryByText(/Log out of/)).not.toBeOnTheScreen();
});
});

it('hides logout option for non-Transak providers even when authenticated', async () => {
mockSelectedProvider = createMockProvider();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,20 @@ import {
} from '../../../utils/ProviderTokenVault';
import { PROVIDER_LINKS } from '../../../Aggregator/types';
import { useElevatedSurface } from '../../../../../../util/theme/themeUtils';
import { canonicalizeRampId } from '../../../utils/rampIdMatch';

/**
* Transak native provider path prefix - matches both production
* ('/providers/transak-native') and staging ('/providers/transak-native-staging')
* Transak native provider code. Matched on the canonical id so it covers both
* the legacy path form (`/providers/transak-native`) and the canonical form
* (`transak-native`), and the `startsWith` still matches the staging variant
* (`transak-native-staging`).
*/
const TRANSAK_NATIVE_PREFIX = '/providers/transak-native';
const TRANSAK_NATIVE_CODE = 'transak-native';

const isTransakNativeProvider = (providerId?: string): boolean =>
providerId?.startsWith(TRANSAK_NATIVE_PREFIX) ?? false;
providerId
? canonicalizeRampId(providerId).startsWith(TRANSAK_NATIVE_CODE)
: false;

export const createSettingsModalNavDetails = createNavigationDetails(
Routes.RAMP.MODALS.ID,
Expand Down
29 changes: 29 additions & 0 deletions app/components/UI/Ramp/utils/determinePreferredProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,35 @@ describe('determinePreferredProvider', () => {
autoSelected: false,
});
});

it('matches a fresh canonical provider against a prefixed persisted order id', () => {
// Order stored in the legacy path form (prefixed era); catalog is fresh
// canonical. provider-1 is NOT transak, so this only passes if the id is
// matched canonically (not via the transak substring fallback).
const completedOrders = [
{ providerId: '/providers/provider-1', completedAt: 1000 },
];
const providers = [mockProvider1, mockProvider2, mockTransakProvider];

const result = determinePreferredProvider(completedOrders, providers);

expect(result).toEqual({ provider: mockProvider1, autoSelected: false });
});

it('exact-matches transak-native across prefixed persisted id and canonical catalog', () => {
const nativeProvider: Provider = {
...mockTransakProvider,
id: 'transak-native',
};
const completedOrders = [
{ providerId: '/providers/transak-native', completedAt: 1000 },
];
const providers = [mockProvider1, nativeProvider];

const result = determinePreferredProvider(completedOrders, providers);

expect(result).toEqual({ provider: nativeProvider, autoSelected: false });
});
});

describe('when user has no orders', () => {
Expand Down
3 changes: 2 additions & 1 deletion app/components/UI/Ramp/utils/determinePreferredProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
FIAT_ORDER_PROVIDERS,
FIAT_ORDER_STATES,
} from '../../../../constants/on-ramp';
import { rampIdsEqual } from './rampIdMatch';

/**
* Minimal representation of a completed order used for provider selection
Expand Down Expand Up @@ -100,7 +101,7 @@ export function determinePreferredProvider(

const foundProvider = availableProviders.find(
(provider) =>
provider.id?.toLowerCase() === mostRecent.providerId.toLowerCase() ||
rampIdsEqual(provider.id, mostRecent.providerId) ||
provider.name?.toLowerCase() === mostRecent.providerId.toLowerCase(),
);

Expand Down
85 changes: 85 additions & 0 deletions app/components/UI/Ramp/utils/rampIdMatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { canonicalizeRampId, rampIdsEqual, rampIdInList } from './rampIdMatch';

describe('canonicalizeRampId', () => {
it('strips a leading collection prefix for each entity type', () => {
expect(canonicalizeRampId('/providers/transak')).toBe('transak');
expect(canonicalizeRampId('/payments/debit-credit-card')).toBe(
'debit-credit-card',
);
expect(canonicalizeRampId('/regions/us-ca')).toBe('us-ca');
});

it('preserves nested currency structure', () => {
expect(canonicalizeRampId('/currencies/crypto/1/eth')).toBe('crypto/1/eth');
});

it('is idempotent on already-canonical ids', () => {
expect(canonicalizeRampId('transak')).toBe('transak');
expect(canonicalizeRampId('transak-native')).toBe('transak-native');
});

it('leaves unknown prefixes untouched', () => {
expect(canonicalizeRampId('/unknown/x')).toBe('/unknown/x');
});
});

describe('rampIdsEqual', () => {
it('matches the legacy path form against the canonical form (provider)', () => {
expect(rampIdsEqual('/providers/transak', 'transak')).toBe(true);
expect(rampIdsEqual('transak', '/providers/transak')).toBe(true);
});

it('matches payment method ids across forms', () => {
expect(
rampIdsEqual('/payments/debit-credit-card', 'debit-credit-card'),
).toBe(true);
});

it('matches nested currency ids across forms', () => {
expect(rampIdsEqual('/currencies/crypto/1/eth', 'crypto/1/eth')).toBe(true);
});

it('is case-insensitive', () => {
expect(rampIdsEqual('/providers/Transak', 'transak')).toBe(true);
});

it('returns true for identical canonical ids', () => {
expect(rampIdsEqual('transak', 'transak')).toBe(true);
});

it('does not over-match distinct ids that share a prefix', () => {
expect(rampIdsEqual('transak-native', 'transak')).toBe(false);
expect(rampIdsEqual('/providers/transak', 'moonpay')).toBe(false);
});

it('returns false when either id is nullish', () => {
expect(rampIdsEqual(undefined, 'transak')).toBe(false);
expect(rampIdsEqual('transak', null)).toBe(false);
expect(rampIdsEqual(null, undefined)).toBe(false);
});
});

describe('rampIdInList', () => {
it('finds a canonical id in a mixed legacy + canonical list', () => {
const list = ['/providers/transak', 'moonpay'];
expect(rampIdInList(list, 'transak')).toBe(true);
expect(rampIdInList(list, '/providers/moonpay')).toBe(true);
});

it('returns false when no entry matches', () => {
expect(rampIdInList(['/providers/transak', 'moonpay'], 'banxa')).toBe(
false,
);
});

it('returns false for a nullish id or empty list', () => {
expect(rampIdInList(['/providers/transak'], null)).toBe(false);
expect(rampIdInList([], 'transak')).toBe(false);
});

it('tolerates nullish entries in the list', () => {
expect(
rampIdInList([null, undefined, '/providers/transak'], 'transak'),
).toBe(true);
});
});
68 changes: 68 additions & 0 deletions app/components/UI/Ramp/utils/rampIdMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Format-agnostic matching for ramps entity ids (providers, payment methods,
* currencies, regions).
*
* Backend entity ids are migrating from the legacy path form
* (e.g. `/providers/transak`) to the canonical form (e.g. `transak`), gated
* server-side. A comparison can therefore see either form on either side: a
* value persisted or hardcoded before the migration compared against a fresh
* response, or the flag being toggled between builds. Canonicalizing both
* operands makes the comparison correct regardless of which form each side is
* in.
*/

/**
* Strips a single leading collection prefix (`/providers/`, `/payments/`,
* `/currencies/`, `/regions/`) from a ramps entity id, so the legacy path form
* and the canonical form reduce to the same value. Idempotent on
* already-canonical ids, and preserves nested currency structure
* (`/currencies/crypto/1/eth` -> `crypto/1/eth`).
*
* @param id - A ramps entity id in either the legacy path form or canonical
* form.
* @returns The canonical id with any known leading collection prefix removed.
*/
export function canonicalizeRampId(id: string): string {
return id.replace(/^\/(?:providers|payments|currencies|regions)\//u, '');
}

/**
* Returns true when two ramps ids refer to the same entity, ignoring whether
* either is in the legacy path form or the canonical form. Case-insensitive, to
* match the slug-style ids the backend uses. Returns false if either id is
* nullish.
*
* @param a - First ramps id, in either form.
* @param b - Second ramps id, in either form.
* @returns Whether the two ids canonically match.
*/
export function rampIdsEqual(
a: string | null | undefined,
b: string | null | undefined,
): boolean {
if (a == null || b == null) {
return false;
}
return (
canonicalizeRampId(a).toLowerCase() === canonicalizeRampId(b).toLowerCase()
);
}

/**
* Returns true when `id` canonically matches any entry in `list`, ignoring
* legacy-vs-canonical form on either side. Useful for mixed-format lists such
* as the combined legacy + v2 "previously used providers" list.
*
* @param list - Candidate ramps ids, each in either form.
* @param id - The ramps id to look for, in either form.
* @returns Whether `id` canonically matches any entry.
*/
export function rampIdInList(
list: readonly (string | null | undefined)[],
id: string | null | undefined,
): boolean {
if (id == null) {
return false;
}
return list.some((entry) => rampIdsEqual(entry, id));
}
Loading