diff --git a/src/components/Card/YourCardScreen.tsx b/src/components/Card/YourCardScreen.tsx index 9fdfc23823..ac3636c3bd 100644 --- a/src/components/Card/YourCardScreen.tsx +++ b/src/components/Card/YourCardScreen.tsx @@ -15,7 +15,6 @@ import LockCardModal from '@/components/Card/LockCardModal' import { shouldShowAutoRenewBanner, daysUntilExpiry } from '@/components/Card/cardExpiry.utils' import { useCardReveal } from '@/hooks/useCardReveal' import { useWalletPlatform } from '@/hooks/useWalletPlatform' -import { cardBalanceDueCents } from '@/utils/balance.utils' import { copyTextToClipboardWithFallback } from '@/utils/general.utils' import type { RainCardOverview, RainCardSummary } from '@/services/rain' @@ -27,7 +26,7 @@ interface Props { onPrev?: () => void } -const YourCardScreen: FC = ({ overview, card, onPrev }) => { +const YourCardScreen: FC = ({ card, onPrev }) => { const [autoRenewDismissed, setAutoRenewDismissed] = useState(false) const [action, setAction] = useQueryState('action', parseAsStringEnum(['lock', 'unlock', 'cancel'])) const { revealed, isLoading: isRevealing, error: revealError, toggle } = useCardReveal({ cardId: card.id }) @@ -41,7 +40,6 @@ const YourCardScreen: FC = ({ overview, card, onPrev }) => { const closeAction = () => void setAction(null) const showAutoRenew = !autoRenewDismissed && shouldShowAutoRenewBanner(card.expiryMonth, card.expiryYear) const daysLeft = daysUntilExpiry(card.expiryMonth, card.expiryYear) - const balanceDueCents = cardBalanceDueCents(overview.balance?.spendingPower) const handleCopy = useCallback( (value: string, field: 'pan' | 'cvv') => { @@ -90,15 +88,6 @@ const YourCardScreen: FC = ({ overview, card, onPrev }) => { )} - {balanceDueCents > 0 && ( - - )} - = ({ const isDeclinedCardSpend = status === 'failed' && isCardPaymentEntry(transaction) && !transaction.extraDataForDrawer?.cardPayment?.isRefund - // Settlement cleared at a different amount than authorized (tip / FX - // true-up) — flag the row so the balance impact isn't invisible in the - // feed; the receipt carries the authorized/adjustment breakdown. Refunds - // excluded like isDeclinedCardSpend above — a refund-auth that clears at - // a different amount would otherwise render "Refund · Adjusted". - const isAdjustedCardSpend = - isCardPaymentEntry(transaction) && - Boolean(transaction.extraDataForDrawer?.cardPayment?.settlementAdjusted) && - !transaction.extraDataForDrawer?.cardPayment?.isRefund - return ( <> {/* the clickable card */} @@ -254,7 +244,6 @@ const TransactionCard: React.FC = ({ : getActionText(type, status)} {status && } - {isAdjustedCardSpend && · Adjusted} diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 39a394123f..1df2353653 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -58,7 +58,6 @@ import { buildSplitBillRequestUrl } from './splitBill.utils' import { CardPaymentRows } from './provider-rows/CardPaymentRows' import { LocalRailNudge } from './provider-rows/LocalRailNudge' import { CardUsdAbroadNotice } from './provider-rows/CardUsdAbroadNotice' -import { CardAdjustmentNotice } from './provider-rows/CardAdjustmentNotice' import { MantecaDepositInfo } from './provider-rows/MantecaDepositInfo' import { BridgeDepositInstructions } from './provider-rows/BridgeDepositInstructions' import { CancelDepositActions } from './provider-actions/CancelDepositActions' @@ -639,12 +638,6 @@ export const TransactionDetailsReceipt = ({ - {/* Over-capture explainer — the words for the Initial hold / - Adjustment rows in the details card and the merchant-recourse - path. First of the notices: it explains THIS receipt's numbers; - the others nudge future behavior. */} - {!isPublic && } - {/* Local-rail nudge — card spends in a country with a cheaper first-party rail (AR → QR, BR → Pix). Self-gates: renders nothing for other countries / non-card-spend transactions. diff --git a/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx b/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx index 285d64346f..ed089ac7b2 100644 --- a/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx +++ b/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx @@ -130,35 +130,3 @@ describe('TransactionCard — clickable counterparty name', () => { expect(openTransactionDetails).toHaveBeenCalledTimes(1) }) }) - -/** A Rain card spend row; `cardPayment` overrides shape the flag cases. */ -function cardSpendTx(cardPayment: Record): TransactionDetails { - const tx = eligibleTx() - ;(tx.extraDataForDrawer as Record).cardPayment = { - merchantName: 'Savannah Taphouse', - isRefund: false, - settlementAdjusted: false, - ...cardPayment, - } - return tx -} - -// The '· Adjusted' feed flag — settlement cleared at a different amount than -// authorized. Refunds are excluded even when the BE forwards the flag on a -// negative-auth refund clear (they'd read "Refund · Adjusted" otherwise). -describe('TransactionCard — settlement-adjusted flag', () => { - it('shows · Adjusted for an adjusted card spend', () => { - renderCard(cardSpendTx({ settlementAdjusted: true })) - expect(screen.getByText('· Adjusted')).toBeInTheDocument() - }) - - it('hides it for a non-adjusted card spend', () => { - renderCard(cardSpendTx({ settlementAdjusted: false })) - expect(screen.queryByText('· Adjusted')).not.toBeInTheDocument() - }) - - it('hides it for an adjusted card REFUND', () => { - renderCard(cardSpendTx({ settlementAdjusted: true, isRefund: true })) - expect(screen.queryByText('· Adjusted')).not.toBeInTheDocument() - }) -}) diff --git a/src/components/TransactionDetails/provider-rows/CardAdjustmentNotice.tsx b/src/components/TransactionDetails/provider-rows/CardAdjustmentNotice.tsx deleted file mode 100644 index d30cc28e59..0000000000 --- a/src/components/TransactionDetails/provider-rows/CardAdjustmentNotice.tsx +++ /dev/null @@ -1,39 +0,0 @@ -'use client' - -import InfoCard from '@/components/Global/InfoCard' -import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' -import { parseCents } from '@/components/TransactionDetails/transaction-details.utils' - -/** - * Notice on a card-spend receipt whose settlement captured MORE than its auth - * hold. Pairs with the Initial hold / Adjustment breakdown rows in - * CardPaymentRows: the rows carry the math, this carries the words and the - * merchant-recourse path. A visible card, not a row tooltip — the recourse - * sentence is the receipt's only action, and info-icon tooltips go mostly - * unopened. - * - * Fires only for over-captures. Under-captures return money — nothing to - * warn about; the breakdown rows already show the math. Refunds are excluded - * like the feed's "Adjusted" flag — a refund clearing at a different amount - * isn't a merchant overcharge. Copy names example causes ("tips and updated - * totals") without asserting one — Rain doesn't report why capture ≠ auth. - */ -export function CardAdjustmentNotice({ transaction }: { transaction: TransactionDetails }) { - const card = transaction.extraDataForDrawer?.cardPayment - if (!card?.settlementAdjusted || card.isRefund) return null - - const authCents = parseCents(card.authAmount) - const settledCents = parseCents(card.settledAmount) - if (authCents == null || settledCents == null) return null - - const deltaCents = settledCents - authCents - if (deltaCents <= 0) return null - - return ( - - ) -} diff --git a/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx b/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx index 46d4091a15..805985bab2 100644 --- a/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx +++ b/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx @@ -6,7 +6,7 @@ import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { type DisputeStatus, type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { friendlyDeclineReason } from '@/utils/cardDeclineReason' import { getFlagUrl } from '@/constants/countryCurrencyMapping' -import { extractMerchantIso2, parseCents } from '@/components/TransactionDetails/transaction-details.utils' +import { extractMerchantIso2 } from '@/components/TransactionDetails/transaction-details.utils' /** Strings from Rain's sandbox arrive whitespace-padded (" ", " - ") and * legacy intents in the DB pre-date the backend cleanField pass — treat any @@ -19,6 +19,14 @@ function nonBlank(value: string | null | undefined): string | 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 +} + /** * Friendly copy for the dispute status row. The drawer shows ONE row labeled * "Dispute" with the status-mapped text. Keep terminal-state copy actionable: @@ -142,28 +150,15 @@ export function CardPaymentRows({ } // Spec §4.6 — settled amount differs from the original auth (overcapture, - // tip, partial capture). Break out the hold vs the delta so the math is - // explicit. "Initial hold" over "Authorized": hold is the one card term - // users already know (hotels, gas stations). The words — examples and the - // merchant-recourse path — live in CardAdjustmentNotice below the details - // card, visible instead of behind an info-icon tooltip nobody opens. + // tip, partial capture). Show the original auth amount as a hint. if (card.settlementAdjusted) { const authCents = parseCents(card.authAmount) - const settledCents = parseCents(card.settledAmount) if (authCents != null) { subRows.push({ - key: 'initialHold', - label: 'Initial hold', + key: 'adjustedFrom', + label: 'Original amount', value: `$${(authCents / 100).toFixed(2)}`, }) - if (settledCents != null && settledCents !== authCents) { - const deltaCents = settledCents - authCents - subRows.push({ - key: 'settlementAdjustment', - label: 'Adjustment', - value: `${deltaCents > 0 ? '+' : '-'}$${(Math.abs(deltaCents) / 100).toFixed(2)}`, - }) - } } } diff --git a/src/components/TransactionDetails/provider-rows/__tests__/CardAdjustmentNotice.test.tsx b/src/components/TransactionDetails/provider-rows/__tests__/CardAdjustmentNotice.test.tsx deleted file mode 100644 index 2db99d7209..0000000000 --- a/src/components/TransactionDetails/provider-rows/__tests__/CardAdjustmentNotice.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/** - * CardAdjustmentNotice — the visible explainer for over-captured card spends. - * Replaced the Initial hold row's info-icon tooltip: the merchant-recourse - * sentence is the receipt's only action, so it must not hide behind a tap. - */ -import React from 'react' -import { render, screen } from '@testing-library/react' - -jest.mock('@/components/Global/InfoCard', () => ({ - __esModule: true, - default: ({ description }: { description?: React.ReactNode }) =>
{description}
, -})) - -// import must come after jest.mock -import { CardAdjustmentNotice } from '../CardAdjustmentNotice' -import type { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' - -const ADJUSTED_SPEND = { - settlementAdjusted: true, - authAmount: '4591', - settledAmount: '5509', - isRefund: false, -} - -function makeTransaction(cardPayment: Record): TransactionDetails { - return { - status: 'completed', - extraDataForDrawer: { cardPayment }, - } as unknown as TransactionDetails -} - -describe('CardAdjustmentNotice', () => { - test('over-capture renders delta, example causes, and merchant recourse (incident: 4591 → 5509)', () => { - render() - expect(screen.getByTestId('info-card')).toHaveTextContent( - 'The final amount was $9.18 higher than the initial hold. This is common with tips and updated totals. Don’t recognize it? Contact the merchant.' - ) - }) - - test('under-capture (money back) renders nothing', () => { - const { container } = render( - - ) - expect(container).toBeEmptyDOMElement() - }) - - test('equal amounts (stuck-true flag) renders nothing', () => { - const { container } = render( - - ) - expect(container).toBeEmptyDOMElement() - }) - - test('missing settledAmount renders nothing', () => { - const { container } = render( - - ) - expect(container).toBeEmptyDOMElement() - }) - - test('refunds are excluded even with a positive delta', () => { - const { container } = render( - - ) - expect(container).toBeEmptyDOMElement() - }) - - test('non-adjusted spend renders nothing', () => { - const { container } = render( - - ) - expect(container).toBeEmptyDOMElement() - }) -}) diff --git a/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx b/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx deleted file mode 100644 index 226b064c58..0000000000 --- a/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/** - * CardPaymentRows — Initial hold vs Adjustment breakdown for settlement- - * adjusted card spends (capture ≠ auth: tips, FX true-ups, partial captures). - * - * The 2026-07-21 incident: a restaurant auth of $45.91 settled at $55.09 four - * days later and the receipt showed only a bare "Original amount" hint — the - * +$9.18 delta (which silently overdrew the user's collateral) was nowhere. - * These rows are the receipt's math half; the words and the merchant-recourse - * path live in CardAdjustmentNotice (tested separately). - */ -import React from 'react' -import { render, screen } from '@testing-library/react' - -jest.mock('@/components/Payment/PaymentInfoRow', () => ({ - PaymentInfoRow: ({ label, value }: { label: React.ReactNode; value: React.ReactNode }) => ( -
- {label} - {value} -
- ), -})) -jest.mock('next/image', () => ({ __esModule: true, default: () => })) - -// import must come after jest.mock -import { CardPaymentRows } from '../CardPaymentRows' -import type { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' - -const CARD_PAYMENT_BASE = { - merchantName: 'Savannah Taphouse', - merchantCategory: null, - merchantCity: null, - merchantCountry: null, - merchantMcc: null, - merchantLogo: null, - merchantId: null, - localAmount: null, - localCurrency: null, - declineReason: null, - declineCategory: null, - cancellationReason: null, - parentRainTxId: null, - rainTransactionId: 'rain-tx-1', - isRefund: false, - dispute: null, -} - -function makeTransaction(cardPayment: Record): TransactionDetails { - return { - status: 'completed', - extraDataForDrawer: { cardPayment: { ...CARD_PAYMENT_BASE, ...cardPayment } }, - } as unknown as TransactionDetails -} - -describe('CardPaymentRows — settlement adjustment breakdown', () => { - test('over-capture shows Initial hold and a positive Adjustment (incident: 4591 → 5509)', () => { - render( - - ) - expect(screen.getByText('Initial hold')).toBeInTheDocument() - expect(screen.getByText('$45.91')).toBeInTheDocument() - expect(screen.getByText('Adjustment')).toBeInTheDocument() - expect(screen.getByText('+$9.18')).toBeInTheDocument() - }) - - test('partial capture shows a negative Adjustment', () => { - render( - - ) - expect(screen.getByText('Initial hold')).toBeInTheDocument() - expect(screen.getByText('$113.23')).toBeInTheDocument() - expect(screen.getByText('-$36.70')).toBeInTheDocument() - }) - - test('adjusted but settledAmount missing → Initial hold row only, no Adjustment', () => { - render( - - ) - expect(screen.getByText('Initial hold')).toBeInTheDocument() - expect(screen.queryByText('Adjustment')).not.toBeInTheDocument() - }) - - test('stuck-true flag with equal amounts → no Adjustment row, no false difference claim', () => { - render( - - ) - expect(screen.getByText('Initial hold')).toBeInTheDocument() - expect(screen.queryByText('Adjustment')).not.toBeInTheDocument() - }) - - test('non-adjusted settle renders neither row', () => { - render( - - ) - expect(screen.queryByText('Initial hold')).not.toBeInTheDocument() - expect(screen.queryByText('Adjustment')).not.toBeInTheDocument() - }) -}) diff --git a/src/components/TransactionDetails/transaction-details.utils.ts b/src/components/TransactionDetails/transaction-details.utils.ts index e6deb68c20..29fca2334a 100644 --- a/src/components/TransactionDetails/transaction-details.utils.ts +++ b/src/components/TransactionDetails/transaction-details.utils.ts @@ -105,13 +105,3 @@ export function isNegativeWireAmount(amount: string | number | null | undefined) const n = parseWireAmount(amount) return Number.isFinite(n) && n < 0 } - -/** Parse a cents amount and reject NaN / Infinity / null up-front so the - * drawer never renders "Charged in NaN EUR". Shared by CardPaymentRows - * (breakdown math) and CardAdjustmentNotice (over-capture gate) so both - * read Rain's auth/settled cents identically. */ -export function parseCents(value: string | null | undefined): number | null { - if (value == null) return null - const n = Number(value) - return Number.isFinite(n) ? n : null -} diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index 5157a88297..fc8a7cfb15 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -1,5 +1,4 @@ import { - cardBalanceDueCents, computeAvailableSpendable, computeDisplaySpendable, computeExcessCollateralCents, @@ -67,27 +66,6 @@ describe('balance utils', () => { }) }) - describe('cardBalanceDueCents', () => { - it('surfaces a negative spending power as positive debt cents (incident: -631 = $6.31 due)', () => { - expect(cardBalanceDueCents(-631)).toBe(631) - }) - - it.each([ - [0, 0], - [4218, 0], // healthy positive balance — no debt - [null, 0], - [undefined, 0], - [Number.NaN, 0], - [Number.NEGATIVE_INFINITY, 0], - ])('returns 0 for non-debt input (%s)', (input, expected) => { - expect(cardBalanceDueCents(input)).toBe(expected) - }) - - it('rounds fractional cents from the wire', () => { - expect(cardBalanceDueCents(-630.6)).toBe(631) - }) - }) - describe('computeAvailableSpendable', () => { it('sums smart-account balance with landed collateral', () => { // $150 smart + $49.50 collateral = $199.50 diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index da2c1065fa..c2ab94cd0b 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -84,22 +84,6 @@ export const rainCentsToUsdcUnits = (spendingPowerCents: number | null | undefin return BigInt(Math.floor(spendingPowerCents)) * widenFactor } -/** - * Debt owed to the card, in whole cents (> 0), or 0 when there is none. - * - * Rain reports negative `spendingPower` when a settlement captured more than - * its auth hold (tip / FX true-up) on an already-drained collateral account. - * The clamp in `rainCentsToUsdcUnits` hides that debt from balance sums — the - * card screen surfaces it via this helper instead, because the next - * auto-balance sweep silently repays it from the user's deposit. - */ -export const cardBalanceDueCents = (spendingPowerCents: number | null | undefined): number => { - if (spendingPowerCents == null || !Number.isFinite(spendingPowerCents) || spendingPowerCents >= 0) { - return 0 - } - return Math.round(-spendingPowerCents) -} - /** * Available-now spendable balance, as a USDC base-unit bigint (6dp) — the * smart-account balance plus landed Rain collateral `spendingPower`. This is