diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 56991bfe2..f77ae1ff6 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -5,6 +5,7 @@ import TransactionAvatarBadge from '@/components/TransactionDetails/TransactionA import { getBankAccountCountryCode } from '@/constants/countryCurrencyMapping' import { type TransactionDirection } from '@/components/TransactionDetails/TransactionDetailsHeaderCard' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { isCardPaymentEntry } from '@/components/TransactionDetails/transaction-predicates' import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' import { formatNumberForDisplay, @@ -128,10 +129,21 @@ const TransactionCard: React.FC = ({ currencyDisplayAmount = `≈ ${transaction.currency.code.toUpperCase()} ${formattedCurrencyAmount}` } + // Spec §4.4: declined card transactions stay in the feed but are visually + // de-emphasized so they don't compete with successful items. Scope to + // declined SPENDS specifically — refunds also populate cardPayment, but + // a failed refund (e.g. processing error) shouldn't be greyed out. + const isDeclinedCardSpend = + status === 'failed' && isCardPaymentEntry(transaction) && !transaction.extraDataForDrawer?.cardPayment?.isRefund + return ( <> {/* the clickable card */} - +
{/* txn avatar component handles icon/initials/colors */} diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 0c06f9dc0..522cf9dcb 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -12,7 +12,6 @@ import { getCardPosition } from '@/components/Global/Card/card.utils' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { TRANSACTIONS } from '@/constants/query.consts' -import { BRIDGE_DEFAULT_ACCOUNT_HOLDER_NAME } from '@/constants/payment.consts' import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory' import { useWallet } from '@/hooks/wallet/useWallet' import { useUserStore } from '@/redux/hooks' @@ -21,14 +20,7 @@ import useClaimLink from '@/components/Claim/useClaimLink' import { formatAmount, formatDate, isStableCoin, formatCurrency } from '@/utils/general.utils' import { formatPoints } from '@/utils/format.utils' import { getAvatarUrl } from '@/utils/history.utils' -import { - formatIban, - getContributorsFromCharge, - printableAddress, - shortenAddress, - shortenStringLong, - slugify, -} from '@/utils/general.utils' +import { formatIban, printableAddress, shortenAddress, shortenStringLong, slugify } from '@/utils/general.utils' import { cancelOnramp } from '@/app/actions/onramp' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' @@ -44,28 +36,22 @@ import QRCodeWrapper from '../Global/QRCodeWrapper' import ShareButton from '../Global/ShareButton' import { TransactionDetailsHeaderCard } from './TransactionDetailsHeaderCard' import CopyToClipboard from '../Global/CopyToClipboard' -import MoreInfo from '../Global/MoreInfo' import CancelSendLinkModal from '../Global/CancelSendLinkModal' import { twMerge } from 'tailwind-merge' import { isAddress } from 'viem' -import { - getBankAccountLabel, - type TransactionDetailsRowKey, - transactionDetailsRowKeys, -} from './transaction-details.utils' +import { getBankAccountLabel } from './transaction-details.utils' import { useModalsContext } from '@/context/ModalsContext' import { useRouter } from 'next/navigation' -import { countryData } from '@/components/AddMoney/consts' import { getBankAccountCountryCode } from '@/constants/countryCurrencyMapping' import { useToast } from '@/components/0_Bruddle/Toast' -import { - MANTECA_COUNTRIES_CONFIG, - MANTECA_ARG_DEPOSIT_CUIT, - MANTECA_ARG_DEPOSIT_NAME, -} from '@/constants/manteca.consts' -import { mantecaApi } from '@/services/manteca' +import { isPerkReward as isPerkRewardTransaction, usesCompletedTimestampLabel } from './transaction-predicates' +import { useReceiptViewModel } from './useReceiptViewModel' +import { CardPaymentRows } from './provider-rows/CardPaymentRows' +import { MantecaDepositInfo } from './provider-rows/MantecaDepositInfo' +import { BridgeDepositInstructions } from './provider-rows/BridgeDepositInstructions' +import { CancelDepositActions } from './provider-actions/CancelDepositActions' +import { PerkRewardReceipt } from './provider-receipts/PerkRewardReceipt' import { getReceiptUrl } from '@/utils/history.utils' -import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' import ContributorCard from '../Global/Contributors/ContributorCard' import { requestsApi } from '@/services/requests' import { PasskeyDocsLink } from '../Setup/Views/SignTestTransaction' @@ -104,7 +90,6 @@ export const TransactionDetailsReceipt = ({ const queryClient = useQueryClient() const { fetchBalance } = useWallet() const { cancelLinkAndClaim, pollForClaimConfirmation } = useClaimLink() - const [showBankDetails, setShowBankDetails] = useState(false) const [showCancelLinkModal, setShowCancelLinkModal] = useState(false) const [tokenData, setTokenData] = useState<{ symbol: string; icon: string } | null>(null) const [isTokenDataLoading, setIsTokenDataLoading] = useState(true) @@ -119,235 +104,24 @@ export const TransactionDetailsReceipt = ({ setIsModalOpen?.(showCancelLinkModal) }, [showCancelLinkModal, setIsModalOpen]) - const isGuestBankClaim = useMemo(() => { - if (!transaction) return false - return transaction.extraDataForDrawer?.originalType === EHistoryEntryType.BANK_SEND_LINK_CLAIM - }, [transaction]) - - const isPendingBankRequest = useMemo(() => { - if (!transaction) return false - return ( - transaction.status === 'pending' && - transaction.extraDataForDrawer?.originalType === EHistoryEntryType.REQUEST && - transaction.extraDataForDrawer?.fulfillmentType === 'bridge' - ) - }, [transaction]) - - // check if token is usdc on arbitrum to hide token/network section - const isPeanutWalletToken = useMemo(() => { - if (!transaction) return false - const tokenSymbol = transaction.tokenSymbol?.toUpperCase() - const chainName = transaction.tokenDisplayDetails?.chainName?.toLowerCase() - return tokenSymbol === PEANUT_WALLET_TOKEN_SYMBOL && chainName === PEANUT_WALLET_CHAIN.name.toLowerCase() - }, [transaction]) - - // config to determine which rows are visible in the receipt - // this helps in managing layout and borders without repeating code - const rowVisibilityConfig = useMemo((): Record => { - if (!transaction) { - // if no transaction, return all false - return transactionDetailsRowKeys.reduce( - (acc, key) => { - acc[key] = false - return acc - }, - {} as Record - ) - } - - // if transaction exists, calculate visibility for each row - // Hide the "Created" row when the "Sent"/"Completed" row is about to - // render (both point at the same lifecycle event for off-ramps / - // bank claims; two rows side-by-side is noise). Keep "Created" as - // the fallback for pending states where no completion timestamp exists. - const willShowCompleted = !!( - transaction.status === 'completed' && - transaction.completedAt && - transaction.extraDataForDrawer?.originalType !== EHistoryEntryType.DIRECT_SEND - ) - return { - createdAt: !!transaction.createdAt && !willShowCompleted, - to: transaction.direction === 'claim_external', - tokenAndNetwork: !!( - transaction.tokenDisplayDetails && - transaction.sourceView === 'history' && - !isPeanutWalletToken && - // hide token and network for send links in acitvity drawer for sender - !( - transaction.extraDataForDrawer?.originalType === EHistoryEntryType.SEND_LINK && - transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER - ) && - // hide token and network for refunded entries - transaction.status !== 'refunded' - ), - txId: !!transaction.txHash, - // show cancelled row if status is cancelled, use cancelledDate or fallback to createdAt - cancelled: transaction.status === 'cancelled', - claimed: !!(transaction.status === 'completed' && transaction.claimedAt), - completed: !!( - transaction.status === 'completed' && - transaction.completedAt && - transaction.extraDataForDrawer?.originalType !== EHistoryEntryType.DIRECT_SEND - ), - refunded: transaction.status === 'refunded', - fee: transaction.fee !== undefined && transaction.status !== 'cancelled', - exchangeRate: !!( - (transaction.direction === 'bank_deposit' || - transaction.direction === 'qr_payment' || - transaction.direction === 'bank_withdraw') && - transaction.currency?.code && - transaction.currency.code.toUpperCase() !== 'USD' && - transaction.status !== 'cancelled' - ), - bankAccountDetails: !!( - transaction.bankAccountDetails && - transaction.bankAccountDetails.identifier && - transaction.status !== 'cancelled' - ), - transferId: !!( - transaction.id && - (transaction.direction === 'bank_withdraw' || transaction.direction === 'bank_claim') && - transaction.status !== 'cancelled' - ), - depositInstructions: !!( - (transaction.extraDataForDrawer?.originalType === EHistoryEntryType.BRIDGE_ONRAMP || - (isPendingBankRequest && - transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER)) && - transaction.status === 'pending' && - transaction.extraDataForDrawer?.depositInstructions && - transaction.extraDataForDrawer.depositInstructions.bank_name - ), - peanutFee: false, // Perk fee logic removed - perks now show as separate transactions - points: !!(transaction.points && transaction.points > 0 && transaction.status !== 'cancelled'), - comment: !!(transaction.memo?.trim() && transaction.status !== 'cancelled'), - networkFee: !!( - transaction.networkFeeDetails && - transaction.sourceView === 'status' && - transaction.status !== 'cancelled' - ), - attachment: !!(transaction.attachmentUrl && transaction.status !== 'cancelled'), - mantecaDepositInfo: - !isPublic && - transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_ONRAMP && - transaction.status === 'pending', - closed: !!(transaction.status === 'closed' && transaction.cancelledDate), - } - }, [transaction, isPendingBankRequest]) - - const country = useMemo(() => { - if (!transaction?.currency?.code) return undefined - return countryData.find((c) => c.currency === transaction.currency?.code) - }, [transaction?.currency?.code]) - - const visibleRows = useMemo(() => { - // filter rowkeys to only include visible rows, maintaining the order - return transactionDetailsRowKeys.filter((key) => rowVisibilityConfig[key]) - }, [rowVisibilityConfig]) - - // helper to hide border for the last visible row - const shouldHideBorder = (rowKey: TransactionDetailsRowKey) => { - const lastVisibleRow = visibleRows[visibleRows.length - 1] - return rowKey === lastVisibleRow - } - - // reusable helper to get the last visible row in a specific group - const getLastVisibleInGroup = (groupKeys: TransactionDetailsRowKey[]) => { - const visibleInGroup = groupKeys.filter((key) => rowVisibilityConfig[key]) - return visibleInGroup[visibleInGroup.length - 1] - } - - // define row groups - const rowGroups = useMemo( - () => ({ - dateRows: ['createdAt', 'cancelled', 'claimed', 'completed', 'closed'] as TransactionDetailsRowKey[], - txnDetails: ['tokenAndNetwork', 'txId'] as TransactionDetailsRowKey[], - fees: ['networkFee', 'peanutFee'] as TransactionDetailsRowKey[], - }), - [] - ) - - // get last visible row for each group - const lastVisibleInGroups = useMemo( - () => ({ - dateRows: getLastVisibleInGroup(rowGroups.dateRows), - txnDetails: getLastVisibleInGroup(rowGroups.txnDetails), - fees: getLastVisibleInGroup(rowGroups.fees), - }), - [rowVisibilityConfig] - ) - - // @dev TODO: Enable grouped borders when tackling receipt changes - // reusable helper to check if border should be hidden for a row in a specific group - const shouldHideGroupBorder = (rowKey: TransactionDetailsRowKey, groupName: keyof typeof rowGroups) => { - const isLastInGroup = rowKey === lastVisibleInGroups[groupName] - const isGlobalLast = shouldHideBorder(rowKey) - - // if it's the last in its group, show border UNLESS it's also the global last - if (isLastInGroup) { - return isGlobalLast - } - - // if not last in group, always hide border - return true - } - - const isPendingRequestee = useMemo(() => { - if (!transaction) return false - return ( - transaction.status === 'pending' && - transaction.extraDataForDrawer?.originalType === EHistoryEntryType.REQUEST && - transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER && - !transaction.extraDataForDrawer?.fulfillmentType - ) - }, [transaction]) - - const isPendingRequester = useMemo(() => { - if (!transaction) return false - return ( - transaction.status === 'pending' && - transaction.extraDataForDrawer?.originalType === EHistoryEntryType.REQUEST && - transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.RECIPIENT - ) - }, [transaction]) - - const isPendingSentLink = useMemo(() => { - if (!transaction) return false - return ( - transaction.status === 'pending' && - transaction.extraDataForDrawer?.originalType === EHistoryEntryType.SEND_LINK && - transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER - ) - }, [transaction]) - - const shouldShowShareReceipt = useMemo(() => { - if (isPublic) return false - if (!transaction || isPendingSentLink || isPendingRequester || isPendingRequestee) return false - if (transaction?.txHash && transaction.direction !== 'receive' && transaction.direction !== 'request_sent') - return true - if ( - [ - EHistoryEntryType.MANTECA_QR_PAYMENT, - EHistoryEntryType.SIMPLEFI_QR_PAYMENT, - EHistoryEntryType.MANTECA_OFFRAMP, - EHistoryEntryType.MANTECA_ONRAMP, - ].includes(transaction.extraDataForDrawer!.originalType) - ) - return true - return false - }, [transaction, isPendingSentLink, isPendingRequester, isPendingRequestee]) - - const isQRPayment = - transaction && - [EHistoryEntryType.MANTECA_QR_PAYMENT, EHistoryEntryType.SIMPLEFI_QR_PAYMENT].includes( - transaction.extraDataForDrawer!.originalType - ) - - const requestPotContributors = useMemo(() => { - if (!transaction || !transaction.requestPotPayments) return [] - return getContributorsFromCharge(transaction.requestPotPayments) - }, [transaction]) - - const formattedTotalAmountCollected = formatCurrency(transaction?.totalAmountCollected?.toString() ?? '0', 2, 0) + // All derived row-visibility / status / share-receipt state lives in the + // hook so this component stays focused on JSX + side-effect callbacks. + const { + isGuestBankClaim, + isPendingBankRequest, + isPeanutWalletToken, + isPendingRequestee, + isPendingRequester, + isPendingSentLink, + isQRPayment, + country, + rowVisibilityConfig, + shouldHideBorder, + shouldHideGroupBorder, + shouldShowShareReceipt, + requestPotContributors, + formattedTotalAmountCollected, + } = useReceiptViewModel(transaction, { isPublic }) useEffect(() => { const getTokenDetails = async () => { @@ -447,22 +221,9 @@ export const TransactionDetailsReceipt = ({ transaction.extraDataForDrawer.originalUserRole === EHistoryUserRole.RECIPIENT)) const getLabelText = (transaction: TransactionDetails) => { - const originalType = transaction.extraDataForDrawer?.originalType // Bank off-ramps / on-ramps / bank claims → "Completed" (the user isn't // sending to another person; it's a lifecycle milestone of a bank transfer). - const completionTypes: EHistoryEntryType[] = [ - EHistoryEntryType.WITHDRAW, - EHistoryEntryType.DEPOSIT, - EHistoryEntryType.BRIDGE_OFFRAMP, - EHistoryEntryType.BRIDGE_ONRAMP, - EHistoryEntryType.BRIDGE_GUEST_OFFRAMP, - EHistoryEntryType.BANK_SEND_LINK_CLAIM, - EHistoryEntryType.MANTECA_OFFRAMP, - EHistoryEntryType.MANTECA_ONRAMP, - ] - if (originalType && completionTypes.includes(originalType)) { - return 'Completed' - } + if (usesCompletedTimestampLabel(transaction)) return 'Completed' return transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER ? 'Sent' : 'Received' } @@ -504,102 +265,18 @@ export const TransactionDetailsReceipt = ({ } } // Special rendering for PERK_REWARD type - const isPerkReward = transaction.extraDataForDrawer?.originalType === EHistoryEntryType.PERK_REWARD + const isPerkReward = isPerkRewardTransaction(transaction) const perkRewardData = transaction.extraDataForDrawer?.perkReward if (isPerkReward && perkRewardData) { return ( -
- {/* Perk Reward Header - Top section with logo, amount, and status */} - -
-
- -
-

Peanut Reward

-

{amountDisplay}

-
-
-
- {transaction.status === 'completed' ? ( - - Completed - - ) : transaction.status === 'pending' || transaction.status === 'processing' ? ( - - Processing - - ) : ( - - {transaction.status} - - )} -
-
-

Earn rewards every time your friends use Peanut.

-
- - {/* Perk Details - Middle section with date, reason, and link */} - - - {/* - * HACK: Strip payment UUID from reason field. - * - * The backend stores the payment UUID in the reason field for idempotency - * (e.g., "Alice became a Card Pioneer! (payment: uuid)") because PerkUsage - * lacks a dedicated requestPaymentUuid field. The code in purchase-listener.ts - * uses `reason: { contains: paymentUuid }` to prevent duplicate perk issuance. - * - * Proper fix (backend): Add requestPaymentUuid field to PerkUsage model with - * a unique constraint @@unique([userId, perkId, requestPaymentUuid]), similar - * to how mantecaTransferId/bridgeTransferId/simplefiTransferId are handled. - * Then store clean reason text without the UUID suffix. - */} - - {/* - - {perkRewardData.originatingTxId && ( - { - // Close current drawer so user can find the transaction in history - if (onClose) { - onClose() - } - // Navigate to home where they can see both transactions - router.push('/home') - }} - > - View in history - - - } - hideBottomBorder={true} - /> - )} */} - - - {/* Support link section */} - -
+ ) } @@ -818,39 +495,16 @@ export const TransactionDetailsReceipt = ({ /> )} + {rowVisibilityConfig.cardPayment && ( + + )} + {rowVisibilityConfig.fee && ( )} {rowVisibilityConfig.mantecaDepositInfo && ( - <> - {transaction.extraDataForDrawer?.receipt?.depositDetails?.depositAddress && ( - - )} - - {transaction.extraDataForDrawer?.receipt?.depositDetails?.depositAlias && ( - - )} - {country?.id === 'AR' && ( - <> - - - - )} - + )} {/* Exchange rate and original currency for completed bank_deposit transactions */} @@ -902,257 +556,7 @@ export const TransactionDetailsReceipt = ({ )} {/* Onramp deposit instructions for bridge_onramp transactions */} - {rowVisibilityConfig.depositInstructions && transaction.extraDataForDrawer?.depositInstructions && ( - <> - - Deposit Message - -
- } - value={ -
- - {transaction.extraDataForDrawer.depositInstructions.deposit_message.slice( - 0, - 10 - )} - - -
- } - hideBottomBorder={false} // Always show the border for the deposit message - /> - - {/* Toggle button for bank details */} -
- -
- - {/* Collapsible bank details */} - - {showBankDetails && ( - <> - {/* note: fallback to bridge as account holder name, to cover faster_payments onramp requests as bridge currently doesnt return an account holder name in api response */} - - - - {transaction.extraDataForDrawer.depositInstructions.bank_name} - - -
- } - hideBottomBorder={true} - /> - - - {transaction.extraDataForDrawer.depositInstructions.bank_address} - - - - } - hideBottomBorder={false} - /> - - {/* European format (IBAN/BIC) */} - {transaction.extraDataForDrawer.depositInstructions.iban && - transaction.extraDataForDrawer.depositInstructions.bic ? ( - <> - - - {formatIban( - transaction.extraDataForDrawer.depositInstructions.iban - )} - - - - } - hideBottomBorder={true} - /> - - - {transaction.extraDataForDrawer.depositInstructions.bic} - - - - } - hideBottomBorder={true} - /> - - ) : transaction.extraDataForDrawer.depositInstructions.sort_code && - transaction.extraDataForDrawer.depositInstructions.account_number ? ( - /* UK faster_payments format (Sort Code/Account Number) */ - <> - - - - ) : ( - /* US format (Account Number/Routing Number) */ - <> - - - { - transaction.extraDataForDrawer.depositInstructions - .bank_account_number - } - - - - } - hideBottomBorder={false} - /> - - - { - transaction.extraDataForDrawer.depositInstructions - .bank_routing_number - } - - - - } - hideBottomBorder={false} - /> - {transaction.extraDataForDrawer.depositInstructions - .bank_beneficiary_name && ( - - - { - transaction.extraDataForDrawer.depositInstructions - .bank_beneficiary_name - } - - - - } - hideBottomBorder={true} - /> - )} - {transaction.extraDataForDrawer.depositInstructions - .bank_beneficiary_address && ( - - - { - transaction.extraDataForDrawer.depositInstructions - .bank_beneficiary_address - } - - - - } - hideBottomBorder={true} - /> - )} - - )} - - )} - - )} + {rowVisibilityConfig.depositInstructions && } {rowVisibilityConfig.points && transaction.points && ( )} - {/* Cancel deposit button for bridge_onramp transactions in awaiting_funds state */} - {transaction.direction === 'bank_deposit' && - transaction.extraDataForDrawer?.originalType !== EHistoryEntryType.REQUEST && - transaction.status === 'pending' && - transaction.extraDataForDrawer?.depositInstructions && - setIsLoading && - onClose && ( - - )} - {transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_ONRAMP && - transaction.status === 'pending' && - setIsLoading && - onClose && ( - - )} - - {isPendingBankRequest && - transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER && - setIsLoading && - onClose && ( -
- -
- )} + {/* referral nudge for activated users on completed outbound transactions */} {isActivated && diff --git a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx new file mode 100644 index 000000000..690d65fd4 --- /dev/null +++ b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx @@ -0,0 +1,156 @@ +'use client' + +import { Button } from '@/components/0_Bruddle/Button' +import { Icon } from '@/components/Global/Icons/Icon' +import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory' +import { TRANSACTIONS } from '@/constants/query.consts' +import { cancelOnramp } from '@/app/actions/onramp' +import { chargesApi } from '@/services/charges' +import { mantecaApi } from '@/services/manteca' +import { captureException } from '@sentry/nextjs' +import { useQueryClient } from '@tanstack/react-query' + +/** + * Cancel-deposit buttons for pending bank-deposit-shaped flows. + * + * Replaces three near-identical inline buttons in the receipt: + * - Bridge onramp pending → cancelOnramp(transaction.id) + * - Manteca onramp pending → mantecaApi.cancelDeposit(transaction.id) + * - REQUEST pending + bridge fulfillment + sender role → cancelOnramp(bridgeTransferId) + chargesApi.cancel(transaction.id) + * + * Renders at most one button — conditions are mutually exclusive by + * construction (different originalType / direction / role combos). + */ +export function CancelDepositActions({ + transaction, + isPendingBankRequest, + isLoading, + setIsLoading, + onClose, +}: { + transaction: TransactionDetails + isPendingBankRequest: boolean + isLoading: boolean | undefined + setIsLoading: ((loading: boolean) => void) | undefined + onClose: (() => void) | undefined +}) { + const queryClient = useQueryClient() + if (!setIsLoading || !onClose) return null + + const refetchAndClose = () => + queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }).then(() => { + setIsLoading(false) + onClose() + }) + + const wrapAction = async (run: () => Promise) => { + setIsLoading(true) + try { + await run() + await refetchAndClose() + } catch (error) { + captureException(error) + console.error('Error canceling deposit:', error) + setIsLoading(false) + } + } + + // 1. Bridge onramp pending — generic bank deposit cancel. + const showBridgeOnrampCancel = + transaction.direction === 'bank_deposit' && + transaction.extraDataForDrawer?.originalType !== EHistoryEntryType.REQUEST && + transaction.status === 'pending' && + !!transaction.extraDataForDrawer?.depositInstructions + + if (showBridgeOnrampCancel) { + return ( + + wrapAction(async () => { + const result = await cancelOnramp(transaction.id) + if (result.error) throw new Error(result.error) + }) + } + /> + ) + } + + // 2. Manteca onramp pending. + const showMantecaCancel = + transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_ONRAMP && + transaction.status === 'pending' + + if (showMantecaCancel) { + return ( + + wrapAction(async () => { + const result = await mantecaApi.cancelDeposit(transaction.id) + if (result.error) throw new Error(result.error) + }) + } + /> + ) + } + + // 3. REQUEST pending + bridge fulfillment + sender role — cancels the + // bridge-side onramp first, then the charge so the recipient stops seeing + // the request as outstanding. + const showPendingBankRequestCancel = + isPendingBankRequest && transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER + + if (showPendingBankRequestCancel) { + return ( +
+ + wrapAction(async () => { + const bridgeTransferId = transaction.extraDataForDrawer?.bridgeTransferId + if (!bridgeTransferId) { + throw new Error('Cannot cancel REQUEST: missing bridgeTransferId on transaction') + } + // Bridge cancel must succeed before we cancel the + // charge — otherwise the onramp orphans on Bridge's + // side while the user sees the request as cancelled. + const bridgeResult = await cancelOnramp(bridgeTransferId) + if (bridgeResult.error) throw new Error(bridgeResult.error) + await chargesApi.cancel(transaction.id) + }) + } + /> +
+ ) + } + + return null +} + +function CancelButton({ + label = 'Cancel deposit', + disabled, + onClick, +}: { + label?: string + disabled: boolean + onClick: () => void +}) { + return ( + + ) +} diff --git a/src/components/TransactionDetails/provider-receipts/PerkRewardReceipt.tsx b/src/components/TransactionDetails/provider-receipts/PerkRewardReceipt.tsx new file mode 100644 index 000000000..7d023c968 --- /dev/null +++ b/src/components/TransactionDetails/provider-receipts/PerkRewardReceipt.tsx @@ -0,0 +1,93 @@ +'use client' + +import { type RefObject } from 'react' +import { twMerge } from 'tailwind-merge' +import Card from '@/components/Global/Card' +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { Icon } from '@/components/Global/Icons/Icon' +import { PerkIcon } from '@/components/TransactionDetails/PerkIcon' +import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { type HistoryEntryPerkReward } from '@/services/services.types' +import { formatDate } from '@/utils/general.utils' +import { useModalsContext } from '@/context/ModalsContext' + +/** + * Self-contained receipt for PERK_REWARD entries. Replaces the early-return + * branch in TransactionDetailsReceipt — Perk has its own header (PerkIcon + + * "Peanut Reward" copy), its own status pills, and a tiny detail card with + * date + reason. None of it composes with the generic transaction details + * card, hence a separate top-level layout instead of slotting into rows. + */ +export function PerkRewardReceipt({ + transaction, + perkRewardData, + amountDisplay, + contentRef, + className, +}: { + transaction: TransactionDetails + perkRewardData: HistoryEntryPerkReward + amountDisplay: string + contentRef?: RefObject + className?: string +}) { + const { setIsSupportModalOpen } = useModalsContext() + + return ( +
+ {/* Perk Reward Header — top section with logo, amount, and status */} + +
+
+ +
+

Peanut Reward

+

{amountDisplay}

+
+
+
+ {transaction.status === 'completed' ? ( + + Completed + + ) : transaction.status === 'pending' || transaction.status === 'processing' ? ( + + Processing + + ) : ( + + {transaction.status} + + )} +
+
+

Earn rewards every time your friends use Peanut.

+
+ + {/* Perk details — date + reason. Reason has a payment-UUID suffix + stripped because PerkUsage uses it for idempotency (purchase- + listener.ts) and shouldn't surface to users. Backend follow-up: + add requestPaymentUuid column so reason can be clean. */} + + + + + + +
+ ) +} diff --git a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx new file mode 100644 index 000000000..0d9b016a5 --- /dev/null +++ b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx @@ -0,0 +1,223 @@ +'use client' + +import { useState } from 'react' +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { Icon } from '@/components/Global/Icons/Icon' +import CopyToClipboard from '@/components/Global/CopyToClipboard' +import MoreInfo from '@/components/Global/MoreInfo' +import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { BRIDGE_DEFAULT_ACCOUNT_HOLDER_NAME } from '@/constants/payment.consts' +import { formatIban } from '@/utils/general.utils' + +/** + * Bridge onramp deposit instructions block — multi-country bank fields + * (US routing/account/beneficiary, EU IBAN/BIC, UK sort code, Mexico CLABE). + * + * Slotted into the receipt via rowVisibilityConfig.depositInstructions. + * Owns its own `showBankDetails` toggle (local UI state) so the toggle + * doesn't leak into the parent's state surface. + * + * Format selection precedence: IBAN+BIC → Sort+Account → US fallback. + * Mirrors what Bridge returns in `extraDataForDrawer.depositInstructions`. + */ +export function BridgeDepositInstructions({ transaction }: { transaction: TransactionDetails }) { + const [showBankDetails, setShowBankDetails] = useState(false) + const instructions = transaction.extraDataForDrawer?.depositInstructions + if (!instructions) return null + + return ( + <> + + Deposit Message + + + } + value={ +
+ {/* Display can wrap / be truncated visually via CSS, but + the copyable text MUST be the full reference — Bridge + won't reconcile a deposit if the user enters the + truncated form. */} + {instructions.deposit_message} + +
+ } + hideBottomBorder={false} + /> + + {/* Toggle button for bank details */} +
+ +
+ + {/* Collapsible bank details */} + {showBankDetails && ( + <> + {/* Fallback to bridge as account holder name — covers faster_payments + onramps where bridge doesn't return an account holder name. */} + + + {instructions.bank_name} + + + } + hideBottomBorder={true} + /> + + {instructions.bank_address} + + + } + hideBottomBorder={false} + /> + + {instructions.clabe ? ( + // Mexican format (SPEI) — CLABE is the canonical 18-digit + // bank reference; account/routing aren't applicable. + + {instructions.clabe} + + + } + allowCopy + hideBottomBorder + /> + ) : instructions.iban && instructions.bic ? ( + // European format (IBAN/BIC) + <> + + {formatIban(instructions.iban)} + + + } + hideBottomBorder={true} + /> + + {instructions.bic} + + + } + hideBottomBorder={true} + /> + + ) : instructions.sort_code && instructions.account_number ? ( + // UK faster_payments format (Sort Code/Account Number) + <> + + + + ) : ( + // US format (Account Number/Routing Number + optional beneficiary). + // This branch is the fallback for any payload that doesn't fit + // CLABE/IBAN/UK shapes — including future rails Bridge may add. + // Each row gates on its own data so a partial payload never + // renders or copies `undefined`. + <> + {instructions.bank_account_number && ( + + {instructions.bank_account_number} + + + } + hideBottomBorder={false} + /> + )} + {instructions.bank_routing_number && ( + + {instructions.bank_routing_number} + + + } + hideBottomBorder={false} + /> + )} + {instructions.bank_beneficiary_name && ( + + {instructions.bank_beneficiary_name} + + + } + hideBottomBorder={true} + /> + )} + {instructions.bank_beneficiary_address && ( + + {instructions.bank_beneficiary_address} + + + } + hideBottomBorder={true} + /> + )} + + )} + + )} + + ) +} diff --git a/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx b/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx new file mode 100644 index 000000000..2b82f5d9f --- /dev/null +++ b/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx @@ -0,0 +1,162 @@ +'use client' + +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { friendlyDeclineReason } from '@/utils/cardDeclineReason' + +/** Strings from Rain's sandbox arrive whitespace-padded (" ", " - ") and + * legacy intents in the DB pre-date the backend cleanField pass — treat any + * whitespace-only or placeholder-shaped value as absent. */ +function nonBlank(value: string | null | undefined): string | null { + if (!value) return null + const trimmed = value.trim() + if (trimmed.length === 0) return null + if (/^[-_]{1,3}$/.test(trimmed)) return null + return trimmed +} + +/** Parse a cents amount and reject NaN / Infinity / null up-front so the + * drawer never renders "Charged in NaN EUR". */ +function parseCents(value: string | null | undefined): number | null { + if (value == null) return null + const n = Number(value) + return Number.isFinite(n) ? n : null +} + +/** Whether the transaction's local-currency block represents a real cross- + * currency charge that should be surfaced to the user. Normalizes the + * currency string and skips USD (the display currency). */ +function hasCrossCurrencyCharge(card: NonNullable['cardPayment']): boolean { + if (!card) return false + const currency = nonBlank(card.localCurrency) + if (!currency) return false + if (currency.toLowerCase() === 'usd') return false + return parseCents(card.localAmount) != null +} + +/** + * Whether CardPaymentRows would render any visible sub-row for this + * transaction. The row-config in the receipt uses this to gate the + * `cardPayment` slot — without it, we'd end up with a "visible" but + * empty slot, which throws off `shouldHideBorder` and dangles the + * preceding row's border into empty space. + */ +export function hasCardPaymentRowsContent(transaction: TransactionDetails): boolean { + const card = transaction.extraDataForDrawer?.cardPayment + if (!card) return false + + if (nonBlank(card.merchantCategory)) return true + if (nonBlank(card.merchantCity) || nonBlank(card.merchantCountry)) return true + if (hasCrossCurrencyCharge(card)) return true + if (card.settlementAdjusted && parseCents(card.authAmount) != null) return true + if (transaction.status === 'failed' && card.declineReason) return true + if (card.cancellationReason === 'auto_closed') return true + return false +} + +/** + * Card-payment rows for the receipt's details Card. + * + * Slots into the row sequence between `txId` and `fee` via the + * `cardPayment` rowVisibilityConfig key. Each sub-row is internally + * gated on data presence so this component renders nothing when the + * data is fully absent (which itself shouldn't happen — the parent's + * row config should already gate the slot in that case). + * + * Source data: `transaction.extraDataForDrawer.cardPayment`, populated + * in transactionTransformer for any Rain CARD_SPEND or card-refund + * entry. Backend mirror: src/transaction-intent/history.ts. + */ +export function CardPaymentRows({ + transaction, + isLastRow, +}: { + transaction: TransactionDetails + /** When true, suppresses bottom border on the LAST visible sub-row so it + * meets the Card's edge cleanly. The parent receipt computes this via + * `shouldHideBorder('cardPayment')`. */ + isLastRow: boolean +}) { + const card = transaction.extraDataForDrawer?.cardPayment + if (!card) return null + + // Compose the visible sub-rows in order, then mark the final one as + // border-suppressed if this whole slot is also the receipt's last. + const subRows: { label: string; value: string; key: string }[] = [] + + const categoryClean = nonBlank(card.merchantCategory) + if (categoryClean) { + subRows.push({ key: 'category', label: 'Category', value: categoryClean }) + } + + const cityClean = nonBlank(card.merchantCity) + const countryClean = nonBlank(card.merchantCountry) + if (cityClean || countryClean) { + subRows.push({ + key: 'location', + label: 'Location', + value: [cityClean, countryClean].filter(Boolean).join(', '), + }) + } + + // Cross-currency only — suppress when the local currency matches the USD + // display currency (everyone's seen "Charged in 150 usd" alongside $1.50 + // and it's just noise). Normalized + NaN-guarded to avoid stray junk + // making it past the rendering predicate. + if (hasCrossCurrencyCharge(card)) { + const localCents = parseCents(card.localAmount)! + const currency = nonBlank(card.localCurrency)!.toUpperCase() + subRows.push({ + key: 'chargedIn', + label: 'Charged in', + value: `${(localCents / 100).toFixed(2)} ${currency}`, + }) + } + + // Spec §4.6 — settled amount differs from the original auth (overcapture, + // tip, partial capture). Show the original auth amount as a hint. + if (card.settlementAdjusted) { + const authCents = parseCents(card.authAmount) + if (authCents != null) { + subRows.push({ + key: 'adjustedFrom', + label: 'Original amount', + value: `$${(authCents / 100).toFixed(2)}`, + }) + } + } + + // Spec §4.4 — show why a card spend declined. + if (transaction.status === 'failed' && card.declineReason) { + subRows.push({ + key: 'declineReason', + label: 'Decline reason', + value: friendlyDeclineReason(card.declineReason), + }) + } + + // Spec §4.7 — the periodic stale-auth sweep flips long-standing pending + // auths to cancelled. Distinguish these from merchant-initiated reversals. + if (card.cancellationReason === 'auto_closed') { + subRows.push({ + key: 'autoCloseNote', + label: 'Note', + value: "Automatically cancelled — the merchant didn't complete it", + }) + } + + if (subRows.length === 0) return null + + return ( + <> + {subRows.map((row, idx) => ( + + ))} + + ) +} diff --git a/src/components/TransactionDetails/provider-rows/MantecaDepositInfo.tsx b/src/components/TransactionDetails/provider-rows/MantecaDepositInfo.tsx new file mode 100644 index 000000000..8a1c76829 --- /dev/null +++ b/src/components/TransactionDetails/provider-rows/MantecaDepositInfo.tsx @@ -0,0 +1,56 @@ +'use client' + +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { + MANTECA_COUNTRIES_CONFIG, + MANTECA_ARG_DEPOSIT_NAME, + MANTECA_ARG_DEPOSIT_CUIT, +} from '@/constants/manteca.consts' + +/** + * Manteca-specific deposit info rows for the receipt's mantecaDepositInfo + * slot. Owns: deposit address (label is country-specific), alias, plus the + * Argentina-only Razón Social + CUIT pair. + * + * Slotted into the receipt via rowVisibilityConfig.mantecaDepositInfo. The + * config itself stays in the receipt (it depends on receipt-level state + * like `isPublic` + `transaction.status`); only the rendering moves here. + */ +export function MantecaDepositInfo({ + transaction, + country, +}: { + transaction: TransactionDetails + /** Resolved from transaction.currency.code by the receipt — passed in to + * avoid duplicating the lookup. */ + country: { id: string } | undefined +}) { + const depositDetails = transaction.extraDataForDrawer?.receipt?.depositDetails + if (!depositDetails) return null + + return ( + <> + {depositDetails.depositAddress && ( + + )} + {depositDetails.depositAlias && ( + + )} + {country?.id === 'AR' && ( + <> + + + + )} + + ) +} diff --git a/src/components/TransactionDetails/transaction-details.utils.ts b/src/components/TransactionDetails/transaction-details.utils.ts index 8b4af2775..7f5318722 100644 --- a/src/components/TransactionDetails/transaction-details.utils.ts +++ b/src/components/TransactionDetails/transaction-details.utils.ts @@ -19,6 +19,7 @@ export type TransactionDetailsRowKey = | 'comment' | 'attachment' | 'mantecaDepositInfo' + | 'cardPayment' | 'closed' // order of the rows in the receipt (must match actual rendering order in component) @@ -32,6 +33,7 @@ export const transactionDetailsRowKeys: TransactionDetailsRowKey[] = [ 'to', 'tokenAndNetwork', 'txId', + 'cardPayment', 'fee', 'mantecaDepositInfo', 'exchangeRate', diff --git a/src/components/TransactionDetails/transaction-predicates.ts b/src/components/TransactionDetails/transaction-predicates.ts new file mode 100644 index 000000000..6c0dbc2ce --- /dev/null +++ b/src/components/TransactionDetails/transaction-predicates.ts @@ -0,0 +1,92 @@ +/** + * Receipt-side type predicates. + * + * Pre-M3 the receipt branched on `originalType === EHistoryEntryType.X` in + * many places, often as multi-element OR chains (e.g. "is this any kind of + * QR payment?", "is this any kind of bank flow?"). This module centralises + * those checks so adding a provider is one line in one place rather than a + * grep-and-edit across the receipt. + * + * Predicates take `TransactionDetails` (the drawer view model) — not raw + * `HistoryEntry` — because some require fields that only exist after the + * transformer runs (e.g. `extraDataForDrawer.cardPayment`). + */ + +import { type TransactionDetails } from './transactionTransformer' +// Type-only import — the runtime enum lives in utils/history.utils, which +// ends up in a circular load with this file under the jest module graph +// (utils/history.utils → transactionTransformer → TransactionCard → +// transaction-predicates → back to utils/history.utils, mid-evaluation). +// Importing only the type lets the transpiler erase this line entirely; +// the Sets below use string literals because EHistoryEntryType is a string +// enum, so the runtime values are interchangeable with their literal forms. +import type { EHistoryEntryType } from '@/utils/history.utils' + +const QR_PAYMENT_TYPES: ReadonlySet = new Set([ + 'MANTECA_QR_PAYMENT' as EHistoryEntryType, + 'SIMPLEFI_QR_PAYMENT' as EHistoryEntryType, +]) + +// Types whose receipt is shareable (split-bill prompt + share-receipt button). +// Same as QR-payments today plus Manteca on/off-ramps; kept as its own set so +// "shareable" can diverge from "QR" later without a sweep. +const SHAREABLE_RECEIPT_TYPES: ReadonlySet = new Set([ + 'MANTECA_QR_PAYMENT' as EHistoryEntryType, + 'SIMPLEFI_QR_PAYMENT' as EHistoryEntryType, + 'MANTECA_OFFRAMP' as EHistoryEntryType, + 'MANTECA_ONRAMP' as EHistoryEntryType, +]) + +// Types where the timestamp label reads "Completed" (one-shot bank/onchain +// flows) instead of "Sent"/"Received" (peer-shaped flows). +const COMPLETED_LABEL_TYPES: ReadonlySet = new Set([ + 'WITHDRAW' as EHistoryEntryType, + 'DEPOSIT' as EHistoryEntryType, + 'BRIDGE_OFFRAMP' as EHistoryEntryType, + 'BRIDGE_ONRAMP' as EHistoryEntryType, + 'BRIDGE_GUEST_OFFRAMP' as EHistoryEntryType, + 'BANK_SEND_LINK_CLAIM' as EHistoryEntryType, + 'MANTECA_OFFRAMP' as EHistoryEntryType, + 'MANTECA_ONRAMP' as EHistoryEntryType, +]) + +/** Post-M3, QR payments arrive as TRANSACTION_INTENT entries with + * `extraData.kind === 'QR_PAY'`. Pre-M3 (legacy rows still in the feed) + * used dedicated `originalType` values. Recognize both. */ +function isTransactionIntentKind(transaction: TransactionDetails, kind: string): boolean { + // String comparison — see top-of-file note on the type-only import. The + // enum value 'TRANSACTION_INTENT' is identical to its string at runtime. + return ( + transaction.extraDataForDrawer?.originalType === ('TRANSACTION_INTENT' as EHistoryEntryType) && + transaction.extraDataForDrawer?.kind === kind + ) +} + +export function isQRPayment(transaction: TransactionDetails): boolean { + const type = transaction.extraDataForDrawer?.originalType + if (type && QR_PAYMENT_TYPES.has(type)) return true + return isTransactionIntentKind(transaction, 'QR_PAY') +} + +export function hasShareableReceipt(transaction: TransactionDetails): boolean { + const type = transaction.extraDataForDrawer?.originalType + if (type && SHAREABLE_RECEIPT_TYPES.has(type)) return true + // QR payments via the unified intent path should also be shareable. + return isTransactionIntentKind(transaction, 'QR_PAY') +} + +/** Renders "Completed" label for the timestamp row instead of "Sent"/"Received". */ +export function usesCompletedTimestampLabel(transaction: TransactionDetails): boolean { + const type = transaction.extraDataForDrawer?.originalType + return type ? COMPLETED_LABEL_TYPES.has(type) : false +} + +/** True for any Rain card-spend or card-refund entry. The transformer fills + * `extraDataForDrawer.cardPayment` for both — that's the discriminator. */ +export function isCardPaymentEntry(transaction: TransactionDetails): boolean { + return transaction.extraDataForDrawer?.cardPayment != null +} + +export function isPerkReward(transaction: TransactionDetails): boolean { + return transaction.extraDataForDrawer?.originalType === ('PERK_REWARD' as EHistoryEntryType) +} diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index b7ee31b46..3a9747253 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -61,6 +61,11 @@ export interface TransactionDetails { addressExplorerUrl?: string originalType: EHistoryEntryType originalUserRole: EHistoryUserRole + /** Post-M3 transaction-intent kind (P2P_SEND, QR_PAY, CARD_SPEND, …). + * Some predicates need this to disambiguate within TRANSACTION_INTENT + * entries — e.g. QR payments now arrive as TRANSACTION_INTENT + kind=QR_PAY + * rather than a dedicated originalType. */ + kind?: string link?: string isLinkTransaction?: boolean transactionCardType?: TransactionCardType @@ -116,6 +121,31 @@ export interface TransactionDetails { final_amount?: string exchange_rate?: string } + /** Card-payment specifics — populated for Rain CARD_SPEND / card-refund + * entries only. Drives the merchant hero, status timeline, decline + * reason, and "Adjusted from $X" settlement note in the drawer. */ + cardPayment?: { + merchantName: string | null + merchantCategory: string | null + merchantCity: string | null + merchantCountry: string | null + merchantMcc: string | null + /** Rain-enriched brand logo URL when their enrichment identified the + * merchant. Drawer keeps the generic card icon for v1; this is + * plumbed so a future swap doesn't need a backend change. */ + merchantLogo: string | null + merchantId: string | null + localAmount: string | null + localCurrency: string | null + declineReason: string | null + authAmount: string | null + settledAmount: string | null + settlementAdjusted: boolean + cancellationReason: string | null + parentRainTxId: string | null + rainTransactionId: string | null + isRefund: boolean + } } sourceView?: 'status' | 'history' tokenDisplayDetails?: { @@ -404,13 +434,34 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact nameForDetails = 'Bank Account' isPeerActuallyUser = false break - case 'CARD_SPEND': + 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 = 'Card Payment' + 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 + } direction = 'send' transactionCardType = 'send' nameForDetails = entry.recipientAccount?.identifier || 'Transaction' @@ -596,7 +647,17 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact // drawer's fee row never renders. If this rule changes, update // docs/product-conventions.md first. fee: undefined, - memo: isTestDeposit ? 'Your peanut wallet is ready to use!' : entry.memo?.trim(), + // memo carries free-form user notes from non-card flows (link memos, + // request comments). Card spends + Rain refunds suppress this — the + // merchant name and any decline reason render inside CardPaymentRows + // in the drawer, so a duplicate "Comment" row is just noise. Backend + // already sets memo=undefined for card entries, but defend in depth. + memo: (() => { + if (isTestDeposit) return 'Your peanut wallet is ready to use!' + const isCardEntry = entry.extraData?.kind === 'CARD_SPEND' || !!entry.extraData?.parentRainTxId + if (isCardEntry) return undefined + return entry.memo?.trim() + })(), attachmentUrl: entry.attachmentUrl, cancelledDate: entry.cancelledAt, txHash: entry.txHash, @@ -607,12 +668,45 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact addressExplorerUrl, originalType: entry.type as EHistoryEntryType, originalUserRole: entry.userRole as EHistoryUserRole, + kind: entry.extraData?.kind as string | undefined, link: entry.extraData?.link, isLinkTransaction: isLinkTx, transactionCardType, rewardData, fulfillmentType: entry.extraData?.fulfillmentType, bridgeTransferId: entry.extraData?.bridgeTransferId, + // Card-payment specifics — populated only for Rain CARD_SPEND / + // card-refund entries. Drawer reads these to render the merchant + // hero, status timeline, decline reason, and "Adjusted from $X" + // settlement note. Backend source: src/transaction-intent/history.ts. + // Build the cardPayment block for any card-shaped intent (CARD_SPEND + // kind) and for Rain refunds (parentRainTxId set). Earlier we + // guarded on merchantName presence, but that dropped the block for + // unknown-merchant spends — losing the de-emphasis-on-failed, + // decline-reason rows, and merchant-detail Card. The block falls + // back to "Card payment" downstream when merchantName is null. + cardPayment: + entry.extraData?.kind === 'CARD_SPEND' || entry.extraData?.parentRainTxId + ? { + merchantName: entry.extraData?.merchantName as string | null, + merchantCategory: entry.extraData?.merchantCategory as string | null, + merchantCity: entry.extraData?.merchantCity as string | null, + merchantCountry: entry.extraData?.merchantCountry as string | null, + merchantMcc: entry.extraData?.merchantMcc as string | null, + merchantLogo: entry.extraData?.merchantLogo as string | null, + merchantId: entry.extraData?.merchantId as string | null, + localAmount: entry.extraData?.cardLocalAmount as string | null, + localCurrency: entry.extraData?.cardLocalCurrency as string | null, + declineReason: entry.extraData?.declineReason as string | null, + authAmount: entry.extraData?.cardAuthAmount as string | null, + settledAmount: entry.extraData?.cardSettledAmount as string | null, + settlementAdjusted: Boolean(entry.extraData?.settlementAdjusted), + cancellationReason: entry.extraData?.cancellationReason as string | null, + parentRainTxId: entry.extraData?.parentRainTxId as string | null, + rainTransactionId: entry.extraData?.rainTransactionId as string | null, + isRefund: !!entry.extraData?.parentRainTxId, + } + : undefined, perkReward: entry.extraData?.perkReward as HistoryEntryPerkReward | undefined, perk: entry.extraData?.perk as | { diff --git a/src/components/TransactionDetails/useReceiptViewModel.ts b/src/components/TransactionDetails/useReceiptViewModel.ts new file mode 100644 index 000000000..ee47769c0 --- /dev/null +++ b/src/components/TransactionDetails/useReceiptViewModel.ts @@ -0,0 +1,284 @@ +'use client' + +import { useMemo } from 'react' +import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory' +import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { + type TransactionDetailsRowKey, + transactionDetailsRowKeys, +} from '@/components/TransactionDetails/transaction-details.utils' +import { + hasShareableReceipt, + isCardPaymentEntry, + isQRPayment as isQRPaymentTransaction, +} from '@/components/TransactionDetails/transaction-predicates' +import { hasCardPaymentRowsContent } from '@/components/TransactionDetails/provider-rows/CardPaymentRows' +import { countryData } from '@/components/AddMoney/consts' +import { getContributorsFromCharge, formatCurrency } from '@/utils/general.utils' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' + +const ROW_GROUPS = { + dateRows: ['createdAt', 'cancelled', 'claimed', 'completed', 'closed'] as TransactionDetailsRowKey[], + txnDetails: ['tokenAndNetwork', 'txId'] as TransactionDetailsRowKey[], + fees: ['networkFee', 'peanutFee'] as TransactionDetailsRowKey[], +} as const + +const ALL_ROWS_HIDDEN = transactionDetailsRowKeys.reduce( + (acc, key) => { + acc[key] = false + return acc + }, + {} as Record +) + +export interface ReceiptViewModel { + /** Status / type predicates the receipt branches on. */ + isGuestBankClaim: boolean + isPendingBankRequest: boolean + isPeanutWalletToken: boolean + isPendingRequestee: boolean + isPendingRequester: boolean + isPendingSentLink: boolean + isQRPayment: boolean + + /** Country resolved from the transaction's currency code (used by the + * Manteca deposit-info row for the country-specific address label). */ + country: (typeof countryData)[number] | undefined + + /** Per-row visibility config — drives both rendering and border logic. */ + rowVisibilityConfig: Record + + /** True when this row is the last visible one in the receipt — the row + * uses this to suppress its bottom border so it meets the Card edge. */ + shouldHideBorder: (rowKey: TransactionDetailsRowKey) => boolean + + /** Same idea but scoped to a row group (date rows, txn details, fees). */ + shouldHideGroupBorder: (rowKey: TransactionDetailsRowKey, groupName: keyof typeof ROW_GROUPS) => boolean + + /** Whether the share-receipt button should render at all. */ + shouldShowShareReceipt: boolean + + /** Request-pot contributor list — empty array when not a request pot. */ + requestPotContributors: ReturnType + + /** "$1,234.56" — pre-formatted to keep the receipt JSX trivial. */ + formattedTotalAmountCollected: string +} + +/** + * All the derived state the receipt drawer needs in one place. Receipt + * component stays focused on JSX + side-effect callbacks. + * + * Pure derivation — no IO, no effects, no refs. Memoized off `transaction` + * + `isPublic` so identity stays stable across renders when the input + * doesn't change. + */ +export function useReceiptViewModel( + transaction: TransactionDetails | null, + { isPublic }: { isPublic: boolean } +): ReceiptViewModel { + const isGuestBankClaim = useMemo( + () => transaction?.extraDataForDrawer?.originalType === EHistoryEntryType.BANK_SEND_LINK_CLAIM, + [transaction] + ) + + const isPendingBankRequest = useMemo( + () => + !!transaction && + transaction.status === 'pending' && + transaction.extraDataForDrawer?.originalType === EHistoryEntryType.REQUEST && + transaction.extraDataForDrawer?.fulfillmentType === 'bridge', + [transaction] + ) + + const isPendingRequestee = useMemo( + () => + !!transaction && + transaction.status === 'pending' && + transaction.extraDataForDrawer?.originalType === EHistoryEntryType.REQUEST && + transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER && + !transaction.extraDataForDrawer?.fulfillmentType, + [transaction] + ) + + const isPendingRequester = useMemo( + () => + !!transaction && + transaction.status === 'pending' && + transaction.extraDataForDrawer?.originalType === EHistoryEntryType.REQUEST && + transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.RECIPIENT, + [transaction] + ) + + const isPendingSentLink = useMemo( + () => + !!transaction && + transaction.status === 'pending' && + transaction.extraDataForDrawer?.originalType === EHistoryEntryType.SEND_LINK && + transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER, + [transaction] + ) + + // Hide the token+network row when token = USDC on Arb (the wallet's + // native pair) — that's noise for every Peanut-internal flow. + const isPeanutWalletToken = useMemo(() => { + if (!transaction) return false + const tokenSymbol = transaction.tokenSymbol?.toUpperCase() + const chainName = transaction.tokenDisplayDetails?.chainName?.toLowerCase() + return tokenSymbol === PEANUT_WALLET_TOKEN_SYMBOL && chainName === PEANUT_WALLET_CHAIN.name.toLowerCase() + }, [transaction]) + + const country = useMemo(() => { + if (!transaction?.currency?.code) return undefined + return countryData.find((c) => c.currency === transaction.currency?.code) + }, [transaction?.currency?.code]) + + const rowVisibilityConfig = useMemo>(() => { + if (!transaction) return ALL_ROWS_HIDDEN + + // Hide "Created" when "Sent"/"Completed" is about to render — same + // lifecycle event for off-ramps / bank claims, two rows side-by-side + // is noise. Keep "Created" as the fallback for pending states. + const willShowCompleted = !!( + transaction.status === 'completed' && + transaction.completedAt && + transaction.extraDataForDrawer?.originalType !== EHistoryEntryType.DIRECT_SEND + ) + + return { + createdAt: !!transaction.createdAt && !willShowCompleted, + to: transaction.direction === 'claim_external', + tokenAndNetwork: !!( + transaction.tokenDisplayDetails && + transaction.sourceView === 'history' && + !isPeanutWalletToken && + !( + transaction.extraDataForDrawer?.originalType === EHistoryEntryType.SEND_LINK && + transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER + ) && + transaction.status !== 'refunded' + ), + txId: !!transaction.txHash, + cancelled: transaction.status === 'cancelled', + claimed: !!(transaction.status === 'completed' && transaction.claimedAt), + completed: !!( + transaction.status === 'completed' && + transaction.completedAt && + transaction.extraDataForDrawer?.originalType !== EHistoryEntryType.DIRECT_SEND + ), + refunded: transaction.status === 'refunded', + fee: transaction.fee !== undefined && transaction.status !== 'cancelled', + exchangeRate: !!( + (transaction.direction === 'bank_deposit' || + transaction.direction === 'qr_payment' || + transaction.direction === 'bank_withdraw') && + transaction.currency?.code && + transaction.currency.code.toUpperCase() !== 'USD' && + transaction.status !== 'cancelled' + ), + bankAccountDetails: !!( + transaction.bankAccountDetails && + transaction.bankAccountDetails.identifier && + transaction.status !== 'cancelled' + ), + transferId: !!( + transaction.id && + (transaction.direction === 'bank_withdraw' || transaction.direction === 'bank_claim') && + transaction.status !== 'cancelled' + ), + depositInstructions: !!( + (transaction.extraDataForDrawer?.originalType === EHistoryEntryType.BRIDGE_ONRAMP || + (isPendingBankRequest && + transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER)) && + transaction.status === 'pending' && + transaction.extraDataForDrawer?.depositInstructions && + transaction.extraDataForDrawer.depositInstructions.bank_name + ), + peanutFee: false, // Removed when perks moved to separate transactions. + points: !!(transaction.points && transaction.points > 0 && transaction.status !== 'cancelled'), + comment: !!(transaction.memo?.trim() && transaction.status !== 'cancelled'), + networkFee: !!( + transaction.networkFeeDetails && + transaction.sourceView === 'status' && + transaction.status !== 'cancelled' + ), + attachment: !!(transaction.attachmentUrl && transaction.status !== 'cancelled'), + mantecaDepositInfo: + !isPublic && + transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_ONRAMP && + transaction.status === 'pending', + // Gate on whether CardPaymentRows would actually emit a sub-row. + // Otherwise an "all-data-absent" card spend leaves the slot + // visible-but-empty and `shouldHideBorder` mis-attributes the + // last-visible row, dangling a border below the previous row. + cardPayment: isCardPaymentEntry(transaction) && hasCardPaymentRowsContent(transaction), + closed: !!(transaction.status === 'closed' && transaction.cancelledDate), + } + }, [transaction, isPublic, isPendingBankRequest, isPeanutWalletToken]) + + const visibleRows = useMemo( + () => transactionDetailsRowKeys.filter((key) => rowVisibilityConfig[key]), + [rowVisibilityConfig] + ) + + const shouldHideBorder = useMemo(() => { + const lastVisibleRow = visibleRows[visibleRows.length - 1] + return (rowKey: TransactionDetailsRowKey) => rowKey === lastVisibleRow + }, [visibleRows]) + + const lastVisibleInGroups = useMemo(() => { + const lastIn = (groupKeys: readonly TransactionDetailsRowKey[]) => { + const v = groupKeys.filter((key) => rowVisibilityConfig[key]) + return v[v.length - 1] + } + return { + dateRows: lastIn(ROW_GROUPS.dateRows), + txnDetails: lastIn(ROW_GROUPS.txnDetails), + fees: lastIn(ROW_GROUPS.fees), + } + }, [rowVisibilityConfig]) + + const shouldHideGroupBorder = useMemo(() => { + return (rowKey: TransactionDetailsRowKey, groupName: keyof typeof ROW_GROUPS) => { + const isLastInGroup = rowKey === lastVisibleInGroups[groupName] + const isGlobalLast = shouldHideBorder(rowKey) + // Last-in-group keeps its border unless it's also the global last; + // otherwise always hide (group rows pack visually). + return isLastInGroup ? isGlobalLast : true + } + }, [lastVisibleInGroups, shouldHideBorder]) + + const shouldShowShareReceipt = useMemo(() => { + if (isPublic) return false + if (!transaction || isPendingSentLink || isPendingRequester || isPendingRequestee) return false + if (transaction.txHash && transaction.direction !== 'receive' && transaction.direction !== 'request_sent') { + return true + } + return hasShareableReceipt(transaction) + }, [transaction, isPublic, isPendingSentLink, isPendingRequester, isPendingRequestee]) + + const requestPotContributors = useMemo(() => { + if (!transaction?.requestPotPayments) return [] + return getContributorsFromCharge(transaction.requestPotPayments) + }, [transaction]) + + const isQRPayment = transaction ? isQRPaymentTransaction(transaction) : false + const formattedTotalAmountCollected = formatCurrency(transaction?.totalAmountCollected?.toString() ?? '0', 2, 0) + + return { + isGuestBankClaim, + isPendingBankRequest, + isPeanutWalletToken, + isPendingRequestee, + isPendingRequester, + isPendingSentLink, + isQRPayment, + country, + rowVisibilityConfig, + shouldHideBorder, + shouldHideGroupBorder, + shouldShowShareReceipt, + requestPotContributors, + formattedTotalAmountCollected, + } +} diff --git a/src/utils/cardDeclineReason.ts b/src/utils/cardDeclineReason.ts new file mode 100644 index 000000000..fe59c844a --- /dev/null +++ b/src/utils/cardDeclineReason.ts @@ -0,0 +1,29 @@ +/** + * Translate raw Rain decline codes to friendly messages for the activity feed. + * Backend persists the code as-is on intent metadata; the human-readable mapping + * lives here so it can be tweaked / i18n'd without a migration. + * + * Codes from Rain's gateway responses (snake_case Rain shapes alongside the + * older SCREAMING_CASE ones). Unknown codes fall back to the generic copy + * mandated by the card-activity spec. + */ + +const FRIENDLY: Record = { + INSUFFICIENT_FUNDS: 'Insufficient balance', + insufficient_funds: 'Insufficient balance', + card_spending_limit_exceeded: 'Spending limit reached', + CARD_SPENDING_LIMIT_EXCEEDED: 'Spending limit reached', + blocked_merchant: "This merchant isn't supported", + blocked_mcc: "This merchant isn't supported", + BLOCKED_MERCHANT: "This merchant isn't supported", + BLOCKED_MCC: "This merchant isn't supported", + card_locked: 'Your card is locked', + CARD_LOCKED: 'Your card is locked', + invalid_pin: 'Incorrect PIN', + INVALID_PIN: 'Incorrect PIN', +} + +export function friendlyDeclineReason(code: string | null | undefined): string { + if (!code) return 'Transaction declined' + return FRIENDLY[code] ?? FRIENDLY[code.toLowerCase()] ?? FRIENDLY[code.toUpperCase()] ?? 'Transaction declined' +}