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: 27 additions & 5 deletions src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { addMoneyCountryUrl } from '@/utils/native-routes'
Expand Down Expand Up @@ -65,7 +67,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 70 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 70 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 @@ -113,6 +115,18 @@
const { gateFor } = useCapabilities()
const bankCountry = useMemo(() => railJurisdictionForBank(selectedCountry?.id), [selectedCountry?.id])
const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
// A ready bank rail can still carry a future-dated requirement (the gate's
// `advisory`). Offer it as a skippable pre-empt at the proceed step.
const advisory = gate.kind === 'ready' ? gate.advisory : undefined
const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({
advisory,
isLoading: sumsubFlow.isLoading,
// Route through the self-heal resubmit path (reheal-tagged action) so the
// completed submission round-trips to Bridge. start-action mints a plain
// token whose webhook completion has no Bridge relay → answers are dropped.
onCompleteNow: () =>
advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(),
})
const { guardWithTos, showBridgeTos, hideTos } = useTosGuard()
const { setIsSupportModalOpen } = useModalsContext()

Expand All @@ -123,7 +137,7 @@

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

Check warning on line 140 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 140 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 @@ -233,12 +247,18 @@
return
}

posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
amount_usd: usdEquivalent,
method_type: 'bank',
country: selectedCountryPath,
// ready — offer the skippable advisory pre-empt once; on proceed (now, or
// after "Not now") record the amount-entered event and open the
// confirmation modal. Firing inside the proceed avoids double-counting if
// the user dismisses the advisory and re-clicks.
advisoryIntercept(() => {
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
amount_usd: usdEquivalent,
method_type: 'bank',
country: selectedCountryPath,
})
setShowWarningModal(true)
})
setShowWarningModal(true)
}

const handleWarningConfirm = async () => {
Expand Down Expand Up @@ -440,6 +460,8 @@
regionName={selectedCountry?.title}
/>

<AdvisoryPreemptModal {...advisoryModalProps} />

<SumsubKycModals flow={sumsubFlow} autoStartSdk />

<BridgeTosStep
Expand Down
22 changes: 21 additions & 1 deletion src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useCapabilities } from '@/hooks/useCapabilities'
import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate'
import { useModalsContext } from '@/context/ModalsContext'
Expand Down Expand Up @@ -92,6 +94,18 @@ export default function WithdrawBankPage() {
const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country])
const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
const sumsubFlow = useMultiPhaseKycFlow({})
// A ready bank rail can still carry a future-dated requirement (the gate's
// `advisory`). Offer it as a skippable pre-empt before the withdrawal.
const advisory = gate.kind === 'ready' ? gate.advisory : undefined
const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({
advisory,
isLoading: sumsubFlow.isLoading,
// Route through the self-heal resubmit path (reheal-tagged action) so the
// completed submission round-trips to Bridge. start-action mints a plain
// token whose webhook completion has no Bridge relay → answers are dropped.
onCompleteNow: () =>
advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(),
})
const [showKycModal, setShowKycModal] = useState(false)
const { setIsSupportModalOpen } = useModalsContext()

Expand Down Expand Up @@ -193,7 +207,7 @@ export default function WithdrawBankPage() {
return 'N/A'
}

const handleCreateAndInitiateOfframp = async () => {
const proceedWithOfframp = async () => {
if (gate.kind !== 'ready') {
// Loading and waiting-on-provider both mean "user has no action to
// take" — silently no-op instead of bouncing them through Sumsub.
Expand Down Expand Up @@ -327,6 +341,11 @@ export default function WithdrawBankPage() {
}
}

// Offer the skippable advisory pre-empt once, then run the offramp. When the
// gate isn't `ready` (or nothing is future-dated) this is a no-op and
// proceedWithOfframp runs straight away (it handles the not-ready cases).
const handleCreateAndInitiateOfframp = () => advisoryIntercept(() => void proceedWithOfframp())

const countryCodeForFlag = () => {
if (!bankAccount?.details?.countryCode) return ''
const code =
Expand Down Expand Up @@ -550,6 +569,7 @@ export default function WithdrawBankPage() {
providerMessage={getGateUserMessage(gate)}
regionName={getCountryFromPath(country)?.title}
/>
<AdvisoryPreemptModal {...advisoryModalProps} />
<SumsubKycModals flow={sumsubFlow} />
</div>
)
Expand Down
48 changes: 46 additions & 2 deletions src/app/actions/sumsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,15 @@ export const restartIdentityVerification = async (): Promise<{

// initiate self-heal document resubmission for a provider-rejected user
export const initiateSelfHealResubmission = async (
provider: 'BRIDGE' | 'MANTECA'
provider: 'BRIDGE' | 'MANTECA',
// Optional — target a specific (e.g. future-dated advisory) Bridge requirement
// by key. Omitted for the legacy blocking flow (current nextAction).
requirementKey?: string
): Promise<{ data?: SelfHealResubmissionResponse; error?: string }> => {
try {
const response = await serverFetch('/users/identity/resubmit', {
method: 'POST',
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, ...(requirementKey ? { requirementKey } : {}) }),
})

const responseJson = await response.json()
Expand All @@ -112,3 +115,44 @@ export const initiateSelfHealResubmission = async (
return { error: message }
}
}

export interface StartKycActionResponse {
token: string
levelName: string
externalActionId?: string
}

/**
* Mint a Sumsub WebSDK token for a capability nextAction by its `key`
* (POST /users/kyc/start-action). The capability model returns action
* descriptors (a stable key + a registry levelKey) and never carries a token;
* the FE posts the key here to get an unexpired token bound to the right RFI
* level. Used by the advisory pre-empt — an already-approved user starting a
* future-dated RFI early, where /users/identity would short-circuit on
* "already approved" and never mint a token.
*/
export const startKycAction = async (key: string): Promise<{ data?: StartKycActionResponse; error?: string }> => {
try {
const response = await serverFetch('/users/kyc/start-action', {
method: 'POST',
body: JSON.stringify({ key }),
})
const responseJson = await response.json()
if (!response.ok) {
return { error: responseJson.userMessage || responseJson.error || 'Failed to start verification' }
}
if (!responseJson.sumsubAccessToken) {
return { error: 'Invalid response from server' }
}
return {
data: {
token: responseJson.sumsubAccessToken,
levelName: responseJson.levelName,
externalActionId: responseJson.externalActionId,
},
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'An unexpected error occurred'
return { error: message }
}
}
65 changes: 65 additions & 0 deletions src/components/Kyc/AdvisoryPreemptModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import ActionModal from '@/components/Global/ActionModal'

interface AdvisoryPreemptModalProps {
visible: boolean
/** ISO date the requirement becomes blocking; drives the deadline copy. */
effectiveDate?: string
isLoading?: boolean
/** Launch the verification flow early. */
onCompleteNow: () => void
/** Dismiss and continue with what the user was doing. */
onSkip: () => void
onClose: () => void
}

function formatEffectiveDate(iso?: string): string | null {
if (!iso) return null
const date = new Date(iso)
// `iso` is a date-only YYYY-MM-DD, so `new Date()` parses it at UTC midnight.
// Format in UTC too, or Americas timezones render the day before the deadline.
return Number.isNaN(date.getTime())
? null
: date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' })
}

/**
* Skippable pre-empt for a future-dated verification requirement on a rail that
* still works today (the gate's `ready` + `advisory`). "Complete now" launches
* the verification early; "Not now" lets the user carry on and resolve it later.
* Once the effective date passes the backend reclassifies the requirement to
* blocking and the non-skippable InitiateKycModal takes over — there is no FE
* cutover logic here.
*/
export default function AdvisoryPreemptModal({
visible,
effectiveDate,
isLoading = false,
onCompleteNow,
onSkip,
onClose,
}: AdvisoryPreemptModalProps) {
const formatted = formatEffectiveDate(effectiveDate)
return (
<ActionModal
visible={visible}
onClose={onClose}
icon="badge"
title="One quick step coming up"
description={
formatted
? `To keep using bank transfers, you'll need to complete a short verification by ${formatted}. Take care of it now so nothing pauses later.`
: `To keep using bank transfers, you'll need to complete a short verification soon. Take care of it now so nothing pauses later.`
}
ctas={[
{
text: 'Complete now',
onClick: onCompleteNow,
variant: 'purple',
shadowSize: '4',
disabled: isLoading,
},
{ text: 'Not now', onClick: onSkip, variant: 'stroke', disabled: isLoading },
]}
/>
)
}
81 changes: 81 additions & 0 deletions src/hooks/useAdvisoryPreempt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { act, renderHook } from '@testing-library/react'
import { useAdvisoryPreempt } from './useAdvisoryPreempt'
import type { GateAdvisory } from '@/utils/capability-gate'

const advisory: GateAdvisory = { effectiveDate: '2099-06-29', actionKey: 'sumsub:eea_uplift' }

describe('useAdvisoryPreempt', () => {
test('no advisory → intercept proceeds immediately, modal stays hidden', () => {
const proceed = jest.fn()
const onCompleteNow = jest.fn()
const { result } = renderHook(() => useAdvisoryPreempt({ advisory: undefined, onCompleteNow }))

act(() => result.current.intercept(proceed))

expect(proceed).toHaveBeenCalledTimes(1)
expect(result.current.modalProps.visible).toBe(false)
})

test('advisory present → intercept opens the modal and defers proceed', () => {
const proceed = jest.fn()
const onCompleteNow = jest.fn()
const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow }))

act(() => result.current.intercept(proceed))

expect(proceed).not.toHaveBeenCalled()
expect(result.current.modalProps.visible).toBe(true)
expect(result.current.modalProps.effectiveDate).toBe('2099-06-29')
})

test('skip runs the deferred proceed; once dismissed, later intercepts pass straight through', () => {
const proceed = jest.fn()
const onCompleteNow = jest.fn()
const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow }))

act(() => result.current.intercept(proceed))
act(() => result.current.modalProps.onSkip())

expect(proceed).toHaveBeenCalledTimes(1)
expect(result.current.modalProps.visible).toBe(false)

// Dismissed for the session — a second proceed runs immediately, no re-prompt.
const proceed2 = jest.fn()
act(() => result.current.intercept(proceed2))
expect(proceed2).toHaveBeenCalledTimes(1)
expect(result.current.modalProps.visible).toBe(false)
})

test('onClose dismisses without running the deferred proceed (X must not trigger the money action)', () => {
const proceed = jest.fn()
const onCompleteNow = jest.fn()
const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow }))

act(() => result.current.intercept(proceed))
act(() => result.current.modalProps.onClose())

expect(proceed).not.toHaveBeenCalled()
expect(result.current.modalProps.visible).toBe(false)

// ...but it dismisses for the session — the next click passes through, no re-prompt.
const proceed2 = jest.fn()
act(() => result.current.intercept(proceed2))
expect(proceed2).toHaveBeenCalledTimes(1)
expect(result.current.modalProps.visible).toBe(false)
})

test('completeNow launches the verification and does NOT run the deferred proceed', async () => {
const proceed = jest.fn()
const onCompleteNow = jest.fn()
const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow }))

act(() => result.current.intercept(proceed))
await act(async () => {
await result.current.modalProps.onCompleteNow()
})

expect(onCompleteNow).toHaveBeenCalledTimes(1)
expect(proceed).not.toHaveBeenCalled()
expect(result.current.modalProps.visible).toBe(false)
})
})
Loading
Loading