From d1735ff32076369e452387a65af5063b0b4d5bf7 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Tue, 16 Jun 2026 15:58:02 +0000 Subject: [PATCH 01/38] =?UTF-8?q?chore(content):=20bump=20src/content=20?= =?UTF-8?q?=E2=80=94=20remove=20Red=20ATM=20from=20marketing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps src/content f204f50..5cbbe4f (peanut-content main). Headline change: scrub the Argentina cardless cash-withdrawal ("Red ATM") marketing across all 5 locales — it was promoted as a live feature but is being pulled. The RedATM withdrawal flow in the app is untouched; this is marketing copy only. Competitor/pain-point ATM copy is intentionally preserved (foreign-ATM fee comparisons, Western Union, Revolut/BLIK). Content-only: zero code paths touched. Supersedes the handrolled #2226 and the stale auto/update-content PRs, which were based off main and showed phantom code diffs against dev. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index f204f50bb..5cbbe4f5e 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit f204f50bbbf088465f577dd08ca9c4f1a0c51e7e +Subproject commit 5cbbe4f5e494ab9c9f1dce4657b31507cd0c2a72 From db86328fe8be9256f4a54ca53625fc8acc130c48 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 16 Jun 2026 17:50:15 -0700 Subject: [PATCH 02/38] fix(p2p): correct two request-pot display bugs (slider 49.98%, $1k+ progress bar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cosmetic-but-confusing display bugs in the bill-split / request-pot UI, both reported by Konrad: 1. Slider "snap to 50% → 49.98%": the slider snaps the thumb to a clean percentage on drag, but AmountInput re-derives the thumb position from a cent-floored amount ($33.37 pot → 50% = $16.685 → floored $16.68 → 49.98%), and the Slider's sync effect then clobbered the snapped value. The amount is correct (you can't pay half a cent); only the label drifted. The sync effect now keeps the thumb on a snap point through sub-cent drift. 2. Request-pot progress bar blank for pots that collected >= $1,000: the receipt passed the comma-grouped display string to Number() (Number("1,234.56") === NaN), blanking the bar. Use the raw numeric totalAmountCollected, matching how `goal` already uses transaction.amount. Display-only; no amounts or money paths change. Complements api-ts #1027 (the send-link $0 phantom) as part of the same P2P display-correctness sweep. --- .../Global/Slider/__tests__/index.test.tsx | 33 +++++++++++++++++++ src/components/Global/Slider/index.tsx | 14 +++++--- .../TransactionDetailsReceipt.tsx | 5 ++- 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 src/components/Global/Slider/__tests__/index.test.tsx diff --git a/src/components/Global/Slider/__tests__/index.test.tsx b/src/components/Global/Slider/__tests__/index.test.tsx new file mode 100644 index 000000000..74a727b2a --- /dev/null +++ b/src/components/Global/Slider/__tests__/index.test.tsx @@ -0,0 +1,33 @@ +// Locks the snap-stick fix: a bill-split slider snapped to 50% must keep +// showing 50% even though the parent re-derives the thumb position from a +// cent-floored amount (e.g. a $33.37 pot → $16.685 → floored $16.68 → 49.98%). +// Konrad: "Snap to 50% bugs out — click 50%, confirm, jumps to 49.98%." +import { render } from '@testing-library/react' +import { Slider } from '../index' + +// Radix Slider observes its track size; jsdom has no ResizeObserver. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +;(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver = ResizeObserverStub + +describe('Slider snap-stick on sub-cent drift', () => { + test('keeps the thumb label at 50% when the controlled value drifts to 49.98%', () => { + const { container, rerender } = render( {}} />) + expect(container.textContent).toContain('50%') + + // Parent re-derives 49.98% from the cent-floored amount and feeds it back. + rerender( {}} />) + expect(container.textContent).toContain('50%') + expect(container.textContent).not.toContain('49.98%') + }) + + test('still syncs a genuine off-snap change (no over-sticking)', () => { + const { container, rerender } = render( {}} />) + rerender( {}} />) + expect(container.textContent).toContain('70%') + expect(container.textContent).not.toContain('50%') + }) +}) diff --git a/src/components/Global/Slider/index.tsx b/src/components/Global/Slider/index.tsx index 0dc48a0bc..ae44d1397 100644 --- a/src/components/Global/Slider/index.tsx +++ b/src/components/Global/Slider/index.tsx @@ -17,11 +17,17 @@ function Slider({ // Use internal state for the slider value to enable magnetic snapping const [internalValue, setInternalValue] = React.useState(defaultValue || controlledValue) - // Sync internal state when controlled value changes from external source + // Sync internal state when controlled value changes from external source. + // The parent derives the controlled value from a cent-rounded amount, so a + // percentage the user snapped to (e.g. 50%) comes back as 49.98% for pots + // whose half lands on a sub-cent ($33.37 → $16.685 → floored to $16.68). + // Don't let that sub-cent drift knock the thumb off a snap point it's + // already resting on (the amount stays correct; only the label was wrong). React.useEffect(() => { - if (controlledValue !== undefined && controlledValue[0] !== internalValue[0]) { - setInternalValue(controlledValue) - } + if (controlledValue === undefined || controlledValue[0] === internalValue[0]) return + const restingSnap = SNAP_POINTS.find((snapPoint) => Math.abs(internalValue[0] - snapPoint) < 0.5) + if (restingSnap !== undefined && Math.abs(controlledValue[0] - restingSnap) < 0.5) return + setInternalValue(controlledValue) }, [controlledValue]) // Check if current value is at a snap point (exact match) diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index dc82087ed..fefb7c5e9 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -316,7 +316,10 @@ export const TransactionDetailsReceipt = ({ isAvatarClickable={isAvatarClickable} showProgessBar={transaction.isRequestPotLink} goal={Number(transaction.amount)} - progress={Number(formattedTotalAmountCollected)} + // Use the raw numeric field, NOT formattedTotalAmountCollected — the + // latter is comma-grouped ("1,234.56"), so Number() → NaN for any pot + // that has collected ≥ $1,000, blanking the progress bar. + progress={Number(transaction.totalAmountCollected)} isRequestPotTransaction={transaction.isRequestPotLink} isTransactionClosed={transaction.status === 'closed'} convertedAmount={convertedAmount ?? undefined} From 9a45cff0165a43c83dd2447a2560b2ebd3ac24da Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 17 Jun 2026 10:07:31 -0700 Subject: [PATCH 03/38] feat(card): "Split this bill" CTA on card-spend receipts (TASK-19739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card payments couldn't be split. The "Split this bill" CTA + /request prefill + auto-create already ship for QR pays (gated on isQRPayment); this extends the gate to card spends so a Peanut-card purchase can seed a bill-split request (amount = the spend's USD value, comment = "Bill split for {merchant}"). - isCardSpend predicate: CARD_SPEND_AUTH/CLEAR only — refunds + auth reversals excluded (you didn't pay those). - Threaded through useReceiptViewModel; CTA gate is now (isQRPayment || isCardSpend) and also excludes failed/declined spends. - Omit the merchant param when userName is the "Card payment" fallback so the comment isn't "Bill split for Card payment". FE-only — reuses the existing /request creation + POST /requests. --- .../TransactionDetailsReceipt.tsx | 32 ++++++++++++------- .../__tests__/transaction-predicates.test.ts | 11 +++++++ .../transaction-predicates.ts | 7 ++++ .../TransactionDetails/useReceiptViewModel.ts | 4 +++ 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index fefb7c5e9..74bbb5ccc 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -121,6 +121,7 @@ export const TransactionDetailsReceipt = ({ isPendingRequester, isPendingSentLink, isQRPayment, + isCardSpend, country, rowVisibilityConfig, shouldHideBorder, @@ -742,17 +743,26 @@ export const TransactionDetailsReceipt = ({ )} - {!isPublic && isQRPayment && transaction.status !== 'refunded' && ( - - )} + {!isPublic && + (isQRPayment || isCardSpend) && + transaction.status !== 'refunded' && + transaction.status !== 'failed' && ( + + )} {shouldShowShareReceipt && !!getReceiptUrl(transaction) && (
diff --git a/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts b/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts index e6900b3ea..2cd364b58 100644 --- a/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts +++ b/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts @@ -4,6 +4,7 @@ // `extraData.kind` pinned to a canonical TransactionIntentKind value. import { + isCardSpend, isDirectSendEntry, isFxBearingFlow, isMantecaOnrampEntry, @@ -79,6 +80,16 @@ describe('entry-kind predicates', () => { expect(hasShareableReceipt(tx('SEND_LINK'))).toBe(false) }) + // Gates the "Split this bill" CTA — must fire on real card spends only, + // never on refunds or auth reversals (you didn't pay those). + test('isCardSpend matches CARD_SPEND_AUTH + CARD_SPEND_CLEAR only', () => { + expect(isCardSpend(tx('CARD_SPEND_AUTH'))).toBe(true) + expect(isCardSpend(tx('CARD_SPEND_CLEAR'))).toBe(true) + expect(isCardSpend(tx('CARD_AUTH_REVERSAL'))).toBe(false) + expect(isCardSpend(tx('REFUND'))).toBe(false) + expect(isCardSpend(tx('QR_PAY'))).toBe(false) + }) + describe('isFxBearingFlow', () => { test.each(['ONRAMP', 'OFFRAMP', 'QR_PAY'])('matches fiat-rail kind=%s', (kind) => { expect(isFxBearingFlow(tx(kind))).toBe(true) diff --git a/src/components/TransactionDetails/transaction-predicates.ts b/src/components/TransactionDetails/transaction-predicates.ts index d93fe2af1..2eed62032 100644 --- a/src/components/TransactionDetails/transaction-predicates.ts +++ b/src/components/TransactionDetails/transaction-predicates.ts @@ -23,6 +23,13 @@ export function isQRPayment(transaction: TransactionDetails): boolean { return isKind(transaction, 'QR_PAY') } +/** A Rain card *spend* (not a refund or reversal) — the only card rows eligible + * for a "Split this bill" CTA. Kind-based so refunds (REFUND/OTHER) and auth + * reversals are excluded. */ +export function isCardSpend(transaction: TransactionDetails): boolean { + return isKind(transaction, 'CARD_SPEND_AUTH') || isKind(transaction, 'CARD_SPEND_CLEAR') +} + /** Kinds that move money across a fiat rail: bank on/off-ramps + QR pays. * The single anchor for the receipt-page whitelist (`getReceiptUrl`), the * share gate (`hasShareableReceipt`), and the FX predicate diff --git a/src/components/TransactionDetails/useReceiptViewModel.ts b/src/components/TransactionDetails/useReceiptViewModel.ts index 090bf5726..aba3b38f8 100644 --- a/src/components/TransactionDetails/useReceiptViewModel.ts +++ b/src/components/TransactionDetails/useReceiptViewModel.ts @@ -10,6 +10,7 @@ import { import { hasShareableReceipt, isCardPaymentEntry, + isCardSpend as isCardSpendTransaction, isFxBearingFlow, isDirectSendEntry, isMantecaOnrampEntry, @@ -46,6 +47,7 @@ export interface ReceiptViewModel { isPendingRequester: boolean isPendingSentLink: boolean isQRPayment: boolean + isCardSpend: boolean /** Country resolved from the transaction's currency code (used by the * Manteca deposit-info row for the country-specific address label). */ @@ -295,6 +297,7 @@ export function useReceiptViewModel( }, [transaction]) const isQRPayment = transaction ? isQRPaymentTransaction(transaction) : false + const isCardSpend = transaction ? isCardSpendTransaction(transaction) : false const formattedTotalAmountCollected = formatCurrency(transaction?.totalAmountCollected?.toString() ?? '0', 2, 0) return { @@ -305,6 +308,7 @@ export function useReceiptViewModel( isPendingRequester, isPendingSentLink, isQRPayment, + isCardSpend, country, rowVisibilityConfig, shouldHideBorder, From a3280d1867e7b9325cd5d26df32559f99542e077 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 17 Jun 2026 13:11:23 -0700 Subject: [PATCH 04/38] fix(split-bill): URL-encode merchant name in the split CTA query (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merchant name with reserved characters (e.g. "Tigers & Lions") would break the `/request?amount=…&merchant=…` query string and corrupt the request prefill. encodeURIComponent the merchant value. Addresses CodeRabbit's inline finding on #2235. --- src/components/TransactionDetails/TransactionDetailsReceipt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 74bbb5ccc..69d3d5bec 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -753,7 +753,7 @@ export const TransactionDetailsReceipt = ({ // the merchant param so the request comment isn't "Bill split for Card payment". const merchantParam = transaction.userName && transaction.userName !== 'Card payment' - ? `&merchant=${transaction.userName}` + ? `&merchant=${encodeURIComponent(transaction.userName)}` : '' router.push(`/request?amount=${transaction.amount}${merchantParam}`) }} From 82563a7651872d08e2e1064afa6d846a4cc9aecc Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 17 Jun 2026 13:36:42 -0700 Subject: [PATCH 05/38] fix(pix): canonicalize +55 phone keys that already carry a + prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizePixPhoneNumber returned the raw input untouched when the cleaned value already started with '+', so '+55-11-99999-9999' kept its separators and stayed non-canonical — conflicting with the helper's contract and risking PIX-key mismatches. Always return the separator-free +55 form. From release-PR #2236 CodeRabbit triage; adds the separator-with-+ regression cases the existing +-prefix tests never exercised. --- src/utils/__tests__/withdraw.utils.test.ts | 3 +++ src/utils/withdraw.utils.ts | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts index 5da97f3f4..da08bd091 100644 --- a/src/utils/__tests__/withdraw.utils.test.ts +++ b/src/utils/__tests__/withdraw.utils.test.ts @@ -79,6 +79,9 @@ describe('Withdraw Utilities', () => { // Should keep existing + prefix ['+5511999999999', '+5511999999999'], ['+551199999999', '+551199999999'], + // Should strip separators even when the + prefix is already present + ['+55-11-99999-9999', '+5511999999999'], + ['+55 11 99999 9999', '+5511999999999'], // Should not modify non-phone formats ['12345678901', '12345678901'], // CPF ['12345678901234', '12345678901234'], // CNPJ diff --git a/src/utils/withdraw.utils.ts b/src/utils/withdraw.utils.ts index 9130416c6..bde51d4dc 100644 --- a/src/utils/withdraw.utils.ts +++ b/src/utils/withdraw.utils.ts @@ -212,10 +212,11 @@ export const isPixPhoneNumber = (pixKey: string): boolean => { */ export const normalizePixPhoneNumber = (pixKey: string): string => { const cleaned = pixKey.replace(/[\s-]/g, '') - if (isPixPhoneNumber(cleaned) && !cleaned.startsWith('+')) { - return '+' + cleaned - } - return pixKey + if (!isPixPhoneNumber(cleaned)) return pixKey + // Always return the separator-free +55 form. The old code returned the raw + // input untouched when it already started with '+', so inputs like + // '+55-11-99999-9999' kept their separators and stayed non-canonical. + return cleaned.startsWith('+') ? cleaned : '+' + cleaned } /** From c4b28faabdb4afa36d7ce844b0cdec3587958f2b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 17 Jun 2026 14:06:45 -0700 Subject: [PATCH 06/38] test(split-bill): extract + unit-test the "Split this bill" CTA URL builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CTA's onClick inlined the /request URL build, including the two bug-prone bits — the "Card payment" merchant fallback and the URL-encode CodeRabbit caught on #2235. Extract to buildSplitBillRequestUrl and lock it with unit tests (encode reserved chars, omit fallback, omit empty merchant). Pure refactor + tests; no behavior change. --- .../TransactionDetailsReceipt.tsx | 11 ++------- .../__tests__/splitBill.utils.test.ts | 23 +++++++++++++++++++ .../TransactionDetails/splitBill.utils.ts | 16 +++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/components/TransactionDetails/__tests__/splitBill.utils.test.ts create mode 100644 src/components/TransactionDetails/splitBill.utils.ts diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 69d3d5bec..dcc21fa28 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -52,6 +52,7 @@ import { usesCompletedTimestampLabel, } from './transaction-predicates' import { useReceiptViewModel } from './useReceiptViewModel' +import { buildSplitBillRequestUrl } from './splitBill.utils' import { CardPaymentRows } from './provider-rows/CardPaymentRows' import { LocalRailNudge } from './provider-rows/LocalRailNudge' import { MantecaDepositInfo } from './provider-rows/MantecaDepositInfo' @@ -748,15 +749,7 @@ export const TransactionDetailsReceipt = ({ transaction.status !== 'refunded' && transaction.status !== 'failed' && (
diff --git a/src/constants/rhino.consts.ts b/src/constants/rhino.consts.ts index f693a6dab..0927a1e04 100644 --- a/src/constants/rhino.consts.ts +++ b/src/constants/rhino.consts.ts @@ -26,6 +26,9 @@ export const TOKEN_LOGOS = { export type ChainName = keyof typeof CHAIN_LOGOS export type TokenName = keyof typeof TOKEN_LOGOS +// Mirrors Rhino's live SDA config (`depositAddresses.getSupportedConfigs()`). +// Scroll was removed 2026-06-11: Rhino's live config no longer returns an SDA +// entry for it, and a deposit on an unsupported chain is silently lost. export const SUPPORTED_EVM_CHAINS = [ 'ARBITRUM', 'ETHEREUM', @@ -34,7 +37,6 @@ export const SUPPORTED_EVM_CHAINS = [ 'BNB', 'POLYGON', 'KATANA', - 'SCROLL', 'GNOSIS', 'CELO', ] as const From 7a57b7056c486cdd2e6ad6d4e38325eb389f3965 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 18:42:49 -0700 Subject: [PATCH 16/38] fix(claim): restore dynamic social-preview metadata for claim links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claim-link previews fell back to the generic Peanut image instead of "X sent you $Y via Peanut". The claim page's generateMetadata + force-dynamic were stripped for the native static-export build (d2049447f), but scripts/native-build.js already strips them at build time and restores the source afterwards — so the source-level removal was redundant and silently degraded only the web build. Restore generateMetadata, extracted into a unit-tested helper (buildClaimMetadata) so the title/OG-image logic can't silently regress again. Native build is unaffected: its P0_TRANSFORMS replaces this page by path. --- src/app/(mobile-ui)/claim/page.tsx | 28 ++++ .../__tests__/claim-metadata.utils.test.ts | 83 +++++++++++ src/utils/claim-metadata.utils.ts | 132 ++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 src/utils/__tests__/claim-metadata.utils.test.ts create mode 100644 src/utils/claim-metadata.utils.ts diff --git a/src/app/(mobile-ui)/claim/page.tsx b/src/app/(mobile-ui)/claim/page.tsx index 9bd47c0dd..92f8daa89 100644 --- a/src/app/(mobile-ui)/claim/page.tsx +++ b/src/app/(mobile-ui)/claim/page.tsx @@ -1,4 +1,32 @@ import { Claim } from '@/components' +import { BASE_URL } from '@/constants/general.consts' +import getOrigin from '@/lib/hosting/get-origin' +import { buildClaimMetadata, getClaimLinkData } from '@/utils/claim-metadata.utils' +import { type Metadata } from 'next' + +// Claim previews are resolved per-request from the link's query params, so the +// page must render dynamically on the web. The native (Capacitor static-export) +// build strips this export — scripts/native-build.js replaces this file with a +// metadata-free stub at build time, so SSR-only concerns never reach native. +export const dynamic = 'force-dynamic' + +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +}): Promise { + const resolvedSearchParams = await searchParams + + let siteUrl = BASE_URL + try { + siteUrl = (await getOrigin()) || BASE_URL + } catch { + // getOrigin throws on a missing/invalid host header — fall back to BASE_URL + } + + const claimData = await getClaimLinkData(resolvedSearchParams, siteUrl) + return buildClaimMetadata({ claimData, siteUrl }) +} export default function ClaimPage() { return diff --git a/src/utils/__tests__/claim-metadata.utils.test.ts b/src/utils/__tests__/claim-metadata.utils.test.ts new file mode 100644 index 000000000..527425c80 --- /dev/null +++ b/src/utils/__tests__/claim-metadata.utils.test.ts @@ -0,0 +1,83 @@ +import { buildClaimMetadata, getClaimLinkData, type ClaimLinkData } from '@/utils/claim-metadata.utils' + +const SITE_URL = 'https://peanut.me' + +const ogImageUrl = (metadata: ReturnType): string => { + const image = (metadata.openGraph?.images as Array<{ url: string }>)?.[0] + return image?.url ?? '' +} + +const unclaimed = ( + overrides: Partial = {}, + username: string | null = null +): ClaimLinkData => ({ + username, + linkDetails: { + senderAddress: '0x1111111111111111111111111111111111111111', + tokenAmount: '100', + tokenSymbol: 'USDC', + claimed: false, + ...overrides, + }, +}) + +describe('buildClaimMetadata', () => { + it('falls back to the generic Peanut preview when the link cannot be resolved', () => { + const metadata = buildClaimMetadata({ claimData: null, siteUrl: SITE_URL }) + + expect(metadata.title).toBe('Claim Payment | Peanut') + // Regression guard: the bug shipped exactly this generic image for real links. + expect(ogImageUrl(metadata)).toBe('/metadata-img.png') + expect(metadata.description).toBe('Tap the link to receive instantly and without fees.') + }) + + it('uses the sender username + amount when the link is unclaimed', () => { + const metadata = buildClaimMetadata({ claimData: unclaimed({}, 'kkonrad'), siteUrl: SITE_URL }) + + expect(metadata.title).toBe('kkonrad sent you $100 via Peanut') + + const url = new URL(ogImageUrl(metadata)) + expect(url.origin + url.pathname).toBe('https://peanut.me/api/og') + expect(url.searchParams.get('type')).toBe('send') + expect(url.searchParams.get('username')).toBe('kkonrad') + expect(url.searchParams.get('amount')).toBe('100') + expect(url.searchParams.get('token')).toBe('USDC') + expect(url.searchParams.get('isReceipt')).toBeNull() + }) + + it('falls back to the sender address + token when there is no username', () => { + const metadata = buildClaimMetadata({ claimData: unclaimed(), siteUrl: SITE_URL }) + + expect(metadata.title).toBe('You received 100 in USDC!') + expect(new URL(ogImageUrl(metadata)).searchParams.get('username')).toBe( + '0x1111111111111111111111111111111111111111' + ) + }) + + it('says "some" for dust amounts under a cent', () => { + const metadata = buildClaimMetadata({ claimData: unclaimed({ tokenAmount: '0.004' }), siteUrl: SITE_URL }) + expect(metadata.title).toBe('You received some USDC!') + }) + + it('renders the receipt variant for a claimed link', () => { + const metadata = buildClaimMetadata({ + claimData: unclaimed({ claimed: true }, 'kkonrad'), + siteUrl: SITE_URL, + }) + + expect(metadata.title).toBe('This link has been claimed') + expect(metadata.description).toBe('This payment link has already been claimed.') + + const url = new URL(ogImageUrl(metadata)) + expect(url.searchParams.get('isReceipt')).toBe('true') + expect(url.searchParams.get('amount')).toBeNull() + }) +}) + +describe('getClaimLinkData', () => { + it('returns null without hitting the API when chainId or depositIdx is missing', async () => { + await expect(getClaimLinkData({}, SITE_URL)).resolves.toBeNull() + await expect(getClaimLinkData({ c: '42161' }, SITE_URL)).resolves.toBeNull() + await expect(getClaimLinkData({ i: '159' }, SITE_URL)).resolves.toBeNull() + }) +}) diff --git a/src/utils/claim-metadata.utils.ts b/src/utils/claim-metadata.utils.ts new file mode 100644 index 000000000..ec64cd578 --- /dev/null +++ b/src/utils/claim-metadata.utils.ts @@ -0,0 +1,132 @@ +import { PEANUT_WALLET_TOKEN_DECIMALS, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' +import { sendLinksApi } from '@/services/sendLinks' +import { resolveAddressToUsername } from '@/utils/ens.utils' +import { formatAmount } from '@/utils/general.utils' +import { type Metadata } from 'next' +import { formatUnits } from 'viem' + +export type ClaimLinkDetails = { + senderAddress: string + tokenAmount: string + tokenSymbol: string + claimed: boolean +} + +export type ClaimLinkData = { + linkDetails: ClaimLinkDetails + username: string | null +} + +/** + * Fetches the data needed to render a claim link's social preview from its + * query params (`?c=&v=&i=`). + * + * Uses the password-less `/send-links` lookup (DB + on-chain fallback) so it + * can run during SSR metadata generation. Returns `null` when the link can't + * be resolved — the caller then falls back to the generic Peanut preview. + */ +export async function getClaimLinkData( + searchParams: { [key: string]: string | string[] | undefined }, + siteUrl: string +): Promise { + const chainId = typeof searchParams.c === 'string' ? searchParams.c : undefined + const depositIdx = typeof searchParams.i === 'string' ? searchParams.i : undefined + if (!chainId || !depositIdx) return null + + try { + const contractVersion = (typeof searchParams.v === 'string' && searchParams.v) || 'v4.3' + const sendLink = await sendLinksApi.getByParams({ chainId, depositIdx, contractVersion }) + + const tokenDecimals = sendLink.tokenDecimals ?? PEANUT_WALLET_TOKEN_DECIMALS + const tokenSymbol = sendLink.tokenSymbol ?? PEANUT_WALLET_TOKEN_SYMBOL + + const linkDetails: ClaimLinkDetails = { + senderAddress: sendLink.senderAddress, + tokenAmount: formatUnits(sendLink.amount, tokenDecimals), + tokenSymbol, + claimed: sendLink.status === 'CLAIMED' || sendLink.status === 'CANCELLED', + } + + let username = sendLink.sender?.username || null + + // Fall back to ENS reverse-resolution when the sender has no Peanut + // handle. Race a 3s timeout so a slow ENS lookup never stalls metadata. + if (!username && linkDetails.senderAddress) { + const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 3000)) + const resolved = resolveAddressToUsername(linkDetails.senderAddress, siteUrl).catch(() => null) + username = await Promise.race([resolved, timeout]) + } + + return { linkDetails, username } + } catch { + return null + } +} + +/** + * Builds the Open Graph / Twitter metadata for a claim link from already + * fetched data. Pure + synchronous so the title and OG-image logic is unit + * testable — this is the exact behaviour that silently regressed when the + * claim page's `generateMetadata` was stripped for the native build. + */ +export function buildClaimMetadata({ + claimData, + siteUrl, +}: { + claimData: ClaimLinkData | null + siteUrl: string +}): Metadata { + let title = 'Claim Payment | Peanut' + let ogImageUrl = '/metadata-img.png' + + if (claimData) { + const { linkDetails, username } = claimData + const amount = Number(linkDetails.tokenAmount) + + if (linkDetails.claimed) { + title = 'This link has been claimed' + } else if (username) { + title = `${username} sent you $${formatAmount(amount)} via Peanut` + } else { + title = `You received ${amount < 0.01 ? 'some ' : `${formatAmount(amount)} in `}${linkDetails.tokenSymbol}!` + } + + const ogUrl = new URL('/api/og', siteUrl) + ogUrl.searchParams.set('type', 'send') + ogUrl.searchParams.set('username', username || linkDetails.senderAddress) + if (linkDetails.claimed) { + // claimed links show the "receipt" variant of the OG image + ogUrl.searchParams.set('isReceipt', 'true') + } else { + ogUrl.searchParams.set('amount', linkDetails.tokenAmount) + ogUrl.searchParams.set('token', linkDetails.tokenSymbol) + } + ogImageUrl = ogUrl.toString() + } + + const description = claimData?.linkDetails.claimed + ? 'This payment link has already been claimed.' + : 'Tap the link to receive instantly and without fees.' + + return { + title, + description, + metadataBase: new URL(siteUrl), + icons: { icon: '/favicon.ico' }, + openGraph: { + title, + description, + images: [{ url: ogImageUrl, width: 1200, height: 630 }], + type: 'website', + siteName: 'Peanut', + }, + twitter: { + card: 'summary_large_image', + site: '@PeanutProtocol', + creator: '@PeanutProtocol', + title, + description, + images: [{ url: ogImageUrl }], + }, + } +} From 2d700a799d399509aed25db2200470720013e7fe Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 19:37:54 -0700 Subject: [PATCH 17/38] =?UTF-8?q?fix(claim):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20non-barrel=20import,=20clear=20ENS=20timeout,=20spy?= =?UTF-8?q?=20assertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - import Claim from '@/components/Claim' (specific file) instead of the '@/components' barrel (eslint no-restricted-imports) - clear the ENS resolution timeout after Promise.race settles so no timer is left pending per request (CodeRabbit) - assert sendLinksApi.getByParams is never called on the early-return path (CodeRabbit) --- src/app/(mobile-ui)/claim/page.tsx | 2 +- src/utils/__tests__/claim-metadata.utils.test.ts | 6 ++++++ src/utils/claim-metadata.utils.ts | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/app/(mobile-ui)/claim/page.tsx b/src/app/(mobile-ui)/claim/page.tsx index 92f8daa89..43646bb0e 100644 --- a/src/app/(mobile-ui)/claim/page.tsx +++ b/src/app/(mobile-ui)/claim/page.tsx @@ -1,4 +1,4 @@ -import { Claim } from '@/components' +import { Claim } from '@/components/Claim' import { BASE_URL } from '@/constants/general.consts' import getOrigin from '@/lib/hosting/get-origin' import { buildClaimMetadata, getClaimLinkData } from '@/utils/claim-metadata.utils' diff --git a/src/utils/__tests__/claim-metadata.utils.test.ts b/src/utils/__tests__/claim-metadata.utils.test.ts index 527425c80..47f1b153b 100644 --- a/src/utils/__tests__/claim-metadata.utils.test.ts +++ b/src/utils/__tests__/claim-metadata.utils.test.ts @@ -1,4 +1,5 @@ import { buildClaimMetadata, getClaimLinkData, type ClaimLinkData } from '@/utils/claim-metadata.utils' +import { sendLinksApi } from '@/services/sendLinks' const SITE_URL = 'https://peanut.me' @@ -76,8 +77,13 @@ describe('buildClaimMetadata', () => { describe('getClaimLinkData', () => { it('returns null without hitting the API when chainId or depositIdx is missing', async () => { + const getByParamsSpy = jest.spyOn(sendLinksApi, 'getByParams') + await expect(getClaimLinkData({}, SITE_URL)).resolves.toBeNull() await expect(getClaimLinkData({ c: '42161' }, SITE_URL)).resolves.toBeNull() await expect(getClaimLinkData({ i: '159' }, SITE_URL)).resolves.toBeNull() + + expect(getByParamsSpy).not.toHaveBeenCalled() + getByParamsSpy.mockRestore() }) }) diff --git a/src/utils/claim-metadata.utils.ts b/src/utils/claim-metadata.utils.ts index ec64cd578..d95b97e87 100644 --- a/src/utils/claim-metadata.utils.ts +++ b/src/utils/claim-metadata.utils.ts @@ -52,9 +52,16 @@ export async function getClaimLinkData( // Fall back to ENS reverse-resolution when the sender has no Peanut // handle. Race a 3s timeout so a slow ENS lookup never stalls metadata. if (!username && linkDetails.senderAddress) { - const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 3000)) + let timeoutId: ReturnType | undefined + const timeout = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(null), 3000) + }) const resolved = resolveAddressToUsername(linkDetails.senderAddress, siteUrl).catch(() => null) - username = await Promise.race([resolved, timeout]) + try { + username = await Promise.race([resolved, timeout]) + } finally { + clearTimeout(timeoutId) + } } return { linkDetails, username } From 08ec5affda47d6f3c604a3a74a9ead8db83e089b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 20:02:09 -0700 Subject: [PATCH 18/38] refactor(og): single buildOgImageUrl() helper for /api/og links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/og query-param contract (type/username/amount/token/isReceipt/ isPeanutUsername/isInvite) was hand-built in four places — claim, the [...recipient] payment/profile page, receipts, and invites — so a renamed or added param meant editing N sites with nothing catching a miss. Extract one typed, unit-tested buildOgImageUrl() and route all four through it. Claim + [...recipient] URLs are byte-identical to before; receipt + invite produce the same params (order differs, semantically identical). This also gives the previously-untested catch-all OG logic coverage via the shared helper's tests. Follow-up to #2249. --- src/app/[...recipient]/page.tsx | 38 +++++++---------- src/app/invite/page.tsx | 7 +--- src/app/receipt/[entryId]/page.tsx | 36 ++++++++-------- src/utils/__tests__/og.utils.test.ts | 61 ++++++++++++++++++++++++++++ src/utils/claim-metadata.utils.ts | 24 ++++++----- src/utils/og.utils.ts | 40 ++++++++++++++++++ 6 files changed, 148 insertions(+), 58 deletions(-) create mode 100644 src/utils/__tests__/og.utils.test.ts create mode 100644 src/utils/og.utils.ts diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index 70b4beb0e..af5c7de1a 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -7,6 +7,7 @@ import { isAddress } from 'viem' import { printableAddress, isStableCoin } from '@/utils/general.utils' import { chargesApi } from '@/services/charges' import { parseAmountAndToken } from '@/lib/url-parser/parser' +import { buildOgImageUrl } from '@/utils/og.utils' import { notFound } from 'next/navigation' import { couldBeRecipient, isReservedRoute } from '@/constants/routes' @@ -99,30 +100,19 @@ export async function generateMetadata({ params, searchParams }: any) { if (!siteUrl) { console.error('Error: Unable to determine site origin') } else { - const ogUrl = new URL(`${siteUrl}/api/og`) - ogUrl.searchParams.set('type', 'request') - ogUrl.searchParams.set('username', recipient) - - if (amount) { - ogUrl.searchParams.set('amount', String(amount)) - if (token) { - ogUrl.searchParams.set('token', token.toUpperCase()) - } - } else { - // For ETH addresses/ENS without amount, set to 0 to show "is requesting funds" - ogUrl.searchParams.set('amount', '0') - } - - // Only show as receipt if there's both a chargeId AND it's paid - if (chargeId && isPaid) { - ogUrl.searchParams.set('isReceipt', 'true') - } - - if (isPeanutUsername) { - ogUrl.searchParams.set('isPeanutUsername', 'true') - } - - ogImageUrl = ogUrl.toString() + ogImageUrl = buildOgImageUrl( + { + type: 'request', + username: recipient, + // ETH addresses/ENS without an amount use 0 to show "is requesting funds" + amount: amount ? String(amount) : '0', + token: amount && token ? token.toUpperCase() : undefined, + // only a receipt when there's a chargeId AND it's paid + isReceipt: Boolean(chargeId && isPaid), + isPeanutUsername, + }, + siteUrl + ) } } diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index ad6f00809..dbe935fb8 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -3,6 +3,7 @@ import getOrigin from '@/lib/hosting/get-origin' import { type Metadata } from 'next' import { validateInviteCode } from '../actions/invites' import { BASE_URL } from '@/constants/general.consts' +import { buildOgImageUrl } from '@/utils/og.utils' export const dynamic = 'force-dynamic' @@ -46,14 +47,10 @@ export async function generateMetadata({ description = 'Join Peanut to send and receive money instantly, shop with merchants, and move funds across borders.' - const ogUrl = new URL(`${siteUrl}/api/og`) - ogUrl.searchParams.set('isInvite', 'true') - ogUrl.searchParams.set('username', inviteCodeData.username) - if (!siteUrl) { console.error('Error: Unable to determine site origin') } else { - ogImageUrl = ogUrl.toString() + ogImageUrl = buildOgImageUrl({ username: inviteCodeData.username, isInvite: true }, siteUrl) } } diff --git a/src/app/receipt/[entryId]/page.tsx b/src/app/receipt/[entryId]/page.tsx index f55158c76..68a4131f3 100644 --- a/src/app/receipt/[entryId]/page.tsx +++ b/src/app/receipt/[entryId]/page.tsx @@ -13,6 +13,7 @@ import { generateMetadata as generateBaseMetadata } from '@/app/metadata' import { type Metadata } from 'next' import { BASE_URL } from '@/constants/general.consts' import { formatAmount, formatCurrency, isStableCoin } from '@/utils/general.utils' +import { buildOgImageUrl } from '@/utils/og.utils' import getOrigin from '@/lib/hosting/get-origin' import PageContainer from '@/components/0_Bruddle/PageContainer' @@ -138,32 +139,31 @@ export async function generateMetadata({ // Generate dynamic OG image URL const origin = (await getOrigin()) || BASE_URL - const ogUrl = new URL(`${origin}/api/og`) - - // Map transaction type for OG image const ogType = mapTransactionTypeToOGType(transactionDetails.extraDataForDrawer?.transactionCardType || 'send') - ogUrl.searchParams.set('type', ogType) - ogUrl.searchParams.set('isReceipt', 'true') - - // Add amount if available (always use USD amount) - if (transactionDetails.amount > 0) { - ogUrl.searchParams.set('amount', formatCurrency(Number(transactionDetails.amount).toString())) - ogUrl.searchParams.set('token', 'USDC') - } - - // Add username if available and not an address-like string - if ( + const hasAmount = transactionDetails.amount > 0 + // include username only when present and not an address-like string + const hasUsername = Boolean( transactionDetails.userName && transactionDetails.userName.length < 20 && !transactionDetails.userName.startsWith('0x') - ) { - ogUrl.searchParams.set('username', transactionDetails.userName) - } + ) + + const ogImageUrl = buildOgImageUrl( + { + type: ogType, + isReceipt: true, + username: hasUsername ? transactionDetails.userName : undefined, + // always denominate the receipt amount in USD + amount: hasAmount ? formatCurrency(Number(transactionDetails.amount).toString()) : undefined, + token: hasAmount ? 'USDC' : undefined, + }, + origin + ) return generateBaseMetadata({ title, description, - image: ogUrl.toString(), + image: ogImageUrl, keywords: 'crypto receipt, transaction receipt, payment receipt, Peanut Protocol', }) } diff --git a/src/utils/__tests__/og.utils.test.ts b/src/utils/__tests__/og.utils.test.ts new file mode 100644 index 000000000..d93e871c8 --- /dev/null +++ b/src/utils/__tests__/og.utils.test.ts @@ -0,0 +1,61 @@ +import { buildOgImageUrl } from '@/utils/og.utils' + +const SITE_URL = 'https://peanut.me' +const parse = (url: string) => new URL(url) + +describe('buildOgImageUrl', () => { + it('builds an absolute /api/og URL on the given origin', () => { + const url = parse(buildOgImageUrl({ type: 'send' }, SITE_URL)) + expect(url.origin + url.pathname).toBe('https://peanut.me/api/og') + expect(url.searchParams.get('type')).toBe('send') + }) + + it('send + amount (unclaimed claim link)', () => { + const p = parse( + buildOgImageUrl({ type: 'send', username: 'kkonrad', amount: '100', token: 'USDC' }, SITE_URL) + ).searchParams + expect(p.get('type')).toBe('send') + expect(p.get('username')).toBe('kkonrad') + expect(p.get('amount')).toBe('100') + expect(p.get('token')).toBe('USDC') + expect(p.get('isReceipt')).toBeNull() + }) + + it('send + isReceipt omits amount/token (claimed claim link)', () => { + const p = parse(buildOgImageUrl({ type: 'send', username: 'kkonrad', isReceipt: true }, SITE_URL)).searchParams + expect(p.get('isReceipt')).toBe('true') + expect(p.get('amount')).toBeNull() + expect(p.get('token')).toBeNull() + }) + + it('request + peanut username + amount 0 (address/profile request)', () => { + const p = parse( + buildOgImageUrl({ type: 'request', username: 'kkonrad', amount: '0', isPeanutUsername: true }, SITE_URL) + ).searchParams + expect(p.get('type')).toBe('request') + expect(p.get('amount')).toBe('0') + expect(p.get('isPeanutUsername')).toBe('true') + }) + + it('invite has no type param and sets isInvite', () => { + const p = parse(buildOgImageUrl({ username: 'kkonrad', isInvite: true }, SITE_URL)).searchParams + expect(p.get('type')).toBeNull() + expect(p.get('isInvite')).toBe('true') + expect(p.get('username')).toBe('kkonrad') + }) + + it('omits empty amount/token and false flags', () => { + const p = parse( + buildOgImageUrl( + { type: 'send', username: '', amount: '', token: undefined, isReceipt: false, isPeanutUsername: false }, + SITE_URL + ) + ).searchParams + expect([...p.keys()]).toEqual(['type']) + }) + + it('includes a numeric zero amount', () => { + const p = parse(buildOgImageUrl({ type: 'request', amount: 0 }, SITE_URL)).searchParams + expect(p.get('amount')).toBe('0') + }) +}) diff --git a/src/utils/claim-metadata.utils.ts b/src/utils/claim-metadata.utils.ts index d95b97e87..a6bea20a6 100644 --- a/src/utils/claim-metadata.utils.ts +++ b/src/utils/claim-metadata.utils.ts @@ -2,6 +2,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS, PEANUT_WALLET_TOKEN_SYMBOL } from '@/cons import { sendLinksApi } from '@/services/sendLinks' import { resolveAddressToUsername } from '@/utils/ens.utils' import { formatAmount } from '@/utils/general.utils' +import { buildOgImageUrl } from '@/utils/og.utils' import { type Metadata } from 'next' import { formatUnits } from 'viem' @@ -98,17 +99,18 @@ export function buildClaimMetadata({ title = `You received ${amount < 0.01 ? 'some ' : `${formatAmount(amount)} in `}${linkDetails.tokenSymbol}!` } - const ogUrl = new URL('/api/og', siteUrl) - ogUrl.searchParams.set('type', 'send') - ogUrl.searchParams.set('username', username || linkDetails.senderAddress) - if (linkDetails.claimed) { - // claimed links show the "receipt" variant of the OG image - ogUrl.searchParams.set('isReceipt', 'true') - } else { - ogUrl.searchParams.set('amount', linkDetails.tokenAmount) - ogUrl.searchParams.set('token', linkDetails.tokenSymbol) - } - ogImageUrl = ogUrl.toString() + // claimed links show the "receipt" variant; unclaimed show amount + token + ogImageUrl = buildOgImageUrl( + linkDetails.claimed + ? { type: 'send', username: username || linkDetails.senderAddress, isReceipt: true } + : { + type: 'send', + username: username || linkDetails.senderAddress, + amount: linkDetails.tokenAmount, + token: linkDetails.tokenSymbol, + }, + siteUrl + ) } const description = claimData?.linkDetails.claimed diff --git a/src/utils/og.utils.ts b/src/utils/og.utils.ts new file mode 100644 index 000000000..c9072668d --- /dev/null +++ b/src/utils/og.utils.ts @@ -0,0 +1,40 @@ +/** + * Single source of truth for building `/api/og` social-preview image URLs. + * + * The query-param contract lives here and in the image renderer at + * `src/app/api/og/route.tsx` — keep the param names in the two in sync. Every + * page that needs a dynamic OG image (claim, the `[...recipient]` + * payment/profile page, receipts, invites) builds its URL through this helper, + * so a change to the contract happens in one place instead of N. + */ +export type OgImageParams = { + type?: 'send' | 'request' | 'generic' + username?: string + /** Token amount as a display string/number; omitted from the URL when empty. */ + amount?: string | number + /** Token symbol, e.g. USDC. */ + token?: string + /** Render the already-paid / claimed "receipt" variant. */ + isReceipt?: boolean + /** Mark the recipient as a Peanut handle (profile styling). */ + isPeanutUsername?: boolean + /** Render the invite variant. */ + isInvite?: boolean +} + +/** Build an absolute `/api/og` image URL from the given params and site origin. */ +export function buildOgImageUrl(params: OgImageParams, siteUrl: string): string { + const url = new URL('/api/og', siteUrl) + + if (params.type) url.searchParams.set('type', params.type) + if (params.username) url.searchParams.set('username', params.username) + if (params.amount !== undefined && params.amount !== null && params.amount !== '') { + url.searchParams.set('amount', String(params.amount)) + } + if (params.token) url.searchParams.set('token', params.token) + if (params.isReceipt) url.searchParams.set('isReceipt', 'true') + if (params.isPeanutUsername) url.searchParams.set('isPeanutUsername', 'true') + if (params.isInvite) url.searchParams.set('isInvite', 'true') + + return url.toString() +} From 4abd233497c1a72c9fedc467572528a34aca05ad Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 20:32:05 -0700 Subject: [PATCH 19/38] content: bump src/content to latest peanut-content main (f066775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the Sumsub es-es correction into dev so the release ships fully-current content. Clean dev-based bump (the auto-PR #2247 branched off main and couldn't merge — that workflow bug is being fixed separately). --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index 5eb8453d8..f06677504 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 5eb8453d8cba84a8bc2d200880e36b129c238951 +Subproject commit f0667750492717ff48afed12cd15d6cfd6fa6a0f From 6bac289cdcb0375048a2fedadfeb38d5e0e3c8e0 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 20:36:06 -0700 Subject: [PATCH 20/38] ci(content): base auto-bump on dev + content-only auto-merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The content auto-bump opened PRs but never merged them — 9 piled up since June 3, and they were branched off the default branch (main), which (a) let code drift pollute them (#2226) and (b) made them conflict with dev once dev's content moved (#2247). Fix: - Branch the bump off **dev** (the PR base) so the PR is content-only by construction and always cleanly mergeable. - Auto-merge the PR, guarded so it only fires when the diff is *solely* the src/content pointer (anything else → left for human review). - Create the PR with CONTENT_BOT_TOKEN (PAT) when present so CI triggers and auto-merge can gate on green checks; falls back to GITHUB_TOKEN (no regression). Requires (repo admin, one-time): set CONTENT_BOT_TOKEN secret (PAT w/ peanut-ui contents+PR write) + enable 'Allow auto-merge'. Takes effect once this reaches main (repository_dispatch runs the workflow from the default branch). --- .github/workflows/update-content.yml | 32 +++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/update-content.yml b/.github/workflows/update-content.yml index ee1132ce3..528da2f03 100644 --- a/.github/workflows/update-content.yml +++ b/.github/workflows/update-content.yml @@ -13,7 +13,14 @@ jobs: update: runs-on: ubuntu-latest steps: + # Base the bump on `dev` (the PR target), NOT the repo default branch. + # Branching off a different branch than the base is what let unrelated + # code drift leak into the auto-PR (#2226) and made it conflict with dev + # once dev's content pointer moved (#2247). Off `dev`, the PR is + # content-only by construction and always cleanly mergeable. - uses: actions/checkout@v4 + with: + ref: dev - name: Init and update submodule env: @@ -34,14 +41,19 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Create PR + - name: Create PR + auto-merge (content-only) if: steps.check.outputs.changed == 'true' + # Prefer a PAT (CONTENT_BOT_TOKEN) so the PR triggers CI and auto-merge + # can fire on green checks. Falls back to GITHUB_TOKEN — the PR still + # opens, but GITHUB_TOKEN-created PRs don't trigger CI, so auto-merge + # won't fire until the PAT is configured (no regression vs today). env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.CONTENT_BOT_TOKEN || secrets.GITHUB_TOKEN }} run: | + set -euo pipefail BRANCH="auto/update-content-$(date -u +%Y%m%d-%H%M%S)" SUBMODULE_SHA=$(git -C src/content rev-parse HEAD) - PARENT=$(git rev-parse HEAD) + PARENT=$(git rev-parse HEAD) # dev tip — bump is content-only vs dev BASE_TREE=$(gh api repos/${{ github.repository }}/git/commits/$PARENT --jq '.tree.sha') # Create tree via API with updated submodule pointer TREE=$(gh api repos/${{ github.repository }}/git/trees \ @@ -64,8 +76,18 @@ jobs: --method POST \ -f "ref=refs/heads/$BRANCH" \ -f "sha=$COMMIT_SHA" - gh pr create \ + PR_URL=$(gh pr create \ --head "$BRANCH" \ --base dev \ --title "Update content submodule" \ - --body "Auto-generated: updates content submodule to latest peanut-content main." + --body "Auto-generated: bumps the content submodule to latest peanut-content main. Based on dev, so it's content-only and auto-merges once checks pass.") + # Guard: only auto-merge when the PR touches NOTHING but the submodule + # pointer. If anything else slipped in, leave it for a human. + FILES=$(gh pr diff "$PR_URL" --name-only) + if [ "$FILES" = "src/content" ]; then + gh pr merge "$PR_URL" --auto --squash \ + || echo "::warning::auto-merge not enabled/permitted — left for manual merge ($PR_URL)" + else + echo "::warning::PR touches more than src/content — left for manual review:" + echo "$FILES" + fi From def5c6282384d98f98415f23162b52cc2f86c822 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:02:06 -0700 Subject: [PATCH 21/38] fix(home): show "You're unlocked" modal once, off a milestone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The celebration was gated on the backend showKycCompletedModal flag, which was re-set on every KYC →approved transition — so re-approvals (e.g. the faster_payments endorsement backfill) re-showed it to long-time users, listing rails they'd had for months. Now it shows once: KYC-approved (a rail is enabled) AND activationCelebratedAt is null; dismissal stamps the milestone server-side (dismissActivationCelebration), so it can never resurface. Pairs with peanut-api-ts (activation_celebrated_at + idempotent approval side-effects). --- src/app/(mobile-ui)/home/page.tsx | 16 ++++++++++------ src/interfaces/interfaces.ts | 4 +++- src/types/api.generated.ts | 2 +- src/types/api.openapi.json | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 41a33bd68..b25cadca6 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -75,7 +75,7 @@ export default function Home() { const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false) const [isPostSignupActionModalVisible, setIsPostSignupActionModalVisible] = useState(false) - const [showKycModal, setShowKycModal] = useState(user?.user.showKycCompletedModal ?? false) + const [showKycModal, setShowKycModal] = useState(false) // Track if this is a fresh signup session - captured once on mount so it persists // even after NoMoreJailModal clears the sessionStorage key @@ -89,12 +89,16 @@ export default function Home() { fetchUser() }, []) // eslint-disable-line react-hooks/exhaustive-deps - // sync modal state with user data when it changes + // Show the "You're unlocked" celebration exactly once: the user has a usable + // rail (isKycApproved) and has never dismissed it (activationCelebratedAt is + // null, stamped server-side on dismiss). A KYC re-approval can't resurface it + // — unlike the old showKycCompletedModal flag, which re-fired on every + // `→ approved` transition (e.g. the faster_payments endorsement backfill). useEffect(() => { - if (user?.user.showKycCompletedModal !== undefined) { - setShowKycModal(user.user.showKycCompletedModal) + if (isKycApproved && !user?.user.activationCelebratedAt) { + setShowKycModal(true) } - }, [user?.user.showKycCompletedModal]) + }, [isKycApproved, user?.user.activationCelebratedAt]) const userFullName = useMemo(() => { if (!user) return @@ -256,7 +260,7 @@ export default function Home() { if (user?.user.userId) { await updateUserById({ userId: user.user.userId, - showKycCompletedModal: false, + dismissActivationCelebration: true, }) // refetch user to ensure the modal doesn't reappear await fetchUser() diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index d90457722..abb970c7e 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -198,7 +198,9 @@ export interface User { activatedAt?: string | null activationMilestone?: 'registered' | 'verified' | 'funded' | 'activated' showFullName: boolean - showKycCompletedModal?: boolean + // Null until the user dismisses the "You're unlocked" celebration. The modal + // shows once, when KYC-approved (a rail is enabled) AND this is still null. + activationCelebratedAt?: string | null createdAt: string accounts: Account[] badges?: Array<{ diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index ac8ccb386..084bc7e4c 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -540,7 +540,7 @@ export interface paths { showFullName?: boolean; hasSeenEarlyUserModal?: boolean; bridgeKycStatus?: "not_started" | "incomplete" | "under_review" | "approved" | "rejected"; - showKycCompletedModal?: boolean; + dismissActivationCelebration?: boolean; }; }; }; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index 14ead7852..94b307be4 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -739,7 +739,7 @@ } ] }, - "showKycCompletedModal": { + "dismissActivationCelebration": { "type": "boolean" } }, From 6842d34f13a7e741d89e4aa29fc2de7461e9138b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:05:16 -0700 Subject: [PATCH 22/38] fix(add-money): back button no longer dies on the amount screen Both add-money amount flows declared their nuqs URL state with { history: 'push' }. `amount` is rewritten on every keystroke, so each character pushed a new browser-history entry for the *same* screen. The NavHeader back button (useSafeBack -> router.back()) then only stepped back one of those stale same-screen entries, so the view never changed and the button looked dead. Reported for the MP (Manteca AR/BR) flow; the Bridge bank flow (SEPA/US/UK/MX) had the identical bug. Drop the option so both flows use the nuqs default ('replace'): the URL stays shareable / deep-linkable but no longer grows history, and one tap leaves the screen. Systemic guard: a no-restricted-syntax rule now bans { history: 'push' } in nuqs useQueryState(s). The existing back-button guard pack (#1965, #1997) caught the call-shape anti-patterns (router.back, onBack push, window.history.length) but not this config footgun that defeats useSafeBack from the other side. Plus data-testid="nav-back" on NavHeader and a Playwright regression test that types an amount and asserts a single back tap leaves /bank. --- e2e/flows/add-money.spec.ts | 34 +++++++++++++++++++ eslint.config.js | 10 ++++++ .../add-money/[country]/bank/page.tsx | 16 +++++---- .../AddMoney/components/MantecaAddMoney.tsx | 20 ++++++----- src/components/Global/NavHeader/index.tsx | 10 ++++-- 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/e2e/flows/add-money.spec.ts b/e2e/flows/add-money.spec.ts index 2e7ebdef5..7702ad349 100644 --- a/e2e/flows/add-money.spec.ts +++ b/e2e/flows/add-money.spec.ts @@ -81,4 +81,38 @@ test.describe('Add money flow', () => { consoleLogs.flush(testInfo, 'add-money-us-bank') await context.close() }) + + // Regression: the NavHeader back button must LEAVE the amount screen on the first tap. + // Before fix/addmoney-back-nuqs-replace the flow used nuqs { history: 'push' }, so every + // amount keystroke stacked a same-screen history entry and useSafeBack's router.back() + // only stepped through stale amounts — the back button looked dead (MP/bank reports). + test('add-money/AR/bank — back button leaves the amount screen after typing (verified-ar)', async ({ + browser, + }, testInfo) => { + const context = await browser.newContext({ ...devices['Pixel 7'] }) + await usePersona(context, 'verified-ar') + + const page = await context.newPage() + const consoleLogs = collectConsoleLogs(page) + await installApiMocks(page) + + await page.goto('/add-money/AR/bank') + + // amount step renders (verified persona skips the "country not found" gate) + const amountInput = page.locator('input[inputmode="decimal"]').first() + await amountInput.waitFor({ state: 'visible', timeout: 15000 }) + + // typing writes ?amount= — with the old { history: 'push' } this stacked back-stack + // entries; with the default 'replace' it does not. + await amountInput.fill('100') + await captureStep(page, testInfo, { name: '01-add-money-ar-bank-amount-typed' }) + + // one tap must exit to the country page, not linger on /bank with a stale amount + await page.locator('[data-testid="nav-back"]').first().click() + await expect(page).toHaveURL(/\/add-money\/AR(?:\?.*)?$/) + await captureStep(page, testInfo, { name: '02-add-money-ar-bank-back-left-screen' }) + + consoleLogs.flush(testInfo, 'add-money-ar-bank-back') + await context.close() + }) }) diff --git a/eslint.config.js b/eslint.config.js index 44dbeecb0..051b8f20b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -127,6 +127,16 @@ module.exports = [ message: "window.history.length is the pre-useSafeBack idiom (history.length > 1 ? back : push). It misfires on cold-load from external referrers — useSafeBack's pushState counter is more accurate. See PR #1965.", }, + { + // nuqs `history: 'push'` stacks a browser-history entry on every URL write. + // For per-keystroke params (e.g. `amount`) that poisons the back stack: + // useSafeBack → router.back() then steps through stale same-screen states + // and the back button looks dead (add-money MP/bank reports, June 2026). + selector: + "CallExpression[callee.name=/^useQueryStates?$/] Property[key.name='history'][value.value='push']", + message: + "Don't pass { history: 'push' } to nuqs useQueryState(s) — a history entry per URL write breaks the back button (useSafeBack steps through same-screen states instead of leaving). Use the default 'replace'; the URL stays shareable. If a flow genuinely needs push-per-step, add a scoped file exemption with a comment (see useNativePlugins).", + }, ], }, }, diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index a704a2db1..4dbb06990 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -49,13 +49,15 @@ export default function OnrampBankPage() { // URL state - persisted in query params // Example: /add-money/mexico/bank?step=inputAmount&amount=500 - const [urlState, setUrlState] = useQueryStates( - { - step: parseAsStringEnum(['inputAmount', 'showDetails']), - amount: parseAsString, - }, - { history: 'push' } - ) + // history stays at the nuqs default ('replace'): `amount` is rewritten on every + // keystroke, so 'push' would stack a browser-history entry per character and the + // NavHeader back button (useSafeBack → router.back()) would only step through stale + // amounts of this same screen instead of leaving it. The URL stays shareable either + // way. Enforced by the no-restricted-syntax guard in eslint.config.js. + const [urlState, setUrlState] = useQueryStates({ + step: parseAsStringEnum(['inputAmount', 'showDetails']), + amount: parseAsString, + }) // Amount from URL const rawTokenAmount = urlState.amount ?? '' diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index e402bf540..7084629f1 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -39,15 +39,17 @@ const MantecaAddMoney: FC = () => { // URL state - persisted in query params // Example: /add-money/argentina/manteca?step=inputAmount&amount=100¤cy=ARS - // The `amount` is stored in whatever denomination `currency` specifies - const [urlState, setUrlState] = useQueryStates( - { - step: parseAsStringEnum(['inputAmount', 'depositDetails']), - amount: parseAsString, - currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), - }, - { history: 'push' } - ) + // The `amount` is stored in whatever denomination `currency` specifies. + // history stays at the nuqs default ('replace'): `amount` is rewritten on every + // keystroke, so 'push' would stack a browser-history entry per character and the + // NavHeader back button (useSafeBack → router.back()) would only step through stale + // amounts of this same screen instead of leaving it. The URL stays shareable either + // way. Enforced by the no-restricted-syntax guard in eslint.config.js. + const [urlState, setUrlState] = useQueryStates({ + step: parseAsStringEnum(['inputAmount', 'depositDetails']), + amount: parseAsString, + currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), + }) // Derive state from URL (with defaults) const step: MantecaStep = urlState.step ?? 'inputAmount' diff --git a/src/components/Global/NavHeader/index.tsx b/src/components/Global/NavHeader/index.tsx index d1be84f07..9946fd6bc 100644 --- a/src/components/Global/NavHeader/index.tsx +++ b/src/components/Global/NavHeader/index.tsx @@ -32,7 +32,7 @@ const NavHeader = ({
{!onPrev ? ( -
) diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx new file mode 100644 index 000000000..0287db45f --- /dev/null +++ b/src/components/Kyc/AdvisoryPreemptModal.tsx @@ -0,0 +1,63 @@ +import ActionModal from '@/components/Global/ActionModal' + +interface AdvisoryPreemptModalProps { + visible: boolean + /** ISO date the requirement becomes blocking; drives the deadline copy. */ + effectiveDate?: string + isLoading?: boolean + /** Launch the verification flow early. */ + onCompleteNow: () => void + /** Dismiss and continue with what the user was doing. */ + onSkip: () => void + onClose: () => void +} + +function formatEffectiveDate(iso?: string): string | null { + if (!iso) return null + const date = new Date(iso) + return Number.isNaN(date.getTime()) + ? null + : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) +} + +/** + * Skippable pre-empt for a future-dated verification requirement on a rail that + * still works today (the gate's `ready` + `advisory`). "Complete now" launches + * the verification early; "Not now" lets the user carry on and resolve it later. + * Once the effective date passes the backend reclassifies the requirement to + * blocking and the non-skippable InitiateKycModal takes over — there is no FE + * cutover logic here. + */ +export default function AdvisoryPreemptModal({ + visible, + effectiveDate, + isLoading = false, + onCompleteNow, + onSkip, + onClose, +}: AdvisoryPreemptModalProps) { + const formatted = formatEffectiveDate(effectiveDate) + return ( + + ) +} diff --git a/src/hooks/useAdvisoryPreempt.test.ts b/src/hooks/useAdvisoryPreempt.test.ts new file mode 100644 index 000000000..f4404e332 --- /dev/null +++ b/src/hooks/useAdvisoryPreempt.test.ts @@ -0,0 +1,63 @@ +import { act, renderHook } from '@testing-library/react' +import { useAdvisoryPreempt } from './useAdvisoryPreempt' +import type { GateAdvisory } from '@/utils/capability-gate' + +const advisory: GateAdvisory = { effectiveDate: '2099-06-29', levelKey: 'eea_uplift' } + +describe('useAdvisoryPreempt', () => { + test('no advisory → intercept proceeds immediately, modal stays hidden', () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory: undefined, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + + expect(proceed).toHaveBeenCalledTimes(1) + expect(result.current.modalProps.visible).toBe(false) + }) + + test('advisory present → intercept opens the modal and defers proceed', () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + + expect(proceed).not.toHaveBeenCalled() + expect(result.current.modalProps.visible).toBe(true) + expect(result.current.modalProps.effectiveDate).toBe('2099-06-29') + }) + + test('skip runs the deferred proceed; once dismissed, later intercepts pass straight through', () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + act(() => result.current.modalProps.onSkip()) + + expect(proceed).toHaveBeenCalledTimes(1) + expect(result.current.modalProps.visible).toBe(false) + + // Dismissed for the session — a second proceed runs immediately, no re-prompt. + const proceed2 = jest.fn() + act(() => result.current.intercept(proceed2)) + expect(proceed2).toHaveBeenCalledTimes(1) + expect(result.current.modalProps.visible).toBe(false) + }) + + test('completeNow launches the verification and does NOT run the deferred proceed', async () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + await act(async () => { + await result.current.modalProps.onCompleteNow() + }) + + expect(onCompleteNow).toHaveBeenCalledTimes(1) + expect(proceed).not.toHaveBeenCalled() + expect(result.current.modalProps.visible).toBe(false) + }) +}) diff --git a/src/hooks/useAdvisoryPreempt.ts b/src/hooks/useAdvisoryPreempt.ts new file mode 100644 index 000000000..58fafe93f --- /dev/null +++ b/src/hooks/useAdvisoryPreempt.ts @@ -0,0 +1,67 @@ +import { useCallback, useRef, useState } from 'react' +import type { GateAdvisory } from '@/utils/capability-gate' + +interface UseAdvisoryPreemptArgs { + /** The advisory from a `ready` gate (`gate.kind === 'ready' ? gate.advisory : undefined`). */ + advisory: GateAdvisory | undefined + /** Launch the verification flow early — e.g. `sumsubFlow.handleInitiateKyc(region, advisory.levelKey, …)`. */ + onCompleteNow: () => void | Promise + isLoading?: boolean +} + +/** + * Drives the skippable advisory pre-empt at the add/withdraw entry points. The + * rail is usable now, so we don't block — we intercept the "proceed" step ONCE + * per session with a skippable modal. "Complete now" launches the verification + * early; "Not now" dismisses and runs the original proceed action. Either choice + * marks it dismissed so the user isn't re-prompted mid-session. + * + * Returns `intercept(proceed)` to call in the gate's `ready` branch, and + * `modalProps` to spread onto {@link AdvisoryPreemptModal}. + */ +export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false }: UseAdvisoryPreemptArgs) { + const [dismissed, setDismissed] = useState(false) + const [visible, setVisible] = useState(false) + const pendingProceed = useRef<(() => void) | null>(null) + + const intercept = useCallback( + (proceed: () => void) => { + if (advisory && !dismissed) { + pendingProceed.current = proceed + setVisible(true) + return + } + proceed() + }, + [advisory, dismissed] + ) + + const completeNow = useCallback(async () => { + setDismissed(true) + setVisible(false) + pendingProceed.current = null + await onCompleteNow() + }, [onCompleteNow]) + + const skip = useCallback(() => { + setDismissed(true) + setVisible(false) + const proceed = pendingProceed.current + pendingProceed.current = null + proceed?.() + }, []) + + const close = useCallback(() => setVisible(false), []) + + return { + intercept, + modalProps: { + visible, + effectiveDate: advisory?.effectiveDate, + isLoading, + onCompleteNow: completeNow, + onSkip: skip, + onClose: close, + }, + } +} diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index 2793f0f48..5471d7a30 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -78,6 +78,13 @@ export interface RailCapability { operations?: Partial> /** keys into NextAction.key — actions that unlock currently-unavailable operations on this rail. */ blockingActions?: string[] + /** + * Non-blocking hints — actions the user CAN take on a rail that's otherwise + * working (the rail stays usable): the Bridge advisory pre-empt (a future-dated + * requirement whose NextAction carries `effectiveDate`) and the Manteca + * cap-nudge. Distinct from `blockingActions` so the FE never gates on them. + */ + hintActions?: string[] /** present for requires-info / blocked — normalized reason for uniform FE rendering. */ reason?: CapabilityReason } @@ -103,6 +110,14 @@ export interface NextAction { levelKey?: string /** for kind:'accept-tos' */ tosUrl?: string + /** + * Advisory (non-blocking) actions only — surfaced via RailCapability.hintActions. + * ISO date the requirement becomes blocking (Bridge future_requirements[].effective_date); + * absent on current/blocking actions. The FE renders a skippable "complete before {date}" pre-empt. + */ + effectiveDate?: string + /** Advisory actions only — the provider requirement key, for telemetry / FE branching. */ + requirementKey?: string } export interface CapabilityRestriction { diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index 3532eb3b5..f11f139bf 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -1,5 +1,6 @@ import { deriveGate, + getGateAdvisory, getGateUserMessage, getKycModalVariant, type CapabilityState, @@ -475,3 +476,105 @@ describe('deriveGate — country scoping (the Add Money dead-end class)', () => expect(gate.kind).toBe('needs-enrollment') }) }) + +describe('deriveGate — advisory pre-empt (future-dated requirement on a ready rail)', () => { + // A Bridge rail that's ENABLED now but carries a future-dated requirement, + // surfaced by the BE as a non-blocking hintAction whose NextAction has an + // `effectiveDate` (the 2026-06-29 sof_individual_primary_purpose cohort). + const advisoryAction: NextAction = { + key: 'sumsub:eea_uplift', + kind: 'sumsub', + purpose: 'unlock-bridge', + levelKey: 'eea_uplift', + effectiveDate: '2099-06-29', + requirementKey: 'sof_individual_primary_purpose', + } + + test('enabled rail with a future-dated hint → ready + advisory (rail stays usable)', () => { + const rail = bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'enabled', + hintActions: ['sumsub:eea_uplift'], + }) + const gate = deriveGate(state([rail], [advisoryAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + if (gate.kind === 'ready') { + expect(gate.advisory).toEqual({ + effectiveDate: '2099-06-29', + levelKey: 'eea_uplift', + requirementKey: 'sof_individual_primary_purpose', + }) + } + expect(getGateAdvisory(gate)).toMatchObject({ effectiveDate: '2099-06-29', levelKey: 'eea_uplift' }) + }) + + test('enabled rail with no hint → ready, no advisory (back-compat)', () => { + const gate = deriveGate(state([bankRail({ status: 'enabled' })]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + if (gate.kind === 'ready') expect(gate.advisory).toBeUndefined() + expect(getGateAdvisory(gate)).toBeUndefined() + }) + + test('a hint WITHOUT effectiveDate (Manteca cap-nudge) is not an advisory pre-empt', () => { + const capNudge: NextAction = { + key: 'sumsub:source_of_funds', + kind: 'sumsub', + purpose: 'raise-manteca-limit', + levelKey: 'source_of_funds', + } + const rail = bankRail({ + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + country: 'BR', + currency: 'BRL', + status: 'enabled', + hintActions: ['sumsub:source_of_funds'], + }) + const gate = deriveGate(state([rail], [capNudge]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + if (gate.kind === 'ready') expect(gate.advisory).toBeUndefined() + }) + + test('earliest effectiveDate wins across multiple ready rails', () => { + const later: NextAction = { + key: 'sumsub:tax_identification_number', + kind: 'sumsub', + purpose: 'unlock-bridge', + levelKey: 'tax_identification_number', + effectiveDate: '2099-12-31', + } + const rails = [ + bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'enabled', + hintActions: ['sumsub:tax_identification_number'], + }), + bankRail({ id: 'bridge.ach_us', status: 'enabled', hintActions: ['sumsub:eea_uplift'] }), + ] + const gate = deriveGate(state(rails, [advisoryAction, later]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + if (gate.kind === 'ready') expect(gate.advisory?.effectiveDate).toBe('2099-06-29') + }) + + test('a blocking sibling still wins — advisory only rides on an otherwise-ready scope', () => { + // If the same scope has a CURRENT blocker, that takes priority (ready + // requires an enabled rail); advisory is strictly a ready-state rider. + const blockedRail = bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'requires-info', + blockingActions: ['sumsub:eea_uplift'], + }) + const gate = deriveGate(state([blockedRail], [advisoryAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('fixable-rejection') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 5e3804187..598a87572 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -18,6 +18,23 @@ import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailChannel } from '@/types/capabilities' +/** + * A non-blocking advisory pre-empt riding on a `ready` gate. An ENABLED rail can + * carry a future-dated requirement (Bridge's `future_requirements[].effective_date`, + * surfaced as a `hintAction` whose NextAction has an `effectiveDate`). The rail + * stays usable; the FE offers a SKIPPABLE "complete before {date}" prompt. When + * the date passes the BE reclassifies it to blocking and the gate becomes + * `fixable-rejection` on its own — no FE date logic, no hardcoded cutover. + */ +export interface GateAdvisory { + /** ISO date the requirement becomes blocking. */ + effectiveDate: string + /** registry key for the Sumsub RFI to launch if the user completes it now (NextAction.levelKey). */ + levelKey?: string + /** which requirement — telemetry / FE branching. */ + requirementKey?: string +} + /** * Normalized gate state. Discriminated union — consumers branch on `kind`. * @@ -38,7 +55,7 @@ import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailC */ export type GateState = | { kind: 'loading' } - | { kind: 'ready' } + | { kind: 'ready'; advisory?: GateAdvisory } | { kind: 'pending' } | { kind: 'waiting-on-provider'; userMessage: string | null; reason?: CapabilityReason } | { kind: 'accept-tos'; tosUrl?: string; userMessage: string | null; reason?: CapabilityReason } @@ -93,6 +110,30 @@ function railActions(rail: RailCapability, byKey: Map): Next .filter((action): action is NextAction => action !== undefined) } +/** Resolve a rail's HINT (non-blocking) actions to NextAction descriptors. */ +function railHintActions(rail: RailCapability, byKey: Map): NextAction[] { + return (rail.hintActions ?? []) + .map((key) => byKey.get(key)) + .filter((action): action is NextAction => action !== undefined) +} + +/** + * The most-urgent advisory pre-empt among the given (ready) rails — the + * hintAction whose NextAction carries the earliest `effectiveDate`. Returns + * undefined when no rail has a future-dated hint. + */ +function firstAdvisory(rails: RailCapability[], byKey: Map): GateAdvisory | undefined { + let best: NextAction | undefined + for (const rail of rails) { + for (const action of railHintActions(rail, byKey)) { + if (!action.effectiveDate) continue + if (!best || action.effectiveDate < best.effectiveDate!) best = action + } + } + if (!best) return undefined + return { effectiveDate: best.effectiveDate!, levelKey: best.levelKey, requirementKey: best.requirementKey } +} + /** * Input state for the pure derive. Held separately from the React hook so the * gate is independently testable (and re-usable from non-React callers). @@ -147,8 +188,14 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat // above blocked / accept-tos / fixable-rejection because the user has // a working path; a blocked sibling rail (different currency, KYC // remediation pending) is not the user's problem right now. - const hasReady = candidates.some((rail) => operationStatus(rail, op) === 'enabled') - if (hasReady) return { kind: 'ready' } + const readyRails = candidates.filter((rail) => operationStatus(rail, op) === 'enabled') + if (readyRails.length > 0) { + // A working rail can still carry a future-dated requirement as a + // non-blocking hint — surface it as a SKIPPABLE pre-empt without + // demoting `ready` (the rail is usable now). + const advisory = firstAdvisory(readyRails, actionByKey) + return advisory ? { kind: 'ready', advisory } : { kind: 'ready' } + } // 3. blocked — split: if the rail carries a `restart-identity` action the // user can self-fix by re-verifying with a different document; otherwise @@ -263,3 +310,8 @@ export function getGateUserMessage(gate: GateState): string | undefined { } return undefined } + +/** The advisory pre-empt riding on a `ready` gate, if present. */ +export function getGateAdvisory(gate: GateState): GateAdvisory | undefined { + return gate.kind === 'ready' ? gate.advisory : undefined +} From 59c7ee317b8804b16f55ebe7178642982446dbe6 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:29:39 -0700 Subject: [PATCH 24/38] test(add-money): cover the reported Manteca back-button path in e2e #2254 fixed both add-money amount screens but the Playwright regression only drove the Bridge bank variant (/add-money/AR/bank). Add the same assertion for the originally-reported Manteca (MP) flow (/add-money/argentina/manteca): type an amount, one back tap must leave for /add-money/argentina. Test-only; mirrors the bank case. --- e2e/flows/add-money.spec.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/e2e/flows/add-money.spec.ts b/e2e/flows/add-money.spec.ts index 7702ad349..16c490f3d 100644 --- a/e2e/flows/add-money.spec.ts +++ b/e2e/flows/add-money.spec.ts @@ -115,4 +115,35 @@ test.describe('Add money flow', () => { consoleLogs.flush(testInfo, 'add-money-ar-bank-back') await context.close() }) + + // Same regression, the originally-reported flow: Manteca (MP) AR deposit at + // /add-money/argentina/manteca. Both add-money amount screens shared the + // { history: 'push' } bug; this covers the reported variant directly. + test('add-money/argentina/manteca — back button leaves the amount screen after typing (verified-ar)', async ({ + browser, + }, testInfo) => { + const context = await browser.newContext({ ...devices['Pixel 7'] }) + await usePersona(context, 'verified-ar') + + const page = await context.newPage() + const consoleLogs = collectConsoleLogs(page) + await installApiMocks(page) + + await page.goto('/add-money/argentina/manteca') + + // amount step renders (verified persona; currency rate is mocked) + const amountInput = page.locator('input[inputmode="decimal"]').first() + await amountInput.waitFor({ state: 'visible', timeout: 15000 }) + + await amountInput.fill('100') + await captureStep(page, testInfo, { name: '01-add-money-manteca-amount-typed' }) + + // one tap must exit to the country page, not linger on /manteca with a stale amount + await page.locator('[data-testid="nav-back"]').first().click() + await expect(page).toHaveURL(/\/add-money\/argentina(?:\?.*)?$/) + await captureStep(page, testInfo, { name: '02-add-money-manteca-back-left-screen' }) + + consoleLogs.flush(testInfo, 'add-money-manteca-back') + await context.close() + }) }) From 063ece783aec4b56a5f51fe57b79c1995d888a8d Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:35:31 -0700 Subject: [PATCH 25/38] =?UTF-8?q?fix(kyc):=20advisory=20pre-empt=20?= =?UTF-8?q?=E2=80=94=20dispatch=20via=20start-action,=20safe=20dismiss,=20?= =?UTF-8?q?UTC=20date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit fixes: - 'Complete now' dispatched via handleInitiateKyc -> /users/identity, which ignores the level and no-ops for already-approved users (the only cohort with an enabled advisory rail) -> SDK never opened. Add startKycAction + handleStartAction to POST the NextAction key to /users/kyc/start-action (the capability-native path that resolves key -> RFI level + mints a token). GateAdvisory now carries the action key (not levelKey). - onClose (X/backdrop/Escape) stranded the user + re-prompted every click: now dismisses for the session without auto-triggering the money action. - effectiveDate is date-only YYYY-MM-DD -> format in UTC, else Americas timezones show the day before the deadline. - move add-money's DEPOSIT_AMOUNT_ENTERED into the proceed callback so an X-then-reclick can't double-count it. --- .../add-money/[country]/bank/page.tsx | 26 ++++++------ .../withdraw/[country]/bank/page.tsx | 8 +--- src/app/actions/sumsub.ts | 41 +++++++++++++++++++ src/components/Kyc/AdvisoryPreemptModal.tsx | 4 +- src/hooks/useAdvisoryPreempt.test.ts | 20 ++++++++- src/hooks/useAdvisoryPreempt.ts | 9 +++- src/hooks/useMultiPhaseKycFlow.ts | 2 + src/hooks/useSumsubKycFlow.ts | 39 +++++++++++++++++- src/utils/capability-gate.test.ts | 4 +- src/utils/capability-gate.ts | 6 +-- 10 files changed, 129 insertions(+), 30 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 62eacd57a..641e55e8d 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -119,13 +119,7 @@ export default function OnrampBankPage() { const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, isLoading: sumsubFlow.isLoading, - onCompleteNow: () => - sumsubFlow.handleInitiateKyc( - getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'), - advisory?.levelKey, - undefined, - selectedCountry?.id - ), + onCompleteNow: () => (advisory ? sumsubFlow.handleStartAction(advisory.actionKey) : Promise.resolve()), }) const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const { setIsSupportModalOpen } = useModalsContext() @@ -247,14 +241,18 @@ export default function OnrampBankPage() { return } - posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { - amount_usd: usdEquivalent, - method_type: 'bank', - country: selectedCountryPath, + // ready — offer the skippable advisory pre-empt once; on proceed (now, or + // after "Not now") record the amount-entered event and open the + // confirmation modal. Firing inside the proceed avoids double-counting if + // the user dismisses the advisory and re-clicks. + advisoryIntercept(() => { + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { + amount_usd: usdEquivalent, + method_type: 'bank', + country: selectedCountryPath, + }) + setShowWarningModal(true) }) - // ready — but offer the skippable advisory pre-empt once before the - // confirmation modal (no-op when there's nothing future-dated pending). - advisoryIntercept(() => setShowWarningModal(true)) } const handleWarningConfirm = async () => { diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 3ceb2a593..82c2f6c7b 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -100,13 +100,7 @@ export default function WithdrawBankPage() { const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, isLoading: sumsubFlow.isLoading, - onCompleteNow: () => - sumsubFlow.handleInitiateKyc( - getRegionIntent(getCountryFromPath(country)?.region ?? 'rest-of-the-world'), - advisory?.levelKey, - undefined, - getCountryFromPath(country)?.id - ), + onCompleteNow: () => (advisory ? sumsubFlow.handleStartAction(advisory.actionKey) : Promise.resolve()), }) const [showKycModal, setShowKycModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index a3f72895b..8edfc794e 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -112,3 +112,44 @@ export const initiateSelfHealResubmission = async ( return { error: message } } } + +export interface StartKycActionResponse { + token: string + levelName: string + externalActionId?: string +} + +/** + * Mint a Sumsub WebSDK token for a capability nextAction by its `key` + * (POST /users/kyc/start-action). The capability model returns action + * descriptors (a stable key + a registry levelKey) and never carries a token; + * the FE posts the key here to get an unexpired token bound to the right RFI + * level. Used by the advisory pre-empt — an already-approved user starting a + * future-dated RFI early, where /users/identity would short-circuit on + * "already approved" and never mint a token. + */ +export const startKycAction = async (key: string): Promise<{ data?: StartKycActionResponse; error?: string }> => { + try { + const response = await serverFetch('/users/kyc/start-action', { + method: 'POST', + body: JSON.stringify({ key }), + }) + const responseJson = await response.json() + if (!response.ok) { + return { error: responseJson.userMessage || responseJson.error || 'Failed to start verification' } + } + if (!responseJson.sumsubAccessToken) { + return { error: 'Invalid response from server' } + } + return { + data: { + token: responseJson.sumsubAccessToken, + levelName: responseJson.levelName, + externalActionId: responseJson.externalActionId, + }, + } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + return { error: message } + } +} diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx index 0287db45f..bf0a62a11 100644 --- a/src/components/Kyc/AdvisoryPreemptModal.tsx +++ b/src/components/Kyc/AdvisoryPreemptModal.tsx @@ -15,9 +15,11 @@ interface AdvisoryPreemptModalProps { function formatEffectiveDate(iso?: string): string | null { if (!iso) return null const date = new Date(iso) + // `iso` is a date-only YYYY-MM-DD, so `new Date()` parses it at UTC midnight. + // Format in UTC too, or Americas timezones render the day before the deadline. return Number.isNaN(date.getTime()) ? null - : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }) } /** diff --git a/src/hooks/useAdvisoryPreempt.test.ts b/src/hooks/useAdvisoryPreempt.test.ts index f4404e332..d8991d560 100644 --- a/src/hooks/useAdvisoryPreempt.test.ts +++ b/src/hooks/useAdvisoryPreempt.test.ts @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { useAdvisoryPreempt } from './useAdvisoryPreempt' import type { GateAdvisory } from '@/utils/capability-gate' -const advisory: GateAdvisory = { effectiveDate: '2099-06-29', levelKey: 'eea_uplift' } +const advisory: GateAdvisory = { effectiveDate: '2099-06-29', actionKey: 'sumsub:eea_uplift' } describe('useAdvisoryPreempt', () => { test('no advisory → intercept proceeds immediately, modal stays hidden', () => { @@ -46,6 +46,24 @@ describe('useAdvisoryPreempt', () => { expect(result.current.modalProps.visible).toBe(false) }) + test('onClose dismisses without running the deferred proceed (X must not trigger the money action)', () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + act(() => result.current.modalProps.onClose()) + + expect(proceed).not.toHaveBeenCalled() + expect(result.current.modalProps.visible).toBe(false) + + // ...but it dismisses for the session — the next click passes through, no re-prompt. + const proceed2 = jest.fn() + act(() => result.current.intercept(proceed2)) + expect(proceed2).toHaveBeenCalledTimes(1) + expect(result.current.modalProps.visible).toBe(false) + }) + test('completeNow launches the verification and does NOT run the deferred proceed', async () => { const proceed = jest.fn() const onCompleteNow = jest.fn() diff --git a/src/hooks/useAdvisoryPreempt.ts b/src/hooks/useAdvisoryPreempt.ts index 58fafe93f..a9c01b6bc 100644 --- a/src/hooks/useAdvisoryPreempt.ts +++ b/src/hooks/useAdvisoryPreempt.ts @@ -51,7 +51,14 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false proceed?.() }, []) - const close = useCallback(() => setVisible(false), []) + // X / backdrop / Escape: dismiss for the session WITHOUT running the deferred + // proceed — closing the dialog must not auto-trigger the add/withdraw action. + // The user's next add/withdraw click then passes straight through (dismissed). + const close = useCallback(() => { + setDismissed(true) + setVisible(false) + pendingProceed.current = null + }, []) return { intercept, diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 3405320e5..2e4f83638 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -203,6 +203,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent handleInitiateKyc: originalHandleInitiateKyc, handleRestartIdentity, handleSelfHealResubmit, + handleStartAction, handleSdkComplete: originalHandleSdkComplete, handleClose, refreshToken, @@ -396,6 +397,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent handleInitiateKyc, handleRestartIdentity, handleSelfHealResubmit, + handleStartAction, isLoading, error, liveKycStatus, diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 85a928081..32e5d8e87 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -2,7 +2,12 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useRouter } from 'next/navigation' import { useWebSocket } from '@/hooks/useWebSocket' import { useUserStore } from '@/redux/hooks' -import { initiateSumsubKyc, initiateSelfHealResubmission, restartIdentityVerification } from '@/app/actions/sumsub' +import { + initiateSumsubKyc, + initiateSelfHealResubmission, + restartIdentityVerification, + startKycAction, +} from '@/app/actions/sumsub' import { type KYCRegionIntent, type SumsubKycStatus } from '@/app/actions/types/sumsub.types' import { isMantecaSupportedCountryCode } from '@/constants/manteca.consts' import { isCapacitor } from '@/utils/capacitor' @@ -437,6 +442,37 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: } }, []) + // Start a capability nextAction by key (POST /users/kyc/start-action) and + // open the WebSDK with the returned token. Unlike handleInitiateKyc (which + // resolves the level from region and no-ops for an already-approved user), + // this mints a token for the specific RFI level the key maps to — the path + // the advisory pre-empt needs to start a future-dated requirement early. + const handleStartAction = useCallback(async (key: string) => { + setIsLoading(true) + setError(null) + userInitiatedRef.current = true + selfHealProviderRef.current = null + + try { + const response = await startKycAction(key) + if (response.error || !response.data?.token) { + userInitiatedRef.current = false + setError(response.error || 'Could not start verification. Please try again.') + return + } + levelNameRef.current = response.data.levelName + setAccessToken(response.data.token) + setIsActionFlow(true) + setShowWrapper(true) + } catch (e: unknown) { + userInitiatedRef.current = false + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + setError(message) + } finally { + setIsLoading(false) + } + }, []) + return { isLoading, error, @@ -447,6 +483,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: handleInitiateKyc, handleRestartIdentity, handleSelfHealResubmit, + handleStartAction, handleSdkComplete, handleClose, refreshToken, diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index f11f139bf..72f86ac69 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -504,11 +504,11 @@ describe('deriveGate — advisory pre-empt (future-dated requirement on a ready if (gate.kind === 'ready') { expect(gate.advisory).toEqual({ effectiveDate: '2099-06-29', - levelKey: 'eea_uplift', + actionKey: 'sumsub:eea_uplift', requirementKey: 'sof_individual_primary_purpose', }) } - expect(getGateAdvisory(gate)).toMatchObject({ effectiveDate: '2099-06-29', levelKey: 'eea_uplift' }) + expect(getGateAdvisory(gate)).toMatchObject({ effectiveDate: '2099-06-29', actionKey: 'sumsub:eea_uplift' }) }) test('enabled rail with no hint → ready, no advisory (back-compat)', () => { diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 598a87572..dbc183fe2 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -29,8 +29,8 @@ import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailC export interface GateAdvisory { /** ISO date the requirement becomes blocking. */ effectiveDate: string - /** registry key for the Sumsub RFI to launch if the user completes it now (NextAction.levelKey). */ - levelKey?: string + /** the NextAction `key` to start (POST /users/kyc/start-action) if the user completes it now. */ + actionKey: string /** which requirement — telemetry / FE branching. */ requirementKey?: string } @@ -131,7 +131,7 @@ function firstAdvisory(rails: RailCapability[], byKey: Map): } } if (!best) return undefined - return { effectiveDate: best.effectiveDate!, levelKey: best.levelKey, requirementKey: best.requirementKey } + return { effectiveDate: best.effectiveDate!, actionKey: best.key, requirementKey: best.requirementKey } } /** From f85760dc44dcd65a7ff5118a4e2a17896099e8a7 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:46:29 -0700 Subject: [PATCH 26/38] fix(split-bill): hide "Split this bill" only on charges that didn't stick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CTA gate fired on any QR pay or card spend that wasn't refunded/failed. A reversed/expired card hold (status `cancelled` — "Hold released, funds back on your card") still passed, so a user could ask friends to chip in for a charge that never actually cost them. Extract eligibility into a tested isSplittable() predicate. The CTA is an in-the-moment action right after paying, so a freshly-authorized hold (`pending`) stays splittable — card settlement takes days and we don't make users wait. Only charges that didn't stick are excluded: refunded, failed, and `cancelled`. Also strip a leading sign in buildSplitBillRequestUrl so a stray negative amount can't seed /request?amount=-15. Found in the dev→main release review. --- .../TransactionDetailsReceipt.tsx | 23 +++++----- .../__tests__/splitBill.utils.test.ts | 6 +++ .../__tests__/transaction-predicates.test.ts | 43 +++++++++++++++++++ .../TransactionDetails/splitBill.utils.ts | 7 ++- .../transaction-predicates.ts | 21 +++++++-- 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index dcc21fa28..4ead262e3 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -49,6 +49,7 @@ import { isPerkReward as isPerkRewardTransaction, isRequestEntry, isSendLinkEntry, + isSplittable, usesCompletedTimestampLabel, } from './transaction-predicates' import { useReceiptViewModel } from './useReceiptViewModel' @@ -122,7 +123,6 @@ export const TransactionDetailsReceipt = ({ isPendingRequester, isPendingSentLink, isQRPayment, - isCardSpend, country, rowVisibilityConfig, shouldHideBorder, @@ -744,18 +744,15 @@ export const TransactionDetailsReceipt = ({ )} - {!isPublic && - (isQRPayment || isCardSpend) && - transaction.status !== 'refunded' && - transaction.status !== 'failed' && ( - - )} + {!isPublic && isSplittable(transaction) && ( + + )} {shouldShowShareReceipt && !!getReceiptUrl(transaction) && (
diff --git a/src/components/TransactionDetails/__tests__/splitBill.utils.test.ts b/src/components/TransactionDetails/__tests__/splitBill.utils.test.ts index ec75210fe..9f776edb3 100644 --- a/src/components/TransactionDetails/__tests__/splitBill.utils.test.ts +++ b/src/components/TransactionDetails/__tests__/splitBill.utils.test.ts @@ -20,4 +20,10 @@ describe('buildSplitBillRequestUrl', () => { expect(buildSplitBillRequestUrl(8, null)).toBe('/request?amount=8') expect(buildSplitBillRequestUrl(8, '')).toBe('/request?amount=8') }) + + test('strips a leading minus so the prefill amount is never negative', () => { + expect(buildSplitBillRequestUrl(-15, 'Cafe Tortoni')).toBe('/request?amount=15&merchant=Cafe%20Tortoni') + expect(buildSplitBillRequestUrl('-12.5')).toBe('/request?amount=12.5') + expect(buildSplitBillRequestUrl(-20n)).toBe('/request?amount=20') + }) }) diff --git a/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts b/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts index 2cd364b58..d6fe48af5 100644 --- a/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts +++ b/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts @@ -12,6 +12,7 @@ import { isQRPayment, isRequestEntry, isSendLinkEntry, + isSplittable, hasShareableReceipt, } from '../transaction-predicates' import type { TransactionDetails } from '../transactionTransformer' @@ -109,3 +110,45 @@ describe('entry-kind predicates', () => { }) }) }) + +// Gates the "Split this bill" CTA: a QR payment, or a card spend that went +// through. It's an in-the-moment action right after paying, so a freshly- +// authorized (`pending`) card hold IS splittable — settlement takes days. Only +// charges that didn't stick (refunded/failed/cancelled) are excluded. +describe('isSplittable', () => { + const txWithStatus = (kind: string, status?: string): TransactionDetails => + ({ + status, + extraDataForDrawer: { originalType: 'TRANSACTION_INTENT', kind }, + }) as unknown as TransactionDetails + + test('QR payments are splittable unless refunded/failed (behaviour unchanged)', () => { + expect(isSplittable(txWithStatus('QR_PAY', 'completed'))).toBe(true) + expect(isSplittable(txWithStatus('QR_PAY', 'pending'))).toBe(true) + expect(isSplittable(txWithStatus('QR_PAY', 'refunded'))).toBe(false) + expect(isSplittable(txWithStatus('QR_PAY', 'failed'))).toBe(false) + }) + + test('a freshly-authorized (pending) card hold IS splittable — split in the moment, settlement takes days', () => { + expect(isSplittable(txWithStatus('CARD_SPEND_AUTH', 'pending'))).toBe(true) + }) + + test('settled card spends are splittable', () => { + expect(isSplittable(txWithStatus('CARD_SPEND_CLEAR', 'completed'))).toBe(true) + expect(isSplittable(txWithStatus('CARD_SPEND_AUTH', 'completed'))).toBe(true) + }) + + test('a cancelled (reversed/expired) card hold is NOT splittable — the charge never stuck', () => { + expect(isSplittable(txWithStatus('CARD_SPEND_AUTH', 'cancelled'))).toBe(false) + }) + + test('refunded/failed card spends are NOT splittable', () => { + expect(isSplittable(txWithStatus('CARD_SPEND_CLEAR', 'refunded'))).toBe(false) + expect(isSplittable(txWithStatus('CARD_SPEND_CLEAR', 'failed'))).toBe(false) + }) + + test('non-QR / non-card kinds are never splittable', () => { + expect(isSplittable(txWithStatus('DIRECT_TRANSFER', 'completed'))).toBe(false) + expect(isSplittable(txWithStatus('SEND_LINK', 'completed'))).toBe(false) + }) +}) diff --git a/src/components/TransactionDetails/splitBill.utils.ts b/src/components/TransactionDetails/splitBill.utils.ts index c99af1473..b8567aade 100644 --- a/src/components/TransactionDetails/splitBill.utils.ts +++ b/src/components/TransactionDetails/splitBill.utils.ts @@ -8,9 +8,14 @@ * merchant name) so the request comment isn't "Bill split for Card payment". * - URL-encode the merchant so reserved chars (e.g. "Tigers & Lions") can't * break the query string. + * - Strip a leading sign so the prefill can never be negative. */ export function buildSplitBillRequestUrl(amount: number | bigint | string, merchantName?: string | null): string { const merchantParam = merchantName && merchantName !== 'Card payment' ? `&merchant=${encodeURIComponent(merchantName)}` : '' - return `/request?amount=${encodeURIComponent(String(amount))}${merchantParam}` + // A split request must never carry a negative amount — strip a leading sign + // so a stray "-15" can't seed `/request?amount=-15`. Done on the string form + // to stay exact for bigint/string inputs. + const positiveAmount = String(amount).replace(/^-/, '') + return `/request?amount=${encodeURIComponent(positiveAmount)}${merchantParam}` } diff --git a/src/components/TransactionDetails/transaction-predicates.ts b/src/components/TransactionDetails/transaction-predicates.ts index 2eed62032..bed019ba0 100644 --- a/src/components/TransactionDetails/transaction-predicates.ts +++ b/src/components/TransactionDetails/transaction-predicates.ts @@ -23,13 +23,28 @@ export function isQRPayment(transaction: TransactionDetails): boolean { return isKind(transaction, 'QR_PAY') } -/** A Rain card *spend* (not a refund or reversal) — the only card rows eligible - * for a "Split this bill" CTA. Kind-based so refunds (REFUND/OTHER) and auth - * reversals are excluded. */ +/** A Rain card *spend* (not a refund or reversal). Kind-based so refunds + * (REFUND/OTHER) and auth reversals are excluded. Card-spend eligibility for + * the "Split this bill" CTA layers a settled-status check on top — see + * {@link isSplittable}. */ export function isCardSpend(transaction: TransactionDetails): boolean { return isKind(transaction, 'CARD_SPEND_AUTH') || isKind(transaction, 'CARD_SPEND_CLEAR') } +/** Eligible for the "Split this bill" CTA: a QR payment, or a card spend that + * actually went through. This is an in-the-moment action right after paying, + * so a freshly-authorized card hold (`pending`) IS splittable — settlement can + * take days and we don't make users wait. The only card spends excluded are the + * ones that didn't stick: refunded, failed, and `cancelled` (the auth was + * reversed or expired — "Hold released, funds back on your card"). QR pays keep + * their prior behaviour (splittable unless refunded/failed). */ +export function isSplittable(transaction: TransactionDetails): boolean { + if (transaction.status === 'refunded' || transaction.status === 'failed') return false + if (isQRPayment(transaction)) return true + if (isCardSpend(transaction)) return transaction.status !== 'cancelled' + return false +} + /** Kinds that move money across a fiat rail: bank on/off-ramps + QR pays. * The single anchor for the receipt-page whitelist (`getReceiptUrl`), the * share gate (`hasShareableReceipt`), and the FX predicate From 938e71781868f62c8b38b10c52f2a1e70fc39046 Mon Sep 17 00:00:00 2001 From: peanut Date: Fri, 19 Jun 2026 15:18:38 +0200 Subject: [PATCH 27/38] feat(card): show registered cardholder name on reveal Render the Rain-registered cardholder name on the card face, only in the revealed state (alongside PAN/CVV/expiry) and ph-no-capture since it's PII. Companion to peanut-api-ts card-registered-name, which returns the name as an optional cardholderName on GET /rain/cards/:cardId/details. The field is optional, so the FE degrades gracefully when it's absent. --- src/components/Card/CardFace.tsx | 10 +++++ .../Card/__tests__/CardFace.test.tsx | 39 +++++++++++++++++++ src/services/rain.ts | 3 ++ 3 files changed, 52 insertions(+) create mode 100644 src/components/Card/__tests__/CardFace.test.tsx diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 663660de0..9e16efff2 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -11,6 +11,9 @@ export interface RevealedCardDetails { cvv: string expiryMonth: number expiryYear: number + /** Registered cardholder name from Rain. Optional — the backend resolves it + * best-effort, so a Rain hiccup leaves it absent and the field is hidden. */ + cardholderName?: string } interface Props { @@ -125,6 +128,13 @@ const CardFace: FC = ({ )}
+ {/* Registered cardholder name — PII, kept out of session + * recordings like the other revealed fields. */} + {revealed.cardholderName && ( + + {revealed.cardholderName} + + )}
diff --git a/src/components/Card/__tests__/CardFace.test.tsx b/src/components/Card/__tests__/CardFace.test.tsx new file mode 100644 index 000000000..784ebc767 --- /dev/null +++ b/src/components/Card/__tests__/CardFace.test.tsx @@ -0,0 +1,39 @@ +/** + * CardFace — registered cardholder name. + * + * The name comes from Rain (best-effort) and is shown ONLY in the revealed + * state, alongside PAN/CVV/expiry. It must never appear on the masked card, and + * the card must still render when the reveal payload omits the name (backend + * degraded the Rain lookup). + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import CardFace, { type RevealedCardDetails } from '@/components/Card/CardFace' + +const revealed: RevealedCardDetails = { + pan: '4111111111111234', + cvv: '123', + expiryMonth: 12, + expiryYear: 2030, + cardholderName: 'Jane Doe', +} + +describe('CardFace cardholder name', () => { + it('shows the registered name when the card is revealed', () => { + render() + expect(screen.getByText('Jane Doe')).toBeInTheDocument() + }) + + it('hides the name when the card is masked (not revealed)', () => { + render() + expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument() + }) + + it('still renders the revealed card when the name is absent', () => { + const { cardholderName: _omitted, ...withoutName } = revealed + render() + expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument() + // PAN still renders, proving reveal works without the name. + expect(screen.getByText('4111 1111 1111 1234')).toBeInTheDocument() + }) +}) diff --git a/src/services/rain.ts b/src/services/rain.ts index 0115fc4f2..73463e25b 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -174,6 +174,9 @@ export interface RainCardDetailsResponse { expiryYear: number last4: string network: string + /** Registered cardholder name from Rain. Best-effort on the backend, so it + * may be absent if the Rain user lookup failed. */ + cardholderName?: string } export type RainLimitFrequency = 'perAuthorization' | 'per24HourPeriod' | 'per30DayPeriod' | 'perAllTime' From 23abae6cbed58dcd930efe142b671e7d31ed964f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Fri, 19 Jun 2026 18:00:11 -0300 Subject: [PATCH 28/38] =?UTF-8?q?fix(kyc):=20correct=20DUPLICATE=5FEMAIL?= =?UTF-8?q?=20copy=20=E2=80=94=20sign=20in=20/=20contact=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Verify again with a different email" was wrong advice: the collision is almost always the user's own earlier account, now auto-resolved server-side. The cases that still surface this message are genuine conflicts, where the right action is to sign into the other account or contact support. --- src/constants/sumsub-reject-labels.consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts index 9df35b914..2f9fb85c2 100644 --- a/src/constants/sumsub-reject-labels.consts.ts +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -275,7 +275,7 @@ const REJECT_LABEL_MAP: Record = { DUPLICATE_EMAIL: { title: 'Email already in use', description: - 'The email you entered is already associated with another account. Please verify again with a different email.', + 'This email is already linked to another Peanut account. If it’s yours, sign in to that account to continue — otherwise contact support.', }, } From 784912b8a1b05a6eccc31959ef3bb9cb46322253 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 19 Jun 2026 17:22:02 -0700 Subject: [PATCH 29/38] fix(sentry): don't report qr-payment/init 422 as a client error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peanut-api-ts #1041 made /manteca/qr-payment/init return 422 (instead of 500) for a QR the provider can't decode — a user-input outcome, not a server bug. Add 422 to the skip-list alongside the existing 400 so these stop generating Sentry warning noise, matching how the 400 (open QR awaiting merchant amount) is already handled. --- src/utils/sentry.utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 11b4547ee..5fba47d54 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -12,7 +12,10 @@ const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[] }> = { pattern: /\/get-user(?:\b|$)/, statuses: [400, 401, 403, 404] }, { pattern: /users/, statuses: [400, 401, 403, 404] }, { pattern: /perks/, statuses: [400, 401, 403, 404] }, - { pattern: /qr-payment\/init/, statuses: [400] }, + // qr-payment/init: 400 = open QR awaiting merchant amount; 422 = a QR the + // provider can't decode (bad/expired/unsupported) — both are user-input + // outcomes shown to the user, not server bugs. (BE peanut-api-ts #1041.) + { pattern: /qr-payment\/init/, statuses: [400, 422] }, // Rain card secrets endpoints are intentionally rate-limited (5/min) — a // 429 here is an expected outcome surfaced to the user, not a server bug. { pattern: /\/rain\/cards\/[^/]+\/details/, statuses: [429] }, From c19f6287ac769215fa9927dc80daaf59455772e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Sat, 20 Jun 2026 15:02:18 -0300 Subject: [PATCH 30/38] fix(kyc): route advisory Complete now through self-heal relay Advisory Complete now called handleStartAction (start-action), whose submission never round-trips to Bridge. Switch both add-money and withdraw bank pages to handleSelfHealResubmit('BRIDGE', advisory.requirementKey), the path whose webhook completion relays answers to Bridge. Thread an optional requirementKey through initiateSelfHealResubmission and handleSelfHealResubmit to target the future-dated advisory requirement. handleStartAction/startKycAction are now unused (kept for a focused follow-up cleanup to avoid the isActionFlow cascade in this diff). --- src/app/(mobile-ui)/add-money/[country]/bank/page.tsx | 6 +++++- src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx | 6 +++++- src/app/actions/sumsub.ts | 7 +++++-- src/hooks/useSumsubKycFlow.ts | 8 +++++--- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 886996c6f..e94ae231a 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -121,7 +121,11 @@ export default function OnrampBankPage() { const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, isLoading: sumsubFlow.isLoading, - onCompleteNow: () => (advisory ? sumsubFlow.handleStartAction(advisory.actionKey) : Promise.resolve()), + // Route through the self-heal resubmit path (reheal-tagged action) so the + // completed submission round-trips to Bridge. start-action mints a plain + // token whose webhook completion has no Bridge relay → answers are dropped. + onCompleteNow: () => + advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), }) const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const { setIsSupportModalOpen } = useModalsContext() diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 82c2f6c7b..6a7c89f04 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -100,7 +100,11 @@ export default function WithdrawBankPage() { const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, isLoading: sumsubFlow.isLoading, - onCompleteNow: () => (advisory ? sumsubFlow.handleStartAction(advisory.actionKey) : Promise.resolve()), + // Route through the self-heal resubmit path (reheal-tagged action) so the + // completed submission round-trips to Bridge. start-action mints a plain + // token whose webhook completion has no Bridge relay → answers are dropped. + onCompleteNow: () => + advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), }) const [showKycModal, setShowKycModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 8edfc794e..519e8f43c 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -86,12 +86,15 @@ export const restartIdentityVerification = async (): Promise<{ // initiate self-heal document resubmission for a provider-rejected user export const initiateSelfHealResubmission = async ( - provider: 'BRIDGE' | 'MANTECA' + provider: 'BRIDGE' | 'MANTECA', + // Optional — target a specific (e.g. future-dated advisory) Bridge requirement + // by key. Omitted for the legacy blocking flow (current nextAction). + requirementKey?: string ): Promise<{ data?: SelfHealResubmissionResponse; error?: string }> => { try { const response = await serverFetch('/users/identity/resubmit', { method: 'POST', - body: JSON.stringify({ provider }), + body: JSON.stringify({ provider, ...(requirementKey ? { requirementKey } : {}) }), }) const responseJson = await response.json() diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 32e5d8e87..1c77a60c5 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -407,15 +407,17 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }, []) // initiate self-heal document resubmission: calls the resubmit API - // and opens the sumsub SDK with the action token - const handleSelfHealResubmit = useCallback(async (provider: 'BRIDGE' | 'MANTECA') => { + // and opens the sumsub SDK with the action token. `requirementKey` targets a + // specific (e.g. future-dated advisory) Bridge requirement; omitted for the + // legacy blocking flow. + const handleSelfHealResubmit = useCallback(async (provider: 'BRIDGE' | 'MANTECA', requirementKey?: string) => { setIsLoading(true) setError(null) userInitiatedRef.current = true selfHealProviderRef.current = provider try { - const response = await initiateSelfHealResubmission(provider) + const response = await initiateSelfHealResubmission(provider, requirementKey) if (response.error) { userInitiatedRef.current = false From 832ec52f90a54745b824153a070ea226b04059a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Sat, 20 Jun 2026 18:00:38 -0300 Subject: [PATCH 31/38] fix(kyc): guard against double-submit in advisory completeNow (CodeRabbit) onCompleteNow now fires a real network call (self-heal resubmit), so rapid clicks before isLoading disables the CTA could launch duplicate requests. Add a completingRef in-flight guard. --- src/hooks/useAdvisoryPreempt.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/hooks/useAdvisoryPreempt.ts b/src/hooks/useAdvisoryPreempt.ts index a9c01b6bc..78f29eebb 100644 --- a/src/hooks/useAdvisoryPreempt.ts +++ b/src/hooks/useAdvisoryPreempt.ts @@ -23,6 +23,10 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false const [dismissed, setDismissed] = useState(false) const [visible, setVisible] = useState(false) const pendingProceed = useRef<(() => void) | null>(null) + // Guards against double-submit: onCompleteNow now fires a real network call + // (self-heal resubmit), so rapid clicks before isLoading disables the CTA + // would otherwise launch duplicate requests. + const completingRef = useRef(false) const intercept = useCallback( (proceed: () => void) => { @@ -37,10 +41,16 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false ) const completeNow = useCallback(async () => { + if (completingRef.current) return + completingRef.current = true setDismissed(true) setVisible(false) pendingProceed.current = null - await onCompleteNow() + try { + await onCompleteNow() + } finally { + completingRef.current = false + } }, [onCompleteNow]) const skip = useCallback(() => { From b25f3c8238dd9782dc202e3a55897b4882f70418 Mon Sep 17 00:00:00 2001 From: peanut Date: Tue, 23 Jun 2026 13:49:23 +0200 Subject: [PATCH 32/38] fix(card): drop "Expiry"/"CVV" labels so PAN + name clear the artwork The registered-name line added height to the bottom-pinned (mt-auto) value block, pushing the PAN up into the hand artwork. Removing the two-line Expiry/CVV labels (revealed + loading skeleton) reclaims that height; flexbox shifts PAN + name back down. Values and ph-no-capture PII guards untouched. --- src/components/Card/CardFace.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 9e16efff2..14bff2756 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -138,7 +138,7 @@ const CardFace: FC = ({
-
Expiry
+ {/* "Expiry" label dropped — value row stays one line so PAN/name clear the artwork */} {/* ph-no-capture: expiry digits out of recordings. */}
{String(revealed.expiryMonth).padStart(2, '0')}/ @@ -147,7 +147,7 @@ const CardFace: FC = ({
-
CVV
+ {/* "CVV" label dropped — value only */} {/* ph-no-capture: CVV out of recordings. */}
{revealed.cvv}
@@ -180,11 +180,11 @@ const CardFace: FC = ({
-
Expiry
+ {/* label dropped to match the revealed layout — no height jump on reveal */}
-
CVV
+ {/* label dropped to match the revealed layout */}
From 53aeeb643890a004583786587a832e6219a5c045 Mon Sep 17 00:00:00 2001 From: peanut Date: Tue, 23 Jun 2026 14:08:31 +0200 Subject: [PATCH 33/38] test(card): assert registered name stays inside ph-no-capture wrapper Addresses CodeRabbit nit. The name is PII; asserting only the text would let a future refactor move it outside ph-no-capture without failing a test. Lock the class so the session-recording guard is regression-proof. --- src/components/Card/__tests__/CardFace.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Card/__tests__/CardFace.test.tsx b/src/components/Card/__tests__/CardFace.test.tsx index 784ebc767..2c50a501b 100644 --- a/src/components/Card/__tests__/CardFace.test.tsx +++ b/src/components/Card/__tests__/CardFace.test.tsx @@ -21,7 +21,11 @@ const revealed: RevealedCardDetails = { describe('CardFace cardholder name', () => { it('shows the registered name when the card is revealed', () => { render() - expect(screen.getByText('Jane Doe')).toBeInTheDocument() + const name = screen.getByText('Jane Doe') + expect(name).toBeInTheDocument() + // PII guard: the name must stay inside the ph-no-capture wrapper so it's + // kept out of session recordings — assert the class, not just the text. + expect(name).toHaveClass('ph-no-capture') }) it('hides the name when the card is masked (not revealed)', () => { From bc3d2bbf7bb2d83eadf67a65f052e5b7fd985597 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 2 Jun 2026 23:29:07 +0100 Subject: [PATCH 34/38] fix(offramp): derive destination from account type, not country MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-06-02 21:24 UTC, PEANUT-API-5P/5M/5N. A user with a UK GBP bank account tried to offramp USDC → EUR via SEPA. Bridge rejected with "country is not supported for SEPA" — SEPA only credits EUR-denominated Eurozone accounts, not GBP/UK. Root cause was in BankFlowManager.handleCreateOfframpAndClaim: const destination = getOfframpCurrencyConfig( account.country ?? selectedCountry!.id ) When `account.country` is missing, the picker falls back to `selectedCountry`. If `selectedCountry` doesn't match the saved-account's actual type (e.g. user picks a random country, or country resolution is buggy), the helper's "everything unknown → EUR/SEPA" default fires — even for a clearly GBP/UK account. The account's own `type` already carries the right answer for every Bridge destination we support (us/gb/clabe/iban). Adding a tiny helper `getOfframpConfigFromAccount` that derives currency+rail directly from account.type closes this class of bug. - `gb` → gbp/faster_payments - `us` → usd/ach - `clabe` → mxn/spei - `iban` → eur/sepa - `manteca` → throws (must use the Manteca offramp path, not Bridge) - type missing → falls back to country-based picking (prior behavior) The helper also accepts BE Prisma-shape suffixes (`BANK_IBAN`, `BANK_ACCOUNT_GB`, etc.) so it doesn't matter which side of the API the account row came from. 7 new unit tests in bridge.utils.test.ts cover: - The GB regression (was EUR/SEPA, now GBP/faster_payments) - All 4 supported account types map to the right rail - BE Prisma-suffix shapes (BANK_IBAN, BANK_ACCOUNT_GB) - Manteca throws to surface a wrong-path call early - Missing-type fallback to country picking still works All 71 bridge.utils tests passing. Companion PR: peanut-api-ts #964 — surfaces Bridge's actual error message in Sentry + stops Discord-paging on user-input 4xx errors. Even after this FE fix, other user-input mistakes (e.g. a Bridge customer status change mid-flow) shouldn't page on-call. --- .../Claim/Link/views/BankFlowManager.view.tsx | 10 ++- src/utils/__tests__/bridge.utils.test.ts | 65 +++++++++++++++++++ src/utils/bridge.utils.ts | 31 +++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index f66aacdef..0c6821f14 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -15,7 +15,7 @@ import useClaimLink from '../../useClaimLink' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' import { useAuth } from '@/context/authContext' import { type TCreateOfframpRequest, type TCreateOfframpResponse } from '@/services/services.types' -import { getOfframpCurrencyConfig } from '@/utils/bridge.utils' +import { getOfframpConfigFromAccount } from '@/utils/bridge.utils' import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts.utils' import { generateKeysFromString, getParamsFromLink } from '@/utils/peanut-link.utils' import { getContractAddress } from '@/utils/peanut-claim.utils' @@ -239,7 +239,13 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const externalAccountId = (account.bridgeAccountId ?? account.id) as string - const destination = getOfframpCurrencyConfig(account.country ?? selectedCountry!.id) + // Derive destination currency + rail from the SELECTED ACCOUNT's + // type, not from `selectedCountry`. Pairing a GB/GBP account with + // a SEPA destination is semantically impossible — Bridge rejects + // with "country is not supported for SEPA" (PEANUT-API-5P/5M/5N + // on 2026-06-02). The account's `type` already carries the right + // answer for every Bridge destination we support. + const destination = getOfframpConfigFromAccount(account) // handle offramp request creation const offrampRequestParams: TCreateOfframpRequest = { diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index 326894405..ec128da0e 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -2,6 +2,7 @@ import { BRIDGE_DEVELOPER_FEE_RATE } from '@/constants/payment.consts' import { applyBridgeCrossCurrencyFee, getCurrencyConfig, + getOfframpConfigFromAccount, getOfframpCurrencyConfig, getPaymentRailDisplayName, getMinimumAmount, @@ -142,6 +143,70 @@ describe('bridge.utils', () => { paymentRail: 'sepa', }) }) + }) + + describe('getOfframpConfigFromAccount (PEANUT-API-5P/5M/5N regression)', () => { + // The 2026-06-02 21:24 incident: a GB/GBP account got paired with + // EUR/SEPA because the picker used `account.country ?? selectedCountry` + // and the user-selected country fell through the "everything else → + // EUR/SEPA" default. These cases lock in the behavior that the account + // type alone is enough to pick the right rail. + it('GB account → GBP / faster_payments (was EUR/SEPA in the incident)', () => { + expect(getOfframpConfigFromAccount({ type: 'gb' })).toEqual({ + currency: 'gbp', + paymentRail: 'faster_payments', + }) + }) + + it('US account → USD / ach', () => { + expect(getOfframpConfigFromAccount({ type: 'us' })).toEqual({ + currency: 'usd', + paymentRail: 'ach', + }) + }) + + it('CLABE account → MXN / spei', () => { + expect(getOfframpConfigFromAccount({ type: 'clabe' })).toEqual({ + currency: 'mxn', + paymentRail: 'spei', + }) + }) + + it('IBAN account → EUR / sepa', () => { + expect(getOfframpConfigFromAccount({ type: 'iban' })).toEqual({ + currency: 'eur', + paymentRail: 'sepa', + }) + }) + + it('accepts BE Prisma-shape suffixes like BANK_IBAN / BANK_ACH_GB', () => { + expect(getOfframpConfigFromAccount({ type: 'BANK_IBAN' })).toEqual({ + currency: 'eur', + paymentRail: 'sepa', + }) + expect(getOfframpConfigFromAccount({ type: 'bank_account_gb' })).toEqual({ + currency: 'gbp', + paymentRail: 'faster_payments', + }) + }) + + it('throws on Manteca account type — must use the Manteca offramp path', () => { + expect(() => getOfframpConfigFromAccount({ type: 'manteca' })).toThrow( + 'Manteca accounts route through a separate offramp path' + ) + }) + + it('falls back to country-based picking when type is missing', () => { + expect(getOfframpConfigFromAccount({ country: 'US' })).toEqual({ + currency: 'usd', + paymentRail: 'ach', + }) + // Unknown country → EU/SEPA default, mirrors prior behavior + expect(getOfframpConfigFromAccount({ country: 'XYZ' })).toEqual({ + currency: 'eur', + paymentRail: 'sepa', + }) + }) describe('getMinimumAmount', () => { it('should return 50 for Mexico', () => { diff --git a/src/utils/bridge.utils.ts b/src/utils/bridge.utils.ts index 588e80a2e..aaa3d3655 100644 --- a/src/utils/bridge.utils.ts +++ b/src/utils/bridge.utils.ts @@ -81,6 +81,37 @@ export const getOfframpCurrencyConfig = (countryId: string): CurrencyConfig => { return getCurrencyConfig(countryId, 'offramp') } +/** + * Derive the offramp destination currency + payment rail from the bank + * account's actual `type`, falling back to country only when the type is + * unknown. + * + * Why: `getOfframpCurrencyConfig(country)` defaults *any* unknown country to + * EUR+SEPA. Pairing that default with a GBP/UK account caused Bridge to 400 + * with "country is not supported for SEPA" (PEANUT-API-5P/5M/5N, 2026-06-02 + * 21:24 UTC) — Bridge can't SEPA-credit a GBP account. The bank account's + * `type` already carries the right answer for every Bridge destination we + * support (`us`/`gb`/`clabe`/`iban`), so derive from it directly. + * + * Manteca-type accounts use a non-Bridge rail; the caller must NOT route + * those through this helper. We throw rather than silently misclassify. + */ +export const getOfframpConfigFromAccount = (account: { + type?: string | AccountType | null + country?: string | null +}): CurrencyConfig => { + const t = account.type?.toString().toLowerCase() + if (t === AccountType.US || t?.endsWith('ach') || t?.endsWith('us')) return getCurrencyConfig('US', 'offramp') + if (t === AccountType.GB || t?.endsWith('gb')) return getCurrencyConfig('GB', 'offramp') + if (t === AccountType.CLABE || t?.endsWith('clabe')) return getCurrencyConfig('MX', 'offramp') + if (t === AccountType.IBAN || t?.endsWith('iban')) return getCurrencyConfig('EU', 'offramp') + if (t === AccountType.MANTECA || t?.endsWith('manteca')) { + throw new Error('Manteca accounts route through a separate offramp path, not Bridge.') + } + // type missing / unknown — fall back to country, preserving prior behavior. + return getOfframpCurrencyConfig(account.country ?? 'EU') +} + /** * Map ISO fiat currency → Bridge bank AccountType. Used by onramp quote * + exchange-rate callers that only have the currency string in hand. From 7fdb7925a8981d36fc40c5756c0f563b731920ef Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 19 Jun 2026 17:12:06 -0700 Subject: [PATCH 35/38] fix(withdraw): stop false-rejecting IBANs whose country != the dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bank-details form rejected any IBAN whose country didn't equal the country selected on the previous screen ("IBAN does not match the selected country"). SEPA routes by IBAN, so the dropdown is cosmetic for a EUR payout — this gate blocked two legit cases: a German IBAN with Spain selected, and a UK user withdrawing EUR to a GB IBAN (Ibrahima / bobbyfresco). Gate on actual support instead: validateBankAccount() hits the BE allowedCountries check (SEPA/US/CA), so structurally-valid-but-unsupported IBANs are still rejected — just with an honest "not supported" message rather than a false country-mismatch. Drops the now-orphaned getCountryFromIban import. --- .../AddWithdraw/DynamicBankAccountForm.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 0b27f89ff..78026a57f 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -10,6 +10,7 @@ import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/compo import { useParams, useRouter } from 'next/navigation' import { validateIban, + validateBankAccount, validateBic, isValidRoutingNumber, isValidSortCode, @@ -19,7 +20,7 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import { getBicFromIban } from '@/app/actions/ibanToBic' import PeanutActionDetailsCard, { type PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { getCountryFromIban, validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' +import { validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' import useSavedAccounts from '@/hooks/useSavedAccounts' import { useAppDispatch, useAppSelector } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' @@ -453,9 +454,14 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const isValidIban = await validateIban(val) if (!isValidIban) return 'Invalid IBAN' - if (getCountryFromIban(val)?.toLowerCase() !== selectedCountry) { - return 'IBAN does not match the selected country' - } + // SEPA routes by IBAN — the country picked on the + // previous screen is cosmetic for a EUR payout. Don't + // force the IBAN's country to equal the dropdown: that + // false-rejected a German IBAN with Spain selected, and + // blocked UK users withdrawing EUR to a GB IBAN. Gate on + // actual support instead (BE allowedCountries: SEPA/US/CA). + const isSupported = await validateBankAccount(val) + if (!isSupported) return 'This IBAN isn’t supported for withdrawals' return true }, From 116627a6dcab36935d568cb6a4b1581a5030b26a Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 19 Jun 2026 17:26:37 -0700 Subject: [PATCH 36/38] fix(withdraw): source the Bridge countryCode from the IBAN, not the dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on to dropping the IBAN-country gate: the add-bank-account payload set countryCode/countryName/address.country from the country picked on the previous screen. With the gate gone, a German IBAN entered under "Spain" would reach Bridge with countryCode=ESP and 400. SEPA routes by IBAN, so derive all three country fields from the IBAN itself for IBAN accounts — keeping the payload internally consistent exactly as the old equality gate guaranteed, just sourced from the IBAN. Unblocks the GB-IBAN-EUR case (GB IBAN -> GBR) too. Adds unit coverage for getCountryCodeForWithdraw (GB->GBR/DE->DEU/ES->ESP/US->USA, idempotent on ISO-3) and getCountryFromIban, the derivation the fix relies on. --- .../AddWithdraw/DynamicBankAccountForm.tsx | 26 +++++++++++--- src/utils/__tests__/withdraw.utils.test.ts | 34 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 78026a57f..65da18b27 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -20,7 +20,12 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import { getBicFromIban } from '@/app/actions/ibanToBic' import PeanutActionDetailsCard, { type PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' +import { + getCountryFromIban, + getCountryCodeForWithdraw, + validateMXCLabeAccount, + validateUSBankAccount, +} from '@/utils/withdraw.utils' import useSavedAccounts from '@/hooks/useSavedAccounts' import { useAppDispatch, useAppSelector } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' @@ -214,11 +219,24 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D ? accountNumber.replace(/\s/g, '').padStart(8, '0') : accountNumber.replace(/\s/g, '') + // SEPA routes by IBAN, so the destination country must come from the + // IBAN itself — not the country picked on the previous screen. We + // relaxed the "IBAN must match the selected country" gate, so without + // this a German IBAN entered under "Spain" would reach Bridge with + // countryCode=ESP and 400. Derive all three country fields from the + // IBAN to keep the Bridge payload internally consistent (exactly what + // the old equality gate guaranteed, just sourced from the IBAN). + const ibanCountryCode = isIban + ? getCountryCodeForWithdraw(cleanedAccountNumber.slice(0, 2).toUpperCase()) + : '' + const ibanCountryName = isIban ? (getCountryFromIban(cleanedAccountNumber) ?? selectedCountry) : '' + const resolvedCountryCode = isUs ? 'USA' : isIban ? ibanCountryCode : country.toUpperCase() + const payload: Partial = { accountType, accountNumber: cleanedAccountNumber, - countryCode: isUs ? 'USA' : country.toUpperCase(), - countryName: selectedCountry, + countryCode: resolvedCountryCode, + countryName: isIban ? ibanCountryName : selectedCountry, accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL, accountOwnerName: { firstName: firstName.trim(), @@ -229,7 +247,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D city: data.city ?? '', state: data.state ?? '', postalCode: data.postalCode ?? '', - country: isUs ? 'USA' : country.toUpperCase(), + country: resolvedCountryCode, }, ...(bic && { bic }), } diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts index da08bd091..1335453c0 100644 --- a/src/utils/__tests__/withdraw.utils.test.ts +++ b/src/utils/__tests__/withdraw.utils.test.ts @@ -5,11 +5,45 @@ import { isPixEmvcoQr, normalizePixInput, validatePixKey, + getCountryCodeForWithdraw, + getCountryFromIban, } from '@/utils/withdraw.utils' jest.mock('@/assets', () => ({})) describe('Withdraw Utilities', () => { + // Locks in the IBAN→country derivation the bank-details form relies on after + // dropping the "IBAN must match the selected country" gate: the Bridge payload + // countryCode is now sourced from the IBAN, so a German IBAN entered under any + // SEPA country must resolve to DEU (not the dropdown's country). + describe('getCountryCodeForWithdraw (ISO-2 → ISO-3 for the Bridge payload)', () => { + it.each([ + ['GB', 'GBR'], + ['DE', 'DEU'], + ['ES', 'ESP'], + ['US', 'USA'], + ])('maps %s → %s', (iso2, iso3) => { + expect(getCountryCodeForWithdraw(iso2)).toBe(iso3) + }) + + it('is idempotent on an already ISO-3 code', () => { + expect(getCountryCodeForWithdraw('DEU')).toBe('DEU') + }) + }) + + describe('getCountryFromIban', () => { + it('resolves the country from the IBAN prefix (GB/DE) and is whitespace-tolerant', () => { + // GB IBAN (Revolut-style) — the GB-IBAN-EUR case (Ibrahima). + expect(getCountryFromIban('GB33BUKB20201555555555')).not.toBeNull() + // DE IBAN with spaces — the intra-SEPA mismatch case. + expect(getCountryFromIban('DE89 3704 0044 0532 0130 00')).not.toBeNull() + }) + + it('returns null for an unknown country prefix', () => { + expect(getCountryFromIban('ZZ00')).toBeNull() + }) + }) + describe('validateCbuCvuAlias', () => { it.each([ { value: '', valid: false, message: 'Invalid length' }, From c38e2b8aceebe4e22c8cc462aef66817b7eb3bc0 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 19 Jun 2026 17:45:56 -0700 Subject: [PATCH 37/38] fix(offramp): preserve account type through bank details + normalize IBAN source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (2 major): - BankFlowManager: the bankDetails objects (new-account, saved-account, guest) dropped `type`, so getOfframpConfigFromAccount() fell back to country routing — defeating the derive-from-type fix. Carry `type` through all three (guest path derives it from the response shape: iban/clabe/sort_code→gb/account→us). - DynamicBankAccountForm: derive the IBAN country from a single normalized source (cleanedAccountNumber, falling back to the `iban` form value) so countryCode is never derived off an empty string → no empty countryCode to Bridge. --- .../AddWithdraw/DynamicBankAccountForm.tsx | 9 +++++++-- .../Claim/Link/views/BankFlowManager.view.tsx | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 65da18b27..3b65d8488 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -226,10 +226,15 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D // countryCode=ESP and 400. Derive all three country fields from the // IBAN to keep the Bridge payload internally consistent (exactly what // the old equality gate guaranteed, just sourced from the IBAN). + // Single normalized IBAN source: the IBAN is entered in the + // accountNumber field (→ cleanedAccountNumber), but fall back to the + // separate `iban` form value so the country never derives off an empty + // string (which would send an empty countryCode to Bridge → 400). + const normalizedIban = isIban ? cleanedAccountNumber || (iban || '').replace(/\s/g, '') : '' const ibanCountryCode = isIban - ? getCountryCodeForWithdraw(cleanedAccountNumber.slice(0, 2).toUpperCase()) + ? getCountryCodeForWithdraw(normalizedIban.slice(0, 2).toUpperCase()) : '' - const ibanCountryName = isIban ? (getCountryFromIban(cleanedAccountNumber) ?? selectedCountry) : '' + const ibanCountryName = isIban ? (getCountryFromIban(normalizedIban) ?? selectedCountry) : '' const resolvedCountryCode = isUs ? 'USA' : isIban ? ibanCountryCode : country.toUpperCase() const payload: Partial = { diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 0c6821f14..4fbfb4b24 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -327,6 +327,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { } if (addBankAccountResponse.data?.id) { const bankDetails = { + // carry the account type so getOfframpConfigFromAccount() derives + // the rail from it (GB→GBP) instead of falling back to country + type: addBankAccountResponse.data.type, name: addBankAccountResponse.data.details.accountOwnerName || user?.user.fullName || '', iban: addBankAccountResponse.data.type === 'iban' @@ -416,6 +419,18 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // merge the external account details with the user's details const finalBankDetails = { + // derive the account type from the response shape so + // getOfframpConfigFromAccount() routes by rail (GB sort_code → gb) + // instead of falling back to country + type: externalAccountResponse?.iban + ? 'iban' + : externalAccountResponse?.clabe + ? 'clabe' + : externalAccountResponse?.account?.sort_code + ? 'gb' + : externalAccountResponse?.account + ? 'us' + : undefined, id: externalAccountResponse.id, bridgeAccountId: externalAccountResponse.id, name: externalAccountResponse.bank_name ?? rawData.name, @@ -465,6 +480,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const lastName = lastNameParts.join(' ') const bankDetails = { + // carry the account type so getOfframpConfigFromAccount() + // derives the rail from it instead of falling back to country + type: account.type, name: account.details?.accountOwnerName || user?.user.fullName || '', iban: account.type === 'iban' ? account.identifier || '' : '', clabe: account.type === 'clabe' ? account.identifier || '' : '', From 48e749d80be8e5c339088ee8ef395b6986829baf Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 13:00:20 -0700 Subject: [PATCH 38/38] fix(withdraw): derive offramp config from account + guard fresh account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1.4: destinationDetails derived the currency/rail from a country switch whose default returned an empty rail, so a UK account typed anything but GB (pre-BANK_GB mistype, or a Prisma-shaped 'BANK_GB') dead-ended on 'External account ID is missing.'. Derive from the account via getOfframpConfigFromAccount (GB->GBP), consistent with the claim flow. T1.2: harden the freshly-added-account fallback — if the add-bank-account response lacks bridgeAccountId, surface a retryable error instead of navigating to a confirm screen that dead-ends on 'Bank account is missing.'. --- .../withdraw/[country]/bank/page.tsx | 39 ++++++------------- .../AddWithdraw/AddWithdrawCountriesList.tsx | 8 ++++ src/utils/__tests__/bridge.utils.test.ts | 13 +++++++ 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 6a7c89f04..30b14df4d 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -25,7 +25,7 @@ import { TRANSACTIONS } from '@/constants/query.consts' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' import { ErrorHandler } from '@/utils/friendly-error.utils' import { getBridgeChainName } from '@/utils/bridge-accounts.utils' -import { getOfframpCurrencyConfig, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' +import { getOfframpConfigFromAccount, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' import { createOfframp, confirmOfframp } from '@/app/actions/offramp' import { useAuth } from '@/context/authContext' import { useTosGuard } from '@/hooks/useTosGuard' @@ -160,32 +160,17 @@ export default function WithdrawBankPage() { }, [bankAccount, router, amountToWithdraw, country, view]) const destinationDetails = (account: Account) => { - let countryId: string - - switch (account.type) { - case AccountType.US: - countryId = 'US' - break - case AccountType.IBAN: - // Default to a European country that uses EUR/SEPA - countryId = 'DE' // Germany as default EU country - break - case AccountType.CLABE: - countryId = 'MX' - break - case AccountType.GB: - countryId = 'GB' - break - default: - return { - currency: '', - paymentRail: '', - externalAccountId: null, - } - } - - const { currency, paymentRail } = getOfframpCurrencyConfig(countryId) - + // Derive currency + rail from the account's actual type (GB→GBP, IBAN→EUR, + // US→USD, CLABE→MXN) rather than re-deriving from a country switch whose + // `default` returned an empty currency/rail. A UK account that arrived typed + // anything but GB (the pre-BANK_GB BE mistype, or a Prisma-shaped 'BANK_GB' + // string) fell through that default → empty payload → "External account ID + // is missing.". getOfframpConfigFromAccount tolerates both the projected + // ('gb') and Prisma-shaped ('BANK_GB') strings and keeps this flow + // consistent with the Claim flow (BankFlowManager). Manteca accounts never + // reach this Bridge page (separate /withdraw/manteca route), so its throw + // cannot fire here. + const { currency, paymentRail } = getOfframpConfigFromAccount(account) return { currency, paymentRail, diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 6698a3044..c00fc73de 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -209,6 +209,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // fallback to the previous method if we can't find the new account // this can happen if the user object is not updated immediately const newAccountFromResponse = result.data as Account + // The freshly-added account hasn't surfaced in the user refetch yet. + // The add-bank-account response is the projected wire shape, so it + // already carries bridgeAccountId + the legacy `type`. Guard: without + // a bridgeAccountId the confirm step dead-ends on "Bank account is + // missing", so surface a retryable error rather than navigating. + if (!newAccountFromResponse?.bridgeAccountId) { + return { error: 'Your bank account is still being set up. Please try again in a moment.' } + } // ensure details has accountOwnerName for confirmation page display newAccountFromResponse.details = { ...(newAccountFromResponse.details || {}), diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index ec128da0e..0439e8e0c 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -190,6 +190,19 @@ describe('bridge.utils', () => { }) }) + it('accepts the exact Prisma enum BANK_GB → GBP / faster_payments (uk-gbp-withdraw-flow)', () => { + // The withdraw bank page derives the offramp payload from the account + // via destinationDetails → getOfframpConfigFromAccount. A UK account + // whose `type` arrives as the raw Prisma enum 'BANK_GB' (not the + // projected 'gb') must still map to GBP/faster_payments; the previous + // switch's `default` returned an empty rail → "External account ID is + // missing.". + expect(getOfframpConfigFromAccount({ type: 'BANK_GB' })).toEqual({ + currency: 'gbp', + paymentRail: 'faster_payments', + }) + }) + it('throws on Manteca account type — must use the Manteca offramp path', () => { expect(() => getOfframpConfigFromAccount({ type: 'manteca' })).toThrow( 'Manteca accounts route through a separate offramp path'