From d550911cc5c0479cc0c0c9ec7c6f15367267ce27 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Tue, 14 Jul 2026 17:24:27 +0200 Subject: [PATCH 1/3] fix(deposits): confirm before cancelling + uniform shortened reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user cancelled a funded bank deposit while trying to report a problem: the cancel button sits next to the support link and fired instantly with no confirmation (PostHog: 'Issues with this transaction?' at 09:03:00, 'Cancel deposit' 11s later), making the arriving wire unmatchable. The drawer also showed the FULL deposit reference while Add Money shows the intentionally shortened 10-char form (some banks cap reference fields; Bridge matches on the partial) — the two-different-codes confusion that sent the user hunting in the drawer to begin with. --- .../provider-actions/CancelDepositActions.tsx | 84 ++++++++--- .../__tests__/CancelDepositActions.test.tsx | 140 ++++++++++++++++++ .../BridgeDepositInstructions.tsx | 13 +- .../BridgeDepositInstructions.test.tsx | 59 ++++++++ 4 files changed, 272 insertions(+), 24 deletions(-) create mode 100644 src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx create mode 100644 src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx diff --git a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx index daa566a9c..dbf333f43 100644 --- a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx +++ b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react' import { Button } from '@/components/0_Bruddle/Button' +import ActionModal from '@/components/Global/ActionModal' import ErrorAlert from '@/components/Global/ErrorAlert' import { Icon } from '@/components/Global/Icons/Icon' import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' @@ -40,6 +41,10 @@ export function CancelDepositActions({ }) { const queryClient = useQueryClient() const [error, setError] = useState(null) + // Cancels are irreversible and the button sits next to the support link — + // a real user cancelled a funded deposit while trying to report a problem + // (no way to match the wire once cancelled). Every cancel confirms first. + const [pendingCancel, setPendingCancel] = useState<{ noun: string; run: () => Promise } | null>(null) if (!setIsLoading || !onClose) return null const refetchAndClose = () => @@ -63,11 +68,45 @@ export function CancelDepositActions({ } } - // Render the active cancel button (if any) alongside any failure message. + const confirmThenRun = async () => { + if (!pendingCancel) return + setPendingCancel(null) + await wrapAction(pendingCancel.run) + } + + // Render the active cancel button (if any) alongside the shared + // confirmation modal and any failure message. const withError = (button: ReactNode) => (
{button} {error && } + setPendingCancel(null)} + icon="ban" + iconContainerClassName="bg-purple-1" + iconProps={{ className: 'text-black' }} + title={`Cancel this ${pendingCancel?.noun ?? 'deposit'}?`} + modalClassName="!z-[9999] pointer-events-auto" + description={ + <> + This can't be undone. If you already sent the bank transfer, don't cancel — a + cancelled deposit can no longer be matched to your account, and the money will be returned to + your bank instead. + + } + modalPanelClassName="max-w-sm mx-8 !z-[9999] pointer-events-auto" + contentContainerClassName="relative pointer-events-auto" + classOverlay="!bg-black/40 !z-[9998]" + ctas={[ + { + text: `Yes, cancel ${pendingCancel?.noun ?? 'deposit'}`, + shadowSize: '4', + className: 'md:py-2', + onClick: confirmThenRun, + }, + ]} + />
) @@ -84,9 +123,12 @@ export function CancelDepositActions({ - wrapAction(async () => { - const result = await cancelOnramp(transaction.id) - if (result.error) throw new Error(result.error) + setPendingCancel({ + noun: 'deposit', + run: async () => { + const result = await cancelOnramp(transaction.id) + if (result.error) throw new Error(result.error) + }, }) } /> @@ -101,9 +143,12 @@ export function CancelDepositActions({ - wrapAction(async () => { - const result = await mantecaApi.cancelDeposit(transaction.id) - if (result.error) throw new Error(result.error) + setPendingCancel({ + noun: 'deposit', + run: async () => { + const result = await mantecaApi.cancelDeposit(transaction.id) + if (result.error) throw new Error(result.error) + }, }) } /> @@ -123,17 +168,20 @@ export function CancelDepositActions({ label="Cancel Request" disabled={!!isLoading} onClick={() => - 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) + setPendingCancel({ + noun: 'request', + run: 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) + }, }) } /> diff --git a/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx new file mode 100644 index 000000000..1260a6673 --- /dev/null +++ b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx @@ -0,0 +1,140 @@ +/** + * CancelDepositActions — cancel buttons for pending bank-deposit flows. + * + * Regression tests for the instant-cancel bug: the cancel button fired + * cancelOnramp straight from onClick with no confirmation, sitting right next + * to the support link — a real user cancelled a funded deposit while trying to + * report a problem, making the wire unmatchable. Every cancel must go through + * an explicit confirmation first. Nested primitives are stubbed so only this + * component's own logic is under test. + */ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +const mockCancelOnramp = jest.fn() +jest.mock('@/app/actions/onramp', () => ({ + cancelOnramp: (...args: unknown[]) => mockCancelOnramp(...args), +})) +const mockMantecaCancel = jest.fn() +jest.mock('@/services/manteca', () => ({ + mantecaApi: { cancelDeposit: (...args: unknown[]) => mockMantecaCancel(...args) }, +})) +jest.mock('@/services/charges', () => ({ + chargesApi: { cancel: jest.fn() }, +})) +const mockInvalidateQueries = jest.fn() +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), +})) +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) +jest.mock('@/components/TransactionDetails/transaction-predicates', () => ({ + isMantecaOnrampEntry: () => false, + isRequestEntry: () => false, +})) +jest.mock('@/hooks/useTransactionHistory', () => ({ + EHistoryUserRole: { SENDER: 'SENDER' }, +})) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => ( + + ), +})) +jest.mock('@/components/Global/ErrorAlert', () => ({ + __esModule: true, + default: ({ description }: { description: string }) =>
{description}
, +})) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => })) +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: ({ + visible, + title, + ctas, + onClose, + }: { + visible: boolean + title: React.ReactNode + ctas?: Array<{ text: string; onClick?: () => void }> + onClose: () => void + }) => + visible ? ( +
+

{title}

+ {ctas?.map((cta) => ( + + ))} + +
+ ) : null, +})) + +// import must come after jest.mock +import { CancelDepositActions } from '../CancelDepositActions' + +const pendingBridgeOnramp = { + id: 'tx-1', + direction: 'bank_deposit', + status: 'pending', + extraDataForDrawer: { depositInstructions: { deposit_message: 'BRGTESTREF1234567890' } }, +} as unknown as import('@/components/TransactionDetails/transactionTransformer').TransactionDetails + +const renderCancel = () => + render( + + ) + +beforeEach(() => { + mockCancelOnramp.mockReset().mockResolvedValue({}) + mockMantecaCancel.mockReset() + mockInvalidateQueries.mockReset().mockResolvedValue(undefined) +}) + +describe('CancelDepositActions confirmation gate', () => { + it('does NOT cancel on the first click — it asks for confirmation instead', () => { + renderCancel() + + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Cancel deposit')) + + expect(mockCancelOnramp).not.toHaveBeenCalled() + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + expect(screen.getByText('Cancel this deposit?')).toBeInTheDocument() + }) + + it('cancels only after the user confirms', async () => { + renderCancel() + + fireEvent.click(screen.getByText('Cancel deposit')) + fireEvent.click(screen.getByText('Yes, cancel deposit')) + + await waitFor(() => expect(mockCancelOnramp).toHaveBeenCalledWith('tx-1')) + }) + + it('dismissing the confirmation leaves the deposit untouched', () => { + renderCancel() + + fireEvent.click(screen.getByText('Cancel deposit')) + fireEvent.click(screen.getByText('Dismiss')) + + expect(mockCancelOnramp).not.toHaveBeenCalled() + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx index 0d9b016a5..2223f7c08 100644 --- a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx +++ b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx @@ -36,12 +36,13 @@ export function BridgeDepositInstructions({ transaction }: { transaction: Transa } 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} - + {/* Shortened to 10 chars to match the Add Money screen — some + banks (e.g. Wise) cap reference fields at 10 chars, and + Bridge matches deposits on the partial reference. Showing + the full form only here made users think they wired with + the "wrong" code (two-different-codes confusion). */} + {instructions.deposit_message?.slice(0, 10)} +
} hideBottomBorder={false} diff --git a/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx b/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx new file mode 100644 index 000000000..38872cc8b --- /dev/null +++ b/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx @@ -0,0 +1,59 @@ +/** + * BridgeDepositInstructions — the deposit-instructions block in the + * transaction drawer. + * + * The reference here must be the SAME shortened 10-char form the Add Money + * screen shows (some banks cap reference fields at 10 chars; Bridge matches + * deposits on the partial reference). Showing the full form only here made a + * user believe they wired with the wrong code — the two-different-codes + * confusion. Nested primitives are stubbed. + */ +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('@/components/Global/CopyToClipboard', () => ({ + __esModule: true, + default: ({ textToCopy }: { textToCopy: string }) =>
, +})) +jest.mock('@/components/Global/MoreInfo', () => ({ __esModule: true, default: () => })) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => })) + +// import must come after jest.mock +import { BridgeDepositInstructions } from '../BridgeDepositInstructions' + +const FULL_REFERENCE = 'BRGTESTREF1234567890' +const SHORT_REFERENCE = FULL_REFERENCE.slice(0, 10) + +const transaction = { + extraDataForDrawer: { + depositInstructions: { + deposit_message: FULL_REFERENCE, + bank_name: 'Deutsche Bank', + bank_address: 'Frankfurt, Germany', + account_holder_name: 'Peanut Protocol', + iban: 'DE89370400440532013000', + bic: 'COBADEFFXXX', + }, + }, +} as unknown as import('@/components/TransactionDetails/transactionTransformer').TransactionDetails + +describe('BridgeDepositInstructions deposit message', () => { + it('shows and copies the same shortened 10-char reference as the Add Money screen', () => { + render() + + expect(screen.getByText(SHORT_REFERENCE)).toBeInTheDocument() + expect(screen.queryByText(FULL_REFERENCE)).not.toBeInTheDocument() + + const copyTexts = screen.getAllByTestId('copy').map((el) => el.getAttribute('data-text')) + expect(copyTexts).toContain(SHORT_REFERENCE) + expect(copyTexts).not.toContain(FULL_REFERENCE) + }) +}) From 6347c3b2d26467efe6383e473692dd7c1af79848 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Tue, 14 Jul 2026 18:05:05 +0200 Subject: [PATCH 2/3] fix(support): make SupportDrawer intercept clicks when opened inside a vaul drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the phantom deposit cancels: vaul sets pointer-events:none on body while the transaction drawer is open, and the SupportDrawer's backdrop/panel never re-enabled pointer events — the whole support overlay was click-transparent, so taps on it fell through to the receipt underneath, where 'Cancel deposit' sits adjacent to the support link and fired irreversibly (PostHog: support link 15:05:47.7 → fall-through click on Cancel deposit 15:05:48.8 → BE cancel 15:05:49.4). Also per code review: single shortDepositReference() helper for the 6 reference sites across both screens, separate confirm-modal visibility from the armed action (noun flashed mid-fade), drop no-op icon props. --- .../components/AddMoneyBankDetails.tsx | 10 ++-- .../__tests__/SupportDrawer.test.tsx | 25 ++++++++ src/components/Global/SupportDrawer/index.tsx | 10 +++- .../provider-actions/CancelDepositActions.tsx | 60 +++++++++---------- .../BridgeDepositInstructions.tsx | 16 ++--- src/utils/format.utils.ts | 11 ++++ 6 files changed, 87 insertions(+), 45 deletions(-) diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index 7cbf2592d..44af109d3 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -9,7 +9,7 @@ import { useRouter, useParams } from 'next/navigation' import { useCallback, useEffect, useMemo } from 'react' import { countryData } from '@/components/AddMoney/consts' import { formatCurrencyAmount } from '@/utils/currency' -import { formatBankAccountDisplay } from '@/utils/format.utils' +import { formatBankAccountDisplay, shortDepositReference } from '@/utils/format.utils' import { applyBridgeCrossCurrencyFee, getCurrencyConfig, getCurrencySymbol } from '@/utils/bridge.utils' import { RequestFulfillmentBankFlowStep, useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext' import { formatAmount } from '@/utils/general.utils' @@ -236,7 +236,7 @@ ${routingLabel}: ${routingValue}` } bankDetails += ` -Deposit Reference: ${onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'} +Deposit Reference: ${shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'} Please use these details to complete your bank transfer.` @@ -274,11 +274,11 @@ Please use these details to complete your bank transfer.`

Deposit reference

- {onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'} + {shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'}

{onrampData?.depositInstructions?.depositMessage && ( @@ -416,7 +416,7 @@ Please use these details to complete your bank transfer.` title="Double check in your bank before sending:" items={[ `Amount: ${formattedCurrencyAmount} (exact)`, - `Reference: ${onrampData?.depositInstructions?.depositMessage?.slice(0, 10) || 'Loading...'} (included)`, + `Reference: ${shortDepositReference(onrampData?.depositInstructions?.depositMessage) || 'Loading...'} (included)`, ]} /> diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx index 9ae96095b..59c94c252 100644 --- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx +++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx @@ -149,6 +149,31 @@ describe('SupportDrawer — Crisp load-failure fallback', () => { }) }) +describe('SupportDrawer — pointer-events when opened inside a vaul drawer', () => { + beforeEach(() => { + mockUseCrispUserData.mockReset().mockReturnValue({ userId: undefined, email: undefined }) + mockUseCrispTokenId.mockReset().mockReturnValue(undefined) + mockIsCapacitor.mockReset().mockReturnValue(false) + }) + + it('backdrop and panel explicitly re-enable pointer events while open', () => { + // vaul sets pointer-events:none on while the transaction drawer is + // open. Without an explicit pointer-events-auto, the support overlay + // inherits none and becomes click-transparent — taps fall through to the + // receipt underneath (one landed on "Cancel deposit" and cancelled a + // user's funded bank deposit). + const { container } = render() + + const backdrop = container.querySelector('[aria-hidden="true"]') + const panel = screen.getByRole('dialog', { name: 'Support' }) + + expect(backdrop?.className).toContain('pointer-events-auto') + expect(panel.className).toContain('pointer-events-auto') + expect(backdrop?.className).not.toContain('pointer-events-none') + expect(panel.className).not.toContain('pointer-events-none') + }) +}) + describe('SupportDrawer Crisp session gate — native (Capacitor)', () => { beforeEach(() => { mockUseCrispUserData.mockReset() diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index 83dc7d740..3fe482d63 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -131,9 +131,15 @@ const SupportDrawer = () => { return ( <> {/* backdrop */} + {/* pointer-events-auto is load-bearing on BOTH divs: when this drawer is + opened from inside a vaul drawer (transaction receipt), vaul sets + pointer-events:none on and these divs inherit it — the whole + support overlay becomes click-transparent, and taps fall through to + the receipt underneath (a fall-through tap on "Cancel deposit" + cancelled a user's funded bank deposit). */}
setIsSupportModalOpen(false)} aria-hidden="true" @@ -147,7 +153,7 @@ const SupportDrawer = () => { aria-label="Support" aria-modal={isSupportModalOpen} className={`fixed inset-x-0 bottom-0 z-[999999] flex max-h-[85vh] flex-col rounded-t-[10px] border bg-background pt-4 ${ - isSupportModalOpen ? 'translate-y-0' : 'pointer-events-none translate-y-full' + isSupportModalOpen ? 'pointer-events-auto translate-y-0' : 'pointer-events-none translate-y-full' }`} style={{ transform: isSupportModalOpen ? `translateY(${dragOffset}px)` : 'translateY(100%)', diff --git a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx index dbf333f43..0ec0dc8f4 100644 --- a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx +++ b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx @@ -45,6 +45,10 @@ export function CancelDepositActions({ // a real user cancelled a funded deposit while trying to report a problem // (no way to match the wire once cancelled). Every cancel confirms first. const [pendingCancel, setPendingCancel] = useState<{ noun: string; run: () => Promise } | null>(null) + // Visibility is separate from pendingCancel so the noun stays rendered + // during the modal's fade-out (nulling it mid-fade flashed 'deposit' + // over 'request' titles). + const [confirmOpen, setConfirmOpen] = useState(false) if (!setIsLoading || !onClose) return null const refetchAndClose = () => @@ -68,9 +72,14 @@ export function CancelDepositActions({ } } + const armCancel = (noun: string, run: () => Promise) => { + setPendingCancel({ noun, run }) + setConfirmOpen(true) + } + const confirmThenRun = async () => { if (!pendingCancel) return - setPendingCancel(null) + setConfirmOpen(false) await wrapAction(pendingCancel.run) } @@ -81,11 +90,9 @@ export function CancelDepositActions({ {button} {error && } setPendingCancel(null)} + visible={confirmOpen} + onClose={() => setConfirmOpen(false)} icon="ban" - iconContainerClassName="bg-purple-1" - iconProps={{ className: 'text-black' }} title={`Cancel this ${pendingCancel?.noun ?? 'deposit'}?`} modalClassName="!z-[9999] pointer-events-auto" description={ @@ -123,12 +130,9 @@ export function CancelDepositActions({ - setPendingCancel({ - noun: 'deposit', - run: async () => { - const result = await cancelOnramp(transaction.id) - if (result.error) throw new Error(result.error) - }, + armCancel('deposit', async () => { + const result = await cancelOnramp(transaction.id) + if (result.error) throw new Error(result.error) }) } /> @@ -143,12 +147,9 @@ export function CancelDepositActions({ - setPendingCancel({ - noun: 'deposit', - run: async () => { - const result = await mantecaApi.cancelDeposit(transaction.id) - if (result.error) throw new Error(result.error) - }, + armCancel('deposit', async () => { + const result = await mantecaApi.cancelDeposit(transaction.id) + if (result.error) throw new Error(result.error) }) } /> @@ -168,20 +169,17 @@ export function CancelDepositActions({ label="Cancel Request" disabled={!!isLoading} onClick={() => - setPendingCancel({ - noun: 'request', - run: 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) - }, + armCancel('request', 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) }) } /> diff --git a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx index 2223f7c08..18e75a1e6 100644 --- a/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx +++ b/src/components/TransactionDetails/provider-rows/BridgeDepositInstructions.tsx @@ -7,6 +7,7 @@ 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 { shortDepositReference } from '@/utils/format.utils' import { formatIban } from '@/utils/general.utils' /** @@ -36,13 +37,14 @@ export function BridgeDepositInstructions({ transaction }: { transaction: Transa } value={
- {/* Shortened to 10 chars to match the Add Money screen — some - banks (e.g. Wise) cap reference fields at 10 chars, and - Bridge matches deposits on the partial reference. Showing - the full form only here made users think they wired with - the "wrong" code (two-different-codes confusion). */} - {instructions.deposit_message?.slice(0, 10)} - + {/* Same shortened form as the Add Money screen — rationale on + shortDepositReference. Showing the full form only here made + users think they wired with the "wrong" code. */} + {shortDepositReference(instructions.deposit_message)} +
} hideBottomBorder={false} diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts index 773f78129..90c061ba4 100644 --- a/src/utils/format.utils.ts +++ b/src/utils/format.utils.ts @@ -53,3 +53,14 @@ export const formatBankAccountDisplay = (value: string | undefined, type?: 'iban } export const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + +// Some banks (e.g. Wise) cap the transfer-reference field at 10 characters, and +// Bridge matches incoming deposits on this partial reference. Every surface that +// shows or copies a Bridge deposit reference must use this same shortened form — +// different lengths on different screens made a user believe he wired with the +// wrong code (two-different-codes confusion, peanut-ui#2416). +export function shortDepositReference(reference: string): string +export function shortDepositReference(reference: string | undefined): string | undefined +export function shortDepositReference(reference: string | undefined): string | undefined { + return reference?.slice(0, 10) +} From 3e1f71e760f45cc839afc982d262c7f25b625ae0 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Tue, 14 Jul 2026 18:14:03 +0200 Subject: [PATCH 3/3] fix(deposits): guard confirm CTA against double-tap firing the cancel twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit finding: two taps during the modal fade both see the armed action and start concurrent cancellations. Ref guard, not state — a same-frame double-tap lands before any re-render. --- .../provider-actions/CancelDepositActions.tsx | 15 ++++++++++++--- .../__tests__/CancelDepositActions.test.tsx | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx index 0ec0dc8f4..7aae89c8c 100644 --- a/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx +++ b/src/components/TransactionDetails/provider-actions/CancelDepositActions.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, type ReactNode } from 'react' +import { useRef, useState, type ReactNode } from 'react' import { Button } from '@/components/0_Bruddle/Button' import ActionModal from '@/components/Global/ActionModal' import ErrorAlert from '@/components/Global/ErrorAlert' @@ -49,6 +49,10 @@ export function CancelDepositActions({ // during the modal's fade-out (nulling it mid-fade flashed 'deposit' // over 'request' titles). const [confirmOpen, setConfirmOpen] = useState(false) + // Ref, not state: a double-tap on the confirm CTA during the modal's + // fade-out lands both clicks before a re-render, so a state guard would + // let the cancel fire twice. Refs are synchronous. + const isCancelRunning = useRef(false) if (!setIsLoading || !onClose) return null const refetchAndClose = () => @@ -78,9 +82,14 @@ export function CancelDepositActions({ } const confirmThenRun = async () => { - if (!pendingCancel) return + if (!pendingCancel || isCancelRunning.current) return + isCancelRunning.current = true setConfirmOpen(false) - await wrapAction(pendingCancel.run) + try { + await wrapAction(pendingCancel.run) + } finally { + isCancelRunning.current = false + } } // Render the active cancel button (if any) alongside the shared diff --git a/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx index 1260a6673..93dc9497d 100644 --- a/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx +++ b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx @@ -128,6 +128,21 @@ describe('CancelDepositActions confirmation gate', () => { await waitFor(() => expect(mockCancelOnramp).toHaveBeenCalledWith('tx-1')) }) + it('double-tapping the confirm button fires the cancel exactly once', async () => { + // keep the cancel in-flight so the second tap lands while the first runs + let resolveCancel: (v: unknown) => void + mockCancelOnramp.mockReturnValue(new Promise((resolve) => (resolveCancel = resolve))) + renderCancel() + + fireEvent.click(screen.getByText('Cancel deposit')) + const confirmButton = screen.getByText('Yes, cancel deposit') + fireEvent.click(confirmButton) + fireEvent.click(confirmButton) + + resolveCancel!({}) + await waitFor(() => expect(mockCancelOnramp).toHaveBeenCalledTimes(1)) + }) + it('dismissing the confirmation leaves the deposit untouched', () => { renderCancel()