Skip to content
Open
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
95 changes: 95 additions & 0 deletions __tests__/getMerchantDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/// <reference types="jest" />
import { CardTransaction, CardTransactionCategory } from '@/lib/types';
import { getMerchantDisplay } from '@/lib/utils/cardHelpers';

/**
* Merchant enrichment is optional and never arrives before a transaction settles,
* so every field has to fall back. These pin the order.
*/
describe('getMerchantDisplay', () => {
const transaction = (overrides: Partial<CardTransaction> = {}): CardTransaction =>
({
id: 'txn_1',
card_account_id: 'card_1',
customer_id: 'cust_1',
category: CardTransactionCategory.PURCHASE,
amount: '9.99',
currency: 'usd',
status: 'completed',
description: '',
posted_at: '2026-08-18T23:45:27.243Z',
authorized_at: '2026-08-18T00:15:27.566Z',
related_transaction_ids: [],
...overrides,
}) as CardTransaction;

it('shows the enriched brand name and logo when the issuer has them', () => {
const merchant = getMerchantDisplay(
transaction({
merchant_name: 'GOOGLE *Play Books ',
merchant_city: 'g.co/helppay#',
merchant_country: 'US',
merchant_category: 'Book Stores',
enriched_merchant_name: 'Google Play',
enriched_merchant_icon: 'https://storage.googleapis.com/icons/mrc.png',
}),
);

expect(merchant.name).toBe('Google Play');
expect(merchant.iconUrl).toBe('https://storage.googleapis.com/icons/mrc.png');
// No enriched category on that delivery, so the issuer's own category shows.
expect(merchant.category).toBe('Book Stores');
expect(merchant.location).toBe('g.co/helppay# US');
});

it('prefers the enriched category over the raw one', () => {
const merchant = getMerchantDisplay(
transaction({
merchant_category: 'Book Stores',
enriched_merchant_category: 'Digital Goods',
}),
);

expect(merchant.category).toBe('Digital Goods');
});

it('falls back to the raw descriptor before settlement', () => {
// An authorization never carries enrichment.
const merchant = getMerchantDisplay(
transaction({ merchant_name: 'GOOGLE *Play Books ', status: 'approved' }),
);

expect(merchant.name).toBe('GOOGLE *Play Books');
expect(merchant.iconUrl).toBeUndefined();
expect(merchant.category).toBeUndefined();
});

it('ignores a blank enriched name', () => {
const merchant = getMerchantDisplay(
transaction({ merchant_name: 'NETFLIX.COM', enriched_merchant_name: ' ' }),
);

expect(merchant.name).toBe('NETFLIX.COM');
});

it('falls back to the description, then to Unknown', () => {
expect(getMerchantDisplay(transaction({ description: 'Card funding' })).name).toBe(
'Card funding',
);
expect(getMerchantDisplay(transaction()).name).toBe('Unknown');
});

it('formats the location the way each screen asks for', () => {
const facts = transaction({ merchant_city: 'Seattle', merchant_country: 'US' });

expect(getMerchantDisplay(facts).location).toBe('Seattle US');
expect(
getMerchantDisplay(facts, { locationSeparator: ', ', uppercaseLocation: true }).location,
).toBe('SEATTLE, US');
});

it('omits the location when the transaction carries no place', () => {
expect(getMerchantDisplay(transaction()).location).toBeUndefined();
expect(getMerchantDisplay(transaction({ merchant_city: ' ' })).location).toBeUndefined();
});
});
33 changes: 12 additions & 21 deletions app/(protected)/(tabs)/card/details/transactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { useQueryClient } from '@tanstack/react-query';
import { RotateCw } from 'lucide-react-native';

import MerchantAvatar from '@/components/Activity/MerchantAvatar';
import Loading from '@/components/Loading';
import PageLayout from '@/components/PageLayout';
import RenderTokenIcon from '@/components/RenderTokenIcon';
Expand All @@ -18,14 +19,10 @@
import getTokenIcon from '@/lib/getTokenIcon';
import { CardTransaction, CardTransactionCategory } from '@/lib/types';
import { cn } from '@/lib/utils';
import {
formatCardAmountWithCurrency,
getColorForTransaction,
getInitials,
} from '@/lib/utils/cardHelpers';
import { formatCardAmountWithCurrency, getMerchantDisplay } from '@/lib/utils/cardHelpers';

export default function CardTransactions() {
const router = useRouter();

Check warning on line 25 in app/(protected)/(tabs)/card/details/transactions.tsx

View workflow job for this annotation

GitHub Actions / lint

'router' is assigned a value but never used. Allowed unused vars must match /^_/u
const queryClient = useQueryClient();

const {
Expand Down Expand Up @@ -66,11 +63,7 @@

const renderTransaction = ({ item, index }: { item: CardTransaction; index: number }) => {
const isPurchase = item.category === CardTransactionCategory.PURCHASE;
const merchantName = item.merchant_name || item.description;
const merchantLocation = [item.merchant_city, item.merchant_country]
.filter(Boolean)
.join(' ') || undefined;
const color = getColorForTransaction(merchantName);
const merchant = getMerchantDisplay(item);

const transactionUrl = item.crypto_transaction_details?.tx_hash
? `https://etherscan.io/tx/${item.crypto_transaction_details.tx_hash}`
Expand All @@ -86,14 +79,12 @@
>
<View className="mr-2 flex-1 flex-row items-center gap-2 md:gap-4">
{isPurchase ? (
<View
className="items-center justify-center overflow-hidden rounded-full"
style={{ width: 34, height: 34, backgroundColor: color.bg }}
>
<Text className="text-base font-semibold" style={{ color: color.text }}>
{getInitials(merchantName)}
</Text>
</View>
<MerchantAvatar
name={merchant.name}
iconUrl={merchant.iconUrl}
size={34}
textClassName="text-base font-semibold"
/>
) : (
<RenderTokenIcon
tokenIcon={getTokenIcon({
Expand All @@ -105,11 +96,11 @@
)}
<View className="flex-1">
<Text className="text-lg font-medium" numberOfLines={1}>
{merchantName}
{merchant.name}
</Text>
{merchantLocation && (
{merchant.location && (
<Text className="text-sm text-muted-foreground" numberOfLines={1}>
{merchantLocation}
{merchant.location}
</Text>
)}
<Text className="text-sm text-muted-foreground" numberOfLines={1}>
Expand Down
36 changes: 23 additions & 13 deletions app/(protected)/activity/[clientTxId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { mainnet } from 'viem/chains';
import Diamond from '@/assets/images/diamond';
import SupportIcon from '@/assets/images/support-svg';
import ActivityTokenIcon, { getActivityBadge } from '@/components/Activity/ActivityTokenIcon';
import MerchantAvatar from '@/components/Activity/MerchantAvatar';
import CopyToClipboard from '@/components/CopyToClipboard';
import DepositStepper from '@/components/DepositStepper';
import EstimatedTime from '@/components/EstimatedTime';
Expand Down Expand Up @@ -45,7 +46,7 @@ import {
TransactionType,
} from '@/lib/types';
import { cn, eclipseAddress, formatNumber, toTitleCase, withRefreshToken } from '@/lib/utils';
import { formatCardAmount, getCashbackAmount } from '@/lib/utils/cardHelpers';
import { formatCardAmount, getCashbackAmount, getMerchantDisplay } from '@/lib/utils/cardHelpers';
import {
getDepositProgressRows,
isDepositWithSteps,
Expand Down Expand Up @@ -222,14 +223,16 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
transaction,
cardProvider,
}: CardTransactionDetailProps) {
const merchantName =
transaction.merchant_name?.trim() || transaction.description?.trim() || 'Unknown';
const merchantLocation =
[transaction.merchant_city, transaction.merchant_country]
.filter(Boolean)
.join(', ')
.toUpperCase() || undefined;
const merchantCategory = getMerchantCategory(transaction.merchant_category_code);
const merchant = getMerchantDisplay(transaction, {
locationSeparator: ', ',
uppercaseLocation: true,
});
const merchantName = merchant.name;
const merchantLocation = merchant.location;
// The issuer's own category beats our MCC table when it sent one — it names the
// merchant, where an MCC only names the bucket the acquirer filed it under.
const merchantCategory =
merchant.category ?? getMerchantCategory(transaction.merchant_category_code);
const isPurchase = transaction.category === CardTransactionCategory.PURCHASE;
const { data: cashbacks } = useCashbacks();
const { data: cardDetails } = useCardDetails();
Expand Down Expand Up @@ -379,11 +382,18 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
<Back title="Transaction details" className="text-xl md:text-2xl" />

<View className="items-center gap-4">
{/* Avatar with merchant initial or token icon */}
{/* Merchant logo, else the merchant initial, else the token icon */}
{isPurchase ? (
<View className="h-[75px] w-[75px] items-center justify-center rounded-full bg-[#2A2A2A]">
<Text className="text-3xl text-[#A0A0A0]">{initial}</Text>
</View>
<MerchantAvatar
name={merchantName}
iconUrl={merchant.iconUrl}
size={75}
fallback={
<View className="h-[75px] w-[75px] items-center justify-center rounded-full bg-[#2A2A2A]">
<Text className="text-3xl text-[#A0A0A0]">{initial}</Text>
</View>
}
/>
) : (
<RenderTokenIcon tokenIcon={tokenIcon} size={75} />
)}
Expand Down
35 changes: 8 additions & 27 deletions components/Activity/CardTransactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { router } from 'expo-router';
import { FlashList } from '@shopify/flash-list';

import Diamond from '@/assets/images/diamond';
import MerchantAvatar from '@/components/Activity/MerchantAvatar';
import RenderTokenIcon from '@/components/RenderTokenIcon';
import { Text } from '@/components/ui/text';
import { useActivity } from '@/hooks/useActivity';
Expand All @@ -20,12 +21,7 @@ import {
TransactionType,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import {
formatCardAmount,
getCashbackAmount,
getColorForTransaction,
getInitials,
} from '@/lib/utils/cardHelpers';
import { formatCardAmount, getCashbackAmount, getMerchantDisplay } from '@/lib/utils/cardHelpers';
import { groupByTime, TimeGroup } from '@/lib/utils/timeGrouping';

type CardTransactionWithTimestamp = CardTransaction & { timestamp: number; source: 'card' };
Expand Down Expand Up @@ -155,14 +151,9 @@ export default function CardTransactions() {
}

const transaction = row as CardTransactionWithTimestamp;
const merchantName = transaction.merchant_name || transaction.description || 'Unknown';
const merchantLocation = [transaction.merchant_city, transaction.merchant_country]
.filter(Boolean)
.join(' ') || undefined;
const initials = getInitials(merchantName);
const merchant = getMerchantDisplay(transaction);
const isPurchase = transaction.category === CardTransactionCategory.PURCHASE;
const isDeclined = transaction.status === 'declined';
const color = getColorForTransaction(merchantName);
const cashbackInfo = getCashbackAmount(transaction.id, cashbacks);

return (
Expand All @@ -178,14 +169,7 @@ export default function CardTransactions() {
>
<View className="mr-4 flex-1 flex-row items-center gap-3">
{isPurchase ? (
<View
className="h-[44px] w-[44px] items-center justify-center rounded-full"
style={{ backgroundColor: color.bg }}
>
<Text className="text-lg font-semibold" style={{ color: color.text }}>
{initials}
</Text>
</View>
<MerchantAvatar name={merchant.name} iconUrl={merchant.iconUrl} size={44} />
) : (
<RenderTokenIcon
tokenIcon={getTokenIcon({
Expand All @@ -197,11 +181,11 @@ export default function CardTransactions() {
)}
<View className="flex-1">
<Text className="text-lg font-medium text-white" numberOfLines={1}>
{merchantName}
{merchant.name}
</Text>
{merchantLocation && (
{merchant.location && (
<Text className="text-sm text-[#8E8E93]" numberOfLines={1}>
{merchantLocation}
{merchant.location}
</Text>
)}
{cashbackInfo && (
Expand All @@ -220,10 +204,7 @@ export default function CardTransactions() {
</View>
<View className="items-end">
<Text
className={cn(
'text-xl font-semibold',
isDeclined ? 'text-red-400' : 'text-white',
)}
className={cn('text-xl font-semibold', isDeclined ? 'text-red-400' : 'text-white')}
>
{formatCardAmount(transaction.amount, provider)}
</Text>
Expand Down
75 changes: 75 additions & 0 deletions components/Activity/MerchantAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ReactNode, useCallback, useState } from 'react';
import { View } from 'react-native';
import { Image } from 'expo-image';

import { Text } from '@/components/ui/text';
import { getColorForTransaction, getInitials } from '@/lib/utils/cardHelpers';

type MerchantAvatarProps = {
/** Resolved merchant name — see `getMerchantDisplay`. */
name: string;
/** Merchant logo from issuer enrichment, when there is one. */
iconUrl?: string;
size?: number;
/** Class for the initials text in the default fallback. */
textClassName?: string;
/**
* What to show instead of the default coloured initials. Pass this on a surface
* with its own avatar treatment, so a logo that fails to load falls back to
* that surface's own look rather than to a second, different one.
*/
fallback?: ReactNode;
};

/**
* A card transaction's merchant, as a logo when the issuer enriched one and as
* coloured initials when it did not.
*
* Enrichment is optional and never arrives before settlement, so the initials
* avatar is not a rare edge case — it is what a purchase looks like for its first
* days, and what an unenrichable merchant looks like forever. A logo that fails
* to load falls back to the same initials rather than leaving a blank circle.
*/
export default function MerchantAvatar({
name,
iconUrl,
size = 44,
textClassName,
fallback,
}: MerchantAvatarProps) {
const [iconFailed, setIconFailed] = useState(false);
const handleError = useCallback(() => setIconFailed(true), []);

if (iconUrl && !iconFailed) {
return (
<Image
source={{ uri: iconUrl }}
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: '#2A2A2A',
}}
alt={`${name} logo`}
cachePolicy="memory-disk"
transition={150}
onError={handleError}
/>
);
}

if (fallback) return <>{fallback}</>;

const color = getColorForTransaction(name);

return (
<View
className="items-center justify-center rounded-full"
style={{ width: size, height: size, backgroundColor: color.bg }}
>
<Text className={textClassName ?? 'text-lg font-semibold'} style={{ color: color.text }}>
{getInitials(name)}
</Text>
</View>
);
}
Loading
Loading