Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
65 changes: 44 additions & 21 deletions src/components/AddMoney/components/MantecaAddMoney.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client'
import { type FC, useEffect, useMemo, useState, useCallback } from 'react'
import MantecaDepositShareDetails from '@/components/AddMoney/components/MantecaDepositShareDetails'
import MantecaPixQrDeposit from '@/components/AddMoney/components/MantecaPixQrDeposit'
import CyclingLoading from '@/components/Global/PeanutLoading/CyclingLoading'
import InputAmountStep from '@/components/AddMoney/components/InputAmountStep'
import { useParams, useSearchParams } from 'next/navigation'
import { addMoneyCountryUrl } from '@/utils/native-routes'
Expand All @@ -25,11 +27,9 @@ 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'
type MantecaStep = 'inputAmount' | 'depositDetails' | 'showQR'

// Currency denomination type for URL state
type CurrencyDenomination = 'USD' | 'ARS' | 'BRL' | 'MXN' | 'EUR'
Expand All @@ -48,7 +48,7 @@ const MantecaAddMoney: FC = () => {
// 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<MantecaStep>(['inputAmount', 'depositDetails']),
step: parseAsStringEnum<MantecaStep>(['inputAmount', 'depositDetails', 'showQR']),
amount: parseAsString,
currency: parseAsStringEnum<CurrencyDenomination>(['USD', 'ARS', 'BRL', 'MXN', 'EUR']),
})
Expand All @@ -57,7 +57,6 @@ const MantecaAddMoney: FC = () => {
const step: MantecaStep = urlState.step ?? 'inputAmount'
// Amount from URL - this is in the denomination specified by `currency`
const displayedAmount = urlState.amount ?? ''
const currentDenomination = urlState.currency ?? 'USD'

// Local UI state for tracking both amounts (needed for API call and validation)
const [usdAmount, setUsdAmount] = useState<string>('')
Expand All @@ -73,10 +72,11 @@ const MantecaAddMoney: FC = () => {
const selectedCountry = useMemo(() => {
return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath)
}, [selectedCountryPath])
// Default the input denomination to BRL for Brazil (PIX is in BRL); every other
// country keeps the USD default.
const currentDenomination: CurrencyDenomination =
urlState.currency ?? (selectedCountry?.currency === 'BRL' ? 'BRL' : 'USD')
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 @@ -194,14 +194,20 @@ const MantecaAddMoney: FC = () => {
setError(depositData.error)
return
}
setDepositDetails(depositData.data)
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_CONFIRMED, {
amount_usd: usdAmount,
method_type: 'manteca',
country: selectedCountryPath,
})
// Update URL state to show deposit details step
setUrlState({ step: 'depositDetails' })
// BRL deposits carry the dynamic PIX QR in the ramp-on synthetic's
// details → show the QR step. ARS/others show deposit details.
const data = depositData.data
setDepositDetails(data)
if (selectedCountry?.currency === 'BRL') {
setUrlState({ step: 'showQR' })
} else {
setUrlState({ step: 'depositDetails' })
Comment on lines +202 to +209

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Gate showQR on the returned QR payload, not just the selected currency.

Line 202 sends every BRL deposit to the QR step, but details.qr is optional and MantecaPixQrDeposit never refreshes depositDetails. If the create-deposit response comes back without a QR payload, the user lands on a permanent “Preparing your PIX QR…” screen instead of falling back to deposit details.

🛠️ Suggested fix
             const data = depositData.data
             setDepositDetails(data)
-            if (selectedCountry?.currency === 'BRL') {
+            if (selectedCountry?.currency === 'BRL' && data?.details.qr) {
                 setUrlState({ step: 'showQR' })
             } else {
                 setUrlState({ step: 'depositDetails' })
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// BRL deposits carry the dynamic PIX QR in the ramp-on synthetic's
// details → show the QR step. ARS/others show deposit details.
const data = depositData.data
setDepositDetails(data)
if (selectedCountry?.currency === 'BRL') {
setUrlState({ step: 'showQR' })
} else {
setUrlState({ step: 'depositDetails' })
// BRL deposits carry the dynamic PIX QR in the ramp-on synthetic's
// details → show the QR step. ARS/others show deposit details.
const data = depositData.data
setDepositDetails(data)
if (selectedCountry?.currency === 'BRL' && data?.details.qr) {
setUrlState({ step: 'showQR' })
} else {
setUrlState({ step: 'depositDetails' })
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/AddMoney/components/MantecaAddMoney.tsx` around lines 198 -
205, The BRL branch in MantecaAddMoney currently routes all deposits to showQR
based only on selectedCountry?.currency, but the QR payload is optional and can
be missing from the create-deposit response. Update the logic around
setDepositDetails and setUrlState so it checks the returned deposit data for a
valid QR payload before choosing the showQR step, and otherwise falls back to
depositDetails. Use the existing MantecaAddMoney and MantecaPixQrDeposit flow to
ensure users are not sent to the QR screen when details.qr is absent.

}
} catch (error) {
console.log(error)
const errorMessage = error instanceof Error ? error.message : String(error)
Expand Down Expand Up @@ -229,10 +235,23 @@ const MantecaAddMoney: FC = () => {
if (step === 'depositDetails' && !depositDetails) {
setUrlState({ step: 'inputAmount' })
}
if (step === 'showQR' && !depositDetails) {
setUrlState({ step: 'inputAmount' })
}
}, [step, depositDetails, setUrlState])

if (!selectedCountry) return null

// While the BRL PIX deposit request is in flight (the QR is being generated),
// show the branded processing screen — same as when a PIX payment is processing.
if (isCreatingDeposit && selectedCountry.currency === 'BRL') {
return (
<div className="my-auto flex min-h-[inherit] flex-col justify-center">
<CyclingLoading />
</div>
)
}

if (step === 'inputAmount') {
return (
<>
Expand Down Expand Up @@ -287,16 +306,6 @@ 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 All @@ -316,6 +325,20 @@ const MantecaAddMoney: FC = () => {
)
}

if (step === 'showQR') {
if (!depositDetails) {
return null
}
return (
<MantecaPixQrDeposit
depositDetails={depositDetails}
currencyAmount={localCurrencyAmount}
onBack={() => setUrlState({ step: 'inputAmount' })}
onComplete={() => queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })}
/>
)
}

return null
}

Expand Down
118 changes: 118 additions & 0 deletions src/components/AddMoney/components/MantecaPixQrDeposit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
'use client'

import { type FC, useEffect, useMemo, useState } from 'react'
import NavHeader from '@/components/Global/NavHeader'
import QRCodeWrapper from '@/components/Global/QRCodeWrapper'
import CopyToClipboard from '@/components/Global/CopyToClipboard'
import { Icon } from '@/components/Global/Icons/Icon'
import { Button } from '@/components/0_Bruddle/Button'
import { type MantecaDepositResponseData } from '@/types/manteca.types'
import { useMantecaDepositPolling } from '@/components/AddMoney/hooks/useMantecaDepositPolling'
import CyclingLoading from '@/components/Global/PeanutLoading/CyclingLoading'

const MantecaPixQrDeposit: FC<{
depositDetails: MantecaDepositResponseData
currencyAmount?: string
// Parent owns step navigation — usually setUrlState({ step: 'inputAmount' }).
onBack: () => void
// Fired once when the deposit settles (parent refreshes balance/history).
onComplete: () => void
}> = ({ depositDetails, currencyAmount, onBack, onComplete }) => {
// The dynamic PIX QR (EMVCo copia-e-cola) rides in the ramp-on synthetic's details.
const qr = depositDetails.details.qr
// Poll by the real synthetic id (unchanged polling contract).
const { status } = useMantecaDepositPolling(depositDetails.id, onComplete)

// QR expiry countdown. `priceExpireAt` carries a tz offset, so Date parses it
// directly. We tick once a second and stop once the QR is paid or has lapsed
// (the effect re-runs when isExpired flips and clears the interval).
const expiresAtMs = useMemo(
() => new Date(depositDetails.details.priceExpireAt).getTime(),
[depositDetails.details.priceExpireAt]
)
const [nowMs, setNowMs] = useState(() => Date.now())

const remainingMs = expiresAtMs - nowMs
const isExpired = remainingMs <= 0
const minutes = Math.floor(remainingMs / 60000)
const seconds = Math.floor((remainingMs % 60000) / 1000)
const countdownLabel = isExpired ? null : `${minutes}:${String(seconds).padStart(2, '0')}`

useEffect(() => {
if (status === 'completed' || status === 'processing' || isExpired) return
const interval = setInterval(() => setNowMs(Date.now()), 1000)
return () => clearInterval(interval)
}, [status, isExpired])

if (status === 'completed') {
return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Add Money" onPrev={onBack} />
<div className="my-auto flex flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-1">
<Icon name="check" size={32} />
</div>
<h2 className="text-2xl font-bold text-n-1">Deposit received!</h2>
<p className="text-grey-1">Your balance has been updated.</p>
<Button variant="purple" shadowSize="4" className="w-full" onClick={onBack}>
Done
</Button>
</div>
</div>
)
}

// Payment detected, settling — show the branded processing screen (same as PIX payments).
if (status === 'processing') {
return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Add Money" onPrev={onBack} />
<div className="my-auto flex flex-col justify-center">
<CyclingLoading />
</div>
</div>
)
}

return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Add Money" onPrev={onBack} />
<div className="my-auto flex flex-col gap-6">
<div className="text-center">
<p className="text-sm text-grey-1">Pay with PIX</p>
{currencyAmount && <p className="text-2xl font-bold text-n-1">R$ {currencyAmount}</p>}
</div>

{!qr ? (
<CyclingLoading />
) : (
<>
<QRCodeWrapper url={qr} isBlurred={isExpired} disabled={isExpired} className="max-w-[280px]" />

{countdownLabel && (
<p className="text-center text-sm text-grey-1">Expires in {countdownLabel}</p>
)}

{isExpired ? (
<div className="flex flex-col gap-3 text-center">
<p className="text-sm text-grey-1">This QR code has expired.</p>
<Button variant="stroke" className="w-full" onClick={onBack}>
Go back
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
<p className="text-center text-sm text-grey-1">
Scan with your bank app, or copy the PIX code.
</p>
<CopyToClipboard textToCopy={qr} type="button" className="w-full" />
</div>
)}
</>
)}
</div>
</div>
)
}

export default MantecaPixQrDeposit
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* MantecaPixQrDeposit — the BRL dynamic-PIX-QR screen.
*
* One `details.qr` string drives both the QR and the copy button; a live
* countdown is derived from `details.priceExpireAt`; polling flips the screen to
* a success state. Nested primitives are stubbed so only this component's own
* logic is under test.
*/
import React from 'react'
import { render, screen } from '@testing-library/react'

const mockUseMantecaDepositPolling = jest.fn()
jest.mock('@/components/AddMoney/hooks/useMantecaDepositPolling', () => ({
useMantecaDepositPolling: (...args: unknown[]) => mockUseMantecaDepositPolling(...args),
}))

jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/Global/QRCodeWrapper', () => ({
__esModule: true,
default: ({ url, disabled }: { url: string; disabled?: boolean }) => (
<div data-testid="qr" data-url={url} data-disabled={disabled ? 'true' : 'false'} />
),
}))
jest.mock('@/components/Global/CopyToClipboard', () => ({
__esModule: true,
default: ({ textToCopy }: { textToCopy: string }) => <div data-testid="copy" data-text={textToCopy} />,
}))
jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => <div /> }))
jest.mock('@/components/0_Bruddle/Button', () => ({
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
}))

// eslint-disable-next-line import/first -- must come after jest.mock
import MantecaPixQrDeposit from '../MantecaPixQrDeposit'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const baseDeposit = {
id: 'syn-1',
type: 'RAMP_OPERATION' as const,
details: {
qr: '00020126-COPIA-E-COLA',
priceExpireAt: new Date(Date.now() + 5 * 60_000).toISOString(), // 5 min out
depositAddress: '',
depositAlias: '',
},
stages: {},
} as unknown as import('@/types/manteca.types').MantecaDepositResponseData

beforeEach(() => {
mockUseMantecaDepositPolling.mockReset()
mockUseMantecaDepositPolling.mockReturnValue({ status: 'pending' })
})

describe('MantecaPixQrDeposit', () => {
it('renders the QR + copy from the same `details.qr`, the amount, and a live countdown', () => {
render(
<MantecaPixQrDeposit
depositDetails={baseDeposit}
currencyAmount="10"
onBack={jest.fn()}
onComplete={jest.fn()}
/>
)
expect(screen.getByTestId('qr')).toHaveAttribute('data-url', baseDeposit.details.qr)
expect(screen.getByTestId('copy')).toHaveAttribute('data-text', baseDeposit.details.qr)
expect(screen.getByText('R$ 10')).toBeInTheDocument()
expect(screen.getByText(/Expires in/)).toBeInTheDocument()
})

it('shows the expired state (QR disabled, no countdown) once priceExpireAt has passed', () => {
const expired = {
...baseDeposit,
details: { ...baseDeposit.details, priceExpireAt: new Date(Date.now() - 1000).toISOString() },
}
render(<MantecaPixQrDeposit depositDetails={expired} onBack={jest.fn()} onComplete={jest.fn()} />)

expect(screen.getByText(/expired/i)).toBeInTheDocument()
expect(screen.getByTestId('qr')).toHaveAttribute('data-disabled', 'true')
expect(screen.queryByText(/Expires in/)).not.toBeInTheDocument()
})

it('shows the success state when the deposit completes', () => {
mockUseMantecaDepositPolling.mockReturnValue({ status: 'completed' })
render(<MantecaPixQrDeposit depositDetails={baseDeposit} onBack={jest.fn()} onComplete={jest.fn()} />)

expect(screen.getByText('Deposit received!')).toBeInTheDocument()
expect(screen.queryByTestId('qr')).not.toBeInTheDocument()
})
})
6 changes: 6 additions & 0 deletions src/components/AddMoney/consts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2755,6 +2755,12 @@ countryData.forEach((country) => {
if (method.id === 'pix-add') {
return countryCode === 'BR'
}
// 'From Bank' is redundant for Brazil — it routes to the same Manteca flow
// as 'Pix' (/add-money/brazil/manteca), so hide it and show Pix only.
// Reversible: delete this block to restore the 'From Bank' card for Brazil.
if (method.id === 'bank-transfer-add' && countryCode === 'BR') {
return false
}
return true
}).map((m) => {
const newMethod = { ...m }
Expand Down
Loading
Loading