Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<InputAmountStep
tokenAmount="100"
setTokenAmount={jest.fn()}
onSubmit={jest.fn()}
isLoading={false}
error={null}
setCurrencyAmount={jest.fn()}
limitsValidation={{ isBlocking: false, isWarning: false, currency: 'USD' }}
limitsCurrency="USD"
onBack={jest.fn()}
maintenanceBanner={<div data-testid="pix-maintenance">PIX deposits are under maintenance</div>}
/>
)

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({
Expand Down
4 changes: 4 additions & 0 deletions src/components/AddMoney/components/InputAmountStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -46,6 +48,7 @@ const InputAmountStep = ({
limitsValidation,
limitsCurrency,
onBack,
maintenanceBanner,
}: InputAmountStepProps) => {
if (currencyData?.isLoading) {
return <PeanutLoading />
Expand All @@ -63,6 +66,7 @@ const InputAmountStep = ({
<div className="flex min-h-[inherit] flex-col justify-start space-y-8">
<NavHeader title="Add Money" onPrev={onBack} />
<div className="my-auto flex flex-grow flex-col justify-center gap-4 md:my-0">
{maintenanceBanner}
<div className="text-sm font-bold">How much do you want to add?</div>

<AmountInput
Expand Down
15 changes: 15 additions & 0 deletions src/components/AddMoney/components/MantecaAddMoney.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs'
import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import InfoCard from '@/components/Global/InfoCard'
import underMaintenanceConfig, { PIX_BRAZIL_ONRAMP_MAINTENANCE } from '@/config/underMaintenance.config'

// Step type for URL state
type MantecaStep = 'inputAmount' | 'depositDetails'
Expand Down Expand Up @@ -72,6 +74,9 @@ const MantecaAddMoney: FC = () => {
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
Expand Down Expand Up @@ -282,6 +287,16 @@ const MantecaAddMoney: FC = () => {
limitsValidation={limitsValidation}
limitsCurrency={limitsValidation.currency}
onBack={onBack}
maintenanceBanner={
showPixMaintenance ? (
<InfoCard
variant="warning"
icon="alert"
title={PIX_BRAZIL_ONRAMP_MAINTENANCE.title}
description={PIX_BRAZIL_ONRAMP_MAINTENANCE.description}
/>
) : undefined
}
/>
</>
)
Expand Down
121 changes: 70 additions & 51 deletions src/components/AddWithdraw/AddWithdrawCountriesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -426,58 +427,76 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
<div className="space-y-2">
<h2 className="text-base font-bold">{title}</h2>
<div className="flex flex-col">
{paymentMethods.map((method, index) => (
<ActionListCard
key={method.id}
isDisabled={method.isSoon}
title={method.title}
description={method.description}
descriptionClassName={'text-xs'}
leftIcon={
typeof method.icon === 'string' || method.icon === undefined ? (
<AvatarWithBadge
icon={method.icon as IconName}
name={method.title ?? method.id}
size="extra-small"
inlineStyle={{
backgroundColor:
method.icon === ('bank' as IconName)
? '#FFC900'
: method.id === 'crypto-add' || method.id === 'crypto-withdraw'
? '#FFC900'
: getColorForUsername(method.title).lightShade,
color: method.icon === ('bank' as IconName) ? 'black' : 'black',
}}
/>
) : (
<Image
src={method.icon as StaticImageData}
alt={method.id}
className="h-8 w-8 rounded-full"
width={32}
height={32}
/>
)
}
rightContent={method.isSoon ? <StatusBadge status="soon" size="small" /> : 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 (
<ActionListCard
key={method.id}
isDisabled={method.isSoon}
title={method.title}
description={method.description}
descriptionClassName={'text-xs'}
leftIcon={
typeof method.icon === 'string' || method.icon === undefined ? (
<AvatarWithBadge
icon={method.icon as IconName}
name={method.title ?? method.id}
size="extra-small"
inlineStyle={{
backgroundColor:
method.icon === ('bank' as IconName)
? '#FFC900'
: method.id === 'crypto-add' || method.id === 'crypto-withdraw'
? '#FFC900'
: getColorForUsername(method.title).lightShade,
color: method.icon === ('bank' as IconName) ? 'black' : 'black',
}}
/>
) : (
<Image
src={method.icon as StaticImageData}
alt={method.id}
className="h-8 w-8 rounded-full"
width={32}
height={32}
/>
)
}
}}
position={
paymentMethods.length === 1
? 'single'
: index === 0
? 'first'
: index === paymentMethods.length - 1
? 'last'
: 'middle'
}
/>
))}
rightContent={
method.isSoon ? (
<StatusBadge status="soon" size="small" />
) : isPixOnrampUnderMaintenance ? (
<StatusBadge
status="pending"
customText={PIX_BRAZIL_ONRAMP_MAINTENANCE.badge}
size="small"
/>
) : 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'
}
/>
)
})}
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -128,16 +136,24 @@ jest.mock('@/utils/regions.utils', () => ({ getRegionIntent: () => 'STANDARD' })

jest.mock('@/components/ActionListCard', () => ({
ActionListCard: (props: any) => (
<button data-testid={`method-${props.title?.toLowerCase()}`} onClick={props.onClick}>
<button
data-testid={`method-${props.title?.toLowerCase()}`}
onClick={props.isDisabled ? undefined : props.onClick}
disabled={props.isDisabled}
>
{props.title}
{props.rightContent}
</button>
),
}))
jest.mock('@/components/Global/NavHeader', () => ({
__esModule: true,
default: () => <div data-testid="nav-header" />,
}))
jest.mock('@/components/Global/Badges/StatusBadge', () => ({ __esModule: true, default: () => <span /> }))
jest.mock('@/components/Global/Badges/StatusBadge', () => ({
__esModule: true,
default: (props: any) => <span data-testid="status-badge">{props.customText ?? props.status}</span>,
}))
jest.mock('@/components/Profile/AvatarWithBadge', () => ({ __esModule: true, default: () => <span /> }))
jest.mock('@/components/Global/EmptyStates/EmptyState', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBankAccountForm: () => <div /> }))
Expand Down Expand Up @@ -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(<AddWithdrawCountriesList flow="add" />)

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(<AddWithdrawCountriesList flow="add" />)

expect(within(screen.getByTestId('method-pix')).queryByText('Maintenance')).toBeNull()
expect(within(screen.getByTestId('method-bank')).queryByText('Maintenance')).toBeNull()
})
})
14 changes: 11 additions & 3 deletions src/components/Claim/Claim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}, [])

Expand Down Expand Up @@ -526,7 +534,7 @@ export const Claim = ({}) => {
transaction={selectedTransaction}
setIsLoading={setisLinkCancelling}
isLoading={isLinkCancelling}
onClose={() => setLinkUrl(window.location.href)}
onClose={() => setLinkUrl(resolveClaimLink(window.location.href))}
/>
)}
</PageContainer>
Expand Down
8 changes: 7 additions & 1 deletion src/components/Claim/Link/views/BankFlowManager.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions src/components/Claim/__tests__/claim-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
Loading
Loading