From c72f30771ec1750e5bada11a47e962ad7d6e6c1d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 13:12:18 +0000 Subject: [PATCH] feat(card): show enriched merchant name, logo and category in card activities The issuer now returns merchant enrichment alongside the raw acquirer descriptor: a clean brand name, a category, and a hosted logo URL. Show them where a card transaction names its merchant. - getMerchantDisplay resolves one merchant view for every card surface: brand name -> raw descriptor -> description -> "Unknown", plus logo, category and location. The activity list, the card transaction list and the transaction detail screen all read through it, so they can no longer drift apart. - MerchantAvatar renders the merchant logo, falling back to the existing initials avatar when there is no logo or the image fails to load. The detail screen passes its own grey-circle fallback so its look is unchanged. - the detail screen's Category row prefers the issuer's category over our MCC lookup table, which only names the bucket the acquirer filed the merchant under Enrichment is optional and never arrives before settlement, so the fallbacks are the normal path for a fresh purchase, not an edge case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XnuxHDT4YapsUvRzyRqhUR --- __tests__/getMerchantDisplay.test.ts | 95 +++++++++++++++++++ .../(tabs)/card/details/transactions.tsx | 33 +++---- app/(protected)/activity/[clientTxId].tsx | 36 ++++--- components/Activity/CardTransactions.tsx | 35 ++----- components/Activity/MerchantAvatar.tsx | 75 +++++++++++++++ lib/types.ts | 11 +++ lib/utils/cardHelpers.ts | 63 ++++++++++++ 7 files changed, 287 insertions(+), 61 deletions(-) create mode 100644 __tests__/getMerchantDisplay.test.ts create mode 100644 components/Activity/MerchantAvatar.tsx 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 */