diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index a6435d34e..baa9fea45 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -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' @@ -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' @@ -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(['inputAmount', 'depositDetails']), + step: parseAsStringEnum(['inputAmount', 'depositDetails', 'showQR']), amount: parseAsString, currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), }) @@ -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('') @@ -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 @@ -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' }) + } } catch (error) { console.log(error) const errorMessage = error instanceof Error ? error.message : String(error) @@ -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 ( +
+ +
+ ) + } + if (step === 'inputAmount') { return ( <> @@ -287,16 +306,6 @@ const MantecaAddMoney: FC = () => { limitsValidation={limitsValidation} limitsCurrency={limitsValidation.currency} onBack={onBack} - maintenanceBanner={ - showPixMaintenance ? ( - - ) : undefined - } /> ) @@ -316,6 +325,20 @@ const MantecaAddMoney: FC = () => { ) } + if (step === 'showQR') { + if (!depositDetails) { + return null + } + return ( + setUrlState({ step: 'inputAmount' })} + onComplete={() => queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })} + /> + ) + } + return null } diff --git a/src/components/AddMoney/components/MantecaDepositShareDetails.tsx b/src/components/AddMoney/components/MantecaDepositShareDetails.tsx index c50baefbf..2c9c94102 100644 --- a/src/components/AddMoney/components/MantecaDepositShareDetails.tsx +++ b/src/components/AddMoney/components/MantecaDepositShareDetails.tsx @@ -53,7 +53,9 @@ const MantecaDepositShareDetails = ({ return MANTECA_COUNTRIES_CONFIG[currentCountryDetails.id]?.depositAddressLabel ?? 'Deposit Address' }, [currentCountryDetails]) - const depositAddress = depositDetails.details.depositAddress + // BRL synthetics no longer carry these (QR-only) — but BRL routes to the QR + // screen, never here; the fallback just keeps the ARS/static path total. + const depositAddress = depositDetails.details.depositAddress ?? '' const shortenedAddress = depositAddress.length > 30 ? shortenStringLong(depositAddress, 10) : depositAddress const depositAlias = depositDetails.details.depositAlias const depositAmount = currencyAmount ?? depositDetails.stages['1'].thresholdAmount diff --git a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx new file mode 100644 index 000000000..254e2676e --- /dev/null +++ b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx @@ -0,0 +1,119 @@ +'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.depositAddresses.PIX (confirmed against prod 2026-07-02). + const qr = depositDetails.details.depositAddresses?.PIX?.code + // 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 ( +
+ +
+
+ +
+

Deposit received!

+

Your balance has been updated.

+ +
+
+ ) + } + + // Payment detected, settling — show the branded processing screen (same as PIX payments). + if (status === 'processing') { + return ( +
+ +
+ +
+
+ ) + } + + return ( +
+ +
+
+

Pay with PIX

+ {currencyAmount &&

R$ {currencyAmount}

} +
+ + {!qr ? ( + + ) : ( + <> + + + {countdownLabel && ( +

Expires in {countdownLabel}

+ )} + + {isExpired ? ( +
+

This QR code has expired.

+ +
+ ) : ( +
+

+ Scan with your bank app, or copy the PIX code. +

+ +
+ )} + + )} +
+
+ ) +} + +export default MantecaPixQrDeposit diff --git a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx new file mode 100644 index 000000000..74ba1a3fb --- /dev/null +++ b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx @@ -0,0 +1,98 @@ +/** + * MantecaPixQrDeposit — the BRL dynamic-PIX-QR screen. + * + * One `details.depositAddresses.PIX.code` string (EMVCo copia-e-cola) 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: () =>
})) +jest.mock('@/components/Global/QRCodeWrapper', () => ({ + __esModule: true, + default: ({ url, disabled }: { url: string; disabled?: boolean }) => ( +
+ ), +})) +jest.mock('@/components/Global/CopyToClipboard', () => ({ + __esModule: true, + default: ({ textToCopy }: { textToCopy: string }) =>
, +})) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () =>
})) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), +})) + +// eslint-disable-next-line import/first -- must come after jest.mock +import MantecaPixQrDeposit from '../MantecaPixQrDeposit' + +const PIX_CODE = '00020126-COPIA-E-COLA' +const baseDeposit = { + id: 'syn-1', + type: 'RAMP_OPERATION' as const, + details: { + // prod shape confirmed 2026-07-02 — the QR rides in depositAddresses.PIX + depositAddresses: { + PIX: { + type: 'QR', + code: PIX_CODE, + url: `https://widget.manteca.dev/qr?code=${PIX_CODE}`, + expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(), // ~3 days out + bankId: 'bank-1', + }, + }, + priceExpireAt: new Date(Date.now() + 5 * 60_000).toISOString(), // 5 min out + }, + 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 PIX copia-e-cola code, the amount, and a live countdown', () => { + render( + + ) + expect(screen.getByTestId('qr')).toHaveAttribute('data-url', PIX_CODE) + expect(screen.getByTestId('copy')).toHaveAttribute('data-text', PIX_CODE) + 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() + + 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() + + expect(screen.getByText('Deposit received!')).toBeInTheDocument() + expect(screen.queryByTestId('qr')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx new file mode 100644 index 000000000..fdf4c6108 --- /dev/null +++ b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx @@ -0,0 +1,69 @@ +/** + * useMantecaDepositPolling — maps the BE intent status to a poll status and + * fires onComplete exactly once on COMPLETED. Read-only: it never moves money, + * it mirrors GET /manteca/deposit/:id/status so the QR screen can advance. + */ +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' + +const mockGetDepositStatus = jest.fn() +jest.mock('@/services/manteca', () => ({ + mantecaApi: { getDepositStatus: mockGetDepositStatus }, +})) + +// eslint-disable-next-line import/first -- must come after jest.mock +import { useMantecaDepositPolling } from '../useMantecaDepositPolling' + +describe('useMantecaDepositPolling', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + mockGetDepositStatus.mockReset() + }) + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + it('reports "pending" for a non-terminal status', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-1', jest.fn()), { wrapper }) + + await waitFor(() => expect(mockGetDepositStatus).toHaveBeenCalledWith('dep-1')) + expect(result.current.status).toBe('pending') + }) + + it('reports "completed" and fires onComplete exactly once on COMPLETED', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-2', status: 'COMPLETED' } }) + const onComplete = jest.fn() + const { result, rerender } = renderHook(() => useMantecaDepositPolling('dep-2', onComplete), { wrapper }) + + await waitFor(() => expect(result.current.status).toBe('completed')) + rerender() + rerender() + expect(onComplete).toHaveBeenCalledTimes(1) + }) + + it('reports "failed" for a terminal failure status', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-3', status: 'CANCELLED' } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-3', jest.fn()), { wrapper }) + + await waitFor(() => expect(result.current.status).toBe('failed')) + }) + + it('reports "processing" for an intermediate settling status', async () => { + mockGetDepositStatus.mockResolvedValue({ data: { id: 'dep-4', status: 'PROCESSING' } }) + const { result } = renderHook(() => useMantecaDepositPolling('dep-4', jest.fn()), { wrapper }) + + await waitFor(() => expect(result.current.status).toBe('processing')) + }) + + it('does not query when depositId is undefined', () => { + const { result } = renderHook(() => useMantecaDepositPolling(undefined, jest.fn()), { wrapper }) + + expect(result.current.status).toBe('pending') + expect(mockGetDepositStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/AddMoney/hooks/useMantecaDepositPolling.ts b/src/components/AddMoney/hooks/useMantecaDepositPolling.ts new file mode 100644 index 000000000..eee0b3ff4 --- /dev/null +++ b/src/components/AddMoney/hooks/useMantecaDepositPolling.ts @@ -0,0 +1,51 @@ +'use client' + +import { mantecaApi } from '@/services/manteca' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useRef } from 'react' + +const POLLING_INTERVAL = 5000 + +// Terminal TransactionIntentStatus values returned by GET /manteca/deposit/:id/status. +const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELLED', 'REFUNDED'] +// Non-terminal states that mean "payment detected, settling" — show a processing screen. +const PROCESSING_STATUSES = ['PROCESSING', 'AWAITING_SETTLEMENT'] + +type MantecaDepositPollStatus = 'pending' | 'processing' | 'completed' | 'failed' + +/** + * Poll a BRL PIX deposit intent until it settles. Read-only: the webhook/poller + * post the actual credit — this just mirrors `intent.status` so the QR screen can + * advance to a success state. Fires `onComplete` exactly once on COMPLETED. + */ +export function useMantecaDepositPolling(depositId: string | undefined, onComplete: () => void) { + const hasCompleted = useRef(false) + + const { data } = useQuery({ + queryKey: ['manteca-deposit-status', depositId], + queryFn: () => mantecaApi.getDepositStatus(depositId!), + enabled: !!depositId, + gcTime: 0, // don't carry a settled status across navigations + refetchInterval: (query) => { + const status = (query.state.data as { data?: { status?: string } } | undefined)?.data?.status + return status && TERMINAL_STATUSES.includes(status) ? false : POLLING_INTERVAL + }, + }) + + const status: MantecaDepositPollStatus = useMemo(() => { + const s = data?.data?.status + if (s === 'COMPLETED') return 'completed' + if (s && TERMINAL_STATUSES.includes(s)) return 'failed' + if (s && PROCESSING_STATUSES.includes(s)) return 'processing' + return 'pending' + }, [data]) + + useEffect(() => { + if (status === 'completed' && !hasCompleted.current) { + hasCompleted.current = true + onComplete() + } + }, [status, onComplete]) + + return { status } +} diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx index e6329cfab..0a1737561 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -238,18 +238,24 @@ describe('AddWithdrawCountriesList — bank gate', () => { }) /** - * BRL-via-PIX onramp is unstable, so the Pix option is flagged "under maintenance" - * (config: pixBrazilOnrampMaintenance) — warn-only: it stays visible and clickable. + * When the BRL-via-PIX onramp degrades, the Pix option gets flagged "under + * maintenance" (config: pixBrazilOnrampMaintenance) — warn-only: it stays + * visible and clickable. */ describe('AddWithdrawCountriesList — PIX onramp maintenance tag', () => { + // snapshot/restore the shipped flag so each test can flip it without leaking + // state — and without coupling the restore to the committed default + let originalPixMaintenance: boolean + 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' }]) + originalPixMaintenance = underMaintenanceConfig.pixBrazilOnrampMaintenance }) afterEach(() => { - underMaintenanceConfig.pixBrazilOnrampMaintenance = true + underMaintenanceConfig.pixBrazilOnrampMaintenance = originalPixMaintenance }) it('tags the Pix option "Maintenance" but keeps it clickable (warn-only)', () => { diff --git a/src/components/Global/QRCodeWrapper/index.tsx b/src/components/Global/QRCodeWrapper/index.tsx index ed9d7de39..3cdcaa892 100644 --- a/src/components/Global/QRCodeWrapper/index.tsx +++ b/src/components/Global/QRCodeWrapper/index.tsx @@ -11,6 +11,8 @@ interface QRCodeWrapperProps { disabled?: boolean isBlurred?: boolean centerImage?: string + /** Merged onto the root — pass a `max-w-*` to override the default 160px width. */ + className?: string } const QRCodeWrapper = ({ @@ -19,6 +21,7 @@ const QRCodeWrapper = ({ disabled = false, isBlurred = false, centerImage, + className, }: QRCodeWrapperProps) => { const [qrRendered, setQrRendered] = useState(false) @@ -39,7 +42,7 @@ const QRCodeWrapper = ({ const showLoading = isLoading || !qrRendered || !url return ( -
+
{/* Container with black border and rounded corners */}
=> { + try { + const response = await serverFetch(`/manteca/deposit/${depositId}/status`) + const data = await response.json() + if (!response.ok) { + return { error: data.error || 'Failed to fetch deposit status.' } + } + return { data } + } catch (error) { + return { error: error instanceof Error ? error.message : 'An unexpected error occurred.' } + } + }, + cancelDeposit: async (depositId: string): Promise<{ data?: MantecaDepositResponseData; error?: string }> => { try { const response = await serverFetch(`/manteca/deposit/${depositId}/cancel`, { diff --git a/src/types/manteca.types.ts b/src/types/manteca.types.ts index a25c54834..b354039a6 100644 --- a/src/types/manteca.types.ts +++ b/src/types/manteca.types.ts @@ -11,10 +11,23 @@ export interface MantecaDepositResponseData { type: 'RAMP_OPERATION' details: { depositAddresses: { - BANK_TRANSFER: string + BANK_TRANSFER?: string + // dynamic BRL PIX QR — confirmed against prod 2026-07-02 + // (mono ops/scripts/manteca/probe-brl-rampon-qr.ts) + PIX?: { + type: 'QR' + // EMVCo "copia e cola" string — QR-encode it and offer copy + code: string + // Manteca-hosted QR page (unused by us, kept for parity) + url: string + // QR string validity (~3 days) — NOT the price lock expiry + expiresAt: string + bankId: string + } } - depositAddress: string - depositAlias: string + // absent for BRL — PIX deposits are QR-only since Manteca's med-2.0 switch + depositAddress?: string + depositAlias?: string withdrawCostInAgainst: string withdrawCostInAsset: string price: string