diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index f305d66aa0..d2c45a152f 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -20,6 +20,7 @@ import type { RailCapability, CapabilityRestriction } from '@/types/capabilities // reason message even when the page never touches them. type TestRail = Pick & { reason?: { userMessage: string | null } + resolved?: RailCapability['resolved'] } type TestRestriction = { code: string; affectedRailIds: string[]; userMessage?: string | null } @@ -496,6 +497,7 @@ function capabilitiesForGate(state: GateState, opts: { userMessage?: string | nu canDo: (_op: string, o?: { provider?: string }) => payEnabled && (o?.provider === undefined || o.provider === 'manteca'), railsForProvider: (provider: string) => rails.filter((r) => r.provider === provider), + nextActions: [], restrictionForRail: (railId: string) => restrictions.find((r) => r.affectedRailIds.includes(railId)), } } @@ -699,6 +701,36 @@ describe('GROUP 1: Loading & KYC Gate', () => { expect(screen.getByText('Contact support to continue.')).toBeInTheDocument() }) + test('provide-email verdict maps to the unavailable modal, never the document-upload flow', () => { + // a fixable verdict whose only fix is adding an email must not open + // the Sumsub upload flow (this surface has no email form) — same + // mapping rule as deriveProviderRejection + mockUseCapabilities.mockReturnValue({ + ...capabilitiesForGate('provider_rejection_fixable', { userMessage: 'Add your email to continue.' }), + railsForProvider: () => [ + { + id: MANTECA_RAIL_ID, + provider: 'manteca', + status: 'blocked', + resolved: { + status: 'fixable', + blocking: { + code: 'email_required', + userMessage: 'Add your email to continue.', + selfHealable: true, + selfHealKind: 'provide-email', + }, + }, + } as TestRail, + ], + }) + + renderQrPay({ qrCode: 'mercadopago://pay?id=123', type: 'MERCADO_PAGO', t: '1' }) + + expect(screen.getByText('QR payments are not available')).toBeInTheDocument() + expect(screen.queryByText('Upload document')).not.toBeInTheDocument() + }) + test('Manteca fixable rejection shows updated-document modal', () => { setCapabilitiesGate('provider_rejection_fixable', { userMessage: 'Upload a clearer ID.' }) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 8d26d9feba..738f30c8fb 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -1,5 +1,6 @@ 'use client' +import { railUserMessage, railVerdict } from '@/utils/capability-gate' import { useSearchParams, useRouter } from 'next/navigation' import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react' import { useSafeBack } from '@/hooks/useSafeBack' @@ -165,7 +166,7 @@ export default function QRPayPage() { // manteca 'pending' → IDENTITY_VERIFICATION_IN_PROGRESS. // otherwise → REQUIRES_IDENTITY_VERIFICATION. While loading → LOADING. // userMessage ← the rejecting rail's reason.userMessage (was useProviderRejectionStatus). - const { canDo, railsForProvider, isKycApproved, isLoading: isLoadingCapabilities } = useCapabilities() + const { canDo, railsForProvider, nextActions, isKycApproved, isLoading: isLoadingCapabilities } = useCapabilities() const { user, fetchUser } = useAuth() // On public routes (qr-pay) auth still auto-fetches via React Query, but trigger a one-shot @@ -192,42 +193,59 @@ export default function QRPayPage() { if (canDo('pay', { provider: 'manteca' })) { return { kycGateState: QrKycState.PROCEED_TO_PAY, qrKycUserMessage: null as string | null } } - const mantecaRails = railsForProvider('manteca') - const blockedRail = mantecaRails.find((rail) => rail.status === 'blocked') - if (blockedRail) { - // Blocked === blocked. The US-nationality refinement is now applied in - // the resolver itself (Sumsub-approved + US-restricted → status:enabled - // + operations.pay:enabled, caught by canDo above), so a `blocked` - // status here is a genuine block. - // + // Verdict-first via the shared railVerdict collapse (rail.resolved, + // BE-derived; legacy fallback for older/cached responses). The + // US-nationality refinement is applied in the resolver itself + // (Sumsub-approved + US-restricted → operations.pay enabled, caught by + // canDo above), so a blocked verdict is genuine. + const actionByKey = new Map(nextActions.map((action) => [action.key, action])) + const candidates = railsForProvider('manteca').map((rail) => ({ + rail, + verdict: railVerdict(rail, actionByKey), + })) + // provide-email is NOT a document fix: routing it into the Sumsub + // upload flow dead-ends the user, and this surface has no email form — + // map it to the blocked modal (same rule as deriveProviderRejection). + const isProvideEmail = ({ verdict }: (typeof candidates)[number]) => + verdict.blocking?.selfHealKind === 'provide-email' + const blocked = candidates.find( + (candidate) => candidate.verdict.status === 'blocked' || isProvideEmail(candidate) + ) + if (blocked) { // Country-not-supported is self-fixable: user uploaded a non-AR/BR doc // and can verify again with a different one. Split out for the right CTA. - if (blockedRail.reason?.code === 'country_not_supported') { + // (selfHealKind is the verdict home; the reason-code check covers legacy + // responses — the code rides on blocking.code verbatim.) + if ( + !isProvideEmail(blocked) && + (blocked.verdict.blocking?.selfHealKind === 'restart-identity' || + blocked.verdict.blocking?.code === 'country_not_supported') + ) { return { kycGateState: QrKycState.PROVIDER_RESTART_IDENTITY, - qrKycUserMessage: blockedRail.reason.userMessage ?? null, + qrKycUserMessage: railUserMessage(blocked.rail), } } return { kycGateState: QrKycState.PROVIDER_REJECTION_BLOCKED, - qrKycUserMessage: blockedRail.reason?.userMessage ?? null, + qrKycUserMessage: railUserMessage(blocked.rail), } } - const fixableRail = mantecaRails.find((rail) => rail.status === 'requires-info') - if (fixableRail) { + const fixable = candidates.find((candidate) => candidate.verdict.status === 'fixable') + if (fixable) { return { kycGateState: QrKycState.PROVIDER_REJECTION_FIXABLE, - qrKycUserMessage: fixableRail.reason?.userMessage ?? null, + qrKycUserMessage: railUserMessage(fixable.rail), } } - if (mantecaRails.some((rail) => rail.status === 'pending')) { + if (candidates.some(({ verdict }) => verdict.status === 'pending')) { return { kycGateState: QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS, qrKycUserMessage: null as string | null, } } return { kycGateState: QrKycState.REQUIRES_IDENTITY_VERIFICATION, qrKycUserMessage: null as string | null } - }, [isLoadingCapabilities, canDo, railsForProvider, user, userFetchSettled]) + }, [isLoadingCapabilities, canDo, railsForProvider, nextActions, user, userFetchSettled]) const shouldBlockPay = kycGateState !== QrKycState.PROCEED_TO_PAY diff --git a/src/app/actions/types/users.types.ts b/src/app/actions/types/users.types.ts index 63ca3b0f8d..ff1a2ab96f 100644 --- a/src/app/actions/types/users.types.ts +++ b/src/app/actions/types/users.types.ts @@ -12,7 +12,6 @@ export interface InitiateKycResponse { kycLink: string tosLink?: string bridgeKycStatus: string - tosStatus?: string error?: string // will be present on rejections reasons?: Array<{ developer_reason: string diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index f164579285..e1d59c9363 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -1,5 +1,6 @@ 'use client' +import { railUserMessage, railVerdict } from '@/utils/capability-gate' import { Button } from '@/components/0_Bruddle/Button' import { type ActivationStep } from '@/hooks/useActivationStatus' import { Icon, type IconName } from '@/components/Global/Icons/Icon' @@ -79,7 +80,7 @@ const STEPS: Record, StepConfig> = { export default function ActivationCTAs({ activationStep, onDismissCard }: ActivationCTAsProps) { const router = useRouter() const { setIsQRScannerOpen, openSupportWithMessage } = useModalsContext() - const { rails, channelOf, nextActionsForRail } = useCapabilities() + const { rails, channelOf, nextActions } = useCapabilities() const { user } = useAuth() // Suppress the "Unlock payments" verify CTA while identity is mid-flight // (Sumsub processing / action_required). The user already took the verify @@ -103,14 +104,19 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa const channel = channelOf(rail) return channel === 'bank' || channel === 'qr-only' }) - const fixableRail = rejectableRails.find((rail) => rail.status === 'requires-info') - // Email-blocked rails carry a self-serve provide-email action (same - // contract the capability gate reads) — prefer one over an earlier - // blocked rail with a terminal reason, since one email fixes them all. - const emailBlocked = rejectableRails.find( - (rail) => rail.status === 'blocked' && nextActionsForRail(rail.id).some((a) => a.kind === 'provide-email') + // Verdict-first via the shared railVerdict collapse (rail.resolved, + // BE-derived; legacy fallback for older/cached responses). + const actionByKey = new Map(nextActions.map((action) => [action.key, action])) + const isEmailFix = (rail: (typeof rejectableRails)[number]) => + railVerdict(rail, actionByKey).blocking?.selfHealKind === 'provide-email' + const fixableRail = rejectableRails.find( + (rail) => railVerdict(rail, actionByKey).status === 'fixable' && !isEmailFix(rail) ) - const blocked = emailBlocked ?? rejectableRails.find((rail) => rail.status === 'blocked') + // Email-blocked rails: prefer one over an earlier blocked rail with a + // terminal reason, since one email fixes them all. + const emailBlocked = rejectableRails.find(isEmailFix) + const blocked = + emailBlocked ?? rejectableRails.find((rail) => railVerdict(rail, actionByKey).status === 'blocked') return { hasFixableRejection: !!fixableRail, fixableProvider: @@ -119,11 +125,14 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa : null, hasBlockedRejection: !!blocked, // Same precedence the copy/onClick use: email-blocked → fixable → terminal. - primaryRejectionMessage: (emailBlocked ?? fixableRail ?? blocked)?.reason?.userMessage ?? null, + primaryRejectionMessage: (() => { + const surfaced = emailBlocked ?? fixableRail ?? blocked + return surfaced ? railUserMessage(surfaced) : null + })(), blockedRail: blocked, isEmailBlocked: !!emailBlocked, } - }, [rails, channelOf, nextActionsForRail]) + }, [rails, channelOf, nextActions]) const [showProvideEmail, setShowProvideEmail] = useState(false) diff --git a/src/components/Home/__tests__/ActivationCTAs.test.tsx b/src/components/Home/__tests__/ActivationCTAs.test.tsx index 99c1ba1f17..03152f0975 100644 --- a/src/components/Home/__tests__/ActivationCTAs.test.tsx +++ b/src/components/Home/__tests__/ActivationCTAs.test.tsx @@ -30,6 +30,7 @@ jest.mock('@/hooks/useCapabilities', () => ({ rails: mockRails, channelOf: (rail: { channel: string }) => rail.channel, nextActionsForRail: () => [], + nextActions: [], }), })) jest.mock('@/context/authContext', () => ({ diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 3cf91c5159..403e5dfb43 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -188,8 +188,6 @@ export interface User { email: string profile_picture: string | null username: string | null - tosStatus?: string - tosAcceptedAt?: string bridgeCustomerId: string | null fullName: string telegram: string | null diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index 02dc800acc..318eb0f362 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -385,18 +385,40 @@ export interface paths { withdraw?: "enabled" | "pending" | "requires-info" | "blocked"; }; blockingActions?: string[]; + hintActions?: string[]; reason?: { code: string; userMessage: string; details?: string; }; + resolved?: { + status: "enabled" | "pending" | "fixable" | "blocked"; + blocking?: { + code: string; + userMessage: string; + selfHealable: boolean; + selfHealKind?: "document-resubmit" | "restart-identity" | "provide-email" | "contact-support"; + details?: string; + }; + nextAction?: { + key: string; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + purpose: string; + levelKey?: string; + tosUrl?: string; + effectiveDate?: string; + requirementKey?: string; + }; + }; }[]; nextActions: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; purpose: string; levelKey?: string; tosUrl?: string; + effectiveDate?: string; + requirementKey?: string; }[]; restrictions: { code: string; @@ -536,6 +558,7 @@ export interface paths { fullName?: string; bridge_customer_id?: string; telegramUsername?: string; + offrampHandle?: string; pushSubscriptionId?: string; showFullName?: boolean; hasSeenEarlyUserModal?: boolean; @@ -560,6 +583,41 @@ export interface paths { patch?: never; trace?: never; }; + "/users/me/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header: { + Authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/contacts": { parameters: { query?: never; @@ -910,8 +968,6 @@ export interface paths { "application/json": { userId: string; fullName: string | null; - email: string; - bridgeCustomerId: string | null; username: string | null; showFullName: boolean; canReceiveBankOfframp: boolean; @@ -1128,18 +1184,40 @@ export interface paths { withdraw?: "enabled" | "pending" | "requires-info" | "blocked"; }; blockingActions?: string[]; + hintActions?: string[]; reason?: { code: string; userMessage: string; details?: string; }; + resolved?: { + status: "enabled" | "pending" | "fixable" | "blocked"; + blocking?: { + code: string; + userMessage: string; + selfHealable: boolean; + selfHealKind?: "document-resubmit" | "restart-identity" | "provide-email" | "contact-support"; + details?: string; + }; + nextAction?: { + key: string; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + purpose: string; + levelKey?: string; + tosUrl?: string; + effectiveDate?: string; + requirementKey?: string; + }; + }; }[]; nextActions: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; purpose: string; levelKey?: string; tosUrl?: string; + effectiveDate?: string; + requirementKey?: string; }[]; restrictions: { code: string; @@ -2517,43 +2595,6 @@ export interface paths { patch?: never; trace?: never; }; - "/bridge/kyc-links/{uuid}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path: { - uuid: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/bridge/kyc-links": { parameters: { query?: never; @@ -2636,140 +2677,6 @@ export interface paths { patch?: never; trace?: never; }; - "/bridge/customers/{customerId}/liquidation-addresses": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path: { - customerId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - chain: "arbitrum" | "base" | "avalance_c_chain" | "ethereum" | "optimism" | "polygon" | "solana" | "stellar"; - currency: "dai" | "usdc" | "eurc" | "usdt"; - externalAccountId: string; - prefundedAccountId?: string; - destinationWireMessage?: string; - destinationSepaReference?: string; - destinationSwiftReference?: string; - destinationAchReference?: string; - destinationPaymentRail?: "arbitrum" | "base" | "avalance_c_chain" | "ethereum" | "optimism" | "polygon" | "solana" | "stellar" | "ach" | "ach_push" | "ach_same_day" | "sepa" | "swift" | "wire"; - destinationCurrency?: "eur" | "usd"; - destinationAddress?: string; - destinationBlockchainMemo?: string; - customDeveloperFeePercent?: string; - }; - }; - }; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bridge/customers/{uuid}/liquidation-addresses": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: { - limit?: number; - startingAfter?: string; - endingBefore?: string; - }; - header?: { - "api-key"?: string; - }; - path: { - uuid: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bridge/customers/{customerId}/liquidation-addresses/{liquidationAddressId}/drains": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: { - "api-key"?: string; - }; - path: { - customerId: string; - liquidationAddressId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Default Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/bridge/customers/{uuid}/transfers": { parameters: { query?: never; @@ -2992,6 +2899,14 @@ export interface paths { sepaReference?: string; achReference?: string; }; + beneficiaryName?: string; + beneficiaryAddress?: { + street: string; + city: string; + country: string; + state?: string; + postalCode?: string; + }; }; }; }; @@ -3046,6 +2961,17 @@ export interface paths { }; }; }; + /** @description Default Response */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; }; }; delete?: never; @@ -3788,6 +3714,8 @@ export interface paths { content: { "application/json": { error: string; + /** @enum {string} */ + code?: "STALE_CARD_APPROVAL"; }; }; }; @@ -4049,6 +3977,47 @@ export interface paths { }; trace?: never; }; + "/manteca/deposit/{depositId}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get deposit order status + * @description Poll the status of a deposit order by its Manteca synthetic id. Used by the BRL PIX QR flow to detect completion (intent.status === COMPLETED) without leaving the user on a static QR. Read-only — the webhook/poller remain the authoritative completion trigger. + */ + get: { + parameters: { + query?: never; + header: { + Authorization: string; + }; + path: { + depositId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/manteca/withdraw/init": { parameters: { query?: never; @@ -4756,7 +4725,9 @@ export interface paths { post: { parameters: { query?: never; - header?: never; + header: { + "x-admin-token": string; + }; path?: never; cookie?: never; }; @@ -5801,6 +5772,9 @@ export interface paths { destinationAddress: string; tokenOut: string; senderPeanutWalletAddress?: string; + feeUsd?: number; + payAmount?: string; + receiveAmount?: string; }; }; }; @@ -6013,10 +5987,13 @@ export interface paths { isEligible: boolean; eligibilityReason?: string; flowEarlyAccess: boolean; + isPublicLaunched: boolean; waitlistJoinedAt: string | null; waitlistPosition: number | null; waitlistReleasedAt: string | null; skipBadges: string[]; + waitlistTotal: number; + admittedTotal: number; }; }; }; @@ -7310,6 +7287,7 @@ export interface paths { expiryYear: number; last4: string; network: string; + cardholderName?: string; }; }; }; @@ -8913,7 +8891,7 @@ export interface paths { content: { "application/json": { userId: string; - code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "WAITLIST_SKIP"; + code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026"; revoke?: boolean; }; }; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index d963a12da8..fd63e10430 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -464,6 +464,12 @@ "type": "string" } }, + "hintActions": { + "type": "array", + "items": { + "type": "string" + } + }, "reason": { "type": "object", "properties": { @@ -478,6 +484,122 @@ } }, "required": ["code", "userMessage"] + }, + "resolved": { + "type": "object", + "properties": { + "status": { + "anyOf": [ + { + "type": "string", + "enum": ["enabled"] + }, + { + "type": "string", + "enum": ["pending"] + }, + { + "type": "string", + "enum": ["fixable"] + }, + { + "type": "string", + "enum": ["blocked"] + } + ] + }, + "blocking": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "userMessage": { + "type": "string" + }, + "selfHealable": { + "type": "boolean" + }, + "selfHealKind": { + "anyOf": [ + { + "type": "string", + "enum": ["document-resubmit"] + }, + { + "type": "string", + "enum": ["restart-identity"] + }, + { + "type": "string", + "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["contact-support"] + } + ] + }, + "details": { + "type": "string" + } + }, + "required": [ + "code", + "userMessage", + "selfHealable" + ] + }, + "nextAction": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string", + "enum": ["sumsub"] + }, + { + "type": "string", + "enum": ["accept-tos"] + }, + { + "type": "string", + "enum": ["wait"] + }, + { + "type": "string", + "enum": ["contact-support"] + }, + { + "type": "string", + "enum": ["provide-email"] + } + ] + }, + "purpose": { + "type": "string" + }, + "levelKey": { + "type": "string" + }, + "tosUrl": { + "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "requirementKey": { + "type": "string" + } + }, + "required": ["key", "kind", "purpose"] + } + }, + "required": ["status"] } }, "required": [ @@ -516,6 +638,10 @@ { "type": "string", "enum": ["contact-support"] + }, + { + "type": "string", + "enum": ["provide-email"] } ] }, @@ -527,6 +653,12 @@ }, "tosUrl": { "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "requirementKey": { + "type": "string" } }, "required": ["key", "kind", "purpose"] @@ -706,6 +838,10 @@ "telegramUsername": { "type": "string" }, + "offrampHandle": { + "maxLength": 320, + "type": "string" + }, "pushSubscriptionId": { "type": "string" }, @@ -774,6 +910,25 @@ } } }, + "/users/me/delete": { + "post": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/users/contacts": { "get": { "parameters": [ @@ -1147,19 +1302,6 @@ } ] }, - "email": { - "type": "string" - }, - "bridgeCustomerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "username": { "anyOf": [ { @@ -1183,8 +1325,6 @@ "required": [ "userId", "fullName", - "email", - "bridgeCustomerId", "username", "showFullName", "canReceiveBankOfframp", @@ -1441,6 +1581,12 @@ "type": "string" } }, + "hintActions": { + "type": "array", + "items": { + "type": "string" + } + }, "reason": { "type": "object", "properties": { @@ -1455,6 +1601,122 @@ } }, "required": ["code", "userMessage"] + }, + "resolved": { + "type": "object", + "properties": { + "status": { + "anyOf": [ + { + "type": "string", + "enum": ["enabled"] + }, + { + "type": "string", + "enum": ["pending"] + }, + { + "type": "string", + "enum": ["fixable"] + }, + { + "type": "string", + "enum": ["blocked"] + } + ] + }, + "blocking": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "userMessage": { + "type": "string" + }, + "selfHealable": { + "type": "boolean" + }, + "selfHealKind": { + "anyOf": [ + { + "type": "string", + "enum": ["document-resubmit"] + }, + { + "type": "string", + "enum": ["restart-identity"] + }, + { + "type": "string", + "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["contact-support"] + } + ] + }, + "details": { + "type": "string" + } + }, + "required": [ + "code", + "userMessage", + "selfHealable" + ] + }, + "nextAction": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string", + "enum": ["sumsub"] + }, + { + "type": "string", + "enum": ["accept-tos"] + }, + { + "type": "string", + "enum": ["wait"] + }, + { + "type": "string", + "enum": ["contact-support"] + }, + { + "type": "string", + "enum": ["provide-email"] + } + ] + }, + "purpose": { + "type": "string" + }, + "levelKey": { + "type": "string" + }, + "tosUrl": { + "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "requirementKey": { + "type": "string" + } + }, + "required": ["key", "kind", "purpose"] + } + }, + "required": ["status"] } }, "required": [ @@ -1493,6 +1755,10 @@ { "type": "string", "enum": ["contact-support"] + }, + { + "type": "string", + "enum": ["provide-email"] } ] }, @@ -1504,6 +1770,12 @@ }, "tosUrl": { "type": "string" + }, + "effectiveDate": { + "type": "string" + }, + "requirementKey": { + "type": "string" } }, "required": ["key", "kind", "purpose"] @@ -3114,33 +3386,6 @@ } } }, - "/bridge/kyc-links/{uuid}": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/bridge/kyc-links": { "post": { "requestBody": { @@ -3224,287 +3469,6 @@ } } }, - "/bridge/customers/{customerId}/liquidation-addresses": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chain": { - "anyOf": [ - { - "type": "string", - "enum": ["arbitrum"] - }, - { - "type": "string", - "enum": ["base"] - }, - { - "type": "string", - "enum": ["avalance_c_chain"] - }, - { - "type": "string", - "enum": ["ethereum"] - }, - { - "type": "string", - "enum": ["optimism"] - }, - { - "type": "string", - "enum": ["polygon"] - }, - { - "type": "string", - "enum": ["solana"] - }, - { - "type": "string", - "enum": ["stellar"] - } - ] - }, - "currency": { - "anyOf": [ - { - "type": "string", - "enum": ["dai"] - }, - { - "type": "string", - "enum": ["usdc"] - }, - { - "type": "string", - "enum": ["eurc"] - }, - { - "type": "string", - "enum": ["usdt"] - } - ] - }, - "externalAccountId": { - "type": "string" - }, - "prefundedAccountId": { - "type": "string" - }, - "destinationWireMessage": { - "type": "string" - }, - "destinationSepaReference": { - "type": "string" - }, - "destinationSwiftReference": { - "type": "string" - }, - "destinationAchReference": { - "type": "string" - }, - "destinationPaymentRail": { - "anyOf": [ - { - "type": "string", - "enum": ["arbitrum"] - }, - { - "type": "string", - "enum": ["base"] - }, - { - "type": "string", - "enum": ["avalance_c_chain"] - }, - { - "type": "string", - "enum": ["ethereum"] - }, - { - "type": "string", - "enum": ["optimism"] - }, - { - "type": "string", - "enum": ["polygon"] - }, - { - "type": "string", - "enum": ["solana"] - }, - { - "type": "string", - "enum": ["stellar"] - }, - { - "type": "string", - "enum": ["ach"] - }, - { - "type": "string", - "enum": ["ach_push"] - }, - { - "type": "string", - "enum": ["ach_same_day"] - }, - { - "type": "string", - "enum": ["sepa"] - }, - { - "type": "string", - "enum": ["swift"] - }, - { - "type": "string", - "enum": ["wire"] - } - ] - }, - "destinationCurrency": { - "anyOf": [ - { - "type": "string", - "enum": ["eur"] - }, - { - "type": "string", - "enum": ["usd"] - } - ] - }, - "destinationAddress": { - "type": "string" - }, - "destinationBlockchainMemo": { - "type": "string" - }, - "customDeveloperFeePercent": { - "type": "string" - } - }, - "required": ["chain", "currency", "externalAccountId"] - } - } - }, - "required": true - }, - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "customerId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/customers/{uuid}/liquidation-addresses": { - "get": { - "parameters": [ - { - "schema": { - "type": "number" - }, - "in": "query", - "name": "limit", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "startingAfter", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "endingBefore", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "uuid", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, - "/bridge/customers/{customerId}/liquidation-addresses/{liquidationAddressId}/drains": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "customerId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "liquidationAddressId", - "required": true - }, - { - "schema": { - "type": "string" - }, - "in": "header", - "name": "api-key", - "required": false - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/bridge/customers/{uuid}/transfers": { "get": { "parameters": [ @@ -4034,6 +3998,31 @@ } }, "required": ["currency", "paymentRail", "externalAccountId"] + }, + "beneficiaryName": { + "minLength": 1, + "type": "string" + }, + "beneficiaryAddress": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "country": { + "type": "string" + }, + "state": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + }, + "required": ["street", "city", "country"] } }, "required": ["userId", "source", "destination"] @@ -4156,6 +4145,22 @@ } } } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } } } } @@ -5308,6 +5313,10 @@ "properties": { "error": { "type": "string" + }, + "code": { + "type": "string", + "enum": ["STALE_CARD_APPROVAL"] } }, "required": ["error"] @@ -5701,6 +5710,36 @@ } } }, + "/manteca/deposit/{depositId}/status": { + "get": { + "summary": "Get deposit order status", + "tags": ["manteca"], + "description": "Poll the status of a deposit order by its Manteca synthetic id. Used by the BRL PIX QR flow to detect completion (intent.status === COMPLETED) without leaving the user on a static QR. Read-only \u2014 the webhook/poller remain the authoritative completion trigger.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "depositId", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "Authorization", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/manteca/withdraw/init": { "post": { "summary": "Initialize withdraw with locked price", @@ -6751,6 +6790,16 @@ }, "required": true }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-admin-token", + "required": true + } + ], "responses": { "200": { "description": "Default Response" @@ -7692,6 +7741,15 @@ }, "senderPeanutWalletAddress": { "type": "string" + }, + "feeUsd": { + "type": "number" + }, + "payAmount": { + "type": "string" + }, + "receiveAmount": { + "type": "string" } }, "required": [ @@ -7918,6 +7976,9 @@ "flowEarlyAccess": { "type": "boolean" }, + "isPublicLaunched": { + "type": "boolean" + }, "waitlistJoinedAt": { "anyOf": [ { @@ -7953,16 +8014,25 @@ "items": { "type": "string" } + }, + "waitlistTotal": { + "type": "number" + }, + "admittedTotal": { + "type": "number" } }, "required": [ "hasCardAccess", "isEligible", "flowEarlyAccess", + "isPublicLaunched", "waitlistJoinedAt", "waitlistPosition", "waitlistReleasedAt", - "skipBadges" + "skipBadges", + "waitlistTotal", + "admittedTotal" ] } } @@ -8045,7 +8115,7 @@ }, "/card/waitlist/state": { "get": { - "summary": "Get the user’s current waitlist state", + "summary": "Get the user\u2019s current waitlist state", "tags": ["card"], "responses": { "200": { @@ -9614,6 +9684,9 @@ }, "network": { "type": "string" + }, + "cardholderName": { + "type": "string" } }, "required": ["pan", "cvv", "expiryMonth", "expiryYear", "last4", "network"] @@ -10426,7 +10499,7 @@ "post": { "summary": "Run one polling cycle synchronously", "tags": ["dev"], - "description": "Triggers runPollingCycle() on demand — used by the QA harness to observe provider state transitions without waiting for the 5-minute interval.", + "description": "Triggers runPollingCycle() on demand \u2014 used by the QA harness to observe provider state transitions without waiting for the 5-minute interval.", "responses": { "200": { "description": "Default Response", @@ -10646,7 +10719,7 @@ "post": { "summary": "Run KYC provider submission synchronously for the given user/applicant", "tags": ["dev"], - "description": "Drives Peanut's submitToProviders(userId, applicantId) synchronously, the same function the Sumsub status-processor calls after GREEN. Used by QA harness to test routing (AR user → Manteca, US user → Bridge) end-to-end.", + "description": "Drives Peanut's submitToProviders(userId, applicantId) synchronously, the same function the Sumsub status-processor calls after GREEN. Used by QA harness to test routing (AR user \u2192 Manteca, US user \u2192 Bridge) end-to-end.", "requestBody": { "content": { "application/json": { @@ -10714,7 +10787,7 @@ "post": { "summary": "Synthesise a Sumsub webhook and run the status-processor", "tags": ["dev"], - "description": "Invokes processSumsubKycUpdate({ applicantId, externalUserId, reviewAnswer, reviewStatus, source: 'webhook' }) — same entry point as the real /sumsub/webhooks route, minus HMAC. Used by the QA harness to exercise the full Sumsub → routing chain without real webhook delivery.", + "description": "Invokes processSumsubKycUpdate({ applicantId, externalUserId, reviewAnswer, reviewStatus, source: 'webhook' }) \u2014 same entry point as the real /sumsub/webhooks route, minus HMAC. Used by the QA harness to exercise the full Sumsub \u2192 routing chain without real webhook delivery.", "requestBody": { "content": { "application/json": { @@ -11825,6 +11898,10 @@ "type": "string", "enum": ["SHHHHH"] }, + { + "type": "string", + "enum": ["NOT_SO_SHHHH"] + }, { "type": "string", "enum": ["CARD_FIRST_SWIPE"] @@ -11845,9 +11922,33 @@ "type": "string", "enum": ["ETHFLORIPA_HUB"] }, + { + "type": "string", + "enum": ["IRL_NOMADS"] + }, + { + "type": "string", + "enum": ["EVENT_ALUMNI"] + }, + { + "type": "string", + "enum": ["TOUCHED_GRASS"] + }, + { + "type": "string", + "enum": ["OFFRAMP_USER"] + }, + { + "type": "string", + "enum": ["PSYOPS_DIVISION"] + }, { "type": "string", "enum": ["WAITLIST_SKIP"] + }, + { + "type": "string", + "enum": ["FESTA_JUNINA_2026"] } ] }, diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index e16693627c..c9c77e85d3 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -87,6 +87,41 @@ export interface RailCapability { hintActions?: string[] /** present for requires-info / blocked — normalized reason for uniform FE rendering. */ reason?: CapabilityReason + /** + * THE verdict (BE refactor step 3). Derived server-side from the same + * emission as the legacy status/reason/actions triple, so the two shapes + * can never disagree. The gate renders this instead of re-deriving + * fixable/terminal from status + reason codes + action kinds; the legacy + * fields remain until the BE's step-5 cleanup. + */ + resolved?: ResolvedRail +} + +/** + * The one verdict per rail. `status` collapses the legacy triple: + * - `fixable` — the user can self-serve (document resubmit / accept-tos / + * provide-email); `blocking.selfHealKind` names the flow + * - `blocked` — no self-serve path (contact support / restriction / terminal) + * - `pending` — nothing to do but wait; `nextAction.kind === 'wait'` marks a + * provider-review wait (FE renders waiting-on-provider) vs provisioning + */ +export interface ResolvedRail { + status: 'enabled' | 'pending' | 'fixable' | 'blocked' + /** present when status is fixable/blocked */ + blocking?: { + code: string + userMessage: string + selfHealable: boolean + selfHealKind?: 'document-resubmit' | 'restart-identity' | 'provide-email' | 'contact-support' + /** support pre-fill only, never user-facing copy */ + details?: string + } + /** + * The full action object (not just a key): the first blocking action, or — + * on an enabled rail — the advisory hint (its `effectiveDate` rides on the + * action itself). + */ + nextAction?: NextAction } /** diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index 5fb30871f2..f51a125682 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -657,3 +657,247 @@ describe('deriveGate — provide-email self-serve for email-blocked rails', () = expect(gate.kind).toBe('blocked-rejection') }) }) + +describe('deriveGate — resolved verdict is authoritative (BE step 3)', () => { + // When the BE ships `rail.resolved`, the gate renders IT — even when the + // legacy status/actions would derive something else. These pin the verdict + // as the single source of truth; the legacy paths above only cover rails + // without `resolved` (older BE / cached responses). + + test('resolved fixable wins over a legacy blocked status', () => { + const gate = deriveGate( + state([ + bankRail({ + status: 'blocked', + reason: { code: 'document_rejected', userMessage: 'legacy message' }, + resolved: { + status: 'fixable', + blocking: { + code: 'document_rejected', + userMessage: 'verdict message', + selfHealable: true, + selfHealKind: 'document-resubmit', + }, + }, + }), + ]), + 'deposit' + ) + expect(gate.kind).toBe('fixable-rejection') + expect(getGateUserMessage(gate)).toBe('verdict message') + }) + + test('resolved blocked wins over a legacy requires-info status', () => { + const gate = deriveGate( + state([ + bankRail({ + status: 'requires-info', + resolved: { + status: 'blocked', + blocking: { code: 'verification_blocked', userMessage: 'terminal', selfHealable: false }, + }, + }), + ]), + 'deposit' + ) + expect(gate.kind).toBe('blocked-rejection') + }) + + test('resolved provide-email verdict → provide-email gate without action lookups', () => { + const gate = deriveGate( + state([ + bankRail({ + status: 'blocked', + resolved: { + status: 'fixable', + blocking: { + code: 'email_required', + userMessage: 'add an email', + selfHealable: true, + selfHealKind: 'provide-email', + }, + }, + }), + ]), + 'deposit' + ) + expect(gate.kind).toBe('provide-email') + }) + + test('resolved accept-tos rides the verdict nextAction (tosUrl included)', () => { + const gate = deriveGate( + state([ + bankRail({ + status: 'requires-info', + resolved: { + status: 'fixable', + blocking: { code: 'bridge_tos_required', userMessage: 'accept ToS', selfHealable: true }, + nextAction: { + key: 'accept-tos:bridge', + kind: 'accept-tos', + purpose: 'bridge-tos', + tosUrl: 'https://tos.example', + }, + }, + }), + ]), + 'deposit' + ) + expect(gate.kind).toBe('accept-tos') + expect(gate.kind === 'accept-tos' && gate.tosUrl).toBe('https://tos.example') + }) + + test('resolved pending with a wait nextAction → waiting-on-provider', () => { + const gate = deriveGate( + state([ + bankRail({ + status: 'requires-info', + reason: { code: 'bridge_processing', userMessage: 'we are checking' }, + resolved: { + status: 'pending', + nextAction: { key: 'wait:bridge', kind: 'wait', purpose: 'bridge-review' }, + }, + }), + ]), + 'deposit' + ) + expect(gate.kind).toBe('waiting-on-provider') + expect(getGateUserMessage(gate)).toBe('we are checking') + }) + + test('resolved advisory nextAction on an enabled rail surfaces the ready advisory', () => { + const gate = deriveGate( + state([ + bankRail({ + status: 'enabled', + resolved: { + status: 'enabled', + nextAction: { + key: 'sumsub:sof', + kind: 'sumsub', + purpose: 'pre-empt', + effectiveDate: '2026-08-01', + requirementKey: 'sof_individual_primary_purpose', + }, + }, + }), + ]), + 'deposit' + ) + expect(gate.kind).toBe('ready') + expect(getGateAdvisory(gate)).toEqual({ + effectiveDate: '2026-08-01', + actionKey: 'sumsub:sof', + requirementKey: 'sof_individual_primary_purpose', + }) + }) + + test('resolved restart-identity selfHealKind → restart-identity gate', () => { + const gate = deriveGate( + state([ + bankRail({ + status: 'blocked', + resolved: { + status: 'blocked', + blocking: { + code: 'country_not_supported', + userMessage: 'try another document', + selfHealable: true, + selfHealKind: 'restart-identity', + }, + }, + }), + ]), + 'deposit' + ) + expect(gate.kind).toBe('restart-identity') + }) +}) + +describe('deriveGate — legacy fallback fidelity (code-review regression pins)', () => { + // Shapes the pre-verdict ladder handled that the 32 legacy tests above + // never mixed. Each of these was a CONFIRMED review finding: the fallback + // must key on STATUS first — action kinds only refine within a status. + + const sumsubAction: NextAction = { key: 'sumsub:poa', kind: 'sumsub', purpose: 'rfi', levelKey: 'poa' } + const tosAction: NextAction = { key: 'accept-tos:bridge', kind: 'accept-tos', purpose: 'tos' } + const waitAction: NextAction = { key: 'wait:bridge', kind: 'wait', purpose: 'review' } + const restartAction: NextAction = { key: 'restart', kind: 'restart-identity', purpose: 'restart' } + const supportAction: NextAction = { key: 'support', kind: 'contact-support', purpose: 'support' } + + test('blocked rail with a stale sumsub action stays blocked-rejection (no resurrected upload CTA)', () => { + const gate = deriveGate( + state([bankRail({ status: 'blocked', blockingActions: [sumsubAction.key] })], [sumsubAction]), + 'deposit' + ) + expect(gate.kind).toBe('blocked-rejection') + }) + + test('blocked rail with a stale accept-tos action stays blocked-rejection', () => { + const gate = deriveGate( + state([bankRail({ status: 'blocked', blockingActions: [tosAction.key] })], [tosAction]), + 'deposit' + ) + expect(gate.kind).toBe('blocked-rejection') + }) + + test('blocked rail with only a wait action stays blocked-rejection (not hidden behind "we are checking")', () => { + const gate = deriveGate( + state([bankRail({ status: 'blocked', blockingActions: [waitAction.key] })], [waitAction]), + 'deposit' + ) + expect(gate.kind).toBe('blocked-rejection') + }) + + test('terminal blocked rail first in scope beats a sibling restart-identity rail', () => { + const gate = deriveGate( + state( + [ + bankRail({ id: 'bridge.ach_us', status: 'blocked' }), + bankRail({ + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + country: 'BR', + status: 'blocked', + blockingActions: [restartAction.key], + }), + ], + [restartAction] + ), + 'deposit' + ) + expect(gate.kind).toBe('blocked-rejection') + }) + + test('pending sibling beats an action-less requires-info rail (old 7b placement)', () => { + const gate = deriveGate( + state([ + bankRail({ id: 'bridge.ach_us', status: 'pending' }), + bankRail({ id: 'bridge.sepa_eu', method: 'SEPA_EU', country: 'EU', status: 'requires-info' }), + ]), + 'deposit' + ) + expect(gate.kind).toBe('pending') + }) + + test('requires-info + contact-support-only rail does NOT outrank a fixable sumsub sibling', () => { + const gate = deriveGate( + state( + [ + bankRail({ id: 'bridge.ach_us', status: 'requires-info', blockingActions: [supportAction.key] }), + bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + status: 'requires-info', + blockingActions: [sumsubAction.key], + }), + ], + [supportAction, sumsubAction] + ), + 'deposit' + ) + expect(gate.kind).toBe('fixable-rejection') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 3b6e860d9f..45b7498db3 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -16,7 +16,14 @@ * pool-tier users). */ -import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailChannel } from '@/types/capabilities' +import type { + NextAction, + RailCapability, + RailOperation, + CapabilityReason, + RailChannel, + ResolvedRail, +} from '@/types/capabilities' /** * A non-blocking advisory pre-empt riding on a `ready` gate. An ENABLED rail can @@ -123,18 +130,107 @@ function railHintActions(rail: RailCapability, byKey: Map): .filter((action): action is NextAction => action !== undefined) } +/** + * The per-rail verdict the gate branches on. The BE's `resolved` field is the + * single source of truth (derived server-side from the same emission as the + * legacy fields, so it can never disagree with them); rails from an older BE + * or a cached response fall back to a local collapse that reproduces the OLD + * ladder's per-rail semantics. Delete the fallback when the BE's step-5 + * cleanup makes `resolved` guaranteed. + * + * Fallback rules (status first — action kinds only refine WITHIN a status, + * mirroring the pre-verdict ladder): + * - enabled/pending pass through (the advisory hint rides as nextAction) + * - blocked: provide-email action → fixable/provide-email (self-serve); + * restart-identity action → blocked/restart-identity (the ladder splits + * the CTA on the FIRST blocked rail only); anything else → blocked — + * a stale sumsub/tos/wait action on a blocked rail must NOT resurrect a + * self-heal CTA the old gate never offered. + * - requires-info: wait-only → pending+wait; a non-wait self-serve action → + * fixable with that action; actionless or contact-support-only → fixable + * WITHOUT a nextAction (the old 7b document-punt tier, ranked below + * pending in the ladder). + * + * Exported so every verdict consumer (provider-rejection.utils, qr-pay, + * ActivationCTAs) shares ONE collapse instead of hand-rolling diverging copies. + */ +export function railVerdict(rail: RailCapability, byKey: Map): ResolvedRail { + if (rail.resolved) return rail.resolved + + if (rail.status === 'enabled' || rail.status === 'pending') { + // an enabled rail's advisory hint rides as the verdict's nextAction + // (the BE emits at most one hint per rail) + const hint = railHintActions(rail, byKey)[0] + return { status: rail.status, ...(hint ? { nextAction: hint } : {}) } + } + + const actions = railActions(rail, byKey) + const blocking = (selfHealable: boolean, selfHealKind?: NonNullable['selfHealKind']) => ({ + code: rail.reason?.code ?? 'unknown', + userMessage: rail.reason?.userMessage ?? '', + selfHealable, + ...(selfHealKind ? { selfHealKind } : {}), + ...(rail.reason?.details ? { details: rail.reason.details } : {}), + }) + + if (rail.status === 'blocked') { + const email = actions.find((action) => action.kind === 'provide-email') + if (email) return { status: 'fixable', blocking: blocking(true, 'provide-email'), nextAction: email } + const restart = actions.find((action) => action.kind === 'restart-identity') + if (restart) return { status: 'blocked', blocking: blocking(true, 'restart-identity'), nextAction: restart } + return { status: 'blocked', blocking: blocking(false, 'contact-support') } + } + + // requires-info: an actionable step outranks a wait marker on the same rail + const nextAction = actions.find((action) => action.kind !== 'wait') ?? actions[0] + if (nextAction?.kind === 'wait') return { status: 'pending', nextAction } + if (nextAction && nextAction.kind !== 'contact-support') { + const selfHealKind = + nextAction.kind === 'sumsub' + ? ('document-resubmit' as const) + : nextAction.kind === 'provide-email' + ? ('provide-email' as const) + : nextAction.kind === 'restart-identity' + ? ('restart-identity' as const) + : undefined + return { status: 'fixable', blocking: blocking(true, selfHealKind), nextAction } + } + // actionless (or contact-support-only): the document-punt tier — the BE + // routes these through the resubmit endpoint without a capability action; + // NO nextAction on the verdict, which is what ranks it below pending + return { status: 'fixable', blocking: blocking(true, 'document-resubmit') } +} + +/** verdict-carrying candidate — computed once per derive, shared across branches */ +interface RailWithVerdict { + rail: RailCapability + verdict: ResolvedRail +} + +/** + * The user-facing message for a rail: verdict copy first (single BE source), + * legacy reason as fallback. Exported for the non-gate verdict consumers. + */ +export function railUserMessage(rail: RailCapability): string | null { + return rail.resolved?.blocking?.userMessage || rail.reason?.userMessage || null +} + +/** message helper over a computed candidate (avoids re-deriving inside the ladder) */ +function verdictMessage({ rail, verdict }: RailWithVerdict): string | null { + return verdict.blocking?.userMessage || rail.reason?.userMessage || null +} + /** * 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. + * verdict nextAction carrying the earliest `effectiveDate`. Returns undefined + * when no rail has a future-dated hint. */ -function firstAdvisory(rails: RailCapability[], byKey: Map): GateAdvisory | undefined { +function firstAdvisory(candidates: RailWithVerdict[]): 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 - } + for (const { verdict } of candidates) { + const action = verdict.nextAction + 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 } @@ -154,172 +250,114 @@ export interface CapabilityState { } /** - * Pure gate derivation. Same priority order as the old `deriveBridgeGate`, - * generalized over scope + operation. + * Pure gate derivation over the per-rail VERDICT (`rail.resolved`, BE-derived; + * legacy fallback in {@link railVerdict}). The gate no longer re-derives + * fixable-vs-terminal from reason codes + action kinds — it renders the + * verdict. Priority order is unchanged from the pre-verdict gate: * - * Priority (highest first): * 1. loading — capability state not yet settled - * 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) - * 5. fixable-rejection — requires-info + a `kind: 'sumsub'` action - * (user-actionable, unblocks the scope) - * 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 + * 2. ready — at least one in-scope rail has operationStatus(op) === 'enabled' + * (per-op refinement stays FE-side: the verdict is rail-level). + * Hoisted above every blocker so a working rail (e.g. Manteca + * PIX_BR ENABLED) wins over stuck sibling rails — the + * 2026-06-01 Alexandre incident guard. + * 3. provide-email — any verdict with selfHealKind 'provide-email': a USER-level + * fix (one email unblocks every email-blocked rail), so it + * outranks terminal siblings. + * 4. restart-identity — verdict selfHealKind 'restart-identity' (self-fix by + * re-verifying with a different document). + * 5. blocked-rejection — any verdict status 'blocked' (terminal, contact support). + * 6. accept-tos — fixable verdict whose nextAction is `accept-tos`. + * 7. fixable-rejection — any other fixable verdict (document resubmit / RFI). + * 8. pending — verdict 'pending' without a wait marker (we're provisioning). + * 9. waiting-on-provider — only wait-marked pending verdicts remain (provider is + * reviewing; no user action). Above needs-identity so the + * user sees "we're checking", not a misleading re-verify CTA. + * 10. needs-identity — no functional rail in scope AND identity not verified + * 11. 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' } - const candidates = filterRailsByScope(state.rails, scope) const actionByKey = new Map(state.nextActions.map((action) => [action.key, action])) + const candidates: RailWithVerdict[] = filterRailsByScope(state.rails, scope).map((rail) => ({ + rail, + verdict: railVerdict(rail, actionByKey), + })) - // 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 readyRails = candidates.filter((rail) => operationStatus(rail, op) === 'enabled') + // 2. ready — per-op refinement wins over rail-level status. + 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) + const advisory = firstAdvisory(readyRails) 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 - // the only path is contact-support. - // provide-email is a USER-level fix (one email unblocks every email-blocked - // rail), so any blocked rail carrying it wins over an earlier blocked rail - // with a terminal reason — .find() order must not shadow the self-serve path. - const emailBlocked = candidates.find( - (rail) => - rail.status === 'blocked' && - railActions(rail, actionByKey).some((action) => action.kind === 'provide-email') - ) - if (emailBlocked) { - return { - kind: 'provide-email', - userMessage: emailBlocked.reason?.userMessage ?? null, - reason: emailBlocked.reason, - } + // 3. provide-email + const emailFix = candidates.find(({ verdict }) => verdict.blocking?.selfHealKind === 'provide-email') + if (emailFix) { + return { kind: 'provide-email', userMessage: verdictMessage(emailFix), reason: emailFix.rail.reason } } - const blocked = candidates.find((rail) => rail.status === 'blocked') + + // 4. blocked family — decided on the FIRST blocked verdict in scope, so an + // account-wide terminal block wins over a sibling's restart-identity CTA + // (re-verifying cannot unblock a terminal rail and burns Sumsub attempts). + const blocked = candidates.find(({ verdict }) => verdict.status === 'blocked') if (blocked) { - const hasRestart = railActions(blocked, actionByKey).some((action) => action.kind === 'restart-identity') - if (hasRestart) { - return { - kind: 'restart-identity', - userMessage: blocked.reason?.userMessage ?? null, - reason: blocked.reason, - } - } - return { - kind: 'blocked-rejection', - userMessage: blocked.reason?.userMessage ?? null, - reason: blocked.reason, + if (blocked.verdict.blocking?.selfHealKind === 'restart-identity') { + return { kind: 'restart-identity', userMessage: verdictMessage(blocked), reason: blocked.rail.reason } } + return { kind: 'blocked-rejection', userMessage: verdictMessage(blocked), reason: blocked.rail.reason } } - const requiresInfoRails = candidates.filter((rail) => rail.status === 'requires-info') + const fixables = candidates.filter(({ verdict }) => verdict.status === 'fixable') - // 4. accept-tos - const tosRail = requiresInfoRails.find((rail) => - railActions(rail, actionByKey).some((action) => action.kind === 'accept-tos') - ) - if (tosRail) { - const tosAction = railActions(tosRail, actionByKey).find((a) => a.kind === 'accept-tos') + // 5. accept-tos + const tos = fixables.find(({ verdict }) => verdict.nextAction?.kind === 'accept-tos') + if (tos) { return { kind: 'accept-tos', - tosUrl: tosAction?.tosUrl, - userMessage: tosRail.reason?.userMessage ?? null, - reason: tosRail.reason, + tosUrl: tos.verdict.nextAction?.tosUrl, + userMessage: verdictMessage(tos), + reason: tos.rail.reason, } } - // 5. fixable-rejection (Sumsub RFI / self-heal) - const fixableRail = requiresInfoRails.find((rail) => - railActions(rail, actionByKey).some((action) => action.kind === 'sumsub') - ) - if (fixableRail) { + // 6. fixable with a concrete action (Sumsub RFI / restart) — the user has a + // step to take now, so it outranks provisioning + const actionableFixable = fixables.find(({ verdict }) => verdict.nextAction !== undefined) + if (actionableFixable) { return { kind: 'fixable-rejection', - userMessage: fixableRail.reason?.userMessage ?? null, - reason: fixableRail.reason, + userMessage: verdictMessage(actionableFixable), + reason: actionableFixable.rail.reason, } } - // 6. pending — BE is provisioning, no user action needed - const hasPending = candidates.some((rail) => rail.status === 'pending') + // 7. pending — BE is provisioning, no user action needed + const hasPending = candidates.some( + ({ verdict }) => verdict.status === 'pending' && verdict.nextAction?.kind !== 'wait' + ) if (hasPending) return { kind: 'pending' } - // 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. action-less fixable (the document-punt tier): completable via the + // resubmit endpoint but with nothing concrete to click — ranked below + // pending (matches the old 7b placement: mid-provisioning wins). + if (fixables.length > 0) { + return { kind: 'fixable-rejection', userMessage: verdictMessage(fixables[0]), reason: fixables[0].rail.reason } } - // 7b. Requires-info rails EXIST in scope but none surfaced an actionable step - // (no accept-tos / sumsub / wait). The rail exists so the user is NOT missing a - // region; it needs a document the backend resolver punts to the FE resubmit - // endpoint (e.g. a Bridge government-id DOCUMENT_SELF_HEAL, requirementKey - // government_id_document). Route to the self-heal / document flow - // (fixable-rejection -> handleSelfHealResubmit) instead of the misleading - // Unlock {region} cross-region modal, which sent these users into a fake - // You're unlocked loop (tap Submit document, see success, bounce back with - // nothing changed). - if (requiresInfoRails.length > 0) { - // Prefer an action-less / self-heal rail over a wait-only one, so a mixed - // scope doesn't surface a "we're finalizing with Bridge" wait message on - // the document-upload modal. - const rail = - requiresInfoRails.find((r) => { - const acts = railActions(r, actionByKey) - return acts.length === 0 || acts.some((a) => a.kind !== 'wait') - }) ?? requiresInfoRails[0] - return { - kind: 'fixable-rejection', - userMessage: rail.reason?.userMessage ?? null, - reason: rail.reason, - } + // 9. waiting-on-provider — only wait-marked verdicts remain in scope + const waiting = candidates.find( + ({ verdict }) => verdict.status === 'pending' && verdict.nextAction?.kind === 'wait' + ) + if (waiting) { + return { kind: 'waiting-on-provider', userMessage: verdictMessage(waiting), reason: waiting.rail.reason } } - // 8/9. no functional rail in scope. Distinguish identity-not-cleared vs + // 10/11. 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' } diff --git a/src/utils/provider-rejection.utils.test.ts b/src/utils/provider-rejection.utils.test.ts new file mode 100644 index 0000000000..0b947e261e --- /dev/null +++ b/src/utils/provider-rejection.utils.test.ts @@ -0,0 +1,163 @@ +import { deriveProviderRejection } from './provider-rejection.utils' +import type { NextAction, RailCapability } from '@/types/capabilities' + +function mantecaRail(overrides: Partial = {}): RailCapability { + return { + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + channel: 'bank', + country: 'BR', + currency: 'BRL', + status: 'enabled', + ...overrides, + } +} + +describe('deriveProviderRejection — verdict-first classification', () => { + test('all enabled → happy', () => { + expect(deriveProviderRejection([mantecaRail()], 'MANTECA').state).toBe('happy') + }) + + test('other provider rails are ignored', () => { + expect(deriveProviderRejection([mantecaRail({ status: 'blocked' })], 'BRIDGE').state).toBe('happy') + }) + + test('resolved fixable → fixable, verdict copy wins', () => { + const info = deriveProviderRejection( + [ + mantecaRail({ + status: 'requires-info', + reason: { code: 'document_rejected', userMessage: 'legacy copy' }, + resolved: { + status: 'fixable', + blocking: { + code: 'document_rejected', + userMessage: 'verdict copy', + selfHealable: true, + selfHealKind: 'document-resubmit', + }, + }, + }), + ], + 'MANTECA' + ) + expect(info.state).toBe('fixable') + expect(info.userMessage).toBe('verdict copy') + }) + + test('resolved blocked → blocked', () => { + const info = deriveProviderRejection( + [ + mantecaRail({ + status: 'blocked', + resolved: { + status: 'blocked', + blocking: { code: 'verification_blocked', userMessage: 'terminal', selfHealable: false }, + }, + }), + ], + 'MANTECA' + ) + expect(info.state).toBe('blocked') + expect(info.userMessage).toBe('terminal') + }) + + test('provide-email verdict maps to blocked, never a document-upload fixable', () => { + const info = deriveProviderRejection( + [ + mantecaRail({ + status: 'blocked', + resolved: { + status: 'fixable', + blocking: { + code: 'email_required', + userMessage: 'add an email', + selfHealable: true, + selfHealKind: 'provide-email', + }, + }, + }), + ], + 'MANTECA' + ) + expect(info.state).toBe('blocked') + }) + + test('restart-identity selfHealKind on MANTECA → restart-identity', () => { + const info = deriveProviderRejection( + [ + mantecaRail({ + status: 'blocked', + resolved: { + status: 'blocked', + blocking: { + code: 'country_not_supported', + userMessage: 'try another document', + selfHealable: true, + selfHealKind: 'restart-identity', + }, + }, + }), + ], + 'MANTECA' + ) + expect(info.state).toBe('restart-identity') + }) + + test('legacy country_not_supported reason code still maps to restart-identity (no resolved)', () => { + const info = deriveProviderRejection( + [ + mantecaRail({ + status: 'blocked', + reason: { code: 'country_not_supported', userMessage: 'try another document' }, + }), + ], + 'MANTECA' + ) + expect(info.state).toBe('restart-identity') + }) + + test('legacy requires-info → fixable; legacy blocked → blocked (status semantics preserved)', () => { + expect(deriveProviderRejection([mantecaRail({ status: 'requires-info' })], 'MANTECA').state).toBe('fixable') + expect(deriveProviderRejection([mantecaRail({ status: 'blocked' })], 'MANTECA').state).toBe('blocked') + }) + + test('wait-marked pending verdict → happy (nothing user-actionable, no dead-end CTA)', () => { + const info = deriveProviderRejection( + [ + mantecaRail({ + status: 'requires-info', + resolved: { + status: 'pending', + nextAction: { key: 'wait:bridge', kind: 'wait', purpose: 'review' }, + }, + }), + ], + 'MANTECA' + ) + expect(info.state).toBe('happy') + }) + + test('legacy wait-only rail → happy when nextActions are provided', () => { + const wait: NextAction = { key: 'wait:bridge', kind: 'wait', purpose: 'review' } + const info = deriveProviderRejection( + [mantecaRail({ status: 'requires-info', blockingActions: [wait.key] })], + 'MANTECA', + [wait] + ) + expect(info.state).toBe('happy') + }) + + test('fixable outranks blocked across sibling rails (legacy priority)', () => { + const info = deriveProviderRejection( + [ + mantecaRail({ id: 'manteca.bank_ar', method: 'BANK_TRANSFER_AR', country: 'AR', status: 'blocked' }), + mantecaRail({ status: 'requires-info', reason: { code: 'x', userMessage: 'fix me' } }), + ], + 'MANTECA' + ) + expect(info.state).toBe('fixable') + expect(info.userMessage).toBe('fix me') + }) +}) diff --git a/src/utils/provider-rejection.utils.ts b/src/utils/provider-rejection.utils.ts index 81afefa96c..1c02e2acfd 100644 --- a/src/utils/provider-rejection.utils.ts +++ b/src/utils/provider-rejection.utils.ts @@ -1,4 +1,5 @@ -import { type RailCapability } from '@/types/capabilities' +import { type NextAction, type RailCapability } from '@/types/capabilities' +import { railUserMessage, railVerdict } from '@/utils/capability-gate' /** * Per-provider rejection state for the KYC status surfaces, derived from the @@ -50,31 +51,51 @@ const PROVIDER_CODE: Record<'BRIDGE' | 'MANTECA', 'bridge' | 'manteca'> = { } /** - * Derive the rejection state for a single provider from the capability rails. + * Derive the rejection state for a single provider from the per-rail VERDICT + * ({@link railVerdict}: `rail.resolved` BE-derived, shared legacy fallback). * - * `restart-identity` is detected via the rail's `reason.code` — keeps the - * signature backwards-compatible (no nextActions param) and mirrors the - * backend resolver's split: country-not-supported on a Manteca rail emits a - * `restart-identity` action; on Bridge/Rain it stays `contact-support`. + * Mapping notes: + * - a `provide-email` verdict maps to `blocked` here — these surfaces would + * otherwise open a document-upload flow for a user whose only fix is + * adding an email (matches the legacy blocked-status shape). + * - `restart-identity` comes from the verdict's selfHealKind, with the + * legacy `country_not_supported` reason-code check kept for older + * responses (the code rides on `blocking.code` verbatim). + * - wait-marked rails resolve `pending` → `happy`: nothing user-actionable, + * so no fixable CTA (the legacy status check surfaced one that dead-ended). + * + * `nextActions` powers the legacy fallback's action-kind refinement; callers + * without it get pure status semantics (what this util always had). */ export function deriveProviderRejection( rails: RailCapability[], - provider: 'BRIDGE' | 'MANTECA' + provider: 'BRIDGE' | 'MANTECA', + nextActions: NextAction[] = [] ): ProviderRejectionInfo { - const providerRails = rails.filter((rail) => rail.provider === PROVIDER_CODE[provider]) - const fixableRail = providerRails.find((rail) => rail.status === 'requires-info') - const blockedRail = providerRails.find((rail) => rail.status === 'blocked') - const isRestartIdentity = provider === 'MANTECA' && blockedRail?.reason?.code === 'country_not_supported' - const state: ProviderRejectionState = fixableRail + const byKey = new Map(nextActions.map((action) => [action.key, action])) + const candidates = rails + .filter((rail) => rail.provider === PROVIDER_CODE[provider]) + .map((rail) => ({ rail, verdict: railVerdict(rail, byKey) })) + + const isProvideEmail = ({ verdict }: (typeof candidates)[number]) => + verdict.blocking?.selfHealKind === 'provide-email' + const fixable = candidates.find((candidate) => candidate.verdict.status === 'fixable' && !isProvideEmail(candidate)) + const blocked = candidates.find((candidate) => candidate.verdict.status === 'blocked' || isProvideEmail(candidate)) + const isRestartIdentity = + provider === 'MANTECA' && + (blocked?.verdict.blocking?.selfHealKind === 'restart-identity' || + blocked?.verdict.blocking?.code === 'country_not_supported') + const state: ProviderRejectionState = fixable ? 'fixable' : isRestartIdentity ? 'restart-identity' - : blockedRail + : blocked ? 'blocked' : 'happy' + const surfaced = fixable ?? blocked return { provider, state, - userMessage: (fixableRail ?? blockedRail)?.reason?.userMessage ?? null, + userMessage: surfaced ? railUserMessage(surfaced.rail) || surfaced.verdict.blocking?.userMessage || null : null, } }