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 ( + {`${name} + ); + } + + 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 */