diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index 28e60be53..c3b84f030 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -315,3 +315,97 @@ describe('deriveGate — restart-identity vs contact-support split for blocked r expect(getKycModalVariant(gate.kind)).toBe('blocked') }) }) + +describe('deriveGate — ready-first ordering (Alexandre fix)', () => { + const tosAction: NextAction = { key: 'bridge.tos', kind: 'accept-tos', purpose: 'bridge-tos' } + const sumsubAction: NextAction = { key: 'bridge.rfi', kind: 'sumsub', purpose: 'bridge-rfi' } + + test('Alexandre case: PIX_BR ENABLED + Bridge × US/GB/EU/MX stuck on ToS → ready (no modal)', () => { + const pixBr = bankRail({ + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + country: 'BR', + currency: 'BRL', + status: 'enabled', + }) + const stuck = (id: `bridge.${string}`, country: string, currency: string, method: string): RailCapability => + bankRail({ + id, + provider: 'bridge', + method, + country, + currency, + status: 'requires-info', + blockingActions: ['bridge.tos'], + reason: { code: 'bridge_tos_v2_required', userMessage: 'Accept terms' }, + }) + const rails = [ + pixBr, + stuck('bridge.ach_us', 'US', 'USD', 'ACH_US'), + stuck('bridge.faster_payments_gb', 'GB', 'GBP', 'FASTER_PAYMENTS_GB'), + stuck('bridge.sepa_eu', 'EU', 'EUR', 'SEPA_EU'), + stuck('bridge.spei_mx', 'MX', 'MXN', 'SPEI_MX'), + ] + + const gate = deriveGate(state(rails, [tosAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('ready beats blocked-rejection — working rail trumps unrelated blocked one', () => { + const ready = bankRail({ id: 'manteca.pix_br', provider: 'manteca', country: 'BR', status: 'enabled' }) + const blockedTerminal = bankRail({ + id: 'bridge.ach_us', + status: 'blocked', + reason: { code: 'kyc_rejected_terminal', userMessage: 'Verification failed' }, + }) + + const gate = deriveGate(state([ready, blockedTerminal]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('ready beats fixable-rejection — RFI on another rail does not gate', () => { + const ready = bankRail({ id: 'manteca.pix_br', provider: 'manteca', country: 'BR', status: 'enabled' }) + const rfi = bankRail({ id: 'bridge.ach_us', status: 'requires-info', blockingActions: ['bridge.rfi'] }) + + const gate = deriveGate(state([ready, rfi], [sumsubAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('ready beats restart-identity — self-fixable blocked rail does not gate', () => { + const ready = bankRail({ id: 'manteca.pix_br', provider: 'manteca', country: 'BR', status: 'enabled' }) + const restartable = bankRail({ + id: 'bridge.ach_us', + status: 'blocked', + blockingActions: ['bridge.restart'], + }) + const restartAction: NextAction = { key: 'bridge.restart', kind: 'restart-identity', purpose: 'restart' } + + const gate = deriveGate(state([ready, restartable], [restartAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('no ready rail → falls through to existing blocked / accept-tos / fixable order', () => { + const tosRail = bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + blockingActions: ['bridge.tos'], + reason: { code: 'bridge_tos_required', userMessage: 'Accept terms' }, + }) + + const gate = deriveGate(state([tosRail], [tosAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('accept-tos') + }) + + test('per-op enabled status (not rail-level) is what matters', () => { + const railWithEnabledOp = bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + operations: { deposit: 'enabled', withdraw: 'requires-info' }, + blockingActions: ['bridge.rfi'], + }) + + const gate = deriveGate(state([railWithEnabledOp], [sumsubAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 829f1ca57..5e3804187 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -112,13 +112,18 @@ export interface CapabilityState { * * 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 `kind: 'accept-tos'` action + * 2. ready — at least one in-scope rail has operationStatus(op) === 'enabled'. + * Hoisted to position 2 so a working rail (e.g. Manteca PIX_BR + * ENABLED) wins over stuck sibling rails (e.g. Bridge ACH_US + * terms_of_service_v2). The 2026-06-01 Alexandre incident + * (BR user blocked by Bridge ToS modal while their Manteca + * PIX_BR was ENABLED) was the latest customer-visible failure + * of the prior 'gate any sibling blocker first' order. + * 3. blocked-rejection — any in-scope rail status: 'blocked', and no ready rail + * 4. accept-tos — requires-info + a `kind: 'accept-tos'` action * (user-actionable, unblocks the scope) - * 4. fixable-rejection — requires-info + a `kind: 'sumsub'` action + * 5. 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 @@ -138,7 +143,14 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat const candidates = filterRailsByScope(state.rails, scope) const actionByKey = new Map(state.nextActions.map((action) => [action.key, action])) - // 2. blocked — split: if the rail carries a `restart-identity` action the + // 2. ready — per-op refinement wins over rail-level status. Hoisted + // 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' } + + // 3. blocked — split: if the rail carries a `restart-identity` action the // user can self-fix by re-verifying with a different document; otherwise // the only path is contact-support. const blocked = candidates.find((rail) => rail.status === 'blocked') @@ -160,7 +172,7 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat const requiresInfoRails = candidates.filter((rail) => rail.status === 'requires-info') - // 3. accept-tos + // 4. accept-tos const tosRail = requiresInfoRails.find((rail) => railActions(rail, actionByKey).some((action) => action.kind === 'accept-tos') ) @@ -174,7 +186,7 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat } } - // 4. fixable-rejection (Sumsub RFI / self-heal) + // 5. fixable-rejection (Sumsub RFI / self-heal) const fixableRail = requiresInfoRails.find((rail) => railActions(rail, actionByKey).some((action) => action.kind === 'sumsub') ) @@ -186,10 +198,6 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat } } - // 5. ready — per-op refinement wins over rail-level status - const hasReady = candidates.some((rail) => operationStatus(rail, op) === 'enabled') - if (hasReady) return { kind: 'ready' } - // 6. pending — BE is provisioning, no user action needed const hasPending = candidates.some((rail) => rail.status === 'pending') if (hasPending) return { kind: 'pending' }