diff --git a/__tests__/getMerchantDisplay.test.ts b/__tests__/getMerchantDisplay.test.ts
new file mode 100644
index 000000000..c46a74719
--- /dev/null
+++ b/__tests__/getMerchantDisplay.test.ts
@@ -0,0 +1,95 @@
+///
+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 =>
+ ({
+ 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();
+ });
+});
diff --git a/app/(protected)/(tabs)/card/details/transactions.tsx b/app/(protected)/(tabs)/card/details/transactions.tsx
index 64d9d91a0..1cb1a5718 100644
--- a/app/(protected)/(tabs)/card/details/transactions.tsx
+++ b/app/(protected)/(tabs)/card/details/transactions.tsx
@@ -5,6 +5,7 @@ import { FlashList } from '@shopify/flash-list';
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';
@@ -18,11 +19,7 @@ import { cardTransactionsQueryKey, useCardTransactions } from '@/hooks/useCardTr
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();
@@ -66,11 +63,7 @@ export default function CardTransactions() {
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}`
@@ -86,14 +79,12 @@ export default function CardTransactions() {
>
{isPurchase ? (
-
-
- {getInitials(merchantName)}
-
-
+
) : (
- {merchantName}
+ {merchant.name}
- {merchantLocation && (
+ {merchant.location && (
- {merchantLocation}
+ {merchant.location}
)}
diff --git a/app/(protected)/activity/[clientTxId].tsx b/app/(protected)/activity/[clientTxId].tsx
index bc1d20f3a..380b4eeca 100644
--- a/app/(protected)/activity/[clientTxId].tsx
+++ b/app/(protected)/activity/[clientTxId].tsx
@@ -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';
@@ -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,
@@ -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();
@@ -379,11 +382,18 @@ const CardTransactionDetail = memo(function CardTransactionDetail({
- {/* Avatar with merchant initial or token icon */}
+ {/* Merchant logo, else the merchant initial, else the token icon */}
{isPurchase ? (
-
- {initial}
-
+
+ {initial}
+
+ }
+ />
) : (
)}
diff --git a/components/Activity/CardTransactions.tsx b/components/Activity/CardTransactions.tsx
index 3002db0b0..ca8a37545 100644
--- a/components/Activity/CardTransactions.tsx
+++ b/components/Activity/CardTransactions.tsx
@@ -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';
@@ -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' };
@@ -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 (
@@ -178,14 +169,7 @@ export default function CardTransactions() {
>
{isPurchase ? (
-
-
- {initials}
-
-
+
) : (
- {merchantName}
+ {merchant.name}
- {merchantLocation && (
+ {merchant.location && (
- {merchantLocation}
+ {merchant.location}
)}
{cashbackInfo && (
@@ -220,10 +204,7 @@ export default function CardTransactions() {
{formatCardAmount(transaction.amount, provider)}
diff --git a/components/Activity/MerchantAvatar.tsx b/components/Activity/MerchantAvatar.tsx
new file mode 100644
index 000000000..d0b227967
--- /dev/null
+++ b/components/Activity/MerchantAvatar.tsx
@@ -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 (
+
+ );
+ }
+
+ if (fallback) return <>{fallback}>;
+
+ const color = getColorForTransaction(name);
+
+ return (
+
+
+ {getInitials(name)}
+
+
+ );
+}
diff --git a/lib/types.ts b/lib/types.ts
index fa6b57b33..79a9a2b0c 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -1587,6 +1587,17 @@ export interface CardTransaction {
merchant_location?: string;
merchant_city?: string;
merchant_country?: string;
+ /** The issuer's own human-readable category for the raw descriptor. */
+ merchant_category?: string;
+ /**
+ * Merchant enrichment: the clean brand name ("Google Play" rather than
+ * "GOOGLE *Play Books"), its category, and a logo URL. Any of them may be
+ * absent — the issuer enriches opportunistically and never before settlement —
+ * so read them through `getMerchantDisplay` rather than directly.
+ */
+ enriched_merchant_name?: string;
+ enriched_merchant_category?: string;
+ enriched_merchant_icon?: string;
local_transaction_details?: LocalTransactionDetails;
declined_reason?: string;
}
diff --git a/lib/utils/cardHelpers.ts b/lib/utils/cardHelpers.ts
index c600db7ab..c77eb7fe7 100644
--- a/lib/utils/cardHelpers.ts
+++ b/lib/utils/cardHelpers.ts
@@ -2,6 +2,7 @@ import {
CardProvider,
CardResponse,
CardStatus,
+ CardTransaction,
Cashback,
CashbackInfo,
CashbackStatus,
@@ -59,6 +60,68 @@ export const canToggleCardFreeze = (cardDetails: FreezeState): boolean =>
export const canDepositToCard = (provider: CardProvider | null | undefined): boolean =>
provider !== CardProvider.WIREX;
+/** A trimmed non-empty string, or undefined. */
+const nonEmpty = (value: string | undefined | null): string | undefined => {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : undefined;
+};
+
+/** How a card transaction's merchant should be shown. */
+export type MerchantDisplay = {
+ /** Brand name when the issuer enriched one, else the raw descriptor. */
+ name: string;
+ /** Merchant logo, when the issuer has one. Falls back to an initials avatar. */
+ iconUrl?: string;
+ /** Merchant category, enriched name first, then the issuer's own. */
+ category?: string;
+ /** "SEATTLE, US", when the transaction carries a place. */
+ location?: string;
+};
+
+/**
+ * Resolve what to show for a transaction's merchant.
+ *
+ * The card network sends the descriptor a terminal typed ("GOOGLE *Play Books"),
+ * which is what a user sees unless the issuer has enriched it into a brand name,
+ * a category and a logo. Enrichment is optional and never arrives before the
+ * transaction settles, so every field falls back: brand name → raw descriptor →
+ * transaction description → "Unknown". Shared by the activity list, the card
+ * transaction list and the transaction detail screen so all three name the same
+ * merchant the same way.
+ */
+export const getMerchantDisplay = (
+ transaction: Pick<
+ CardTransaction,
+ | 'merchant_name'
+ | 'merchant_city'
+ | 'merchant_country'
+ | 'merchant_category'
+ | 'enriched_merchant_name'
+ | 'enriched_merchant_category'
+ | 'enriched_merchant_icon'
+ | 'description'
+ >,
+ options: { locationSeparator?: string; uppercaseLocation?: boolean } = {},
+): MerchantDisplay => {
+ const { locationSeparator = ' ', uppercaseLocation = false } = options;
+
+ const location = [nonEmpty(transaction.merchant_city), nonEmpty(transaction.merchant_country)]
+ .filter(Boolean)
+ .join(locationSeparator);
+
+ return {
+ name:
+ nonEmpty(transaction.enriched_merchant_name) ??
+ nonEmpty(transaction.merchant_name) ??
+ nonEmpty(transaction.description) ??
+ 'Unknown',
+ iconUrl: nonEmpty(transaction.enriched_merchant_icon),
+ category:
+ nonEmpty(transaction.enriched_merchant_category) ?? nonEmpty(transaction.merchant_category),
+ location: location ? (uppercaseLocation ? location.toUpperCase() : location) : undefined,
+ };
+};
+
/**
* Get initials from merchant/person name for avatar display
*/