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
4 changes: 3 additions & 1 deletion src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
// Local UI state (not URL-appropriate - transient)
const [showWarningModal, setShowWarningModal] = useState<boolean>(false)
const [showKycModal, setShowKycModal] = useState<boolean>(false)
const [isRiskAccepted, setIsRiskAccepted] = useState<boolean>(false)

Check failure on line 65 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

'isRiskAccepted' is assigned a value but never used. Allowed unused elements of array destructuring must match /^_/u

Check failure on line 65 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

'isRiskAccepted' is assigned a value but never used. Allowed unused elements of array destructuring must match /^_/u
const { setError, error, setOnrampData, onrampData } = useOnrampFlow()

const { balance } = useWallet()
Expand Down Expand Up @@ -116,7 +116,7 @@

useEffect(() => {
fetchUser()
}, [])

Check warning on line 119 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

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

Check warning on line 119 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

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

const peanutWalletBalance = useMemo(() => {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
Expand Down Expand Up @@ -409,7 +409,9 @@
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
if (gate.kind === 'fixable-rejection') {
if (gate.kind === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
} else if (gate.kind === 'fixable-rejection') {
await sumsubFlow.handleSelfHealResubmit('BRIDGE')
} else {
await sumsubFlow.handleInitiateKyc(
Expand Down
47 changes: 38 additions & 9 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@
// 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.
//
// 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') {
return {
kycGateState: QrKycState.PROVIDER_RESTART_IDENTITY,
qrKycUserMessage: blockedRail.reason.userMessage ?? null,
}
}
return {
kycGateState: QrKycState.PROVIDER_REJECTION_BLOCKED,
qrKycUserMessage: blockedRail.reason?.userMessage ?? null,
Expand Down Expand Up @@ -313,7 +322,7 @@
if (isSuccess || !!errorMessage) {
setLoadingState('Idle')
}
}, [isSuccess, errorMessage])

Check warning on line 325 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 325 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 @@ -325,7 +334,7 @@
}

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

Check warning on line 337 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 337 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 @@ -340,7 +349,7 @@
setAmount(paymentLock.paymentAgainstAmount)
setCurrencyAmount(paymentLock.paymentAssetAmount)
}
}, [paymentLock?.code, paymentProcessor])

Check warning on line 352 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 352 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 @@ -362,7 +371,7 @@
}
}
getCurrencyObject().then(setCurrency)
}, [paymentLock?.code, paymentProcessor])

Check warning on line 374 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 374 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(() => {
return !!errorMessage && errorMessage !== 'Please confirm the transaction.'
Expand All @@ -378,7 +387,7 @@
// For dynamic QR codes, backend provides the USD amount
return paymentLock.paymentAgainstAmount
}
}, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount])

Check warning on line 390 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 390 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 Expand Up @@ -921,7 +930,9 @@
kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION ||
kycGateState === QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS
const hasProviderRejection =
kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE || kycGateState === QrKycState.PROVIDER_REJECTION_BLOCKED
kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE ||
kycGateState === QrKycState.PROVIDER_REJECTION_BLOCKED ||
kycGateState === QrKycState.PROVIDER_RESTART_IDENTITY

// show loading while KYC state is being determined
if (isLoadingKycState) {
Expand All @@ -931,18 +942,28 @@
// provider rejection: user is sumsub-approved but manteca rejected
if (hasProviderRejection) {
const isFixable = kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE
const isRestartIdentity = kycGateState === QrKycState.PROVIDER_RESTART_IDENTITY
return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Pay" />
<ActionModal
visible
onClose={onBack}
title={isFixable ? 'We need an updated document' : 'QR payments are not available'}
title={
isFixable
? 'We need an updated document'
: isRestartIdentity
? 'Verify with a different document'
: 'QR payments are not available'
}
description={
isFixable
? 'We need an updated document to enable QR payments. Please upload a clearer photo of your ID.'
: (qrKycUserMessage ??
'QR payments are not available for your account. Contact support for help.')
: isRestartIdentity
? (qrKycUserMessage ??
'QR payments need a document from a supported country. You can verify with a different ID.')
: (qrKycUserMessage ??
'QR payments are not available for your account. Contact support for help.')
}
icon={
methodIcon ? (
Expand All @@ -958,11 +979,19 @@
shadowSize: '4' as const,
icon: 'upload',
}
: {
text: 'Contact support',
onClick: () => setIsSupportModalOpen(true),
variant: 'stroke' as const,
},
: isRestartIdentity
? {
text: 'Verify with a different document',
onClick: () => sumsubFlow.handleRestartIdentity(),
variant: 'purple' as const,
shadowSize: '4' as const,
icon: 'upload',
}
: {
text: 'Contact support',
onClick: () => setIsSupportModalOpen(true),
variant: 'stroke' as const,
},
]}
/>
<SumsubKycModals flow={sumsubFlow} />
Expand Down
4 changes: 3 additions & 1 deletion src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,9 @@ export default function WithdrawBankPage() {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
if (gate.kind === 'fixable-rejection') {
if (gate.kind === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
} else if (gate.kind === 'fixable-rejection') {
await sumsubFlow.handleSelfHealResubmit('BRIDGE')
} else {
await sumsubFlow.handleInitiateKyc(
Expand Down
17 changes: 10 additions & 7 deletions src/app/(mobile-ui)/withdraw/manteca/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -601,8 +601,9 @@ export default function MantecaWithdrawFlow() {
setShowKycModal(false)
return
}
const hasRejection = mantecaRejection.state === 'fixable'
if (hasRejection) {
if (mantecaRejection.state === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
} else if (mantecaRejection.state === 'fixable') {
await sumsubFlow.handleSelfHealResubmit('MANTECA')
} else {
await sumsubFlow.handleInitiateKyc('LATAM', undefined, true, selectedCountry?.id)
Expand All @@ -613,11 +614,13 @@ export default function MantecaWithdrawFlow() {
variant={
mantecaRejection.state === 'blocked'
? 'blocked'
: mantecaRejection.state === 'fixable'
? 'provider_rejection'
: isUserIdentityVerified
? 'cross_region'
: 'default'
: mantecaRejection.state === 'restart-identity'
? 'restart_identity'
: mantecaRejection.state === 'fixable'
? 'provider_rejection'
: isUserIdentityVerified
? 'cross_region'
: 'default'
}
providerMessage={mantecaRejection.userMessage ?? undefined}
regionName={selectedCountry?.title}
Expand Down
30 changes: 30 additions & 0 deletions src/app/actions/sumsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ export interface SelfHealResubmissionResponse {
maxAttempts: number
}

export interface RestartIdentityResponse {
token: string
levelName: string
applicantId: string
}

/**
* Reset the user's Sumsub IDENTITY step and mint a fresh token. Used as the
* "Verify with a different document" CTA on a Manteca rail that's blocked
* because the user verified with a non-AR/BR document.
*/
export const restartIdentityVerification = async (): Promise<{
data?: RestartIdentityResponse
error?: string
}> => {
try {
const response = await serverFetch('/users/identity/restart', { method: 'POST' })
const responseJson = await response.json()
if (!response.ok) {
return {
error: responseJson.userMessage || responseJson.error || 'Failed to restart identity verification',
}
}
return { data: responseJson }
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'An unexpected error occurred'
return { error: message }
}
}

// initiate self-heal document resubmission for a provider-rejected user
export const initiateSelfHealResubmission = async (
provider: 'BRIDGE' | 'MANTECA'
Expand Down
17 changes: 10 additions & 7 deletions src/components/AddMoney/components/MantecaAddMoney.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,9 @@ const MantecaAddMoney: FC = () => {
setShowKycModal(false)
return
}
const hasRejection = mantecaRejection.state === 'fixable'
if (hasRejection) {
if (mantecaRejection.state === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
} else if (mantecaRejection.state === 'fixable') {
await sumsubFlow.handleSelfHealResubmit('MANTECA')
} else {
await sumsubFlow.handleInitiateKyc('LATAM', undefined, true, selectedCountry?.id)
Expand All @@ -253,11 +254,13 @@ const MantecaAddMoney: FC = () => {
variant={
mantecaRejection.state === 'blocked'
? 'blocked'
: mantecaRejection.state === 'fixable'
? 'provider_rejection'
: isUserIdentityVerified
? 'cross_region'
: 'default'
: mantecaRejection.state === 'restart-identity'
? 'restart_identity'
: mantecaRejection.state === 'fixable'
? 'provider_rejection'
: isUserIdentityVerified
? 'cross_region'
: 'default'
}
providerMessage={mantecaRejection.userMessage ?? undefined}
regionName={selectedCountry?.title}
Expand Down
43 changes: 19 additions & 24 deletions src/components/Claim/Link/MantecaFlowManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useCapabilities } from '@/hooks/useCapabilities'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import { deriveProviderRejection } from '@/utils/provider-rejection.utils'

interface MantecaFlowManagerProps {
claimLinkData: ClaimLinkData
Expand All @@ -28,7 +29,7 @@ const MantecaFlowManager: FC<MantecaFlowManagerProps> = ({ claimLinkData, amount
const [currentStep, setCurrentStep] = useState<MercadoPagoStep>(MercadoPagoStep.DETAILS)
const router = useRouter()
const [destinationAddress, setDestinationAddress] = useState('')
const { canDo, isKycApproved, railsForProvider } = useCapabilities()
const { canDo, isKycApproved, rails } = useCapabilities()

// MIGRATION-REVIEW: MercadoPago/PIX claim is a `pay` operation over Manteca. Old gate was
// `isUserMantecaKycApproved` (any MANTECA/SUMSUB-mantecaGeo verification approved). Mapped to
Expand All @@ -37,18 +38,9 @@ const MantecaFlowManager: FC<MantecaFlowManagerProps> = ({ claimLinkData, amount
// mantecaGeo counted as approved).
const isMantecaPayEnabled = canDo('pay', { provider: 'manteca' })

// MIGRATION-REVIEW: old `manteca` rejection state (fixable/blocked + userMessage) derived from
// raw rail status + self-heal metadata. That eligibility now lives backend-side as the rail
// status: requires-info → fixable (self-heal), blocked → blocked (support). userMessage ←
// reason.userMessage. Uses TOP-LEVEL status, so the pool pay-rail (top-level 'enabled') is
// not treated as a rejection.
const mantecaRejection = useMemo(() => {
const mantecaRails = railsForProvider('manteca')
const fixableRail = mantecaRails.find((rail) => rail.status === 'requires-info')
const blockedRail = mantecaRails.find((rail) => rail.status === 'blocked')
const state: 'fixable' | 'blocked' | 'happy' = fixableRail ? 'fixable' : blockedRail ? 'blocked' : 'happy'
return { state, userMessage: (fixableRail ?? blockedRail)?.reason?.userMessage ?? null }
}, [railsForProvider])
// Use the shared rejection util so the `restart-identity` branch (Manteca
// country-ineligibility — user uploaded a non-AR/BR doc) is honored here too.
const mantecaRejection = useMemo(() => deriveProviderRejection(rails, 'MANTECA'), [rails])

// inline sumsub kyc flow for manteca users who need LATAM verification
// regionIntent is NOT passed here to avoid creating a backend record on mount.
Expand Down Expand Up @@ -150,8 +142,9 @@ const MantecaFlowManager: FC<MantecaFlowManagerProps> = ({ claimLinkData, amount
setShowKycModal(false)
return
}
const hasRejection = mantecaRejection.state === 'fixable'
if (hasRejection) {
if (mantecaRejection.state === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
} else if (mantecaRejection.state === 'fixable') {
await sumsubFlow.handleSelfHealResubmit('MANTECA')
} else {
await sumsubFlow.handleInitiateKyc('LATAM', undefined, true)
Expand All @@ -162,15 +155,17 @@ const MantecaFlowManager: FC<MantecaFlowManagerProps> = ({ claimLinkData, amount
variant={
mantecaRejection.state === 'blocked'
? 'blocked'
: mantecaRejection.state === 'fixable'
? 'provider_rejection'
: // MIGRATION-REVIEW: 'cross_region' copy = "you're already verified, just need
// the regional Manteca uplift". Old gate was `isUserSumsubKycApproved`. Sumsub has
// no rail in the capability model; any enabled rail implies identity verification
// was completed at least once, so isKycApproved is the closest faithful proxy.
isKycApproved
? 'cross_region'
: 'default'
: mantecaRejection.state === 'restart-identity'
? 'restart_identity'
: mantecaRejection.state === 'fixable'
? 'provider_rejection'
: // MIGRATION-REVIEW: 'cross_region' copy = "you're already verified, just need
// the regional Manteca uplift". Old gate was `isUserSumsubKycApproved`. Sumsub has
// no rail in the capability model; any enabled rail implies identity verification
// was completed at least once, so isKycApproved is the closest faithful proxy.
isKycApproved
? 'cross_region'
: 'default'
}
providerMessage={mantecaRejection.userMessage ?? undefined}
regionName={selectedCountry?.title}
Expand Down
4 changes: 3 additions & 1 deletion src/components/Claim/Link/views/BankFlowManager.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
if (gate.kind === 'fixable-rejection') {
if (gate.kind === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
} else if (gate.kind === 'fixable-rejection') {
await sumsubFlow.handleSelfHealResubmit('BRIDGE')
} else {
await sumsubFlow.handleInitiateKyc(
Expand Down
Loading
Loading