diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index 4dbb06990..e94ae231a 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -34,6 +34,8 @@ import { useTosGuard } from '@/hooks/useTosGuard'
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'
@@ -113,6 +115,18 @@ export default function OnrampBankPage() {
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()
@@ -233,12 +247,18 @@ export default function OnrampBankPage() {
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 () => {
@@ -440,6 +460,8 @@ export default function OnrampBankPage() {
regionName={selectedCountry?.title}
/>
+
+
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()
@@ -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.
@@ -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 =
@@ -550,6 +569,7 @@ export default function WithdrawBankPage() {
providerMessage={getGateUserMessage(gate)}
regionName={getCountryFromPath(country)?.title}
/>
+
)
diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts
index a3f72895b..519e8f43c 100644
--- a/src/app/actions/sumsub.ts
+++ b/src/app/actions/sumsub.ts
@@ -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()
@@ -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 }
+ }
+}
diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx
new file mode 100644
index 000000000..bf0a62a11
--- /dev/null
+++ b/src/components/Kyc/AdvisoryPreemptModal.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/hooks/useAdvisoryPreempt.test.ts b/src/hooks/useAdvisoryPreempt.test.ts
new file mode 100644
index 000000000..d8991d560
--- /dev/null
+++ b/src/hooks/useAdvisoryPreempt.test.ts
@@ -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)
+ })
+})
diff --git a/src/hooks/useAdvisoryPreempt.ts b/src/hooks/useAdvisoryPreempt.ts
new file mode 100644
index 000000000..78f29eebb
--- /dev/null
+++ b/src/hooks/useAdvisoryPreempt.ts
@@ -0,0 +1,84 @@
+import { useCallback, useRef, useState } from 'react'
+import type { GateAdvisory } from '@/utils/capability-gate'
+
+interface UseAdvisoryPreemptArgs {
+ /** The advisory from a `ready` gate (`gate.kind === 'ready' ? gate.advisory : undefined`). */
+ advisory: GateAdvisory | undefined
+ /** Launch the verification flow early — e.g. `sumsubFlow.handleInitiateKyc(region, advisory.levelKey, …)`. */
+ onCompleteNow: () => void | Promise
+ isLoading?: boolean
+}
+
+/**
+ * Drives the skippable advisory pre-empt at the add/withdraw entry points. The
+ * rail is usable now, so we don't block — we intercept the "proceed" step ONCE
+ * per session with a skippable modal. "Complete now" launches the verification
+ * early; "Not now" dismisses and runs the original proceed action. Either choice
+ * marks it dismissed so the user isn't re-prompted mid-session.
+ *
+ * Returns `intercept(proceed)` to call in the gate's `ready` branch, and
+ * `modalProps` to spread onto {@link AdvisoryPreemptModal}.
+ */
+export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false }: UseAdvisoryPreemptArgs) {
+ const [dismissed, setDismissed] = useState(false)
+ const [visible, setVisible] = useState(false)
+ const pendingProceed = useRef<(() => void) | null>(null)
+ // Guards against double-submit: onCompleteNow now fires a real network call
+ // (self-heal resubmit), so rapid clicks before isLoading disables the CTA
+ // would otherwise launch duplicate requests.
+ const completingRef = useRef(false)
+
+ const intercept = useCallback(
+ (proceed: () => void) => {
+ if (advisory && !dismissed) {
+ pendingProceed.current = proceed
+ setVisible(true)
+ return
+ }
+ proceed()
+ },
+ [advisory, dismissed]
+ )
+
+ const completeNow = useCallback(async () => {
+ if (completingRef.current) return
+ completingRef.current = true
+ setDismissed(true)
+ setVisible(false)
+ pendingProceed.current = null
+ try {
+ await onCompleteNow()
+ } finally {
+ completingRef.current = false
+ }
+ }, [onCompleteNow])
+
+ const skip = useCallback(() => {
+ setDismissed(true)
+ setVisible(false)
+ const proceed = pendingProceed.current
+ pendingProceed.current = null
+ proceed?.()
+ }, [])
+
+ // X / backdrop / Escape: dismiss for the session WITHOUT running the deferred
+ // proceed — closing the dialog must not auto-trigger the add/withdraw action.
+ // The user's next add/withdraw click then passes straight through (dismissed).
+ const close = useCallback(() => {
+ setDismissed(true)
+ setVisible(false)
+ pendingProceed.current = null
+ }, [])
+
+ return {
+ intercept,
+ modalProps: {
+ visible,
+ effectiveDate: advisory?.effectiveDate,
+ isLoading,
+ onCompleteNow: completeNow,
+ onSkip: skip,
+ onClose: close,
+ },
+ }
+}
diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts
index 3405320e5..2e4f83638 100644
--- a/src/hooks/useMultiPhaseKycFlow.ts
+++ b/src/hooks/useMultiPhaseKycFlow.ts
@@ -203,6 +203,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
handleInitiateKyc: originalHandleInitiateKyc,
handleRestartIdentity,
handleSelfHealResubmit,
+ handleStartAction,
handleSdkComplete: originalHandleSdkComplete,
handleClose,
refreshToken,
@@ -396,6 +397,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
handleInitiateKyc,
handleRestartIdentity,
handleSelfHealResubmit,
+ handleStartAction,
isLoading,
error,
liveKycStatus,
diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts
index 85a928081..1c77a60c5 100644
--- a/src/hooks/useSumsubKycFlow.ts
+++ b/src/hooks/useSumsubKycFlow.ts
@@ -2,7 +2,12 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useWebSocket } from '@/hooks/useWebSocket'
import { useUserStore } from '@/redux/hooks'
-import { initiateSumsubKyc, initiateSelfHealResubmission, restartIdentityVerification } from '@/app/actions/sumsub'
+import {
+ initiateSumsubKyc,
+ initiateSelfHealResubmission,
+ restartIdentityVerification,
+ startKycAction,
+} from '@/app/actions/sumsub'
import { type KYCRegionIntent, type SumsubKycStatus } from '@/app/actions/types/sumsub.types'
import { isMantecaSupportedCountryCode } from '@/constants/manteca.consts'
import { isCapacitor } from '@/utils/capacitor'
@@ -402,15 +407,17 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
}, [])
// initiate self-heal document resubmission: calls the resubmit API
- // and opens the sumsub SDK with the action token
- const handleSelfHealResubmit = useCallback(async (provider: 'BRIDGE' | 'MANTECA') => {
+ // and opens the sumsub SDK with the action token. `requirementKey` targets a
+ // specific (e.g. future-dated advisory) Bridge requirement; omitted for the
+ // legacy blocking flow.
+ const handleSelfHealResubmit = useCallback(async (provider: 'BRIDGE' | 'MANTECA', requirementKey?: string) => {
setIsLoading(true)
setError(null)
userInitiatedRef.current = true
selfHealProviderRef.current = provider
try {
- const response = await initiateSelfHealResubmission(provider)
+ const response = await initiateSelfHealResubmission(provider, requirementKey)
if (response.error) {
userInitiatedRef.current = false
@@ -437,6 +444,37 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
}
}, [])
+ // Start a capability nextAction by key (POST /users/kyc/start-action) and
+ // open the WebSDK with the returned token. Unlike handleInitiateKyc (which
+ // resolves the level from region and no-ops for an already-approved user),
+ // this mints a token for the specific RFI level the key maps to — the path
+ // the advisory pre-empt needs to start a future-dated requirement early.
+ const handleStartAction = useCallback(async (key: string) => {
+ setIsLoading(true)
+ setError(null)
+ userInitiatedRef.current = true
+ selfHealProviderRef.current = null
+
+ try {
+ const response = await startKycAction(key)
+ if (response.error || !response.data?.token) {
+ userInitiatedRef.current = false
+ setError(response.error || 'Could not start verification. Please try again.')
+ return
+ }
+ levelNameRef.current = response.data.levelName
+ setAccessToken(response.data.token)
+ setIsActionFlow(true)
+ setShowWrapper(true)
+ } catch (e: unknown) {
+ userInitiatedRef.current = false
+ const message = e instanceof Error ? e.message : 'An unexpected error occurred'
+ setError(message)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
return {
isLoading,
error,
@@ -447,6 +485,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
handleInitiateKyc,
handleRestartIdentity,
handleSelfHealResubmit,
+ handleStartAction,
handleSdkComplete,
handleClose,
refreshToken,
diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts
index 2793f0f48..5471d7a30 100644
--- a/src/types/capabilities.ts
+++ b/src/types/capabilities.ts
@@ -78,6 +78,13 @@ export interface RailCapability {
operations?: Partial>
/** keys into NextAction.key — actions that unlock currently-unavailable operations on this rail. */
blockingActions?: string[]
+ /**
+ * Non-blocking hints — actions the user CAN take on a rail that's otherwise
+ * working (the rail stays usable): the Bridge advisory pre-empt (a future-dated
+ * requirement whose NextAction carries `effectiveDate`) and the Manteca
+ * cap-nudge. Distinct from `blockingActions` so the FE never gates on them.
+ */
+ hintActions?: string[]
/** present for requires-info / blocked — normalized reason for uniform FE rendering. */
reason?: CapabilityReason
}
@@ -103,6 +110,14 @@ export interface NextAction {
levelKey?: string
/** for kind:'accept-tos' */
tosUrl?: string
+ /**
+ * Advisory (non-blocking) actions only — surfaced via RailCapability.hintActions.
+ * ISO date the requirement becomes blocking (Bridge future_requirements[].effective_date);
+ * absent on current/blocking actions. The FE renders a skippable "complete before {date}" pre-empt.
+ */
+ effectiveDate?: string
+ /** Advisory actions only — the provider requirement key, for telemetry / FE branching. */
+ requirementKey?: string
}
export interface CapabilityRestriction {
diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts
index 3532eb3b5..72f86ac69 100644
--- a/src/utils/capability-gate.test.ts
+++ b/src/utils/capability-gate.test.ts
@@ -1,5 +1,6 @@
import {
deriveGate,
+ getGateAdvisory,
getGateUserMessage,
getKycModalVariant,
type CapabilityState,
@@ -475,3 +476,105 @@ describe('deriveGate — country scoping (the Add Money dead-end class)', () =>
expect(gate.kind).toBe('needs-enrollment')
})
})
+
+describe('deriveGate — advisory pre-empt (future-dated requirement on a ready rail)', () => {
+ // A Bridge rail that's ENABLED now but carries a future-dated requirement,
+ // surfaced by the BE as a non-blocking hintAction whose NextAction has an
+ // `effectiveDate` (the 2026-06-29 sof_individual_primary_purpose cohort).
+ const advisoryAction: NextAction = {
+ key: 'sumsub:eea_uplift',
+ kind: 'sumsub',
+ purpose: 'unlock-bridge',
+ levelKey: 'eea_uplift',
+ effectiveDate: '2099-06-29',
+ requirementKey: 'sof_individual_primary_purpose',
+ }
+
+ test('enabled rail with a future-dated hint → ready + advisory (rail stays usable)', () => {
+ const rail = bankRail({
+ id: 'bridge.sepa_eu',
+ method: 'SEPA_EU',
+ country: 'EU',
+ currency: 'EUR',
+ status: 'enabled',
+ hintActions: ['sumsub:eea_uplift'],
+ })
+ const gate = deriveGate(state([rail], [advisoryAction]), 'deposit', { channel: 'bank' })
+ expect(gate.kind).toBe('ready')
+ if (gate.kind === 'ready') {
+ expect(gate.advisory).toEqual({
+ effectiveDate: '2099-06-29',
+ actionKey: 'sumsub:eea_uplift',
+ requirementKey: 'sof_individual_primary_purpose',
+ })
+ }
+ expect(getGateAdvisory(gate)).toMatchObject({ effectiveDate: '2099-06-29', actionKey: 'sumsub:eea_uplift' })
+ })
+
+ test('enabled rail with no hint → ready, no advisory (back-compat)', () => {
+ const gate = deriveGate(state([bankRail({ status: 'enabled' })]), 'deposit', { channel: 'bank' })
+ expect(gate.kind).toBe('ready')
+ if (gate.kind === 'ready') expect(gate.advisory).toBeUndefined()
+ expect(getGateAdvisory(gate)).toBeUndefined()
+ })
+
+ test('a hint WITHOUT effectiveDate (Manteca cap-nudge) is not an advisory pre-empt', () => {
+ const capNudge: NextAction = {
+ key: 'sumsub:source_of_funds',
+ kind: 'sumsub',
+ purpose: 'raise-manteca-limit',
+ levelKey: 'source_of_funds',
+ }
+ const rail = bankRail({
+ id: 'manteca.pix_br',
+ provider: 'manteca',
+ method: 'PIX_BR',
+ country: 'BR',
+ currency: 'BRL',
+ status: 'enabled',
+ hintActions: ['sumsub:source_of_funds'],
+ })
+ const gate = deriveGate(state([rail], [capNudge]), 'deposit', { channel: 'bank' })
+ expect(gate.kind).toBe('ready')
+ if (gate.kind === 'ready') expect(gate.advisory).toBeUndefined()
+ })
+
+ test('earliest effectiveDate wins across multiple ready rails', () => {
+ const later: NextAction = {
+ key: 'sumsub:tax_identification_number',
+ kind: 'sumsub',
+ purpose: 'unlock-bridge',
+ levelKey: 'tax_identification_number',
+ effectiveDate: '2099-12-31',
+ }
+ const rails = [
+ bankRail({
+ id: 'bridge.sepa_eu',
+ method: 'SEPA_EU',
+ country: 'EU',
+ currency: 'EUR',
+ status: 'enabled',
+ hintActions: ['sumsub:tax_identification_number'],
+ }),
+ bankRail({ id: 'bridge.ach_us', status: 'enabled', hintActions: ['sumsub:eea_uplift'] }),
+ ]
+ const gate = deriveGate(state(rails, [advisoryAction, later]), 'deposit', { channel: 'bank' })
+ expect(gate.kind).toBe('ready')
+ if (gate.kind === 'ready') expect(gate.advisory?.effectiveDate).toBe('2099-06-29')
+ })
+
+ test('a blocking sibling still wins — advisory only rides on an otherwise-ready scope', () => {
+ // If the same scope has a CURRENT blocker, that takes priority (ready
+ // requires an enabled rail); advisory is strictly a ready-state rider.
+ const blockedRail = bankRail({
+ id: 'bridge.sepa_eu',
+ method: 'SEPA_EU',
+ country: 'EU',
+ currency: 'EUR',
+ status: 'requires-info',
+ blockingActions: ['sumsub:eea_uplift'],
+ })
+ const gate = deriveGate(state([blockedRail], [advisoryAction]), 'deposit', { channel: 'bank' })
+ expect(gate.kind).toBe('fixable-rejection')
+ })
+})
diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts
index 5e3804187..dbc183fe2 100644
--- a/src/utils/capability-gate.ts
+++ b/src/utils/capability-gate.ts
@@ -18,6 +18,23 @@
import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailChannel } from '@/types/capabilities'
+/**
+ * A non-blocking advisory pre-empt riding on a `ready` gate. An ENABLED rail can
+ * carry a future-dated requirement (Bridge's `future_requirements[].effective_date`,
+ * surfaced as a `hintAction` whose NextAction has an `effectiveDate`). The rail
+ * stays usable; the FE offers a SKIPPABLE "complete before {date}" prompt. When
+ * the date passes the BE reclassifies it to blocking and the gate becomes
+ * `fixable-rejection` on its own — no FE date logic, no hardcoded cutover.
+ */
+export interface GateAdvisory {
+ /** ISO date the requirement becomes blocking. */
+ effectiveDate: string
+ /** the NextAction `key` to start (POST /users/kyc/start-action) if the user completes it now. */
+ actionKey: string
+ /** which requirement — telemetry / FE branching. */
+ requirementKey?: string
+}
+
/**
* Normalized gate state. Discriminated union — consumers branch on `kind`.
*
@@ -38,7 +55,7 @@ import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailC
*/
export type GateState =
| { kind: 'loading' }
- | { kind: 'ready' }
+ | { kind: 'ready'; advisory?: GateAdvisory }
| { kind: 'pending' }
| { kind: 'waiting-on-provider'; userMessage: string | null; reason?: CapabilityReason }
| { kind: 'accept-tos'; tosUrl?: string; userMessage: string | null; reason?: CapabilityReason }
@@ -93,6 +110,30 @@ function railActions(rail: RailCapability, byKey: Map): Next
.filter((action): action is NextAction => action !== undefined)
}
+/** Resolve a rail's HINT (non-blocking) actions to NextAction descriptors. */
+function railHintActions(rail: RailCapability, byKey: Map): NextAction[] {
+ return (rail.hintActions ?? [])
+ .map((key) => byKey.get(key))
+ .filter((action): action is NextAction => action !== undefined)
+}
+
+/**
+ * The most-urgent advisory pre-empt among the given (ready) rails — the
+ * hintAction whose NextAction carries the earliest `effectiveDate`. Returns
+ * undefined when no rail has a future-dated hint.
+ */
+function firstAdvisory(rails: RailCapability[], byKey: Map): GateAdvisory | undefined {
+ let best: NextAction | undefined
+ for (const rail of rails) {
+ for (const action of railHintActions(rail, byKey)) {
+ if (!action.effectiveDate) continue
+ if (!best || action.effectiveDate < best.effectiveDate!) best = action
+ }
+ }
+ if (!best) return undefined
+ return { effectiveDate: best.effectiveDate!, actionKey: best.key, requirementKey: best.requirementKey }
+}
+
/**
* Input state for the pure derive. Held separately from the React hook so the
* gate is independently testable (and re-usable from non-React callers).
@@ -147,8 +188,14 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat
// above blocked / accept-tos / fixable-rejection because the user has
// a working path; a blocked sibling rail (different currency, KYC
// remediation pending) is not the user's problem right now.
- const hasReady = candidates.some((rail) => operationStatus(rail, op) === 'enabled')
- if (hasReady) return { kind: 'ready' }
+ const readyRails = candidates.filter((rail) => operationStatus(rail, op) === 'enabled')
+ if (readyRails.length > 0) {
+ // A working rail can still carry a future-dated requirement as a
+ // non-blocking hint — surface it as a SKIPPABLE pre-empt without
+ // demoting `ready` (the rail is usable now).
+ const advisory = firstAdvisory(readyRails, actionByKey)
+ return advisory ? { kind: 'ready', advisory } : { kind: 'ready' }
+ }
// 3. blocked — split: if the rail carries a `restart-identity` action the
// user can self-fix by re-verifying with a different document; otherwise
@@ -263,3 +310,8 @@ export function getGateUserMessage(gate: GateState): string | undefined {
}
return undefined
}
+
+/** The advisory pre-empt riding on a `ready` gate, if present. */
+export function getGateAdvisory(gate: GateState): GateAdvisory | undefined {
+ return gate.kind === 'ready' ? gate.advisory : undefined
+}