diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index 4e67893f2..d749e8a0e 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -1503,6 +1503,27 @@ describe('GROUP 8: InputAmountStep Component', () => { expect(screen.getByText('Continue')).not.toBeDisabled() }) + test('renders maintenanceBanner and keeps Continue enabled (warn-only)', () => { + renderWithProviders( + PIX deposits are under maintenance} + /> + ) + + expect(screen.getByTestId('pix-maintenance')).toBeInTheDocument() + // warn-only: the banner is informational and must not block submission + expect(screen.getByText('Continue')).not.toBeDisabled() + }) + test('Continue disabled when limits blocking', () => { const { getLimitsWarningCardProps } = require('@/features/limits/utils') getLimitsWarningCardProps.mockReturnValue({ diff --git a/src/components/AddMoney/components/InputAmountStep.tsx b/src/components/AddMoney/components/InputAmountStep.tsx index dc6b78dac..4aa528ee3 100644 --- a/src/components/AddMoney/components/InputAmountStep.tsx +++ b/src/components/AddMoney/components/InputAmountStep.tsx @@ -30,6 +30,8 @@ interface InputAmountStepProps { // required - must be provided by caller based on the payment flow's currency (ARS, BRL, USD) limitsCurrency: LimitCurrency onBack: () => void + // optional warning banner rendered at the top of the step (e.g. PIX-under-maintenance) + maintenanceBanner?: React.ReactNode } const InputAmountStep = ({ @@ -46,6 +48,7 @@ const InputAmountStep = ({ limitsValidation, limitsCurrency, onBack, + maintenanceBanner, }: InputAmountStepProps) => { if (currencyData?.isLoading) { return @@ -63,6 +66,7 @@ const InputAmountStep = ({
+ {maintenanceBanner}
How much do you want to add?
{ return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) const onBack = useSafeBack(addMoneyCountryUrl(selectedCountryPath)) + // BRL-via-PIX onramp warn-only maintenance flag (see underMaintenance.config.ts). + // Brazil-scoped so the Argentina/ARS Manteca onramp is unaffected. + const showPixMaintenance = selectedCountry?.id === 'BR' && underMaintenanceConfig.pixBrazilOnrampMaintenance // The pool→full upgrade gate asks "did the user clear ID verification?", // not "do they have an enabled rail elsewhere?" — read the identity // signal directly (Sumsub-cleared the human) instead of the old @@ -282,6 +287,16 @@ const MantecaAddMoney: FC = () => { limitsValidation={limitsValidation} limitsCurrency={limitsValidation.currency} onBack={onBack} + maintenanceBanner={ + showPixMaintenance ? ( + + ) : undefined + } /> ) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 0005b3f54..6698a3044 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -35,6 +35,7 @@ import { getRegionIntent } from '@/utils/regions.utils' import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useModalsContext } from '@/context/ModalsContext' +import underMaintenanceConfig, { PIX_BRAZIL_ONRAMP_MAINTENANCE } from '@/config/underMaintenance.config' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -426,58 +427,76 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {

{title}

- {paymentMethods.map((method, index) => ( - - ) : ( - {method.id} - ) - } - rightContent={method.isSoon ? : null} - onClick={() => { - if (flow === 'withdraw') { - handleWithdrawMethodClick(method) - } else if (method.path) { - handleAddMethodClick(method) + {paymentMethods.map((method, index) => { + // BRL-via-PIX onramp is warn-only under maintenance: tag the Pix option but + // keep it clickable (do not set isDisabled). + const isPixOnrampUnderMaintenance = + flow === 'add' && + method.id === 'pix-add' && + underMaintenanceConfig.pixBrazilOnrampMaintenance + return ( + + ) : ( + {method.id} + ) } - }} - position={ - paymentMethods.length === 1 - ? 'single' - : index === 0 - ? 'first' - : index === paymentMethods.length - 1 - ? 'last' - : 'middle' - } - /> - ))} + rightContent={ + method.isSoon ? ( + + ) : isPixOnrampUnderMaintenance ? ( + + ) : null + } + onClick={() => { + if (flow === 'withdraw') { + handleWithdrawMethodClick(method) + } else if (method.path) { + handleAddMethodClick(method) + } + }} + position={ + paymentMethods.length === 1 + ? 'single' + : index === 0 + ? 'first' + : index === paymentMethods.length - 1 + ? 'last' + : 'middle' + } + /> + ) + })}
) diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx index 972c92fd6..e6329cfab 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -15,8 +15,9 @@ * gate is NOT ready — so the fix didn't just delete the guard wholesale. */ import React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, within } from '@testing-library/react' import AddWithdrawCountriesList from '../AddWithdrawCountriesList' +import underMaintenanceConfig from '@/config/underMaintenance.config' // ---- routing ---- const mockPush = jest.fn() @@ -41,6 +42,13 @@ jest.mock('@/components/AddMoney/consts', () => ({ icon: 'bank', path: '/add-money/testland/bank', }, + { + id: 'pix-add', + title: 'Pix', + description: 'Instant transfers', + icon: 'pix', + path: '/add-money/brazil/manteca', + }, ], // id contains 'default-bank-withdraw' → routes through checkBridgeGate // (not the Manteca direct path), so it exercises the same gate. @@ -128,8 +136,13 @@ jest.mock('@/utils/regions.utils', () => ({ getRegionIntent: () => 'STANDARD' }) jest.mock('@/components/ActionListCard', () => ({ ActionListCard: (props: any) => ( - ), })) @@ -137,7 +150,10 @@ jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () =>
, })) -jest.mock('@/components/Global/Badges/StatusBadge', () => ({ __esModule: true, default: () => })) +jest.mock('@/components/Global/Badges/StatusBadge', () => ({ + __esModule: true, + default: (props: any) => {props.customText ?? props.status}, +})) jest.mock('@/components/Profile/AvatarWithBadge', () => ({ __esModule: true, default: () => })) jest.mock('@/components/Global/EmptyStates/EmptyState', () => ({ __esModule: true, default: () =>
})) jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBankAccountForm: () =>
})) @@ -220,3 +236,41 @@ describe('AddWithdrawCountriesList — bank gate', () => { expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument() }) }) + +/** + * BRL-via-PIX onramp is unstable, so the Pix option is flagged "under maintenance" + * (config: pixBrazilOnrampMaintenance) — warn-only: it stays visible and clickable. + */ +describe('AddWithdrawCountriesList — PIX onramp maintenance tag', () => { + beforeEach(() => { + mockPush.mockClear() + // a ready gate so a click can navigate — proving the option is not blocked + setCapabilities('ready', [{ status: 'enabled', channel: 'bank', country: 'US' }]) + }) + + afterEach(() => { + underMaintenanceConfig.pixBrazilOnrampMaintenance = true + }) + + it('tags the Pix option "Maintenance" but keeps it clickable (warn-only)', () => { + underMaintenanceConfig.pixBrazilOnrampMaintenance = true + + render() + + const pixCard = screen.getByTestId('method-pix') + expect(within(pixCard).getByText('Maintenance')).toBeInTheDocument() + + // warn-only: still navigates into the deposit flow + fireEvent.click(pixCard) + expect(mockPush).toHaveBeenCalledWith('/add-money/brazil/manteca') + }) + + it('shows no maintenance tag when the flag is off, and never tags non-Pix methods', () => { + underMaintenanceConfig.pixBrazilOnrampMaintenance = false + + render() + + expect(within(screen.getByTestId('method-pix')).queryByText('Maintenance')).toBeNull() + expect(within(screen.getByTestId('method-bank')).queryByText('Maintenance')).toBeNull() + }) +}) diff --git a/src/components/Claim/Claim.tsx b/src/components/Claim/Claim.tsx index 1a3ed11d3..04da70171 100644 --- a/src/components/Claim/Claim.tsx +++ b/src/components/Claim/Claim.tsx @@ -14,7 +14,13 @@ import { EHistoryUserRole } from '@/hooks/useTransactionHistory' import { useUserInteractions } from '@/hooks/useUserInteractions' import { useWallet } from '@/hooks/wallet/useWallet' import type { RecipientType } from '@/interfaces/interfaces' -import { ESendLinkStatus, getParamsFromLink, sendLinksApi, type ClaimLinkData } from '@/services/sendLinks' +import { + ESendLinkStatus, + getParamsFromLink, + resolveClaimLink, + sendLinksApi, + type ClaimLinkData, +} from '@/services/sendLinks' import { getInitialsFromName, getTokenDetails, @@ -408,7 +414,9 @@ export const Claim = ({}) => { useEffect(() => { const pageUrl = typeof window !== 'undefined' ? window.location.href : '' if (pageUrl) { - setLinkUrl(pageUrl) // TanStack Query will automatically fetch when linkUrl changes + // resolveClaimLink restores the pristine `#p=` password if an auth/KYC + // redirect mangled the current URL's fragment (TASK-20193). + setLinkUrl(resolveClaimLink(pageUrl)) // TanStack Query will automatically fetch when linkUrl changes } }, []) @@ -526,7 +534,7 @@ export const Claim = ({}) => { transaction={selectedTransaction} setIsLoading={setisLinkCancelling} isLoading={isLinkCancelling} - onClose={() => setLinkUrl(window.location.href)} + onClose={() => setLinkUrl(resolveClaimLink(window.location.href))} /> )} diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 975ab3a9b..f66aacdef 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -396,7 +396,13 @@ export const BankFlowManager = (props: IClaimScreenProps) => { payloadWithCountry ) if ('error' in externalAccountResponse && externalAccountResponse.error) { - throw new Error(String(externalAccountResponse.error)) + // The backend returns a curated, user-facing message for bank-account + // validation failures (e.g. an unverifiable billing address). Surface it + // verbatim — routing it through ErrorHandler would collapse it into the + // generic "contact support" fallback, hiding the actionable detail. (TASK-20194) + const accountError = String(externalAccountResponse.error) + Sentry.captureException(new Error(`External account creation failed: ${accountError}`)) + return { error: accountError } } if (!('id' in externalAccountResponse)) { throw new Error('Failed to create external account') diff --git a/src/components/Claim/__tests__/claim-states.test.tsx b/src/components/Claim/__tests__/claim-states.test.tsx index ecca0edb6..e99acc139 100644 --- a/src/components/Claim/__tests__/claim-states.test.tsx +++ b/src/components/Claim/__tests__/claim-states.test.tsx @@ -157,6 +157,7 @@ jest.mock('@/services/sendLinks', () => ({ }, sendLinksApi: mockSendLinksApi, getParamsFromLink: (...args: any[]) => mockGetParamsFromLink(...args), + resolveClaimLink: (link: string) => link, })) jest.mock('@/utils/peanut-link.utils', () => ({ diff --git a/src/components/Profile/views/UnlockedRegions.view.tsx b/src/components/Profile/views/UnlockedRegions.view.tsx index 092056610..7660728c5 100644 --- a/src/components/Profile/views/UnlockedRegions.view.tsx +++ b/src/components/Profile/views/UnlockedRegions.view.tsx @@ -21,6 +21,10 @@ import Image from 'next/image' import { useSafeBack } from '@/hooks/useSafeBack' import { useState, useCallback, useRef, useMemo } from 'react' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' +import { useRouter } from 'next/navigation' +import { useCardInfo } from '@/hooks/useCardInfo' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { findActiveCard } from '@/components/Card/cardState.utils' type ModalVariant = 'start' | 'processing' | 'action_required' | 'rejected' @@ -59,6 +63,19 @@ function getModalVariant(rail: RailCapability | undefined, hasSumsubAction: bool const UnlockedRegions = () => { const onBack = useSafeBack('/profile', { replace: true }) + const router = useRouter() + // Card-priority guard: an eligible user (skip badge / admin grant → + // hasCardAccess) without an active card is steered to /card from the region + // picker (see handleStartKyc), regardless of region. `hasActiveCard` excludes + // users who already hold a card so they can still unlock bank regions here. + const { hasCardAccess } = useCardInfo() + const { overview } = useRainCardOverview() + // Tri-state: undefined while the overview is still loading, so a card-holder + // isn't briefly treated as "no card" and bounced to /card before it resolves. + const hasActiveCard = useMemo(() => { + if (!overview) return undefined + return !!findActiveCard(overview) + }, [overview]) const { rails, isKycApproved, railsForProvider, nextActionsForRail } = useCapabilities() // MIGRATION-REVIEW: unlockedRegions/lockedRegions previously came from // `useIdentityVerification` (raw rails + Sumsub flags). Now derived from the @@ -152,6 +169,16 @@ const UnlockedRegions = () => { }, []) const handleStartKyc = useCallback(async () => { + // Card takes priority for eligible users: if the user has card access but + // no active card yet, send them to /card (KYC on rain-requirements — no + // regionIntent, no Bridge/Manteca rail enrollment) instead of starting + // region KYC, for ANY region they picked. Card-holders fall through and + // can still unlock bank regions here. + if (hasCardAccess === true && hasActiveCard === false) { + setSelectedRegion(null) + router.push('/card') + return + } const intent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined if (intent) setActiveRegionIntent(intent) setErrorAcknowledged(false) @@ -166,7 +193,7 @@ const UnlockedRegions = () => { // Fresh-KYC safe: the BE's cross-region branches all require an // APPROVED verification, so for first-time users the flag is a no-op. await flow.handleInitiateKyc(intent, undefined, true) - }, [flow.handleInitiateKyc, selectedRegion]) + }, [flow.handleInitiateKyc, selectedRegion, hasCardAccess, hasActiveCard, router]) // re-submission: skip StartVerificationView since user already consented const handleResubmitKyc = useCallback(async () => { diff --git a/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx b/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx index 552df0a35..805985bab 100644 --- a/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx +++ b/src/components/TransactionDetails/provider-rows/CardPaymentRows.tsx @@ -3,7 +3,7 @@ import Image from 'next/image' import { type ReactNode } from 'react' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' -import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' +import { type DisputeStatus, type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' import { friendlyDeclineReason } from '@/utils/cardDeclineReason' import { getFlagUrl } from '@/constants/countryCurrencyMapping' import { extractMerchantIso2 } from '@/components/TransactionDetails/transaction-details.utils' @@ -27,6 +27,30 @@ function parseCents(value: string | null | undefined): number | null { return Number.isFinite(n) ? n : null } +/** + * Friendly copy for the dispute status row. The drawer shows ONE row labeled + * "Dispute" with the status-mapped text. Keep terminal-state copy actionable: + * "Resolved by merchant refund" / "Accepted (refund issued)" both signal that + * money has been returned (or is being returned), which is the user's actual + * concern at that point. + */ +export function disputeStatusLabel(status: DisputeStatus): string { + switch (status) { + case 'pending': + return 'Disputed — Awaiting review' + case 'inReview': + return 'Disputed — In review' + case 'accepted': + return 'Disputed — Accepted (refund issued)' + case 'rejected': + return 'Disputed — Rejected' + case 'canceled': + return 'Disputed — Cancelled' + case 'resolvedByMerchant': + return 'Disputed — Resolved by merchant refund' + } +} + /** * Whether CardPaymentRows would render any visible sub-row for this * transaction. The row-config in the receipt uses this to gate the @@ -65,6 +89,7 @@ export function hasCardPaymentRowsContent(transaction: TransactionDetails): bool if (card.cancellationReason === 'auth_reversed' || card.cancellationReason === 'auth_expired_uncaptured') { return true } + if (card.dispute) return true return false } @@ -198,7 +223,11 @@ export function CardPaymentRows({ card.cancellationReason === 'auto_closed' || card.cancellationReason === 'auth_reversed' || card.cancellationReason === 'auth_expired_uncaptured' - if (transaction.status === 'pending' && !card.isRefund && !hasCancellationNote) { + // Active disputes flip the pill to pending too (see transactionTransformer), + // but the dispute row IS the status — the spend already settled, so + // "Authorized, awaiting settlement" is a lie next to "Disputed — In review". + const hasActiveDispute = card.dispute?.status === 'pending' || card.dispute?.status === 'inReview' + if (transaction.status === 'pending' && !card.isRefund && !hasCancellationNote && !hasActiveDispute) { subRows.push({ key: 'pendingNote', label: 'Status', @@ -206,6 +235,28 @@ export function CardPaymentRows({ }) } + // Dispute lifecycle. One row for the status; an additional row when Rain + // has requested evidence so the user knows to upload it. Both render + // regardless of the spend's status — disputes outlive the spend lifecycle. + if (card.dispute) { + subRows.push({ + key: 'disputeStatus', + label: 'Dispute', + value: disputeStatusLabel(card.dispute.status), + }) + // Rain prompt text is free-form prose; the same nonBlank gate the + // decline-reason row uses keeps whitespace-only payloads from + // rendering an empty "Evidence requested" row. + const evidenceMessage = nonBlank(card.dispute.evidenceRequestedMessage) + if (evidenceMessage) { + subRows.push({ + key: 'disputeEvidenceRequest', + label: 'Evidence requested', + value: evidenceMessage, + }) + } + } + if (subRows.length === 0) return null return ( diff --git a/src/components/TransactionDetails/provider-rows/__tests__/dispute-label.test.ts b/src/components/TransactionDetails/provider-rows/__tests__/dispute-label.test.ts new file mode 100644 index 000000000..46afb2519 --- /dev/null +++ b/src/components/TransactionDetails/provider-rows/__tests__/dispute-label.test.ts @@ -0,0 +1,21 @@ +// Locks in the Disputed-status row copy. Keep changes here in sync with the +// BE's dispute event handler in src/ledger/rain-mapper.ts so the FE label +// always covers every status string the BE may write. + +import { disputeStatusLabel } from '../CardPaymentRows' +import { type DisputeStatus } from '@/components/TransactionDetails/transactionTransformer' + +const CASES: Array<[DisputeStatus, string]> = [ + ['pending', 'Disputed — Awaiting review'], + ['inReview', 'Disputed — In review'], + ['accepted', 'Disputed — Accepted (refund issued)'], + ['rejected', 'Disputed — Rejected'], + ['canceled', 'Disputed — Cancelled'], + ['resolvedByMerchant', 'Disputed — Resolved by merchant refund'], +] + +describe('disputeStatusLabel', () => { + test.each(CASES)('%s → %s', (status: DisputeStatus, label: string) => { + expect(disputeStatusLabel(status)).toBe(label) + }) +}) diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts index b93e28a91..4a33340be 100644 --- a/src/components/TransactionDetails/transactionTransformer.ts +++ b/src/components/TransactionDetails/transactionTransformer.ts @@ -18,6 +18,22 @@ import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts' import { type HistoryEntryPerkReward, type ChargeEntry } from '@/services/services.types' import { dispatchStrategy, isIntentKind, type IntentKind } from './strategies/registry' +/** Rain dispute lifecycle status values. Source: Rain dispute.* webhooks. */ +export type DisputeStatus = 'pending' | 'inReview' | 'accepted' | 'rejected' | 'canceled' | 'resolvedByMerchant' + +const DISPUTE_STATUSES: ReadonlySet = new Set([ + 'pending', + 'inReview', + 'accepted', + 'rejected', + 'canceled', + 'resolvedByMerchant', +]) + +function isDisputeStatus(value: unknown): value is DisputeStatus { + return typeof value === 'string' && DISPUTE_STATUSES.has(value) +} + // Mirror of peanut-api-ts `enum TransactionProvider`. Receipts that branch // on provider (e.g. Manteca-specific deposit-info row) use this typed // value via `extraDataForDrawer.provider` for positive identity rather @@ -374,6 +390,17 @@ export interface TransactionDetails { parentRainTxId: string | null rainTransactionId: string | null isRefund: boolean + /** Populated when Rain has fired any dispute.* webhook for this + * spend. Status drives the drawer's "Disputed —