Skip to content
Merged
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
32 changes: 32 additions & 0 deletions src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
// reason message even when the page never touches them.
type TestRail = Pick<RailCapability, 'id' | 'provider' | 'status' | 'operations'> & {
reason?: { userMessage: string | null }
resolved?: RailCapability['resolved']
}
type TestRestriction = { code: string; affectedRailIds: string[]; userMessage?: string | null }

Expand Down Expand Up @@ -49,7 +50,7 @@
default: (props: any) => {
// next/image uses 'fill' boolean; strip non-DOM props
const { priority, layout, objectFit, fill, ...rest } = props
return <img {...rest} />

Check warning on line 53 in src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

View workflow job for this annotation

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element

Check warning on line 53 in src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx

View workflow job for this annotation

GitHub Actions / eslint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
},
}))

Expand Down Expand Up @@ -496,6 +497,7 @@
canDo: (_op: string, o?: { provider?: string }) =>
payEnabled && (o?.provider === undefined || o.provider === 'manteca'),
railsForProvider: (provider: string) => rails.filter((r) => r.provider === provider),
nextActions: [],
restrictionForRail: (railId: string) => restrictions.find((r) => r.affectedRailIds.includes(railId)),
}
}
Expand Down Expand Up @@ -699,6 +701,36 @@
expect(screen.getByText('Contact support to continue.')).toBeInTheDocument()
})

test('provide-email verdict maps to the unavailable modal, never the document-upload flow', () => {
// a fixable verdict whose only fix is adding an email must not open
// the Sumsub upload flow (this surface has no email form) — same
// mapping rule as deriveProviderRejection
mockUseCapabilities.mockReturnValue({
...capabilitiesForGate('provider_rejection_fixable', { userMessage: 'Add your email to continue.' }),
railsForProvider: () => [
{
id: MANTECA_RAIL_ID,
provider: 'manteca',
status: 'blocked',
resolved: {
status: 'fixable',
blocking: {
code: 'email_required',
userMessage: 'Add your email to continue.',
selfHealable: true,
selfHealKind: 'provide-email',
},
},
} as TestRail,
],
})

renderQrPay({ qrCode: 'mercadopago://pay?id=123', type: 'MERCADO_PAGO', t: '1' })

expect(screen.getByText('QR payments are not available')).toBeInTheDocument()
expect(screen.queryByText('Upload document')).not.toBeInTheDocument()
})

test('Manteca fixable rejection shows updated-document modal', () => {
setCapabilitiesGate('provider_rejection_fixable', { userMessage: 'Upload a clearer ID.' })

Expand Down
52 changes: 35 additions & 17 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { railUserMessage, railVerdict } from '@/utils/capability-gate'
import { useSearchParams, useRouter } from 'next/navigation'
import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react'
import { useSafeBack } from '@/hooks/useSafeBack'
Expand Down Expand Up @@ -165,7 +166,7 @@
// manteca 'pending' → IDENTITY_VERIFICATION_IN_PROGRESS.
// otherwise → REQUIRES_IDENTITY_VERIFICATION. While loading → LOADING.
// userMessage ← the rejecting rail's reason.userMessage (was useProviderRejectionStatus).
const { canDo, railsForProvider, isKycApproved, isLoading: isLoadingCapabilities } = useCapabilities()
const { canDo, railsForProvider, nextActions, isKycApproved, isLoading: isLoadingCapabilities } = useCapabilities()
const { user, fetchUser } = useAuth()

// On public routes (qr-pay) auth still auto-fetches via React Query, but trigger a one-shot
Expand All @@ -192,42 +193,59 @@
if (canDo('pay', { provider: 'manteca' })) {
return { kycGateState: QrKycState.PROCEED_TO_PAY, qrKycUserMessage: null as string | null }
}
const mantecaRails = railsForProvider('manteca')
const blockedRail = mantecaRails.find((rail) => rail.status === 'blocked')
if (blockedRail) {
// Blocked === blocked. The US-nationality refinement is now applied in
// the resolver itself (Sumsub-approved + US-restricted → status:enabled
// + operations.pay:enabled, caught by canDo above), so a `blocked`
// status here is a genuine block.
//
// Verdict-first via the shared railVerdict collapse (rail.resolved,
// BE-derived; legacy fallback for older/cached responses). The
// US-nationality refinement is applied in the resolver itself
// (Sumsub-approved + US-restricted → operations.pay enabled, caught by
// canDo above), so a blocked verdict is genuine.
const actionByKey = new Map(nextActions.map((action) => [action.key, action]))
const candidates = railsForProvider('manteca').map((rail) => ({
rail,
verdict: railVerdict(rail, actionByKey),
}))
// provide-email is NOT a document fix: routing it into the Sumsub
// upload flow dead-ends the user, and this surface has no email form —
// map it to the blocked modal (same rule as deriveProviderRejection).
const isProvideEmail = ({ verdict }: (typeof candidates)[number]) =>
verdict.blocking?.selfHealKind === 'provide-email'
const blocked = candidates.find(
(candidate) => candidate.verdict.status === 'blocked' || isProvideEmail(candidate)
)
if (blocked) {
// Country-not-supported is self-fixable: user uploaded a non-AR/BR doc
// and can verify again with a different one. Split out for the right CTA.
if (blockedRail.reason?.code === 'country_not_supported') {
// (selfHealKind is the verdict home; the reason-code check covers legacy
// responses — the code rides on blocking.code verbatim.)
if (
!isProvideEmail(blocked) &&
(blocked.verdict.blocking?.selfHealKind === 'restart-identity' ||
blocked.verdict.blocking?.code === 'country_not_supported')
) {
return {
kycGateState: QrKycState.PROVIDER_RESTART_IDENTITY,
qrKycUserMessage: blockedRail.reason.userMessage ?? null,
qrKycUserMessage: railUserMessage(blocked.rail),
}
}
return {
kycGateState: QrKycState.PROVIDER_REJECTION_BLOCKED,
qrKycUserMessage: blockedRail.reason?.userMessage ?? null,
qrKycUserMessage: railUserMessage(blocked.rail),
}
}
const fixableRail = mantecaRails.find((rail) => rail.status === 'requires-info')
if (fixableRail) {
const fixable = candidates.find((candidate) => candidate.verdict.status === 'fixable')
if (fixable) {
return {
kycGateState: QrKycState.PROVIDER_REJECTION_FIXABLE,
qrKycUserMessage: fixableRail.reason?.userMessage ?? null,
qrKycUserMessage: railUserMessage(fixable.rail),
}
}
if (mantecaRails.some((rail) => rail.status === 'pending')) {
if (candidates.some(({ verdict }) => verdict.status === 'pending')) {
return {
kycGateState: QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS,
qrKycUserMessage: null as string | null,
}
}
return { kycGateState: QrKycState.REQUIRES_IDENTITY_VERIFICATION, qrKycUserMessage: null as string | null }
}, [isLoadingCapabilities, canDo, railsForProvider, user, userFetchSettled])
}, [isLoadingCapabilities, canDo, railsForProvider, nextActions, user, userFetchSettled])

const shouldBlockPay = kycGateState !== QrKycState.PROCEED_TO_PAY

Expand All @@ -250,7 +268,7 @@
if (sumsubFlow.showWrapper || sumsubFlow.isModalOpen) {
sumsubFlow.completeFlow()
}
}, [kycGateState, sumsubFlow.showWrapper, sumsubFlow.isModalOpen, sumsubFlow.completeFlow])

Check warning on line 271 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'sumsubFlow'. Either include it or remove the dependency array

Check warning on line 271 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'sumsubFlow'. Either include it or remove the dependency array

const queryClient = useQueryClient()
const [isShaking, setIsShaking] = useState(false)
Expand Down Expand Up @@ -361,7 +379,7 @@
if (isSuccess || !!errorMessage) {
setLoadingState('Idle')
}
}, [isSuccess, errorMessage])

Check warning on line 382 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'setLoadingState'. Either include it or remove the dependency array

Check warning on line 382 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'setLoadingState'. Either include it or remove the dependency array

// First fetch for qrcode info — only after KYC gating allows proceeding
useEffect(() => {
Expand All @@ -380,7 +398,7 @@
}

setIsFirstLoad(false)
}, [timestamp, paymentProcessor, qrCode])

Check warning on line 401 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array

Check warning on line 401 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'resetState'. Either include it or remove the dependency array

// Get amount from payment lock (Manteca)
useEffect(() => {
Expand All @@ -395,7 +413,7 @@
setAmount(paymentLock.paymentAgainstAmount)
setCurrencyAmount(paymentLock.paymentAssetAmount)
}
}, [paymentLock?.code, paymentProcessor])

Check warning on line 416 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 416 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

// Get currency object from payment lock (Manteca)
useEffect(() => {
Expand All @@ -417,7 +435,7 @@
}
}
getCurrencyObject().then(setCurrency)
}, [paymentLock?.code, paymentProcessor])

Check warning on line 438 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 438 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

const isBlockingError = useMemo(() => {
// The settling failure says "try again in a few seconds" — keep the Pay
Expand All @@ -439,7 +457,7 @@
// For dynamic QR codes, backend provides the USD amount
return paymentLock.paymentAgainstAmount
}
}, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount])

Check warning on line 460 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

Check warning on line 460 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useMemo has a missing dependency: 'paymentLock'. Either include it or remove the dependency array

// Live card-vs-local-rail markup, driven by Manteca's rate + (for ARS)
// BCRA's official rate. Used by both the confirm-screen "Save vs card"
Expand Down
1 change: 0 additions & 1 deletion src/app/actions/types/users.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export interface InitiateKycResponse {
kycLink: string
tosLink?: string
bridgeKycStatus: string
tosStatus?: string
error?: string // will be present on rejections
reasons?: Array<{
developer_reason: string
Expand Down
29 changes: 19 additions & 10 deletions src/components/Home/ActivationCTAs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { railUserMessage, railVerdict } from '@/utils/capability-gate'
import { Button } from '@/components/0_Bruddle/Button'
import { type ActivationStep } from '@/hooks/useActivationStatus'
import { Icon, type IconName } from '@/components/Global/Icons/Icon'
Expand Down Expand Up @@ -79,7 +80,7 @@ const STEPS: Record<Exclude<ActivationStep, 'completed'>, StepConfig> = {
export default function ActivationCTAs({ activationStep, onDismissCard }: ActivationCTAsProps) {
const router = useRouter()
const { setIsQRScannerOpen, openSupportWithMessage } = useModalsContext()
const { rails, channelOf, nextActionsForRail } = useCapabilities()
const { rails, channelOf, nextActions } = useCapabilities()
const { user } = useAuth()
// Suppress the "Unlock payments" verify CTA while identity is mid-flight
// (Sumsub processing / action_required). The user already took the verify
Expand All @@ -103,14 +104,19 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa
const channel = channelOf(rail)
return channel === 'bank' || channel === 'qr-only'
})
const fixableRail = rejectableRails.find((rail) => rail.status === 'requires-info')
// Email-blocked rails carry a self-serve provide-email action (same
// contract the capability gate reads) — prefer one over an earlier
// blocked rail with a terminal reason, since one email fixes them all.
const emailBlocked = rejectableRails.find(
(rail) => rail.status === 'blocked' && nextActionsForRail(rail.id).some((a) => a.kind === 'provide-email')
// Verdict-first via the shared railVerdict collapse (rail.resolved,
// BE-derived; legacy fallback for older/cached responses).
const actionByKey = new Map(nextActions.map((action) => [action.key, action]))
const isEmailFix = (rail: (typeof rejectableRails)[number]) =>
railVerdict(rail, actionByKey).blocking?.selfHealKind === 'provide-email'
const fixableRail = rejectableRails.find(
(rail) => railVerdict(rail, actionByKey).status === 'fixable' && !isEmailFix(rail)
)
const blocked = emailBlocked ?? rejectableRails.find((rail) => rail.status === 'blocked')
// Email-blocked rails: prefer one over an earlier blocked rail with a
// terminal reason, since one email fixes them all.
const emailBlocked = rejectableRails.find(isEmailFix)
const blocked =
emailBlocked ?? rejectableRails.find((rail) => railVerdict(rail, actionByKey).status === 'blocked')
return {
hasFixableRejection: !!fixableRail,
fixableProvider:
Expand All @@ -119,11 +125,14 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa
: null,
hasBlockedRejection: !!blocked,
// Same precedence the copy/onClick use: email-blocked → fixable → terminal.
primaryRejectionMessage: (emailBlocked ?? fixableRail ?? blocked)?.reason?.userMessage ?? null,
primaryRejectionMessage: (() => {
const surfaced = emailBlocked ?? fixableRail ?? blocked
return surfaced ? railUserMessage(surfaced) : null
})(),
blockedRail: blocked,
isEmailBlocked: !!emailBlocked,
}
}, [rails, channelOf, nextActionsForRail])
}, [rails, channelOf, nextActions])

const [showProvideEmail, setShowProvideEmail] = useState(false)

Expand Down
1 change: 1 addition & 0 deletions src/components/Home/__tests__/ActivationCTAs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jest.mock('@/hooks/useCapabilities', () => ({
rails: mockRails,
channelOf: (rail: { channel: string }) => rail.channel,
nextActionsForRail: () => [],
nextActions: [],
}),
}))
jest.mock('@/context/authContext', () => ({
Expand Down
2 changes: 0 additions & 2 deletions src/interfaces/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,6 @@ export interface User {
email: string
profile_picture: string | null
username: string | null
tosStatus?: string
tosAcceptedAt?: string
bridgeCustomerId: string | null
fullName: string
telegram: string | null
Expand Down
Loading
Loading