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 bac9ba481..e4c0a509f 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -213,9 +213,11 @@ export default function OnrampBankPage() { if (!validateAmount(rawTokenAmount)) return if (gate.kind !== 'ready') { - // capabilities still loading — silently no-op instead of flashing a - // needs_kyc modal on top of state we don't know yet. - if (gate.kind === 'loading') return + // capabilities still loading OR provider doing internal review — + // silently no-op instead of flashing a misleading needs_kyc modal. + // `waiting-on-provider` means the user has nothing to do; opening + // a KYC modal would imply otherwise. + if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return if (gate.kind === 'accept-tos') { guardWithTos() } else { @@ -437,6 +439,7 @@ export default function OnrampBankPage() { handleWarningConfirm() }} onSkip={hideTos} + reasonCode={gate.kind === 'accept-tos' ? gate.reason?.code : undefined} /> ) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index fd2861064..689f65fdd 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -178,7 +178,9 @@ export default function WithdrawBankPage() { const handleCreateAndInitiateOfframp = async () => { if (gate.kind !== 'ready') { - if (gate.kind === 'loading') return + // Loading and waiting-on-provider both mean "user has no action to + // take" — silently no-op instead of bouncing them through Sumsub. + if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return if (gate.kind === 'accept-tos') { guardWithTos() } else { @@ -469,6 +471,7 @@ export default function WithdrawBankPage() { handleCreateAndInitiateOfframp() }} onSkip={hideTos} + reasonCode={gate.kind === 'accept-tos' ? gate.reason?.code : undefined} /> => { const onboardingErrorPatterns = ['fund origin', 'profile incomplete', 'onboarding required'] diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 247dcaa91..388d4919b 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -110,10 +110,12 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const checkBridgeGate = useCallback( (onAfterTos?: () => void): boolean => { if (gate.kind !== 'ready') { - // capabilities still loading — caller should wait, NOT open a KYC modal - // on top of state we don't yet know (would falsely "needs_kyc" an approved - // user mid-load). Returning true keeps the caller's early-return path. - if (gate.kind === 'loading') return true + // capabilities still loading OR provider doing internal review — + // caller should wait, NOT open a KYC modal. For `loading` we + // don't yet know if the user is approved. For `waiting-on-provider` + // (Bridge KYC review, post_processing) there's no user action to + // take; opening the modal would imply otherwise. + if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return true if (gate.kind === 'accept-tos') { pendingAfterTosRef.current = onAfterTos ?? null guardWithTos() @@ -142,9 +144,12 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // unified bridge gate: tos → fixable rejection → blocked → enrollment // return a non-visible error to prevent the form from treating this as success if (gate.kind !== 'ready') { - // capabilities still loading — silently no-op (don't show a KYC modal on - // top of state we don't yet know). - if (gate.kind === 'loading') return { error: 'gate_blocked', silent: true } + // capabilities still loading OR provider doing internal review — + // silently no-op (don't show a KYC modal). `waiting-on-provider` + // means no user action available. + if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') { + return { error: 'gate_blocked', silent: true } + } if (gate.kind === 'accept-tos') { guardWithTos() } else { @@ -338,6 +343,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { else formRef.current?.handleSubmit() }} onSkip={hideTos} + reasonCode={gate.kind === 'accept-tos' ? gate.reason?.code : undefined} /> diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 748040251..4fdea53ef 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -165,9 +165,13 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // for logged-in users, check bank-rail readiness before proceeding const isGuestFlow = bankClaimType === BankClaimType.GuestBankClaim if (!isGuestFlow && gate.kind !== 'ready') { - // capabilities still loading — silently return; the CTA that triggered - // this should be disabled too, but defend against double-click races. - if (gate.kind === 'loading') return + // capabilities still loading OR provider doing internal review — + // silently return; the CTA that triggered this should be disabled + // too, but defend against double-click races. `waiting-on-provider` + // means there's no user action to take (Bridge KYC review, + // post_processing), so opening the KYC modal would falsely imply + // the user has something to do. + if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return if (gate.kind === 'accept-tos') { guardWithTos() } else { @@ -523,6 +527,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { handleCreateOfframpAndClaim(localBankDetails) }} onSkip={hideTos} + reasonCode={gate.kind === 'accept-tos' ? gate.reason?.code : undefined} /> void onSkip: () => void + /** + * BE-emitted reason code from the rail capability (`reason.code`). Used + * solely to vary copy between Bridge's base ToS (`bridge_tos_required`, + * US/ACH/Wire) and the SEPA v2 ToS (`bridge_tos_v2_required`, EUR + GBP + * inherited). The Bridge `tos_acceptance_link` endpoint is opaque to + * endorsement — Bridge serves the correct ToS based on the customer's + * pending requirements — so this prop ONLY affects user-facing copy, not + * the endpoint we call. Defaults to base copy if absent. + */ + reasonCode?: string } +// Capability reason codes emitted by the BE resolver for Bridge ToS rails. +// Pinned as `const` so the comparison below catches typos at compile time — +// the upstream `CapabilityReason.code` is a free-form string by contract. +const BRIDGE_TOS_V2_REQUIRED = 'bridge_tos_v2_required' as const + +const TOS_COPY = { + base: { + title: 'Accept Terms of Service', + description: "To enable bank transfers, you need to accept our payment partner's Terms of Service.", + }, + sepa: { + title: 'Accept SEPA Terms of Service', + description: + "To enable EUR (SEPA) and GBP (Faster Payments) bank transfers, you need to accept our payment partner's updated Terms of Service.", + }, +} as const + // shown immediately after sumsub kyc approval when bridge rails need ToS acceptance. // displays a prompt, then opens the bridge ToS iframe. -export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProps) => { +export const BridgeTosStep = ({ visible, onComplete, onSkip, reasonCode }: BridgeTosStepProps) => { const { fetchUser } = useAuth() const [showIframe, setShowIframe] = useState(false) const [tosLink, setTosLink] = useState(null) @@ -80,6 +107,8 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp if (!visible) return null + const copy = reasonCode === BRIDGE_TOS_V2_REQUIRED ? TOS_COPY.sepa : TOS_COPY.base + return ( <> {/* confirmation modal — hidden when iframe is open or ToS is being confirmed */} @@ -87,10 +116,8 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp visible={visible && !showIframe && !isConfirming} onClose={onSkip} icon={error ? ('alert' as IconName) : ('badge' as IconName)} - title={error ? 'Could not load terms' : 'Accept Terms of Service'} - description={ - error || "To enable bank transfers, you need to accept our payment partner's Terms of Service." - } + title={error ? 'Could not load terms' : copy.title} + description={error || copy.description} ctas={[ { text: isLoading ? 'Loading...' : error ? 'Try again' : 'Accept Terms', diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts new file mode 100644 index 000000000..61c3fb5d9 --- /dev/null +++ b/src/utils/capability-gate.test.ts @@ -0,0 +1,266 @@ +import { + deriveGate, + getGateUserMessage, + getKycModalVariant, + type CapabilityState, + type GateState, +} from './capability-gate' +import type { NextAction, RailCapability } from '@/types/capabilities' + +/** + * Capability-gate tests focused on the priority order, in particular the new + * `waiting-on-provider` branch (from the BE adding `kind: 'wait'` NextActions + * for `wait:bridge` — Bridge internal review / post-processing / generic + * Stripe lookups). Without this branch, a rail in `requires-info` with only a + * `wait` action falls through to `needs-enrollment` and the user sees a + * misleading "verify your identity" CTA when there's nothing for them to do. + */ + +function bankRail(overrides: Partial = {}): RailCapability { + return { + id: 'bridge.ach_us', + provider: 'bridge', + method: 'ACH_US', + channel: 'bank', + country: 'US', + currency: 'USD', + status: 'enabled', + ...overrides, + } +} + +function state(rails: RailCapability[], nextActions: NextAction[] = [], identityVerified = true): CapabilityState { + return { rails, nextActions, identityVerified, isLoading: false } +} + +describe('deriveGate — waiting-on-provider (kind: wait)', () => { + const waitAction: NextAction = { key: 'wait:bridge', kind: 'wait', purpose: 'bridge-review' } + + test('requires-info rail with only a `wait` action → waiting-on-provider', () => { + const rail = bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'requires-info', + blockingActions: ['wait:bridge'], + reason: { + code: 'bridge_processing', + userMessage: 'We’re finalizing your verification with Bridge — this usually takes a few minutes.', + }, + }) + + const gate = deriveGate(state([rail], [waitAction]), 'deposit', { channel: 'bank' }) + + expect(gate.kind).toBe('waiting-on-provider') + if (gate.kind === 'waiting-on-provider') { + expect(gate.userMessage).toMatch(/finalizing your verification/) + expect(gate.reason?.code).toBe('bridge_processing') + } + }) + + test('priority: accept-tos beats waiting-on-provider (ToS is user-actionable)', () => { + const tosAction: NextAction = { key: 'accept-tos', kind: 'accept-tos', purpose: 'accept-bridge-tos' } + // Two rails in scope: one needs ToS, one is mid-review. Bank scope. + const rails = [ + bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'requires-info', + blockingActions: ['wait:bridge'], + }), + bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + blockingActions: ['accept-tos'], + reason: { code: 'bridge_tos_required', userMessage: 'Accept the Bridge terms to continue.' }, + }), + ] + const gate = deriveGate(state(rails, [tosAction, waitAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('accept-tos') + }) + + test('priority: fixable-rejection beats waiting-on-provider (actionable wins)', () => { + // If ANY in-scope rail has a sumsub action, surface that — the user + // can act on it and unblock part of the scope. Don't gate them on + // wait copy when they have something they can do. + const sumsubAction: NextAction = { + key: 'sumsub:tax_identification_number', + kind: 'sumsub', + purpose: 'unlock-bridge', + levelKey: 'tax_identification_number', + } + const rails = [ + bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + blockingActions: ['wait:bridge'], + }), + bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'requires-info', + blockingActions: ['sumsub:tax_identification_number'], + }), + ] + const gate = deriveGate(state(rails, [waitAction, sumsubAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('fixable-rejection') + }) + + test("priority: ready beats waiting-on-provider (don't block the user from a usable rail)", () => { + // User has SEPA waiting AND ACH ready — let them use ACH instead of + // showing wait copy that prevents the whole scope. + const rails = [ + bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'requires-info', + blockingActions: ['wait:bridge'], + }), + bankRail({ + id: 'bridge.ach_us', + status: 'enabled', + }), + ] + const gate = deriveGate(state(rails, [waitAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('priority: pending beats waiting-on-provider (provisioning is also a wait, but more concrete)', () => { + const rails = [ + bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'requires-info', + blockingActions: ['wait:bridge'], + }), + bankRail({ + id: 'bridge.ach_us', + status: 'pending', + }), + ] + const gate = deriveGate(state(rails, [waitAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('pending') + }) + + test('priority: waiting-on-provider beats needs-identity / needs-enrollment', () => { + // ONLY a wait-only rail in scope, identity not verified → without this + // priority the gate would land on `needs-identity` and show "verify + // your identity" — which is wrong when a Bridge customer EXISTS and + // is being reviewed. Surface the wait copy instead. + const rail = bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'requires-info', + blockingActions: ['wait:bridge'], + }) + const gate = deriveGate(state([rail], [waitAction], /* identityVerified */ false), 'deposit', { + channel: 'bank', + }) + expect(gate.kind).toBe('waiting-on-provider') + }) + + test('rail with BOTH wait AND sumsub actions → fixable-rejection (user has something to do)', () => { + // Defensive: the BE shouldn't emit this combo today (waits suppress + // sumsub actions in the resolver), but if it ever does we surface the + // actionable branch rather than asking the user to wait. + const sumsubAction: NextAction = { + key: 'sumsub:tax_identification_number', + kind: 'sumsub', + purpose: 'unlock-bridge', + levelKey: 'tax_identification_number', + } + const rail = bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + blockingActions: ['wait:bridge', 'sumsub:tax_identification_number'], + }) + const gate = deriveGate(state([rail], [waitAction, sumsubAction]), 'deposit', { channel: 'bank' }) + // wait-only branch requires `every action is wait` — mixed-action rail + // falls through and the fixable-rejection branch catches it. + expect(gate.kind).toBe('fixable-rejection') + }) + + test('rail with NO actions stays out of waiting-on-provider (would falsely match `every of empty = true`)', () => { + // Edge case: a requires-info rail with no blockingActions should NOT be + // misclassified as waiting-on-provider (Array.every returns true on []). + // Our predicate guards with actions.length > 0. + const rail = bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + blockingActions: [], + }) + const gate = deriveGate(state([rail], []), 'deposit', { channel: 'bank' }) + expect(gate.kind).not.toBe('waiting-on-provider') + }) + + test('a wait action with no in-scope rail returns whatever the scope deserves', () => { + // Manteca BR rail in scope (channel filtered out — only bank); Bridge SEPA + // out of scope (country=EU not selected). Wait action exists but doesn't + // match any in-scope rail, so we fall through. + const rail = bankRail({ + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + country: 'BR', + currency: 'BRL', + status: 'enabled', + }) + const gate = deriveGate(state([rail], [waitAction]), 'deposit', { channel: 'bank', country: 'BR' }) + expect(gate.kind).toBe('ready') + }) +}) + +describe('deriveGate — accept-tos carries reason for downstream copy variation', () => { + test('accept-tos GateState propagates rail reason (SEPA v2 vs base)', () => { + const tosAction: NextAction = { key: 'accept-tos:sepa', kind: 'accept-tos', purpose: 'accept-bridge-tos-sepa' } + const rail = bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'requires-info', + blockingActions: ['accept-tos:sepa'], + reason: { + code: 'bridge_tos_v2_required', + userMessage: 'Accept the SEPA terms of service to enable EUR / GBP rails.', + }, + }) + const gate = deriveGate(state([rail], [tosAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('accept-tos') + if (gate.kind === 'accept-tos') { + expect(gate.reason?.code).toBe('bridge_tos_v2_required') + expect(gate.userMessage).toMatch(/SEPA terms/) + } + }) +}) + +describe('getGateUserMessage', () => { + test('returns userMessage for waiting-on-provider', () => { + const gate: GateState = { + kind: 'waiting-on-provider', + userMessage: 'We’re finalizing with Bridge.', + } + expect(getGateUserMessage(gate)).toBe('We’re finalizing with Bridge.') + }) +}) + +describe('getKycModalVariant — waiting-on-provider', () => { + test('maps to default (no specialized modal copy yet — consumers suppress modal entirely)', () => { + // The dispatch sites prevent the modal from opening when gate.kind is + // 'waiting-on-provider' (treated like 'loading'). This mapping is a + // belt-and-braces fallback if the modal does open: generic copy beats + // a misleading "needs-enrollment" / "cross-region" variant. + expect(getKycModalVariant('waiting-on-provider')).toBe('default') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 339c80175..f09903cec 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -29,13 +29,19 @@ import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailC * needs_enrollment → needs-enrollment * ready → ready * loading → loading - * (new) → pending (BE working — surface explicitly) + * (new) → pending (BE working — surface explicitly) + * (new) → waiting-on-provider (provider doing internal review, + * no user action — distinct from + * our `pending` which is in-flight + * provisioning. Sourced from a rail + * with a `kind: 'wait'` NextAction.) */ export type GateState = | { kind: 'loading' } | { kind: 'ready' } | { kind: 'pending' } - | { kind: 'accept-tos'; tosUrl?: string; userMessage: string | null } + | { kind: 'waiting-on-provider'; userMessage: string | null; reason?: CapabilityReason } + | { kind: 'accept-tos'; tosUrl?: string; userMessage: string | null; reason?: CapabilityReason } | { kind: 'fixable-rejection'; userMessage: string | null; reason?: CapabilityReason } | { kind: 'blocked-rejection'; userMessage: string | null; reason?: CapabilityReason } | { kind: 'needs-identity' } @@ -98,14 +104,26 @@ export interface CapabilityState { * generalized over scope + operation. * * Priority (highest first): - * 1. loading — capability state not yet settled - * 2. blocked-rejection — any in-scope rail status: 'blocked' - * 3. accept-tos — requires-info + a `bridge-tos` action (carries tosUrl) - * 4. fixable-rejection — requires-info + a `sumsub` action - * 5. ready — at least one in-scope rail has operationStatus(op) === 'enabled' - * 6. pending — at least one in-scope rail status === 'pending' (provisioning) - * 7. needs-identity — no functional rail in scope AND identity not verified - * 8. needs-enrollment — no functional rail in scope BUT identity verified + * 1. loading — capability state not yet settled + * 2. blocked-rejection — any in-scope rail status: 'blocked' + * 3. accept-tos — requires-info + a `kind: 'accept-tos'` action + * (user-actionable, unblocks the scope) + * 4. fixable-rejection — requires-info + a `kind: 'sumsub'` action + * (user-actionable, unblocks the scope) + * 5. ready — at least one in-scope rail has operationStatus(op) === 'enabled' + * (user can transact NOW) + * 6. pending — at least one in-scope rail status === 'pending' (we're provisioning) + * 7. waiting-on-provider — only requires-info rails AND every one has only + * `kind: 'wait'` actions (e.g. Bridge internal KYC + * review, post_processing, generic stripe lookup). + * Placed BELOW `ready` / `pending` so a ready rail + * wins — if the user has another rail they can use + * or is mid-provisioning, surface that instead. + * Placed ABOVE `needs-identity` / `needs-enrollment` + * so the user sees "we're checking" instead of a + * misleading "verify your identity" CTA. + * 8. needs-identity — no functional rail in scope AND identity not verified + * 9. needs-enrollment — no functional rail in scope BUT identity verified */ export function deriveGate(state: CapabilityState, op: RailOperation, scope: GateScope = {}): GateState { if (state.isLoading) return { kind: 'loading' } @@ -135,6 +153,7 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat kind: 'accept-tos', tosUrl: tosAction?.tosUrl, userMessage: tosRail.reason?.userMessage ?? null, + reason: tosRail.reason, } } @@ -158,7 +177,31 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat const hasPending = candidates.some((rail) => rail.status === 'pending') if (hasPending) return { kind: 'pending' } - // 7/8. no functional rail in scope. Distinguish identity-not-cleared vs + // 7. waiting-on-provider — every in-scope requires-info rail has ONLY + // `wait` actions (no actionable alternative anywhere in scope). Placed + // after ready/pending so a usable rail wins; placed before needs-identity/ + // enrollment so the user sees "we're checking" instead of a misleading + // "verify your identity" CTA. Without this branch the rail would fall + // through to needs-enrollment and bounce the user back into Sumsub for + // nothing. + const allWaiting = + requiresInfoRails.length > 0 && + requiresInfoRails.every((rail) => { + const actions = railActions(rail, actionByKey) + return actions.length > 0 && actions.every((action) => action.kind === 'wait') + }) + if (allWaiting) { + // Pick the first wait-only rail's reason for the message (consumers + // typically have one rail per scope anyway). + const waitRail = requiresInfoRails[0] + return { + kind: 'waiting-on-provider', + userMessage: waitRail.reason?.userMessage ?? null, + reason: waitRail.reason, + } + } + + // 8/9. no functional rail in scope. Distinguish identity-not-cleared vs // identity-cleared-but-no-rail-for-this-scope (needs enrollment / cross-region). if (!state.identityVerified) return { kind: 'needs-identity' } return { kind: 'needs-enrollment' } @@ -183,7 +226,12 @@ export function getKycModalVariant( * `reason.userMessage` from the BE which is already user-friendly + provider-blind). */ export function getGateUserMessage(gate: GateState): string | undefined { - if (gate.kind === 'fixable-rejection' || gate.kind === 'blocked-rejection' || gate.kind === 'accept-tos') { + if ( + gate.kind === 'fixable-rejection' || + gate.kind === 'blocked-rejection' || + gate.kind === 'accept-tos' || + gate.kind === 'waiting-on-provider' + ) { return gate.userMessage ?? undefined } return undefined