From 81c4b14dc4c7711b9f6a198baa250d7de9c69dd6 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:22:58 +0530 Subject: [PATCH 1/2] feat: handle manteca qr-pay kyc gating --- src/app/(mobile-ui)/qr-pay/page.tsx | 93 ++++++++++++++++--- src/app/actions/bridge/get-customer.ts | 66 +++++++++++++ src/components/AddMoney/UserDetailsForm.tsx | 4 +- .../Kyc/InitiateMantecaKYCModal.tsx | 3 + .../views/IdentityVerification.view.tsx | 23 ++++- src/hooks/useMantecaKycFlow.ts | 46 +++++---- src/hooks/useQrKycGate.ts | 71 ++++++++++++++ src/utils/general.utils.ts | 10 ++ 8 files changed, 282 insertions(+), 34 deletions(-) create mode 100644 src/app/actions/bridge/get-customer.ts create mode 100644 src/hooks/useQrKycGate.ts diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index c8f6c2df4..aad1f6bf2 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -13,7 +13,7 @@ import Image from 'next/image' import PeanutLoading from '@/components/Global/PeanutLoading' import TokenAmountInput from '@/components/Global/TokenAmountInput' import { useWallet } from '@/hooks/wallet/useWallet' -import { isTxReverted } from '@/utils/general.utils' +import { clearRedirectUrl, getRedirectUrl, isTxReverted } from '@/utils/general.utils' import ErrorAlert from '@/components/Global/ErrorAlert' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants' import { formatUnits, parseUnits } from 'viem' @@ -25,6 +25,10 @@ import { getCurrencyPrice } from '@/app/actions/currency' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { captureException } from '@sentry/nextjs' import { isPaymentProcessorQR } from '@/components/Global/DirectSendQR/utils' +import { QrKycState, useQrKycGate } from '@/hooks/useQrKycGate' +import ActionModal from '@/components/Global/ActionModal' +import { saveRedirectUrl } from '@/utils/general.utils' +import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' const MANTECA_DEPOSIT_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053' const MAX_QR_PAYMENT_AMOUNT = '200' @@ -48,6 +52,7 @@ export default function QRPayPage() { const { openTransactionDetails, selectedTransaction, isDrawerOpen, closeTransactionDetails } = useTransactionDetailsDrawer() const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext) + const { shouldBlockPay, kycGateState } = useQrKycGate() const resetState = () => { setIsSuccess(false) @@ -63,7 +68,7 @@ export default function QRPayPage() { setLoadingState('Idle') } - // First fetch for qrcode info + // First fetch for qrcode info — only after KYC gating allows proceeding useEffect(() => { resetState() @@ -72,17 +77,8 @@ export default function QRPayPage() { return } - mantecaApi - .initiateQrPayment({ qrCode }) - .then((paymentLock) => { - setPaymentLock(paymentLock) - }) - .catch((error) => { - setErrorInitiatingPayment(error.message) - }) - .finally(() => { - setIsFirstLoad(false) - }) + // defer until gating computed later in component + setIsFirstLoad(false) // Trigger on rescan }, [timestamp]) @@ -137,6 +133,20 @@ export default function QRPayPage() { } }, [paymentLock]) + // fetch payment lock only when gating allows proceeding and we don't yet have a lock + useEffect(() => { + if (!qrCode || !isPaymentProcessorQR(qrCode)) return + if (!!paymentLock) return + if (kycGateState !== QrKycState.PROCEED_TO_PAY) return + + setLoadingState('Fetching details') + mantecaApi + .initiateQrPayment({ qrCode }) + .then((pl) => setPaymentLock(pl)) + .catch((error) => setErrorInitiatingPayment(error.message)) + .finally(() => setLoadingState('Idle')) + }, [kycGateState, paymentLock, qrCode, setLoadingState]) + const merchantName = useMemo(() => { if (!paymentLock) return null return paymentLock.paymentRecipientName @@ -223,6 +233,56 @@ export default function QRPayPage() { ) } + if (shouldBlockPay) { + return ( +
+ + { + router.back() + }} + isMantecaModalOpen={kycGateState === QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER} + onKycSuccess={() => { + saveRedirectUrl() + const redirectUrl = getRedirectUrl() + if (redirectUrl) { + clearRedirectUrl() + router.push(redirectUrl) + } else { + router.replace('/home') + } + }} + /> + router.back()} + title="Verify your identity to continue" + description="You'll need to verify your identity before paying with a QR code. Don't worry it usually just takes a few minutes." + icon="shield" + ctas={[ + { + text: 'Verify now', + onClick: () => { + saveRedirectUrl() + router.push('/profile/identity-verification') + }, + variant: 'purple', + shadowSize: '4', + icon: 'check-circle', + }, + ]} + footer={ +

+ Peanut doesn't store any personal information +

+ } + /> +
+ ) + } + if (isFirstLoad || !paymentLock || !currency) { return } @@ -361,7 +421,12 @@ export default function QRPayPage() { shadowSize="4" loading={isLoading} disabled={ - !!errorInitiatingPayment || !!errorMessage || !amount || isLoading || !!balanceErrorMessage + !!errorInitiatingPayment || + !!errorMessage || + !amount || + isLoading || + !!balanceErrorMessage || + shouldBlockPay } > {isLoading ? loadingState : 'Pay'} diff --git a/src/app/actions/bridge/get-customer.ts b/src/app/actions/bridge/get-customer.ts new file mode 100644 index 000000000..66a47b757 --- /dev/null +++ b/src/app/actions/bridge/get-customer.ts @@ -0,0 +1,66 @@ +'use server' + +import { unstable_cache } from 'next/cache' +import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants' +import { countryData } from '@/components/AddMoney/consts' + +type BridgeCustomer = { + id: string + email: string + type: string + status?: string + residential_address?: { + subdivision?: string | null + country?: string | null + } | null +} + +// build a comprehensive ISO3 -> ISO2 map from our country list to normalize country codes and avoid issues with bridge kyc +const ISO3_TO_ISO2: Record = (() => { + const map: Record = {} + for (const c of countryData) { + const iso2 = (c as any).iso2 || c.id + const iso3 = (c as any).iso3 + if (iso2 && iso3) { + map[String(iso3).toUpperCase()] = String(iso2).toUpperCase() + } + } + return map +})() + +const normalizeCountry = (input?: string | null): string | null => { + if (!input) return null + const upper = input.toUpperCase() + if (upper.length === 2) return upper + return ISO3_TO_ISO2[upper] ?? null +} + +export const getBridgeCustomerCountry = async ( + bridgeCustomerId: string +): Promise<{ countryCode: string | null; rawCountry: string | null }> => { + const runner = unstable_cache( + async () => { + const response = await fetch(`${PEANUT_API_URL}/bridge/customers/${bridgeCustomerId}` as string, { + headers: { + 'Content-Type': 'application/json', + 'api-key': PEANUT_API_KEY, + }, + cache: 'no-store', + }) + + if (!response.ok) { + // do not throw to avoid breaking callers; return nulls for graceful fallback + return { countryCode: null, rawCountry: null } as const + } + const data = (await response.json()) as BridgeCustomer + const raw = data?.residential_address?.country ?? null + const normalized = normalizeCountry(raw) + console.log('normalized kushagra', normalized) + return { countryCode: normalized, rawCountry: raw } + }, + ['getBridgeCustomerCountry', bridgeCustomerId], + { revalidate: 5 * 60 } + ) + + return runner() +} diff --git a/src/components/AddMoney/UserDetailsForm.tsx b/src/components/AddMoney/UserDetailsForm.tsx index 041d89e64..4f3cedda1 100644 --- a/src/components/AddMoney/UserDetailsForm.tsx +++ b/src/components/AddMoney/UserDetailsForm.tsx @@ -17,13 +17,13 @@ interface UserDetailsFormProps { } export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDetailsFormProps>( - ({ onSubmit, isSubmitting, onValidChange, initialData }, ref) => { + ({ onSubmit, onValidChange, initialData }, ref) => { const [submissionError, setSubmissionError] = useState(null) const { control, handleSubmit, - formState: { errors, isValid, isValidating }, + formState: { errors, isValid }, } = useForm({ defaultValues: { fullName: initialData?.fullName ?? '', diff --git a/src/components/Kyc/InitiateMantecaKYCModal.tsx b/src/components/Kyc/InitiateMantecaKYCModal.tsx index 19e22a426..2454bec02 100644 --- a/src/components/Kyc/InitiateMantecaKYCModal.tsx +++ b/src/components/Kyc/InitiateMantecaKYCModal.tsx @@ -74,11 +74,13 @@ export const MantecaGeoSpecificKycModal = ({ selectedCountry, setIsMantecaModalOpen, isMantecaModalOpen, + onKycSuccess, }: { isUserBridgeKycApproved: boolean selectedCountry: { id: string; title: string } setIsMantecaModalOpen: (isOpen: boolean) => void isMantecaModalOpen: boolean + onKycSuccess: () => void }) => { return ( setIsMantecaModalOpen(false)} onKycSuccess={() => { setIsMantecaModalOpen(false) + onKycSuccess?.() }} onManualClose={() => setIsMantecaModalOpen(false)} country={{ id: selectedCountry.id, title: selectedCountry.title, type: 'country', path: '' }} diff --git a/src/components/Profile/views/IdentityVerification.view.tsx b/src/components/Profile/views/IdentityVerification.view.tsx index 5f74d4bb7..f8f757fe5 100644 --- a/src/components/Profile/views/IdentityVerification.view.tsx +++ b/src/components/Profile/views/IdentityVerification.view.tsx @@ -20,6 +20,7 @@ import { MantecaKycStatus } from '@/interfaces' import { useRouter } from 'next/navigation' import { useCallback, useMemo, useRef, useState } from 'react' import useKycStatus from '@/hooks/useKycStatus' +import { getRedirectUrl, clearRedirectUrl } from '@/utils/general.utils' const IdentityVerificationView = () => { const { user, fetchUser } = useAuth() @@ -34,6 +35,25 @@ const IdentityVerificationView = () => { const [selectedCountry, setSelectedCountry] = useState<{ id: string; title: string } | null>(null) const [userClickedCountry, setUserClickedCountry] = useState<{ id: string; title: string } | null>(null) + const handleRedirect = useCallback(() => { + const redirectUrl = getRedirectUrl() + if (redirectUrl) { + clearRedirectUrl() + router.push(redirectUrl) + } else { + router.replace('/profile') + } + }, [router]) + + const handleKycSuccess = useCallback(async () => { + await fetchUser() + handleRedirect() + }, [router, fetchUser]) + + const handleMantecaKycSuccess = useCallback(() => { + handleRedirect() + }, [router]) + const { iframeOptions, handleInitiateKyc, @@ -42,7 +62,7 @@ const IdentityVerificationView = () => { closeVerificationProgressModal, error: kycError, isLoading: isKycLoading, - } = useBridgeKycFlow() + } = useBridgeKycFlow({ onKycSuccess: handleKycSuccess }) const { isUserBridgeKycApproved } = useKycStatus() @@ -232,6 +252,7 @@ const IdentityVerificationView = () => { selectedCountry={selectedCountry} setIsMantecaModalOpen={setIsMantecaModalOpen} isMantecaModalOpen={isMantecaModalOpen} + onKycSuccess={handleMantecaKycSuccess} /> )} diff --git a/src/hooks/useMantecaKycFlow.ts b/src/hooks/useMantecaKycFlow.ts index 25d6f697d..f6339b8df 100644 --- a/src/hooks/useMantecaKycFlow.ts +++ b/src/hooks/useMantecaKycFlow.ts @@ -5,6 +5,7 @@ import { useAuth } from '@/context/authContext' import { CountryData, MantecaSupportedExchanges } from '@/components/AddMoney/consts' import { BASE_URL } from '@/constants' import { MantecaKycStatus } from '@/interfaces' +import { useWebSocket } from './useWebSocket' type UseMantecaKycFlowOptions = { onClose?: () => void @@ -21,11 +22,38 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country } visible: false, closeConfirmMessage: undefined, }) - const { user } = useAuth() + const { user, fetchUser } = useAuth() const [isMantecaKycRequired, setNeedsMantecaKyc] = useState(false) const userKycVerifications = user?.user?.kycVerifications + const handleIframeClose = useCallback( + (source?: 'manual' | 'completed' | 'tos_accepted') => { + setIframeOptions((prev) => ({ ...prev, visible: false })) + if (source === 'completed') { + onSuccess?.() + return + } + if (source === 'manual') { + onManualClose?.() + return + } + onClose?.() + }, + [onClose, onSuccess, onManualClose] + ) + + useWebSocket({ + username: user?.user.username ?? undefined, + autoConnect: true, + onMantecaKycStatusUpdate: async (status) => { + if (status === MantecaKycStatus.ACTIVE || status === 'WIDGET_FINISHED') { + await fetchUser() + handleIframeClose('completed') + } + }, + }) + useEffect(() => { // determine if manteca kyc is required based on geo data available in kycVerifications const selectedGeo = country?.id @@ -71,22 +99,6 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country } } }, []) - const handleIframeClose = useCallback( - (source?: 'manual' | 'completed' | 'tos_accepted') => { - setIframeOptions((prev) => ({ ...prev, visible: false })) - if (source === 'completed') { - onSuccess?.() - return - } - if (source === 'manual') { - onManualClose?.() - return - } - onClose?.() - }, - [onClose, onSuccess, onManualClose] - ) - return { isLoading, error, diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts new file mode 100644 index 000000000..ce1f28196 --- /dev/null +++ b/src/hooks/useQrKycGate.ts @@ -0,0 +1,71 @@ +import { useCallback, useState, useEffect } from 'react' +import { useAuth } from '@/context/authContext' +import { MantecaKycStatus } from '@/interfaces' +import { getBridgeCustomerCountry } from '@/app/actions/bridge/get-customer' + +export enum QrKycState { + LOADING = 'loading', + PROCEED_TO_PAY = 'proceed_to_pay', + REQUIRES_IDENTITY_VERIFICATION = 'requires_identity_verification', + REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER = 'requires_manteca_kyc_for_arg_bridge_user', +} + +export interface QrKycGateResult { + kycGateState: QrKycState + shouldBlockPay: boolean +} + +/** + * This hook determines the KYC gate state for the QR pay page. + * It checks the user's KYC status and the country of the QR code to determine the appropriate action. + * @returns {QrKycGateResult} An object with the KYC gate state and a boolean indicating if the user should be blocked from paying. + */ +export function useQrKycGate(): QrKycGateResult { + const { user } = useAuth() + const [kycGateState, setKycGateState] = useState(QrKycState.LOADING) + + const determineKycGateState = useCallback(async () => { + const currentUser = user?.user + if (!currentUser) { + setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) + return + } + + const hasAnyMantecaKyc = + currentUser.kycVerifications?.some( + (v) => v.provider === 'MANTECA' && v.status === MantecaKycStatus.ACTIVE + ) ?? false + + if (hasAnyMantecaKyc) { + setKycGateState(QrKycState.PROCEED_TO_PAY) + return + } + + if (currentUser.bridgeKycStatus === 'approved' && currentUser.bridgeCustomerId) { + const { countryCode } = await getBridgeCustomerCountry(currentUser.bridgeCustomerId) + + if (countryCode && countryCode.toUpperCase() === 'AR') { + setKycGateState(QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER) + } else { + setKycGateState(QrKycState.PROCEED_TO_PAY) + } + return + } + + setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) + }, [user?.user]) + + useEffect(() => { + determineKycGateState() + }, [determineKycGateState]) + + const result: QrKycGateResult = { + kycGateState, + shouldBlockPay: + kycGateState === QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER || + kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION || + kycGateState === QrKycState.LOADING, + } + + return result +} diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index c30e97248..192a401f3 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -1217,6 +1217,16 @@ export const saveRedirectUrl = () => { saveToLocalStorage('redirect', relativeUrl) } +export const getRedirectUrl = () => { + return getFromLocalStorage('redirect') +} + +export const clearRedirectUrl = () => { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('redirect') + } +} + export const sanitizeRedirectURL = (redirectUrl: string): string => { try { const u = new URL(redirectUrl, window.location.origin) From 7ad0fe0bf006e529405dde292078ef3896cc411a Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:45:48 +0530 Subject: [PATCH 2/2] fix: cr comments --- src/hooks/useQrKycGate.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index ce1f28196..603d466b6 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -1,3 +1,5 @@ +'use client' + import { useCallback, useState, useEffect } from 'react' import { useAuth } from '@/context/authContext' import { MantecaKycStatus } from '@/interfaces' @@ -42,12 +44,16 @@ export function useQrKycGate(): QrKycGateResult { } if (currentUser.bridgeKycStatus === 'approved' && currentUser.bridgeCustomerId) { - const { countryCode } = await getBridgeCustomerCountry(currentUser.bridgeCustomerId) - - if (countryCode && countryCode.toUpperCase() === 'AR') { - setKycGateState(QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER) - } else { - setKycGateState(QrKycState.PROCEED_TO_PAY) + try { + const { countryCode } = await getBridgeCustomerCountry(currentUser.bridgeCustomerId) + if (countryCode && countryCode.toUpperCase() === 'AR') { + setKycGateState(QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER) + } else { + setKycGateState(QrKycState.PROCEED_TO_PAY) + } + } catch { + // fail to require identity verification to avoid blocking pay due to rare outages + setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) } return }