diff --git a/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts b/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts index 7c3b1ff1a..7f6a0d9aa 100644 --- a/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts +++ b/src/components/TransactionDetails/__tests__/transactionTransformer.test.ts @@ -1,10 +1,5 @@ import { mapTransactionDataForDrawer } from '../transactionTransformer' -import { - EHistoryEntryType, - EHistoryUserRole, - EHistoryStatus, - type HistoryEntry, -} from '@/utils/history.utils' +import { EHistoryEntryType, EHistoryUserRole, EHistoryStatus, type HistoryEntry } from '@/utils/history.utils' jest.mock('@/assets', () => ({})) jest.mock('@/assets/payment-apps', () => ({ MERCADO_PAGO: '', PIX: '', SIMPLEFI: '' })) @@ -80,88 +75,180 @@ const cases: TestCase[] = [ // ───── DIRECT_SEND ───── { name: 'DIRECT_SEND × SENDER → outgoing send to user', - entry: baseEntry({ type: EHistoryEntryType.DIRECT_SEND, userRole: EHistoryUserRole.SENDER, recipientAccount: aliceUser, isVerified: true }), - expect: { direction: 'send', transactionCardType: 'send', userName: 'alice', isPeerActuallyUser: true, isLinkTransaction: false }, + entry: baseEntry({ + type: EHistoryEntryType.DIRECT_SEND, + userRole: EHistoryUserRole.SENDER, + recipientAccount: aliceUser, + isVerified: true, + }), + expect: { + direction: 'send', + transactionCardType: 'send', + userName: 'alice', + isPeerActuallyUser: true, + isLinkTransaction: false, + }, }, { name: 'DIRECT_SEND × RECIPIENT → incoming receive from user', - entry: baseEntry({ type: EHistoryEntryType.DIRECT_SEND, userRole: EHistoryUserRole.RECIPIENT, senderAccount: bobUser, recipientAccount: aliceUser, isVerified: true }), + entry: baseEntry({ + type: EHistoryEntryType.DIRECT_SEND, + userRole: EHistoryUserRole.RECIPIENT, + senderAccount: bobUser, + recipientAccount: aliceUser, + isVerified: true, + }), expect: { direction: 'receive', transactionCardType: 'receive', userName: 'bob', isPeerActuallyUser: true }, }, // ───── SEND_LINK ───── { name: 'SEND_LINK × SENDER (claimed by peanut user) → send to claimer username', - entry: baseEntry({ type: EHistoryEntryType.SEND_LINK, userRole: EHistoryUserRole.SENDER, recipientAccount: aliceUser, isVerified: true }), - expect: { direction: 'send', transactionCardType: 'send', userName: 'alice', isPeerActuallyUser: true, isLinkTransaction: false }, + entry: baseEntry({ + type: EHistoryEntryType.SEND_LINK, + userRole: EHistoryUserRole.SENDER, + recipientAccount: aliceUser, + isVerified: true, + }), + expect: { + direction: 'send', + transactionCardType: 'send', + userName: 'alice', + isPeerActuallyUser: true, + isLinkTransaction: false, + }, }, { name: 'SEND_LINK × SENDER (unclaimed) → still send via link, no peer', - entry: baseEntry({ type: EHistoryEntryType.SEND_LINK, userRole: EHistoryUserRole.SENDER, status: EHistoryStatus.PENDING, recipientAccount: { ...externalEoa }, isVerified: true }), + entry: baseEntry({ + type: EHistoryEntryType.SEND_LINK, + userRole: EHistoryUserRole.SENDER, + status: EHistoryStatus.PENDING, + recipientAccount: { ...externalEoa }, + isVerified: true, + }), expect: { direction: 'send', transactionCardType: 'send', isPeerActuallyUser: false, isLinkTransaction: true }, }, { name: 'SEND_LINK × RECIPIENT (claimed by external addr) → claim_external', - entry: baseEntry({ type: EHistoryEntryType.SEND_LINK, userRole: EHistoryUserRole.RECIPIENT, recipientAccount: externalEoa }), - expect: { direction: 'claim_external', transactionCardType: 'claim_external', userName: externalEoa.identifier, isLinkTransaction: true }, + entry: baseEntry({ + type: EHistoryEntryType.SEND_LINK, + userRole: EHistoryUserRole.RECIPIENT, + recipientAccount: externalEoa, + }), + expect: { + direction: 'claim_external', + transactionCardType: 'claim_external', + userName: externalEoa.identifier, + isLinkTransaction: true, + }, }, { name: 'SEND_LINK × BOTH → cancelled-by-self (link tx, peer = self)', - entry: baseEntry({ type: EHistoryEntryType.SEND_LINK, userRole: EHistoryUserRole.BOTH, recipientAccount: aliceUser }), + entry: baseEntry({ + type: EHistoryEntryType.SEND_LINK, + userRole: EHistoryUserRole.BOTH, + recipientAccount: aliceUser, + }), expect: { isLinkTransaction: true }, }, // ───── BRIDGE_OFFRAMP ───── { name: 'BRIDGE_OFFRAMP → bank_withdraw with bankAccountDetails populated', - entry: baseEntry({ type: EHistoryEntryType.BRIDGE_OFFRAMP, userRole: EHistoryUserRole.SENDER, recipientAccount: ibanAccountES }), - expect: { direction: 'bank_withdraw', transactionCardType: 'bank_withdraw', userName: 'Bank Account', bankAccountDetailsDefined: true }, + entry: baseEntry({ + type: EHistoryEntryType.BRIDGE_OFFRAMP, + userRole: EHistoryUserRole.SENDER, + recipientAccount: ibanAccountES, + }), + expect: { + direction: 'bank_withdraw', + transactionCardType: 'bank_withdraw', + userName: 'Bank Account', + bankAccountDetailsDefined: true, + }, }, // ───── MANTECA_OFFRAMP — bankAccountDetails plumbed (legacy bug fixed in PR-B) ───── { name: 'MANTECA_OFFRAMP → bank_withdraw with bankAccountDetails populated (post-PR-B)', - entry: baseEntry({ type: EHistoryEntryType.MANTECA_OFFRAMP, userRole: EHistoryUserRole.SENDER, recipientAccount: ibanAccountES }), + entry: baseEntry({ + type: EHistoryEntryType.MANTECA_OFFRAMP, + userRole: EHistoryUserRole.SENDER, + recipientAccount: ibanAccountES, + }), expect: { direction: 'bank_withdraw', transactionCardType: 'bank_withdraw', bankAccountDetailsDefined: true }, }, // ───── BRIDGE_ONRAMP / MANTECA_ONRAMP ───── { name: 'BRIDGE_ONRAMP → bank_deposit', - entry: baseEntry({ type: EHistoryEntryType.BRIDGE_ONRAMP, userRole: EHistoryUserRole.RECIPIENT, recipientAccount: aliceUser }), + entry: baseEntry({ + type: EHistoryEntryType.BRIDGE_ONRAMP, + userRole: EHistoryUserRole.RECIPIENT, + recipientAccount: aliceUser, + }), expect: { direction: 'bank_deposit', transactionCardType: 'bank_deposit', userName: 'Bank Account' }, }, { name: 'MANTECA_ONRAMP → bank_deposit', - entry: baseEntry({ type: EHistoryEntryType.MANTECA_ONRAMP, userRole: EHistoryUserRole.RECIPIENT, recipientAccount: aliceUser }), + entry: baseEntry({ + type: EHistoryEntryType.MANTECA_ONRAMP, + userRole: EHistoryUserRole.RECIPIENT, + recipientAccount: aliceUser, + }), expect: { direction: 'bank_deposit', transactionCardType: 'bank_deposit', userName: 'Bank Account' }, }, // ───── DEPOSIT (CRYPTO_DEPOSIT legacy) ───── { name: 'DEPOSIT regular → add, with sender identifier (legacy: never marks peer as user)', - entry: baseEntry({ type: EHistoryEntryType.DEPOSIT, userRole: EHistoryUserRole.RECIPIENT, senderAccount: bobUser, recipientAccount: aliceUser, isVerified: true }), + entry: baseEntry({ + type: EHistoryEntryType.DEPOSIT, + userRole: EHistoryUserRole.RECIPIENT, + senderAccount: bobUser, + recipientAccount: aliceUser, + isVerified: true, + }), // Legacy DEPOSIT case sets isPeerActuallyUser=false even when sender is a user. // PR-B's TRANSACTION_INTENT/CRYPTO_DEPOSIT branch will improve on this; legacy stays as-is. - expect: { direction: 'add', transactionCardType: 'add', userName: bobUser.identifier, isPeerActuallyUser: false }, + expect: { + direction: 'add', + transactionCardType: 'add', + userName: bobUser.identifier, + isPeerActuallyUser: false, + }, }, { name: 'DEPOSIT zero-amount test transaction → "Enjoy Peanut!"', - entry: baseEntry({ type: EHistoryEntryType.DEPOSIT, userRole: EHistoryUserRole.RECIPIENT, amount: '0', recipientAccount: aliceUser }), + entry: baseEntry({ + type: EHistoryEntryType.DEPOSIT, + userRole: EHistoryUserRole.RECIPIENT, + amount: '0', + recipientAccount: aliceUser, + }), expect: { direction: 'add', transactionCardType: 'add', userName: 'Enjoy Peanut!' }, }, // ───── QR PAYMENTS ───── { name: 'MANTECA_QR_PAYMENT → qr_payment / pay', - entry: baseEntry({ type: EHistoryEntryType.MANTECA_QR_PAYMENT, userRole: EHistoryUserRole.SENDER, recipientAccount: { identifier: 'merchant-xyz', type: 'MERCHANT', isUser: false } }), + entry: baseEntry({ + type: EHistoryEntryType.MANTECA_QR_PAYMENT, + userRole: EHistoryUserRole.SENDER, + recipientAccount: { identifier: 'merchant-xyz', type: 'MERCHANT', isUser: false }, + }), expect: { direction: 'qr_payment', transactionCardType: 'pay', userName: 'merchant-xyz' }, }, // ───── PERK_REWARD ───── { name: 'PERK_REWARD → receive Peanut Reward', - entry: baseEntry({ type: EHistoryEntryType.PERK_REWARD, userRole: EHistoryUserRole.RECIPIENT, recipientAccount: aliceUser }), + entry: baseEntry({ + type: EHistoryEntryType.PERK_REWARD, + userRole: EHistoryUserRole.RECIPIENT, + recipientAccount: aliceUser, + }), expect: { direction: 'receive', transactionCardType: 'receive', userName: 'Peanut Reward' }, }, @@ -227,11 +314,19 @@ const cases: TestCase[] = [ entry: baseEntry({ type: EHistoryEntryType.TRANSACTION_INTENT, userRole: EHistoryUserRole.RECIPIENT, - senderAccount: { identifier: '0xSomeone0000000000000000000000000000000000', type: 'WALLET_EXTERNAL', isUser: false }, + senderAccount: { + identifier: '0xSomeone0000000000000000000000000000000000', + type: 'WALLET_EXTERNAL', + isUser: false, + }, recipientAccount: aliceUser, extraData: { kind: 'CRYPTO_WITHDRAW' }, }), - expect: { direction: 'add', transactionCardType: 'add', userName: '0xSomeone0000000000000000000000000000000000' }, + expect: { + direction: 'add', + transactionCardType: 'add', + userName: '0xSomeone0000000000000000000000000000000000', + }, }, { name: 'TRANSACTION_INTENT × FIAT_OFFRAMP × SENDER → bank_withdraw', @@ -251,7 +346,12 @@ const cases: TestCase[] = [ recipientAccount: aliceUser, extraData: { kind: 'CARD_SPEND', merchantName: 'Acme Coffee', rainTransactionId: 'rain-123' }, }), - expect: { direction: 'qr_payment', transactionCardType: 'pay', userName: 'Acme Coffee', cardPaymentDefined: true }, + expect: { + direction: 'qr_payment', + transactionCardType: 'pay', + userName: 'Acme Coffee', + cardPaymentDefined: true, + }, }, { name: 'TRANSACTION_INTENT × CARD_SPEND with no merchant → fallback "Card payment"', @@ -261,7 +361,12 @@ const cases: TestCase[] = [ recipientAccount: aliceUser, extraData: { kind: 'CARD_SPEND' }, }), - expect: { direction: 'qr_payment', transactionCardType: 'pay', userName: 'Card payment', cardPaymentDefined: true }, + expect: { + direction: 'qr_payment', + transactionCardType: 'pay', + userName: 'Card payment', + cardPaymentDefined: true, + }, }, { name: 'TRANSACTION_INTENT × OTHER + parentRainTxId → card refund', @@ -271,7 +376,12 @@ const cases: TestCase[] = [ recipientAccount: aliceUser, extraData: { kind: 'OTHER', parentRainTxId: 'rain-456', merchantName: 'Acme Coffee' }, }), - expect: { direction: 'receive', transactionCardType: 'receive', userName: 'Refund from Acme Coffee', cardPaymentDefined: true }, + expect: { + direction: 'receive', + transactionCardType: 'receive', + userName: 'Refund from Acme Coffee', + cardPaymentDefined: true, + }, }, // ═════════════════════════════════════════════════════════════════════ @@ -324,7 +434,11 @@ const cases: TestCase[] = [ }, { name: '[PR-B] MANTECA_OFFRAMP plumbs bankAccountDetails (independent legacy bug)', - entry: baseEntry({ type: EHistoryEntryType.MANTECA_OFFRAMP, userRole: EHistoryUserRole.SENDER, recipientAccount: ibanAccountES }), + entry: baseEntry({ + type: EHistoryEntryType.MANTECA_OFFRAMP, + userRole: EHistoryUserRole.SENDER, + recipientAccount: ibanAccountES, + }), expect: { bankAccountDetailsDefined: true }, }, @@ -371,10 +485,14 @@ describe('mapTransactionDataForDrawer', () => { if (e.direction !== undefined) expect(result.direction).toBe(e.direction) if (e.userName !== undefined) expect(result.userName).toBe(e.userName) - if (e.transactionCardType !== undefined) expect(result.extraDataForDrawer?.transactionCardType).toBe(e.transactionCardType) - if (e.isLinkTransaction !== undefined) expect(result.extraDataForDrawer?.isLinkTransaction).toBe(e.isLinkTransaction) - if (e.cardPaymentDefined !== undefined) expect(!!result.extraDataForDrawer?.cardPayment).toBe(e.cardPaymentDefined) - if (e.bankAccountDetailsDefined !== undefined) expect(!!result.bankAccountDetails).toBe(e.bankAccountDetailsDefined) + if (e.transactionCardType !== undefined) + expect(result.extraDataForDrawer?.transactionCardType).toBe(e.transactionCardType) + if (e.isLinkTransaction !== undefined) + expect(result.extraDataForDrawer?.isLinkTransaction).toBe(e.isLinkTransaction) + if (e.cardPaymentDefined !== undefined) + expect(!!result.extraDataForDrawer?.cardPayment).toBe(e.cardPaymentDefined) + if (e.bankAccountDetailsDefined !== undefined) + expect(!!result.bankAccountDetails).toBe(e.bankAccountDetailsDefined) if (e.isPeerActuallyUser !== undefined) { // isPeerActuallyUser isn't directly exposed; isVerified output is gated by it // (isVerified = entry.isVerified && isPeerActuallyUser). Cases that assert this diff --git a/src/components/TransactionDetails/strategies/fallback.ts b/src/components/TransactionDetails/strategies/fallback.ts new file mode 100644 index 000000000..e6770ad76 --- /dev/null +++ b/src/components/TransactionDetails/strategies/fallback.ts @@ -0,0 +1,44 @@ +import { type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from './types' +import { cardRefund } from './intent/card' + +export const intentFallback: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + const kind = (entry.extraData?.kind as string | undefined) ?? 'OTHER' + + // Card refunds arrive with kind ∈ {OTHER, REFUND} + parentRainTxId set + // (provider === RAIN). Scope strictly to these two kinds — guarding only + // on parentRainTxId would misroute any future intent carrying the linkage. + if ((kind === 'OTHER' || kind === 'REFUND') && entry.extraData?.parentRainTxId) { + return cardRefund(entry) + } + + // Lazy Sentry import keeps it out of test / SSR bundles. PR #1914 will + // consolidate this onto the shared pipelineAlert helper. + if (typeof window !== 'undefined') { + import('@sentry/nextjs') + .then((Sentry) => + Sentry.captureMessage(`transactionTransformer: unhandled TRANSACTION_INTENT kind "${kind}"`, { + level: 'warning', + tags: { feature: 'history', kind }, + extra: { entryUuid: entry.uuid, userRole: entry.userRole }, + }) + ) + .catch(() => {}) + } + + return { + direction: 'send', + transactionCardType: 'send', + nameForDetails: entry.recipientAccount?.identifier || 'Transaction', + isPeerActuallyUser: false, + isLinkTx: false, + } +} + +export const legacyFallback: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => ({ + direction: 'send', + transactionCardType: 'send', + nameForDetails: entry.recipientAccount?.identifier || 'Unknown', + isPeerActuallyUser: !!entry.recipientAccount?.isUser, + isLinkTx: false, +}) diff --git a/src/components/TransactionDetails/strategies/intent/card.ts b/src/components/TransactionDetails/strategies/intent/card.ts new file mode 100644 index 000000000..6941ba588 --- /dev/null +++ b/src/components/TransactionDetails/strategies/intent/card.ts @@ -0,0 +1,32 @@ +import { type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +export const qrPay: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => ({ + direction: 'qr_payment', + transactionCardType: 'pay', + nameForDetails: entry.recipientAccount?.identifier || 'Merchant', + isPeerActuallyUser: false, + isLinkTx: false, +}) + +export const cardSpend: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + const merchantName = (entry.extraData?.merchantName as string | null | undefined) ?? null + return { + direction: 'qr_payment', + transactionCardType: 'pay', + nameForDetails: merchantName || 'Card payment', + isPeerActuallyUser: false, + isLinkTx: false, + } +} + +export const cardRefund: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + const merchantName = (entry.extraData?.merchantName as string | null | undefined) ?? null + return { + direction: 'receive', + transactionCardType: 'receive', + nameForDetails: merchantName ? `Refund from ${merchantName}` : 'Card refund', + isPeerActuallyUser: false, + isLinkTx: false, + } +} diff --git a/src/components/TransactionDetails/strategies/intent/crypto.ts b/src/components/TransactionDetails/strategies/intent/crypto.ts new file mode 100644 index 000000000..eaaaca91d --- /dev/null +++ b/src/components/TransactionDetails/strategies/intent/crypto.ts @@ -0,0 +1,31 @@ +import { EHistoryUserRole, type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +export const cryptoDeposit: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => ({ + direction: 'add', + transactionCardType: 'add', + nameForDetails: entry.senderAccount?.username || entry.senderAccount?.identifier || 'Deposit Source', + fullName: entry.senderAccount?.fullName ?? '', + showFullName: entry.senderAccount?.showFullName, + isPeerActuallyUser: !!entry.senderAccount?.isUser, + isLinkTx: false, +}) + +export const cryptoWithdraw: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + if (entry.userRole === EHistoryUserRole.RECIPIENT) { + return { + direction: 'add', + transactionCardType: 'add', + nameForDetails: entry.senderAccount?.username || entry.senderAccount?.identifier || 'External Wallet', + isPeerActuallyUser: !!entry.senderAccount?.isUser, + isLinkTx: false, + } + } + return { + direction: 'withdraw', + transactionCardType: 'withdraw', + nameForDetails: entry.recipientAccount?.identifier || 'External Account', + isPeerActuallyUser: false, + isLinkTx: false, + } +} diff --git a/src/components/TransactionDetails/strategies/intent/fiat-offramp.ts b/src/components/TransactionDetails/strategies/intent/fiat-offramp.ts new file mode 100644 index 000000000..2d0594b77 --- /dev/null +++ b/src/components/TransactionDetails/strategies/intent/fiat-offramp.ts @@ -0,0 +1,24 @@ +import { EHistoryUserRole, type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +export const fiatOfframp: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + if (entry.userRole === EHistoryUserRole.RECIPIENT) { + // Multi-user fulfillment edge case — viewer received a bank + // withdraw initiated by another user. USDC arrives in viewer's + // wallet from offramp funder. + return { + direction: 'receive', + transactionCardType: 'receive', + nameForDetails: entry.senderAccount?.username || entry.senderAccount?.identifier || 'Bank Account', + isPeerActuallyUser: !!entry.senderAccount?.isUser, + isLinkTx: false, + } + } + return { + direction: 'bank_withdraw', + transactionCardType: 'bank_withdraw', + nameForDetails: 'Bank Account', + isPeerActuallyUser: false, + isLinkTx: false, + } +} diff --git a/src/components/TransactionDetails/strategies/intent/link-create.ts b/src/components/TransactionDetails/strategies/intent/link-create.ts new file mode 100644 index 000000000..0c18d73d0 --- /dev/null +++ b/src/components/TransactionDetails/strategies/intent/link-create.ts @@ -0,0 +1,36 @@ +import { EHistoryUserRole, type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +export const linkCreate: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + if (entry.userRole === EHistoryUserRole.RECIPIENT) { + // Viewer claimed someone else's link. + const isPeerActuallyUser = !!entry.senderAccount?.isUser + return { + direction: 'receive', + transactionCardType: 'receive', + nameForDetails: entry.senderAccount?.username || entry.senderAccount?.identifier || 'Received via Link', + fullName: entry.senderAccount?.fullName ?? '', + showFullName: isPeerActuallyUser ? entry.senderAccount?.showFullName : undefined, + isPeerActuallyUser, + isLinkTx: !isPeerActuallyUser, + } + } + if (entry.recipientAccount?.isUser) { + return { + direction: 'send', + transactionCardType: 'send', + nameForDetails: entry.recipientAccount?.username ?? entry.recipientAccount?.identifier ?? '', + fullName: entry.recipientAccount?.fullName ?? '', + showFullName: entry.recipientAccount?.showFullName, + isPeerActuallyUser: true, + isLinkTx: false, + } + } + return { + direction: 'send', + transactionCardType: 'send', + nameForDetails: 'Sent via link', + isPeerActuallyUser: false, + isLinkTx: true, + } +} diff --git a/src/components/TransactionDetails/strategies/intent/p2p-send.ts b/src/components/TransactionDetails/strategies/intent/p2p-send.ts new file mode 100644 index 000000000..ca2d36d16 --- /dev/null +++ b/src/components/TransactionDetails/strategies/intent/p2p-send.ts @@ -0,0 +1,56 @@ +// REQUEST_PAY is the post-decomplexify rename of P2P_REQUEST_FULFILL; +// shares this strategy with P2P_SEND. + +import { EHistoryUserRole, type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +export const p2pSendOrRequestPay: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + const kind = entry.extraData?.kind as string | undefined + + // Bridge-fulfilled requests render as bank-request fulfillments on the + // sender side. Viewer is paying via bank rails. + if ( + kind === 'REQUEST_PAY' && + entry.extraData?.fulfillmentType === 'bridge' && + entry.userRole === EHistoryUserRole.SENDER + ) { + return { + direction: 'bank_request_fulfillment', + transactionCardType: 'bank_request_fulfillment', + nameForDetails: entry.recipientAccount?.username ?? entry.recipientAccount?.identifier ?? 'Recipient', + fullName: entry.recipientAccount?.fullName ?? '', + showFullName: entry.recipientAccount?.showFullName, + isPeerActuallyUser: !!entry.recipientAccount?.isUser || !!entry.senderAccount?.isUser, + isLinkTx: false, + } + } + + if (entry.userRole === EHistoryUserRole.RECIPIENT) { + const senderResolved = !!entry.senderAccount?.identifier + if (senderResolved) { + return { + direction: 'receive', + transactionCardType: 'receive', + nameForDetails: entry.senderAccount?.username || entry.senderAccount?.identifier || 'Sender', + isPeerActuallyUser: !!entry.senderAccount?.isUser, + isLinkTx: false, + } + } + // Unfulfilled request the viewer created. + return { + direction: 'request_received', + transactionCardType: 'request', + nameForDetails: 'Request', + isPeerActuallyUser: false, + isLinkTx: false, + } + } + + return { + direction: 'send', + transactionCardType: 'send', + nameForDetails: entry.recipientAccount?.username || entry.recipientAccount?.identifier || 'Recipient', + isPeerActuallyUser: !!entry.recipientAccount?.isUser, + isLinkTx: false, + } +} diff --git a/src/components/TransactionDetails/strategies/legacy/bank-send-link-claim.ts b/src/components/TransactionDetails/strategies/legacy/bank-send-link-claim.ts new file mode 100644 index 000000000..9468f68dd --- /dev/null +++ b/src/components/TransactionDetails/strategies/legacy/bank-send-link-claim.ts @@ -0,0 +1,29 @@ +import { EHistoryUserRole, type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +const BANK_CLAIM: TransactionStrategyOutput = { + direction: 'bank_claim', + transactionCardType: 'bank_claim', + nameForDetails: 'Claimed to Bank', + isPeerActuallyUser: false, + isLinkTx: false, +} + +export const bankSendLinkClaim: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + const senderSide = entry.userRole === EHistoryUserRole.SENDER || entry.userRole === EHistoryUserRole.BOTH + if (senderSide && entry.recipientAccount.isUser) { + // Claimed by a peanut user (kyc'd or not). Render as direct send. + return { + direction: 'send', + transactionCardType: 'send', + nameForDetails: + entry.recipientAccount?.username ?? + entry.recipientAccount?.fullName ?? + entry.recipientAccount?.identifier, + fullName: entry.recipientAccount?.fullName ?? '', + isPeerActuallyUser: true, + isLinkTx: false, + } + } + return BANK_CLAIM +} diff --git a/src/components/TransactionDetails/strategies/legacy/direct-send.ts b/src/components/TransactionDetails/strategies/legacy/direct-send.ts new file mode 100644 index 000000000..2cba53208 --- /dev/null +++ b/src/components/TransactionDetails/strategies/legacy/direct-send.ts @@ -0,0 +1,29 @@ +import { EHistoryUserRole, type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +export const directSend: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + if (entry.userRole === EHistoryUserRole.SENDER) { + return { + direction: 'send', + transactionCardType: 'send', + nameForDetails: entry.recipientAccount?.username ?? entry.recipientAccount?.identifier ?? '', + fullName: entry.recipientAccount?.fullName ?? '', + showFullName: entry.recipientAccount?.showFullName, + isPeerActuallyUser: true, + isLinkTx: false, + } + } + return { + direction: 'receive', + transactionCardType: 'receive', + nameForDetails: entry.senderAccount?.username ?? entry.senderAccount?.identifier ?? 'Requested via Link', + fullName: entry.senderAccount?.fullName ?? '', + showFullName: entry.senderAccount?.showFullName, + isPeerActuallyUser: true, + // Original behaviour: if the sender side has no senderAccount, the + // entry was created by an external (non-Peanut) actor, render as a + // public-link receive. The legacy switch toggled `isLinkTx` based on + // `!entry.senderAccount` regardless of `isPeerActuallyUser`. + isLinkTx: !entry.senderAccount, + } +} diff --git a/src/components/TransactionDetails/strategies/legacy/external-account.ts b/src/components/TransactionDetails/strategies/legacy/external-account.ts new file mode 100644 index 000000000..25e58003c --- /dev/null +++ b/src/components/TransactionDetails/strategies/legacy/external-account.ts @@ -0,0 +1,55 @@ +// Strategies for entries that withdraw to / deposit from external (non-user) +// destinations: bank accounts, raw wallet addresses, merchant accounts. + +import { type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +const noPeer = ( + direction: TransactionStrategyOutput['direction'], + transactionCardType: TransactionStrategyOutput['transactionCardType'], + nameForDetails: string +): TransactionStrategyOutput => ({ + direction, + transactionCardType, + nameForDetails, + isPeerActuallyUser: false, + isLinkTx: false, +}) + +export const withdraw: TransactionStrategy = (entry: HistoryEntry) => + noPeer('withdraw', 'withdraw', entry.recipientAccount?.identifier || 'External Account') + +export const cashout: TransactionStrategy = (entry: HistoryEntry) => + noPeer('withdraw', 'withdraw', entry.recipientAccount?.identifier || 'Bank Account') + +export const bankOfframp: TransactionStrategy = () => noPeer('bank_withdraw', 'bank_withdraw', 'Bank Account') + +export const bankOnramp: TransactionStrategy = () => noPeer('bank_deposit', 'bank_deposit', 'Bank Account') + +export const mantecaQrPayment: TransactionStrategy = (entry: HistoryEntry) => + noPeer('qr_payment', 'pay', entry.recipientAccount?.identifier || 'Merchant') + +export const simplefiQrPayment: TransactionStrategy = (entry: HistoryEntry) => { + // No merchant name — prettify the slug: dashes → spaces, title-case. + const raw = entry.recipientAccount?.identifier || 'Merchant' + const nameForDetails = raw.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + return noPeer('qr_payment', 'pay', nameForDetails) +} + +export const perkReward: TransactionStrategy = () => ({ + direction: 'receive', + transactionCardType: 'receive', + nameForDetails: 'Peanut Reward', + fullName: 'Peanut Rewards', + isPeerActuallyUser: false, + isLinkTx: false, +}) + +export const deposit: TransactionStrategy = (entry: HistoryEntry) => { + const isTestTransaction = String(entry.amount) === '0' || entry.extraData?.usdAmount === '0' + return noPeer( + 'add', + 'add', + isTestTransaction ? 'Enjoy Peanut!' : entry.senderAccount?.identifier || 'Deposit Source' + ) +} diff --git a/src/components/TransactionDetails/strategies/legacy/request.ts b/src/components/TransactionDetails/strategies/legacy/request.ts new file mode 100644 index 000000000..8fde0445c --- /dev/null +++ b/src/components/TransactionDetails/strategies/legacy/request.ts @@ -0,0 +1,58 @@ +import { EHistoryUserRole, type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +export const request: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + if (entry.extraData?.fulfillmentType === 'bridge' && entry.userRole === EHistoryUserRole.SENDER) { + return { + direction: 'bank_request_fulfillment', + transactionCardType: 'bank_request_fulfillment', + nameForDetails: entry.recipientAccount?.username ?? entry.recipientAccount?.identifier ?? '', + fullName: entry.recipientAccount?.fullName ?? '', + showFullName: entry.recipientAccount?.showFullName, + isPeerActuallyUser: !!entry.recipientAccount?.isUser || !!entry.senderAccount?.isUser, + // Mirrors the legacy fall-through: REQUEST × bridge × SENDER + // doesn't explicitly set isLinkTx, so it lands on the + // post-switch `isLinkTx = !isPeerActuallyUser` line. + isLinkTx: !(!!entry.recipientAccount?.isUser || !!entry.senderAccount?.isUser), + } + } + if (entry.userRole === EHistoryUserRole.RECIPIENT) { + const isPeerActuallyUser = !!entry.senderAccount?.isUser + return { + direction: 'request_sent', + transactionCardType: 'request', + nameForDetails: entry.senderAccount?.username || entry.senderAccount?.identifier || 'Requested via Link', + fullName: entry.senderAccount?.fullName ?? '', + showFullName: entry.senderAccount?.showFullName, + isPeerActuallyUser, + isLinkTx: !isPeerActuallyUser, + } + } + if ( + entry.status?.toUpperCase() === 'NEW' || + (entry.status?.toUpperCase() === 'PENDING' && !entry.extraData?.fulfillmentType) + ) { + const isPeerActuallyUser = !!entry.recipientAccount?.isUser + return { + direction: 'request_received', + transactionCardType: 'request', + nameForDetails: + entry.recipientAccount?.username || + entry.recipientAccount?.identifier || + `Request From ${entry.recipientAccount?.username || entry.recipientAccount?.identifier}`, + fullName: entry.recipientAccount?.fullName ?? '', + showFullName: entry.recipientAccount?.showFullName, + isPeerActuallyUser, + isLinkTx: !isPeerActuallyUser, + } + } + const isPeerActuallyUser = !!entry.recipientAccount?.isUser + return { + direction: 'send', + transactionCardType: 'send', + nameForDetails: entry.recipientAccount?.username || entry.recipientAccount?.identifier || 'Paid Request To', + fullName: entry.recipientAccount?.fullName ?? '', + isPeerActuallyUser, + isLinkTx: !isPeerActuallyUser, + } +} diff --git a/src/components/TransactionDetails/strategies/legacy/send-link.ts b/src/components/TransactionDetails/strategies/legacy/send-link.ts new file mode 100644 index 000000000..3d830f21f --- /dev/null +++ b/src/components/TransactionDetails/strategies/legacy/send-link.ts @@ -0,0 +1,65 @@ +import { EHistoryUserRole, type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy, type TransactionStrategyOutput } from '../types' + +export const sendLink: TransactionStrategy = (entry: HistoryEntry): TransactionStrategyOutput => { + if (entry.userRole === EHistoryUserRole.SENDER) { + const nameForDetails = + entry.recipientAccount?.username || + entry.recipientAccount?.identifier || + (entry.status === 'COMPLETED' ? 'You sent via link' : "You're sending via link") + const isPeerActuallyUser = !!entry.recipientAccount?.isUser + return { + direction: 'send', + transactionCardType: 'send', + nameForDetails, + fullName: entry.recipientAccount?.username ?? '', + showFullName: entry.recipientAccount?.showFullName, + isPeerActuallyUser, + isLinkTx: !isPeerActuallyUser, + } + } + if (entry.userRole === EHistoryUserRole.RECIPIENT) { + if (entry.recipientAccount && !entry.recipientAccount.isUser) { + return { + direction: 'claim_external', + transactionCardType: 'claim_external', + nameForDetails: entry.recipientAccount.identifier, + isPeerActuallyUser: false, + isLinkTx: true, + } + } + const isPeerActuallyUser = !!entry.senderAccount?.isUser + return { + direction: 'receive', + transactionCardType: 'receive', + nameForDetails: entry.senderAccount?.username || entry.senderAccount?.identifier || 'Received via Link', + fullName: entry.senderAccount?.fullName ?? '', + showFullName: entry.senderAccount?.showFullName, + isPeerActuallyUser, + isLinkTx: !isPeerActuallyUser, + } + } + if (entry.userRole === EHistoryUserRole.BOTH) { + // Sender claimed their own link → cancelled. uiStatus override + // matches the pre-strategy switch (set uiStatus='cancelled' before + // the global status mapper runs). + return { + direction: 'send', + transactionCardType: 'send', + nameForDetails: 'Sent via Link', + isPeerActuallyUser: true, + isLinkTx: true, + uiStatus: 'cancelled', + } + } + // userRole = SENDER_PUBLIC / unknown — public-link claim path + return { + direction: 'claim_external', + transactionCardType: 'claim_external', + nameForDetails: entry.recipientAccount?.username || entry.recipientAccount?.identifier || '', + fullName: entry.recipientAccount?.username ?? '', + showFullName: entry.recipientAccount?.showFullName, + isPeerActuallyUser: false, + isLinkTx: true, + } +} diff --git a/src/components/TransactionDetails/strategies/registry.ts b/src/components/TransactionDetails/strategies/registry.ts new file mode 100644 index 000000000..3b2a24cde --- /dev/null +++ b/src/components/TransactionDetails/strategies/registry.ts @@ -0,0 +1,81 @@ +// Composite-key registry for the transformer's per-kind strategies. +// +// Key shape: +// - Legacy EHistoryEntryType cases: just the type ("DIRECT_SEND") +// - TRANSACTION_INTENT cases: "TRANSACTION_INTENT:" so each +// intent kind owns its own strategy without nested-switch dispatch. +// +// `dispatchStrategy(entry)` returns the matching strategy, or — for +// TRANSACTION_INTENT entries with an unknown kind — the intent fallback, +// which routes card refunds to cardRefund and logs the rest via +// pipelineAlert. Legacy types with no strategy fall to legacyFallback. + +import { type HistoryEntry } from '@/hooks/useTransactionHistory' +import { type TransactionStrategy } from './types' +import { directSend } from './legacy/direct-send' +import { sendLink } from './legacy/send-link' +import { request } from './legacy/request' +import { bankSendLinkClaim } from './legacy/bank-send-link-claim' +import { + withdraw, + cashout, + bankOfframp, + bankOnramp, + deposit, + mantecaQrPayment, + simplefiQrPayment, + perkReward, +} from './legacy/external-account' +import { p2pSendOrRequestPay } from './intent/p2p-send' +import { linkCreate } from './intent/link-create' +import { cryptoDeposit, cryptoWithdraw } from './intent/crypto' +import { fiatOfframp } from './intent/fiat-offramp' +import { qrPay, cardSpend } from './intent/card' +import { intentFallback, legacyFallback } from './fallback' + +// String literal values match the EHistoryEntryType enum (history.utils.ts) +// and the BE TransactionIntentKind enum. Inlined here so tests that mock +// `@/hooks/useTransactionHistory` (and thus break the enum re-export) can +// still load this module — the registry is wired at module-init time. +const TRANSACTION_INTENT = 'TRANSACTION_INTENT' +const INTENT_KEY = (kind: string): string => `${TRANSACTION_INTENT}:${kind}` + +const STRATEGIES: Record = { + DIRECT_SEND: directSend, + SEND_LINK: sendLink, + REQUEST: request, + WITHDRAW: withdraw, + CASHOUT: cashout, + BRIDGE_OFFRAMP: bankOfframp, + MANTECA_OFFRAMP: bankOfframp, + BANK_SEND_LINK_CLAIM: bankSendLinkClaim, + BRIDGE_ONRAMP: bankOnramp, + MANTECA_ONRAMP: bankOnramp, + DEPOSIT: deposit, + MANTECA_QR_PAYMENT: mantecaQrPayment, + SIMPLEFI_QR_PAYMENT: simplefiQrPayment, + PERK_REWARD: perkReward, + [INTENT_KEY('P2P_SEND')]: p2pSendOrRequestPay, + [INTENT_KEY('REQUEST_PAY')]: p2pSendOrRequestPay, + [INTENT_KEY('QR_PAY')]: qrPay, + [INTENT_KEY('LINK_CREATE')]: linkCreate, + [INTENT_KEY('CRYPTO_DEPOSIT')]: cryptoDeposit, + [INTENT_KEY('CRYPTO_WITHDRAW')]: cryptoWithdraw, + [INTENT_KEY('FIAT_OFFRAMP')]: fiatOfframp, + [INTENT_KEY('CARD_SPEND')]: cardSpend, +} + +function key(entry: HistoryEntry): string { + if (entry.type === TRANSACTION_INTENT) { + const kind = (entry.extraData?.kind as string | undefined) ?? '' + return INTENT_KEY(kind) + } + return String(entry.type) +} + +export function dispatchStrategy(entry: HistoryEntry): TransactionStrategy { + const direct = STRATEGIES[key(entry)] + if (direct) return direct + if (entry.type === TRANSACTION_INTENT) return intentFallback + return legacyFallback +} diff --git a/src/components/TransactionDetails/strategies/types.ts b/src/components/TransactionDetails/strategies/types.ts new file mode 100644 index 000000000..45b2142ea --- /dev/null +++ b/src/components/TransactionDetails/strategies/types.ts @@ -0,0 +1,27 @@ +// Per-kind strategy contract for transactionTransformer's pre-globals +// switch. Each strategy decides direction + card type + counterparty +// name + a few flags from the row's shape; the post-strategy code in +// mapTransactionDataForDrawer handles status mapping, reaper override, +// derived fields (explorer URL, token logos, initials). +// +// Strategies are pure functions of HistoryEntry — no DOM, no fetches, +// no mutable state. Tests import them directly. + +import { type TransactionType as TransactionCardType } from '@/components/TransactionDetails/TransactionCard' +import { type TransactionDirection } from '@/components/TransactionDetails/TransactionDetailsHeaderCard' +import { type StatusPillType } from '@/components/Global/StatusPill' +import { type HistoryEntry } from '@/hooks/useTransactionHistory' + +export interface TransactionStrategyOutput { + direction: TransactionDirection + transactionCardType: TransactionCardType + nameForDetails: string + isPeerActuallyUser: boolean + isLinkTx: boolean + fullName?: string + showFullName?: boolean + /** Optional override; most strategies leave status mapping to the global mapper. */ + uiStatus?: StatusPillType +} + +export type TransactionStrategy = (entry: HistoryEntry) => TransactionStrategyOutput diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index 5031ae7d0..1fbbba328 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -14,6 +14,7 @@ import { type StatusPillType } from '../Global/StatusPill' import type { Address } from 'viem' import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts' import { type HistoryEntryPerkReward, type ChargeEntry } from '@/services/services.types' +import { dispatchStrategy } from './strategies/registry' /** * @fileoverview maps raw transaction history data from the api/hook to the format needed by ui components. @@ -35,10 +36,7 @@ import { type HistoryEntryPerkReward, type ChargeEntry } from '@/services/servic function shouldPlumbBankAccountDetails(entry: HistoryEntry): boolean { if (entry.type === EHistoryEntryType.BRIDGE_OFFRAMP) return true if (entry.type === EHistoryEntryType.MANTECA_OFFRAMP) return true - if ( - entry.type === EHistoryEntryType.BANK_SEND_LINK_CLAIM && - entry.userRole === EHistoryUserRole.RECIPIENT - ) { + if (entry.type === EHistoryEntryType.BANK_SEND_LINK_CLAIM && entry.userRole === EHistoryUserRole.RECIPIENT) { return true } if (entry.type === EHistoryEntryType.TRANSACTION_INTENT) { @@ -243,429 +241,18 @@ interface MappedTransactionData { */ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransactionData { // initialize variables - let direction: TransactionDirection - let transactionCardType: TransactionCardType - let nameForDetails = '' - let uiStatus: StatusPillType = 'pending' - let isLinkTx = false - let isPeerActuallyUser = false - let fullName = '' // Full name of the user for PFP Avatar - let showFullName: boolean | undefined = undefined // User's preference for showing full name - - // determine direction, card type, peer name, and flags based on original type and user role - switch (entry.type) { - case EHistoryEntryType.DIRECT_SEND: - isPeerActuallyUser = true - direction = 'send' - transactionCardType = 'send' - if (entry.userRole === EHistoryUserRole.SENDER) { - nameForDetails = entry.recipientAccount?.username ?? entry.recipientAccount?.identifier - fullName = entry.recipientAccount?.fullName ?? '' - showFullName = entry.recipientAccount?.showFullName - } else { - direction = 'receive' - transactionCardType = 'receive' - nameForDetails = - entry.senderAccount?.username ?? entry.senderAccount?.identifier ?? 'Requested via Link' - ;((fullName = entry.senderAccount?.fullName ?? ''), (isLinkTx = !entry.senderAccount)) // If the sender is not an user then it's a public link - showFullName = entry.senderAccount?.showFullName - } - break - case EHistoryEntryType.SEND_LINK: - isLinkTx = true - direction = 'send' - transactionCardType = 'send' - if (entry.userRole === EHistoryUserRole.SENDER) { - nameForDetails = - entry.recipientAccount?.username || - entry.recipientAccount?.identifier || - (entry.status === 'COMPLETED' ? 'You sent via link' : "You're sending via link") - fullName = entry.recipientAccount?.username ?? '' - showFullName = entry.recipientAccount?.showFullName - isPeerActuallyUser = !!entry.recipientAccount?.isUser - isLinkTx = !isPeerActuallyUser - } else if (entry.userRole === EHistoryUserRole.RECIPIENT) { - // if the recipient is not a peanut user, it's an external claim - if (entry.recipientAccount && !entry.recipientAccount.isUser) { - direction = 'claim_external' - transactionCardType = 'claim_external' - nameForDetails = entry.recipientAccount.identifier - isPeerActuallyUser = false - isLinkTx = true - } else { - direction = 'receive' - transactionCardType = 'receive' - nameForDetails = - entry.senderAccount?.username || entry.senderAccount?.identifier || 'Received via Link' - fullName = entry.senderAccount?.fullName ?? '' - showFullName = entry.senderAccount?.showFullName - isPeerActuallyUser = !!entry.senderAccount?.isUser - isLinkTx = !isPeerActuallyUser - } - } else if (entry.userRole === EHistoryUserRole.BOTH) { - isPeerActuallyUser = true - uiStatus = 'cancelled' - nameForDetails = 'Sent via Link' - } else { - direction = 'claim_external' - transactionCardType = 'claim_external' - nameForDetails = entry.recipientAccount?.username || entry.recipientAccount?.identifier - fullName = entry.recipientAccount?.username ?? '' - showFullName = entry.recipientAccount?.showFullName - } - break - case EHistoryEntryType.REQUEST: - if (entry.extraData?.fulfillmentType === 'bridge' && entry.userRole === EHistoryUserRole.SENDER) { - transactionCardType = 'bank_request_fulfillment' - direction = 'bank_request_fulfillment' - nameForDetails = entry.recipientAccount?.username ?? entry.recipientAccount?.identifier - fullName = entry.recipientAccount?.fullName ?? '' - showFullName = entry.recipientAccount?.showFullName - isPeerActuallyUser = !!entry.recipientAccount?.isUser || !!entry.senderAccount?.isUser - } else if (entry.userRole === EHistoryUserRole.RECIPIENT) { - direction = 'request_sent' - transactionCardType = 'request' - nameForDetails = - entry.senderAccount?.username || entry.senderAccount?.identifier || 'Requested via Link' - fullName = entry.senderAccount?.fullName ?? '' - showFullName = entry.senderAccount?.showFullName - isPeerActuallyUser = !!entry.senderAccount?.isUser - } else { - if ( - entry.status?.toUpperCase() === 'NEW' || - (entry.status?.toUpperCase() === 'PENDING' && !entry.extraData?.fulfillmentType) - ) { - direction = 'request_received' - transactionCardType = 'request' - nameForDetails = - entry.recipientAccount?.username || - entry.recipientAccount?.identifier || - `Request From ${entry.recipientAccount?.username || entry.recipientAccount?.identifier}` - fullName = entry.recipientAccount?.fullName ?? '' - showFullName = entry.recipientAccount?.showFullName - isPeerActuallyUser = !!entry.recipientAccount?.isUser - } else { - direction = 'send' - transactionCardType = 'send' - nameForDetails = - entry.recipientAccount?.username || entry.recipientAccount?.identifier || 'Paid Request To' - fullName = entry.recipientAccount?.fullName ?? '' - isPeerActuallyUser = !!entry.recipientAccount?.isUser - } - } - isLinkTx = !isPeerActuallyUser - break - case EHistoryEntryType.WITHDRAW: - direction = 'withdraw' - transactionCardType = 'withdraw' - nameForDetails = entry.recipientAccount?.identifier || 'External Account' - isPeerActuallyUser = false - break - case EHistoryEntryType.CASHOUT: - direction = 'withdraw' - transactionCardType = 'withdraw' - nameForDetails = entry.recipientAccount?.identifier || 'Bank Account' - isPeerActuallyUser = false - break - case EHistoryEntryType.BRIDGE_OFFRAMP: - case EHistoryEntryType.MANTECA_OFFRAMP: - direction = 'bank_withdraw' - transactionCardType = 'bank_withdraw' - nameForDetails = 'Bank Account' - isPeerActuallyUser = false - break - case EHistoryEntryType.BANK_SEND_LINK_CLAIM: - // this handles how a bank claim is displayed in the transaction history. - if (entry.userRole === EHistoryUserRole.SENDER || entry.userRole === EHistoryUserRole.BOTH) { - // from the sender's perspective (or when sender claims their own link). - if (entry.recipientAccount.isUser) { - // cases 1 & 2: claimed by a peanut user (kyc'd or not). show as direct send. - direction = 'send' - transactionCardType = 'send' - nameForDetails = - entry.recipientAccount?.username ?? - entry.recipientAccount?.fullName ?? - entry.recipientAccount?.identifier - fullName = entry.recipientAccount?.fullName ?? '' - isPeerActuallyUser = true - } else { - // case 3: claimed by a guest. show as generic bank claim. - direction = 'bank_claim' - transactionCardType = 'bank_claim' - nameForDetails = 'Claimed to Bank' - isPeerActuallyUser = false - } - } else { - // from the claimant's perspective, it's always a bank claim. - direction = 'bank_claim' - transactionCardType = 'bank_claim' - nameForDetails = 'Claimed to Bank' - isPeerActuallyUser = false - } - break - case EHistoryEntryType.BRIDGE_ONRAMP: - case EHistoryEntryType.MANTECA_ONRAMP: - direction = 'bank_deposit' - transactionCardType = 'bank_deposit' - nameForDetails = 'Bank Account' - isPeerActuallyUser = false - break - case EHistoryEntryType.DEPOSIT: { - direction = 'add' - transactionCardType = 'add' - // check if this is a test transaction (0 amount deposit during account setup), ideally this should be handled in the backend, but for now we'll handle it here cuz its a quick fix, and in promisland of post devconnect this should be handled in the backend. - const isTestTransaction = String(entry.amount) === '0' || entry.extraData?.usdAmount === '0' - if (isTestTransaction) { - nameForDetails = 'Enjoy Peanut!' - } else { - nameForDetails = entry.senderAccount?.identifier || 'Deposit Source' - } - isPeerActuallyUser = false - break - } - case EHistoryEntryType.MANTECA_QR_PAYMENT: - direction = 'qr_payment' - transactionCardType = 'pay' - nameForDetails = entry.recipientAccount?.identifier || 'Merchant' - isPeerActuallyUser = false - break - case EHistoryEntryType.SIMPLEFI_QR_PAYMENT: - direction = 'qr_payment' - transactionCardType = 'pay' - nameForDetails = entry.recipientAccount?.identifier || 'Merchant' - // We dont have merchant name so we try to prettify the slug, - // replacing dashws with speaces and making the first letter uppercase - nameForDetails = nameForDetails.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) - isPeerActuallyUser = false - break - case EHistoryEntryType.PERK_REWARD: - direction = 'receive' - transactionCardType = 'receive' - nameForDetails = 'Peanut Reward' - fullName = 'Peanut Rewards' - isPeerActuallyUser = false - break - case EHistoryEntryType.TRANSACTION_INTENT: { - // Intent-sourced entries carry a user-semantic `kind` that drives - // the card label. Direction + recipient come from the intent, not - // from account lookups (intents store the raw recipient address). - const kind = (entry.extraData?.kind as string | undefined) ?? 'OTHER' - switch (kind) { - case 'P2P_SEND': - case 'REQUEST_PAY': - // Bridge-fulfilled requests render as bank-request - // fulfillments on the sender side (mirrors legacy REQUEST - // case at line 268). Viewer is paying via bank rails. - if ( - kind === 'REQUEST_PAY' && - entry.extraData?.fulfillmentType === 'bridge' && - entry.userRole === 'SENDER' - ) { - direction = 'bank_request_fulfillment' - transactionCardType = 'bank_request_fulfillment' - nameForDetails = - entry.recipientAccount?.username ?? entry.recipientAccount?.identifier ?? 'Recipient' - fullName = entry.recipientAccount?.fullName ?? '' - showFullName = entry.recipientAccount?.showFullName - isPeerActuallyUser = - !!entry.recipientAccount?.isUser || !!entry.senderAccount?.isUser - break - } - if (entry.userRole === 'RECIPIENT') { - // Viewer is on the receiving side. Two sub-cases: - // (a) Senderaccount.identifier is set → an actual paid - // send. Render as a receive. - // (b) Sender is empty → unfulfilled request the viewer - // created. Render as a request_received with a - // neutral label ("Request" — the FE's - // TransactionDetailsHeaderCard already styles this). - const senderResolved = !!entry.senderAccount?.identifier - if (senderResolved) { - direction = 'receive' - transactionCardType = 'receive' - nameForDetails = - entry.senderAccount?.username || entry.senderAccount?.identifier || 'Sender' - isPeerActuallyUser = !!entry.senderAccount?.isUser - } else { - direction = 'request_received' - transactionCardType = 'request' - nameForDetails = 'Request' - isPeerActuallyUser = false - } - } else { - direction = 'send' - transactionCardType = 'send' - nameForDetails = - entry.recipientAccount?.username || entry.recipientAccount?.identifier || 'Recipient' - isPeerActuallyUser = !!entry.recipientAccount?.isUser - } - break - case 'QR_PAY': - direction = 'qr_payment' - transactionCardType = 'pay' - nameForDetails = entry.recipientAccount?.identifier || 'Merchant' - isPeerActuallyUser = false - break - case 'LINK_CREATE': - if (entry.userRole === 'RECIPIENT') { - // The viewer claimed someone else's link. Mirrors the legacy - // SEND_LINK × RECIPIENT branch — show the sender as the peer. - if (entry.senderAccount?.isUser) { - direction = 'receive' - transactionCardType = 'receive' - nameForDetails = - entry.senderAccount?.username || - entry.senderAccount?.identifier || - 'Received via Link' - fullName = entry.senderAccount?.fullName ?? '' - showFullName = entry.senderAccount?.showFullName - isPeerActuallyUser = true - isLinkTx = false - } else { - direction = 'receive' - transactionCardType = 'receive' - nameForDetails = - entry.senderAccount?.username || - entry.senderAccount?.identifier || - 'Received via Link' - fullName = entry.senderAccount?.fullName ?? '' - isPeerActuallyUser = false - isLinkTx = true - } - } else { - // SENDER (creator). Resolve claimer if it's a peanut user; - // otherwise show the link-shaped placeholder. - if (entry.recipientAccount?.isUser) { - direction = 'send' - transactionCardType = 'send' - nameForDetails = - entry.recipientAccount?.username ?? entry.recipientAccount?.identifier - fullName = entry.recipientAccount?.fullName ?? '' - showFullName = entry.recipientAccount?.showFullName - isPeerActuallyUser = true - isLinkTx = false - } else { - direction = 'send' - transactionCardType = 'send' - nameForDetails = 'Sent via link' - isLinkTx = true - isPeerActuallyUser = false - } - } - break - case 'CRYPTO_DEPOSIT': - // Incoming on-chain deposit. If the sender resolved to a known - // Peanut user, surface their username + clickable avatar - // (improvement over the legacy DEPOSIT branch which always - // forced isPeerActuallyUser=false). - direction = 'add' - transactionCardType = 'add' - nameForDetails = - entry.senderAccount?.username || entry.senderAccount?.identifier || 'Deposit Source' - fullName = entry.senderAccount?.fullName ?? '' - showFullName = entry.senderAccount?.showFullName - isPeerActuallyUser = !!entry.senderAccount?.isUser - break - case 'CRYPTO_WITHDRAW': - if (entry.userRole === 'RECIPIENT') { - // The viewer received crypto from someone else's withdraw - // (e.g. another user sent to this user's wallet via a - // CRYPTO_WITHDRAW). Render as a deposit-style row. - direction = 'add' - transactionCardType = 'add' - nameForDetails = - entry.senderAccount?.username || entry.senderAccount?.identifier || 'External Wallet' - isPeerActuallyUser = !!entry.senderAccount?.isUser - } else { - direction = 'withdraw' - transactionCardType = 'withdraw' - nameForDetails = entry.recipientAccount?.identifier || 'External Account' - isPeerActuallyUser = false - } - break - case 'FIAT_OFFRAMP': - if (entry.userRole === 'RECIPIENT') { - // Multi-user fulfillment edge case — viewer received a - // bank withdraw initiated by another user. Render as a - // receive (USDC arrives in viewer's wallet from offramp - // funder). - direction = 'receive' - transactionCardType = 'receive' - nameForDetails = - entry.senderAccount?.username || entry.senderAccount?.identifier || 'Bank Account' - isPeerActuallyUser = !!entry.senderAccount?.isUser - } else { - direction = 'bank_withdraw' - transactionCardType = 'bank_withdraw' - nameForDetails = 'Bank Account' - isPeerActuallyUser = false - } - break - case 'CARD_SPEND': { - // Merchant fields come from the M3 history fetcher's extraData - // (peanut-api-ts/src/transaction-intent/history.ts). Falling - // back to "Card payment" only when the merchant name is - // genuinely unknown — which should be rare once Rain enrichment - // is live for the user. - const merchantName = (entry.extraData?.merchantName as string | null | undefined) ?? null - direction = 'qr_payment' - transactionCardType = 'pay' - nameForDetails = merchantName || 'Card payment' - isPeerActuallyUser = false - break - } - default: - // Card refunds come back with kind === 'REFUND', provider === RAIN - // (toLegacyKindLabel surfaces them as 'OTHER' today since - // there's no dedicated CARD_REFUND legacy kind). Scope - // the refund branch to those two kinds — guarding on - // parentRainTxId alone risks misrouting any future intent - // that carries the linkage for some other reason. - if ((kind === 'OTHER' || kind === 'REFUND') && entry.extraData?.parentRainTxId) { - const merchantName = (entry.extraData?.merchantName as string | null | undefined) ?? null - direction = 'receive' - transactionCardType = 'receive' - nameForDetails = merchantName ? `Refund from ${merchantName}` : 'Card refund' - isPeerActuallyUser = false - break - } - // Unknown TRANSACTION_INTENT kind — log to Sentry so we - // catch BE-added kinds the FE doesn't yet handle. Render - // a defensive fallback so the row still appears. - if (typeof window !== 'undefined') { - // Lazy import to avoid bundling Sentry in non-browser - // contexts (test, SSR). Logged as a warning, not a - // hard error — the row still renders. - import('@sentry/nextjs') - .then((Sentry) => - Sentry.captureMessage( - `transactionTransformer: unhandled TRANSACTION_INTENT kind "${kind}"`, - { - level: 'warning', - tags: { feature: 'history', kind }, - extra: { entryUuid: entry.uuid, userRole: entry.userRole }, - } - ) - ) - .catch(() => { - // Sentry not available (test env) — no-op. - }) - } - direction = 'send' - transactionCardType = 'send' - nameForDetails = entry.recipientAccount?.identifier || 'Transaction' - isPeerActuallyUser = false - break - } - break - } - default: - direction = 'send' - transactionCardType = 'send' - nameForDetails = entry.recipientAccount?.identifier || 'Unknown' - isPeerActuallyUser = !!entry.recipientAccount?.isUser - break - } + // Pick the per-kind strategy. Post-strategy code below applies status + // mapping, the reaper-failed override, and derived fields (explorer + // URL, token logos, initials). + const out = dispatchStrategy(entry)(entry) + const direction: TransactionDirection = out.direction + const transactionCardType: TransactionCardType = out.transactionCardType + let nameForDetails = out.nameForDetails + let isPeerActuallyUser = out.isPeerActuallyUser + const isLinkTx = out.isLinkTx + let fullName = out.fullName ?? '' + const showFullName = out.showFullName + let uiStatus: StatusPillType = out.uiStatus ?? 'pending' if (!isPeerActuallyUser) { isPeerActuallyUser = false diff --git a/src/context/PeanutDebug.tsx b/src/context/PeanutDebug.tsx index 2e0cb65b7..0e716e6f2 100644 --- a/src/context/PeanutDebug.tsx +++ b/src/context/PeanutDebug.tsx @@ -95,8 +95,7 @@ export function PeanutDebug() { // works in one shot — no need to pass `{ pk }` every time. Local-only; // gated by HARNESS_ENABLED + the dev cheat route's requireTestMode. // Sandbox harness keys are NOT secrets — Konrad's call. - const DEFAULT_HARNESS_PK = - '0x8501e6e37f45d268618debb9f0d95528ca90a2eadcb29ac2277c0284d0ec861b' + const DEFAULT_HARNESS_PK = '0x8501e6e37f45d268618debb9f0d95528ca90a2eadcb29ac2277c0284d0ec861b' const debugApi: any = { // Local-dev impersonation cheat. Mints a JWT for the given userId diff --git a/src/utils/account-mask.utils.ts b/src/utils/account-mask.utils.ts index 569c2794f..f10efac2e 100644 --- a/src/utils/account-mask.utils.ts +++ b/src/utils/account-mask.utils.ts @@ -54,7 +54,10 @@ const MASK_RULES: Record = { * @param accountType the rail (`IBAN`, `CLABE`, `PIX`, …) — falls back to plain if unknown * @returns the masked display string. Empty/missing input returns ''. */ -export function maskAccountIdentifier(identifier: string | null | undefined, accountType: string | null | undefined): string { +export function maskAccountIdentifier( + identifier: string | null | undefined, + accountType: string | null | undefined +): string { if (!identifier) return '' const rail = (accountType ?? '').toUpperCase() const rule = MASK_RULES[rail] ?? { mode: 'plain' as MaskMode }