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 +}