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
94 changes: 94 additions & 0 deletions src/utils/capability-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
32 changes: 20 additions & 12 deletions src/utils/capability-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand All @@ -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')
)
Expand All @@ -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')
)
Expand All @@ -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' }
Expand Down
Loading