From 6d31267ee3eb0ae4fb6d3f7646ca3613bd32a34a Mon Sep 17 00:00:00 2001 From: peanut Date: Tue, 30 Jun 2026 19:59:41 +0200 Subject: [PATCH 1/5] feat(manteca): BRL PIX dynamic-QR deposit screen (FE) Companion to peanut-api-ts #1093. For BRL, Add Money now shows Manteca's dynamic PIX QR instead of the static bank-details screen: render the copia-e-cola via QRCodeWrapper, a copy button, a live m:ss expiry countdown, and poll the deposit intent status to auto-advance to a success state. ARS path untouched. - showQR step (nuqs) routed by the response discriminator (type === 'QR') - useMantecaDepositPolling: read-only mirror of GET /manteca/deposit/:id/status - QRCodeWrapper gains an optional className width-override (160 -> 280px here) - hook + component tests --- .../AddMoney/components/MantecaAddMoney.tsx | 40 ++++++-- .../components/MantecaPixQrDeposit.tsx | 96 +++++++++++++++++++ .../__tests__/MantecaPixQrDeposit.test.tsx | 77 +++++++++++++++ .../useMantecaDepositPolling.test.tsx | 62 ++++++++++++ .../hooks/useMantecaDepositPolling.ts | 48 ++++++++++ src/components/Global/QRCodeWrapper/index.tsx | 5 +- src/services/manteca.ts | 16 +++- src/types/manteca.types.ts | 13 +++ 8 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 src/components/AddMoney/components/MantecaPixQrDeposit.tsx create mode 100644 src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx create mode 100644 src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx create mode 100644 src/components/AddMoney/hooks/useMantecaDepositPolling.ts diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index a6435d34e..df69162c2 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -1,12 +1,13 @@ '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 InputAmountStep from '@/components/AddMoney/components/InputAmountStep' import { useParams, useSearchParams } from 'next/navigation' import { addMoneyCountryUrl } from '@/utils/native-routes' import { useSafeBack } from '@/hooks/useSafeBack' import { type CountryData, countryData } from '@/components/AddMoney/consts' -import { type MantecaDepositResponseData } from '@/types/manteca.types' +import { type MantecaDepositResponseData, type MantecaPixDepositData } from '@/types/manteca.types' import { useCurrency } from '@/hooks/useCurrency' import { mantecaApi } from '@/services/manteca' import { parseUnits } from 'viem' @@ -29,7 +30,7 @@ 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 +49,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']), }) @@ -67,6 +68,7 @@ const MantecaAddMoney: FC = () => { const [isCreatingDeposit, setIsCreatingDeposit] = useState(false) const [error, setError] = useState(null) const [depositDetails, setDepositDetails] = useState() + const [pixDeposit, setPixDeposit] = useState() // path params (web) or query params (native static export) const selectedCountryPath = (params.country as string) || searchParams.get('country') || '' @@ -194,14 +196,21 @@ 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 return a dynamic PIX QR (type: 'QR') → show the QR step. + // ARS/others return the static ramp-on shape → show deposit details. + const data = depositData.data + if (data?.type === 'QR') { + setPixDeposit(data) + setUrlState({ step: 'showQR' }) + } else { + setDepositDetails(data) + setUrlState({ step: 'depositDetails' }) + } } catch (error) { console.log(error) const errorMessage = error instanceof Error ? error.message : String(error) @@ -229,7 +238,10 @@ const MantecaAddMoney: FC = () => { if (step === 'depositDetails' && !depositDetails) { setUrlState({ step: 'inputAmount' }) } - }, [step, depositDetails, setUrlState]) + if (step === 'showQR' && !pixDeposit) { + setUrlState({ step: 'inputAmount' }) + } + }, [step, depositDetails, pixDeposit, setUrlState]) if (!selectedCountry) return null @@ -316,6 +328,20 @@ const MantecaAddMoney: FC = () => { ) } + if (step === 'showQR') { + if (!pixDeposit) { + return null + } + return ( + setUrlState({ step: 'inputAmount' })} + onComplete={() => queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })} + /> + ) + } + return null } diff --git a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx new file mode 100644 index 000000000..a04307aeb --- /dev/null +++ b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx @@ -0,0 +1,96 @@ +'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 MantecaPixDepositData } from '@/types/manteca.types' +import { useMantecaDepositPolling } from '@/components/AddMoney/hooks/useMantecaDepositPolling' + +const MantecaPixQrDeposit: FC<{ + pixDeposit: MantecaPixDepositData + currencyAmount?: string + // Parent owns step navigation — usually setUrlState({ step: 'inputAmount' }). + onBack: () => void + // Fired once when the deposit settles (parent refreshes balance/history). + onComplete: () => void +}> = ({ pixDeposit, currencyAmount, onBack, onComplete }) => { + const { status } = useMantecaDepositPolling(pixDeposit.bankId, onComplete) + + // QR expiry countdown. `expiresAt` 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(pixDeposit.expiresAt).getTime(), [pixDeposit.expiresAt]) + 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' || 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.

+ +
+
+ ) + } + + return ( +
+ +
+
+

Pay with PIX

+ {currencyAmount &&

R$ {currencyAmount}

} +
+ + + + {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..91e980a84 --- /dev/null +++ b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx @@ -0,0 +1,77 @@ +/** + * MantecaPixQrDeposit — the BRL dynamic-PIX-QR screen. + * + * One `code` string drives both the QR and the copy button; a live countdown is + * derived from `expiresAt`; 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 basePix = { + type: 'QR' as const, + code: '00020126-COPIA-E-COLA', + url: 'https://widget-qa.manteca.dev/qr?code=x', + bankId: 'bank-1', + expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), // 5 min out +} + +beforeEach(() => { + mockUseMantecaDepositPolling.mockReset() + mockUseMantecaDepositPolling.mockReturnValue({ status: 'pending' }) +}) + +describe('MantecaPixQrDeposit', () => { + it('renders the QR + copy from the same `code`, the amount, and a live countdown', () => { + render( + + ) + expect(screen.getByTestId('qr')).toHaveAttribute('data-url', basePix.code) + expect(screen.getByTestId('copy')).toHaveAttribute('data-text', basePix.code) + expect(screen.getByText('R$ 10')).toBeInTheDocument() + expect(screen.getByText(/Expires in/)).toBeInTheDocument() + }) + + it('shows the expired state (QR disabled, no countdown) once expiresAt has passed', () => { + const expired = { ...basePix, expiresAt: 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..ed25711a6 --- /dev/null +++ b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx @@ -0,0 +1,62 @@ +/** + * 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('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..1729e2019 --- /dev/null +++ b/src/components/AddMoney/hooks/useMantecaDepositPolling.ts @@ -0,0 +1,48 @@ +'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'] + +type MantecaDepositPollStatus = 'pending' | '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' + return 'pending' + }, [data]) + + useEffect(() => { + if (status === 'completed' && !hasCompleted.current) { + hasCompleted.current = true + onComplete() + } + }, [status, onComplete]) + + return { status } +} 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 */}
=> { + ): Promise<{ data?: MantecaDepositResponseData | MantecaPixDepositData; error?: string }> => { try { const response = await serverFetch('/manteca/deposit', { method: 'POST', @@ -287,6 +288,19 @@ export const mantecaApi = { } }, + getDepositStatus: async (depositId: string): Promise<{ data?: { id: string; status: string }; error?: string }> => { + 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..9e55ec4be 100644 --- a/src/types/manteca.types.ts +++ b/src/types/manteca.types.ts @@ -55,6 +55,19 @@ export interface MantecaDepositResponseData { updatedAt: string } +/** + * BRL dynamic PIX QR deposit response (from POST /manteca/deposit when currency=BRL). + * Discriminated from the ramp-on shape by `type: 'QR'`. The amount + currency are + * embedded in `code` (the EMVCo "copia e cola" BR Code). + */ +export interface MantecaPixDepositData { + type: 'QR' + code: string + url: string + bankId: string + expiresAt: string +} + export enum MercadoPagoStep { DETAILS = 'details', REVIEW = 'review', From d0ff7d2e0ad5eebc811dbd794f2d75991208dddc Mon Sep 17 00:00:00 2001 From: peanut Date: Tue, 30 Jun 2026 21:37:57 +0200 Subject: [PATCH 2/5] refactor(manteca): repoint BRL PIX QR screen to the ramp-on synthetic's details Drops MantecaPixDepositData; the dynamic QR now reads from the existing MantecaDepositResponseData.details.qr (the ramp-on synthetic), routed to showQR by currency. Matches the BE pivot (api #1093) off deposit-request. details.qr is a stub until Manteca confirms the field name. --- .../AddMoney/components/MantecaAddMoney.tsx | 35 +++-------- .../components/MantecaPixQrDeposit.tsx | 63 +++++++++++-------- .../__tests__/MantecaPixQrDeposit.test.tsx | 49 +++++++++------ src/services/manteca.ts | 3 +- src/types/manteca.types.ts | 15 +---- 5 files changed, 79 insertions(+), 86 deletions(-) diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index df69162c2..f4f0a474c 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -7,7 +7,7 @@ import { useParams, useSearchParams } from 'next/navigation' import { addMoneyCountryUrl } from '@/utils/native-routes' import { useSafeBack } from '@/hooks/useSafeBack' import { type CountryData, countryData } from '@/components/AddMoney/consts' -import { type MantecaDepositResponseData, type MantecaPixDepositData } from '@/types/manteca.types' +import { type MantecaDepositResponseData } from '@/types/manteca.types' import { useCurrency } from '@/hooks/useCurrency' import { mantecaApi } from '@/services/manteca' import { parseUnits } from 'viem' @@ -26,8 +26,6 @@ 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' | 'showQR' @@ -68,7 +66,6 @@ const MantecaAddMoney: FC = () => { const [isCreatingDeposit, setIsCreatingDeposit] = useState(false) const [error, setError] = useState(null) const [depositDetails, setDepositDetails] = useState() - const [pixDeposit, setPixDeposit] = useState() // path params (web) or query params (native static export) const selectedCountryPath = (params.country as string) || searchParams.get('country') || '' @@ -76,9 +73,6 @@ 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 @@ -201,14 +195,13 @@ const MantecaAddMoney: FC = () => { method_type: 'manteca', country: selectedCountryPath, }) - // BRL deposits return a dynamic PIX QR (type: 'QR') → show the QR step. - // ARS/others return the static ramp-on shape → show deposit details. + // 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 - if (data?.type === 'QR') { - setPixDeposit(data) + setDepositDetails(data) + if (selectedCountry?.currency === 'BRL') { setUrlState({ step: 'showQR' }) } else { - setDepositDetails(data) setUrlState({ step: 'depositDetails' }) } } catch (error) { @@ -238,10 +231,10 @@ const MantecaAddMoney: FC = () => { if (step === 'depositDetails' && !depositDetails) { setUrlState({ step: 'inputAmount' }) } - if (step === 'showQR' && !pixDeposit) { + if (step === 'showQR' && !depositDetails) { setUrlState({ step: 'inputAmount' }) } - }, [step, depositDetails, pixDeposit, setUrlState]) + }, [step, depositDetails, setUrlState]) if (!selectedCountry) return null @@ -299,16 +292,6 @@ const MantecaAddMoney: FC = () => { limitsValidation={limitsValidation} limitsCurrency={limitsValidation.currency} onBack={onBack} - maintenanceBanner={ - showPixMaintenance ? ( - - ) : undefined - } /> ) @@ -329,12 +312,12 @@ const MantecaAddMoney: FC = () => { } if (step === 'showQR') { - if (!pixDeposit) { + if (!depositDetails) { return null } return ( setUrlState({ step: 'inputAmount' })} onComplete={() => queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })} diff --git a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx index a04307aeb..476bf76fc 100644 --- a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx +++ b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx @@ -6,23 +6,29 @@ 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 MantecaPixDepositData } from '@/types/manteca.types' +import { type MantecaDepositResponseData } from '@/types/manteca.types' import { useMantecaDepositPolling } from '@/components/AddMoney/hooks/useMantecaDepositPolling' const MantecaPixQrDeposit: FC<{ - pixDeposit: MantecaPixDepositData + 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 -}> = ({ pixDeposit, currencyAmount, onBack, onComplete }) => { - const { status } = useMantecaDepositPolling(pixDeposit.bankId, onComplete) +}> = ({ 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. `expiresAt` carries a tz offset, so Date parses it + // 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(pixDeposit.expiresAt).getTime(), [pixDeposit.expiresAt]) + const expiresAtMs = useMemo( + () => new Date(depositDetails.details.priceExpireAt).getTime(), + [depositDetails.details.priceExpireAt] + ) const [nowMs, setNowMs] = useState(() => Date.now()) const remainingMs = expiresAtMs - nowMs @@ -64,29 +70,32 @@ const MantecaPixQrDeposit: FC<{ {currencyAmount &&

R$ {currencyAmount}

}
- + {!qr ? ( +

Preparing your PIX QR…

+ ) : ( + <> + - {countdownLabel &&

Expires in {countdownLabel}

} + {countdownLabel && ( +

Expires in {countdownLabel}

+ )} - {isExpired ? ( -
-

This QR code has expired.

- -
- ) : ( -
-

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

- -
+ {isExpired ? ( +
+

This QR code has expired.

+ +
+ ) : ( +
+

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

+ +
+ )} + )}
diff --git a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx index 91e980a84..0658d8864 100644 --- a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx +++ b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx @@ -1,9 +1,10 @@ /** * MantecaPixQrDeposit — the BRL dynamic-PIX-QR screen. * - * One `code` string drives both the QR and the copy button; a live countdown is - * derived from `expiresAt`; polling flips the screen to a success state. Nested - * primitives are stubbed so only this component's own logic is under test. + * 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' @@ -34,13 +35,17 @@ jest.mock('@/components/0_Bruddle/Button', () => ({ // eslint-disable-next-line import/first -- must come after jest.mock import MantecaPixQrDeposit from '../MantecaPixQrDeposit' -const basePix = { - type: 'QR' as const, - code: '00020126-COPIA-E-COLA', - url: 'https://widget-qa.manteca.dev/qr?code=x', - bankId: 'bank-1', - expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), // 5 min out -} +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() @@ -48,19 +53,27 @@ beforeEach(() => { }) describe('MantecaPixQrDeposit', () => { - it('renders the QR + copy from the same `code`, the amount, and a live countdown', () => { + it('renders the QR + copy from the same `details.qr`, the amount, and a live countdown', () => { render( - + ) - expect(screen.getByTestId('qr')).toHaveAttribute('data-url', basePix.code) - expect(screen.getByTestId('copy')).toHaveAttribute('data-text', basePix.code) + 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 expiresAt has passed', () => { - const expired = { ...basePix, expiresAt: new Date(Date.now() - 1000).toISOString() } - render() + 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') @@ -69,7 +82,7 @@ describe('MantecaPixQrDeposit', () => { it('shows the success state when the deposit completes', () => { mockUseMantecaDepositPolling.mockReturnValue({ status: 'completed' }) - render() + render() expect(screen.getByText('Deposit received!')).toBeInTheDocument() expect(screen.queryByTestId('qr')).not.toBeInTheDocument() diff --git a/src/services/manteca.ts b/src/services/manteca.ts index c5064834a..2429dd3b0 100644 --- a/src/services/manteca.ts +++ b/src/services/manteca.ts @@ -1,6 +1,5 @@ import { type MantecaDepositResponseData, - type MantecaPixDepositData, type MantecaWithdrawData, type MantecaWithdrawResponse, type CreateMantecaOnrampParams, @@ -258,7 +257,7 @@ export const mantecaApi = { deposit: async ( params: CreateMantecaOnrampParams - ): Promise<{ data?: MantecaDepositResponseData | MantecaPixDepositData; error?: string }> => { + ): Promise<{ data?: MantecaDepositResponseData; error?: string }> => { try { const response = await serverFetch('/manteca/deposit', { method: 'POST', diff --git a/src/types/manteca.types.ts b/src/types/manteca.types.ts index 9e55ec4be..47458a776 100644 --- a/src/types/manteca.types.ts +++ b/src/types/manteca.types.ts @@ -19,6 +19,8 @@ export interface MantecaDepositResponseData { withdrawCostInAsset: string price: string priceExpireAt: string + // STUB: dynamic PIX QR (EMVCo copia-e-cola) Manteca will add to BRL ramp-on details — exact field name TBC when they ship it (Slack 2026-06-30). + qr?: string } currentStage: number stages: { @@ -55,19 +57,6 @@ export interface MantecaDepositResponseData { updatedAt: string } -/** - * BRL dynamic PIX QR deposit response (from POST /manteca/deposit when currency=BRL). - * Discriminated from the ramp-on shape by `type: 'QR'`. The amount + currency are - * embedded in `code` (the EMVCo "copia e cola" BR Code). - */ -export interface MantecaPixDepositData { - type: 'QR' - code: string - url: string - bankId: string - expiresAt: string -} - export enum MercadoPagoStep { DETAILS = 'details', REVIEW = 'review', From dfdd8934d5a5c28d5f9db8064239fcac82726289 Mon Sep 17 00:00:00 2001 From: peanut Date: Wed, 1 Jul 2026 15:35:10 +0200 Subject: [PATCH 3/5] feat(manteca): default to local currency for BRL/ARS + branded waiting screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two PIX-deposit polish items: - Add money → Brazil now defaults the input denomination to BRL (Argentina → ARS) instead of USD — you deposit in your local currency. - Show the branded CyclingLoading screen (spinning peanut + rotating messages, same as PIX-payment processing) while the BRL QR is being generated, and in place of the plain "Preparing your PIX QR…" fallback. --- .../AddMoney/components/MantecaAddMoney.tsx | 15 ++++++++++++++- .../AddMoney/components/MantecaPixQrDeposit.tsx | 3 ++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index f4f0a474c..856b69bed 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -2,6 +2,7 @@ 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' @@ -56,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('') @@ -72,6 +72,9 @@ const MantecaAddMoney: FC = () => { const selectedCountry = useMemo(() => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) + // Default the input denomination to the country's local currency (Brazil→BRL, + // Argentina→ARS) instead of USD — you deposit in your local currency. + const currentDenomination = urlState.currency ?? (selectedCountry?.currency as CurrencyDenomination) ?? 'USD' const onBack = useSafeBack(addMoneyCountryUrl(selectedCountryPath)) // The pool→full upgrade gate asks "did the user clear ID verification?", // not "do they have an enabled rail elsewhere?" — read the identity @@ -238,6 +241,16 @@ const MantecaAddMoney: FC = () => { 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 ( <> diff --git a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx index 476bf76fc..c5982bb0e 100644 --- a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx +++ b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx @@ -8,6 +8,7 @@ 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 @@ -71,7 +72,7 @@ const MantecaPixQrDeposit: FC<{
{!qr ? ( -

Preparing your PIX QR…

+ ) : ( <> From 3ab0668852740e90f67d993d95c9849e77be842a Mon Sep 17 00:00:00 2001 From: peanut Date: Wed, 1 Jul 2026 15:44:06 +0200 Subject: [PATCH 4/5] feat(manteca): narrow local-currency default to Brazil + branded settlement screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add money → Brazil defaults the denomination to BRL (PIX is in BRL); every other country keeps USD (Argentina reverts to USD). - useMantecaDepositPolling now surfaces a 'processing' status for the payment-settling window (Manteca PROCESSING / AWAITING_SETTLEMENT); the QR screen shows the branded CyclingLoading during it, so the flow is QR → processing → "Deposit received!". --- .../AddMoney/components/MantecaAddMoney.tsx | 7 ++++--- .../AddMoney/components/MantecaPixQrDeposit.tsx | 14 +++++++++++++- .../__tests__/useMantecaDepositPolling.test.tsx | 7 +++++++ .../AddMoney/hooks/useMantecaDepositPolling.ts | 5 ++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index 856b69bed..baa9fea45 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -72,9 +72,10 @@ const MantecaAddMoney: FC = () => { const selectedCountry = useMemo(() => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) - // Default the input denomination to the country's local currency (Brazil→BRL, - // Argentina→ARS) instead of USD — you deposit in your local currency. - const currentDenomination = urlState.currency ?? (selectedCountry?.currency as CurrencyDenomination) ?? 'USD' + // 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)) // The pool→full upgrade gate asks "did the user clear ID verification?", // not "do they have an enabled rail elsewhere?" — read the identity diff --git a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx index c5982bb0e..726571e87 100644 --- a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx +++ b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx @@ -39,7 +39,7 @@ const MantecaPixQrDeposit: FC<{ const countdownLabel = isExpired ? null : `${minutes}:${String(seconds).padStart(2, '0')}` useEffect(() => { - if (status === 'completed' || isExpired) return + if (status === 'completed' || status === 'processing' || isExpired) return const interval = setInterval(() => setNowMs(Date.now()), 1000) return () => clearInterval(interval) }, [status, isExpired]) @@ -62,6 +62,18 @@ const MantecaPixQrDeposit: FC<{ ) } + // Payment detected, settling — show the branded processing screen (same as PIX payments). + if (status === 'processing') { + return ( +
+ +
+ +
+
+ ) + } + return (
diff --git a/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx index ed25711a6..fdf4c6108 100644 --- a/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx +++ b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx @@ -53,6 +53,13 @@ describe('useMantecaDepositPolling', () => { 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 }) diff --git a/src/components/AddMoney/hooks/useMantecaDepositPolling.ts b/src/components/AddMoney/hooks/useMantecaDepositPolling.ts index 1729e2019..eee0b3ff4 100644 --- a/src/components/AddMoney/hooks/useMantecaDepositPolling.ts +++ b/src/components/AddMoney/hooks/useMantecaDepositPolling.ts @@ -8,8 +8,10 @@ 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' | 'completed' | 'failed' +type MantecaDepositPollStatus = 'pending' | 'processing' | 'completed' | 'failed' /** * Poll a BRL PIX deposit intent until it settles. Read-only: the webhook/poller @@ -34,6 +36,7 @@ export function useMantecaDepositPolling(depositId: string | undefined, onComple 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]) From 9d506bbb7fb3ddab9d5f121a1b2a64cbe356826c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 2 Jul 2026 16:03:32 -0300 Subject: [PATCH 5/5] feat(manteca): wire PIX QR screen to the prod-confirmed contract; retire the maintenance warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manteca shipped the dynamic BRL QR to prod today. Probe (mono ops/scripts/manteca/probe-brl-rampon-qr.ts) pinned the real shape: the EMVCo copia-e-cola rides in details.depositAddresses.PIX.{code,url, expiresAt,bankId}; the old static depositAddress/depositAlias are gone for BRL (now optional in the type — the ARS share-details screen keeps a '' fallback but BRL never routes there). With the QR flow live the warn-only maintenance surface comes off: pixBrazilOnrampMaintenance flips false (machinery stays for future outages), the unused banner copy is trimmed to the badge, and the maintenance test now snapshots/restores the shipped flag instead of hardcoding true — flipping the committed default no longer poisons the config singleton for later tests. --- .../components/MantecaDepositShareDetails.tsx | 4 ++- .../components/MantecaPixQrDeposit.tsx | 5 ++-- .../__tests__/MantecaPixQrDeposit.test.tsx | 28 ++++++++++++------- .../AddWithdrawCountriesList.test.tsx | 12 ++++++-- src/config/underMaintenance.config.ts | 12 +++----- src/types/manteca.types.ts | 21 ++++++++++---- 6 files changed, 53 insertions(+), 29 deletions(-) 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 index 726571e87..254e2676e 100644 --- a/src/components/AddMoney/components/MantecaPixQrDeposit.tsx +++ b/src/components/AddMoney/components/MantecaPixQrDeposit.tsx @@ -18,8 +18,9 @@ const MantecaPixQrDeposit: FC<{ // 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 + // 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) diff --git a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx index 0658d8864..74ba1a3fb 100644 --- a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx +++ b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx @@ -1,10 +1,10 @@ /** * 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. + * 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' @@ -35,14 +35,22 @@ jest.mock('@/components/0_Bruddle/Button', () => ({ // 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: { - qr: '00020126-COPIA-E-COLA', + // 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 - depositAddress: '', - depositAlias: '', }, stages: {}, } as unknown as import('@/types/manteca.types').MantecaDepositResponseData @@ -53,7 +61,7 @@ beforeEach(() => { }) describe('MantecaPixQrDeposit', () => { - it('renders the QR + copy from the same `details.qr`, the amount, and a live countdown', () => { + it('renders the QR + copy from the same PIX copia-e-cola code, the amount, and a live countdown', () => { render( { 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.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() }) 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/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts index eb0b096b5..317c28652 100644 --- a/src/config/underMaintenance.config.ts +++ b/src/config/underMaintenance.config.ts @@ -36,9 +36,8 @@ * * 7. pixBrazilOnrampMaintenance: warn-only flag for the BRL-via-PIX onramp (Manteca Brazil deposit) * - shows a "Maintenance" tag on the Pix option in /add-money/brazil - * - shows a warning banner inside the deposit flow (/add-money/brazil/manteca) * - does NOT block deposits — the option stays usable (warn-only) - * - set to false when PIX deposits are stable again + * - set to true if the PIX onramp degrades again * * 8. disableCardLaunchCTA: kill-switch for the in-app "shhh" card CTA (the home nudge) * - true hides BOTH the activation-funnel card step and the activated-base home splash @@ -84,7 +83,7 @@ const underMaintenanceConfig: MaintenanceConfig = { disableXchainSend: true, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum) disableCardPioneers: true, // set to false to enable the Card Pioneers waitlist feature disableCardLaunchCTA: false, // kill-switch for the in-app "shhh" card CTA (funnel card step + activated home splash). Set true to mute it (dial down in-app load); /card flow + /shhhhh + waitlist stay reachable regardless. - pixBrazilOnrampMaintenance: true, // set to false when BRL-via-PIX deposits are stable again + pixBrazilOnrampMaintenance: false, // BRL deposits restored via dynamic PIX QR (2026-07-02). Set true if the onramp degrades again. disabledMantecaCurrencies: [], // Manteca restored (ARS + BRL live). Add a currency here to block it during a future outage. } @@ -92,13 +91,10 @@ const underMaintenanceConfig: MaintenanceConfig = { export const CROSS_CHAIN_DISABLED_MESSAGE = 'Cross-chain claims are temporarily unavailable. Try claiming to an external wallet on the same chain as the link, or try again later.' -// shared user-facing copy for the BRL-via-PIX onramp maintenance warning — keep the list tag and -// the in-flow banner aligned +// user-facing copy for the BRL-via-PIX onramp maintenance tag (the in-flow banner was +// retired when the dynamic-QR deposit flow shipped — the tag is the remaining surface) export const PIX_BRAZIL_ONRAMP_MAINTENANCE = { badge: 'Maintenance', - title: 'PIX deposits are under maintenance', - description: - 'PIX deposits are currently unstable and may be delayed or fail. You can still continue, but service may be unreliable until this is resolved.', } export default underMaintenanceConfig diff --git a/src/types/manteca.types.ts b/src/types/manteca.types.ts index 47458a776..b354039a6 100644 --- a/src/types/manteca.types.ts +++ b/src/types/manteca.types.ts @@ -11,16 +11,27 @@ 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 priceExpireAt: string - // STUB: dynamic PIX QR (EMVCo copia-e-cola) Manteca will add to BRL ramp-on details — exact field name TBC when they ship it (Slack 2026-06-30). - qr?: string } currentStage: number stages: {