diff --git a/README.md b/README.md index c68b300cb33..e878f4bba1a 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,7 @@ linkStyle default opacity:0.5 profile_sync_controller --> seedless_onboarding_controller; ramps_controller --> base_controller; ramps_controller --> controller_utils; + ramps_controller --> kyc_controller; ramps_controller --> messenger; ramps_controller --> profile_sync_controller; ramps_controller --> remote_feature_flag_controller; diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c916801be88..32c9b22b6b6 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `RampsController.hydrateNeobankStore` and persisted `state.neobank` with a `NeobankOnboardingStage` enum so Mobile can route Money Account onboarding after cold start or missed KYC events. The method refreshes `KycController` status, looks up wallet registration / autoramp readiness, and derives a single stage without navigating or auto-submitting wallet/autoramp creation. Hosts must also delegate `KycController:getState`, `KycController:getCustomerIdentity`, and `KycController:refreshKycStatus` (now listed in `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`). Also exports `deriveNeobankOnboardingStage`, `getDefaultNeobankState`, and related helpers. ([#10116](https://github.com/MetaMask/core/pull/10116)) + - SumSub `abandoned` / retryable `failed` map to `KycStartedIncomplete` (KYCPage hook may relaunch). `vendorProcessing` and `userStatus === 'pending'` map to `KycPending` (do not launch the SDK). Controller `phase === 'error'` is `LookupFailed`, not a terminal KYC reject. `form` / `check` count as in-progress. Terminal reject is only `userStatus === 'terminal-failure'`. + ### Changed - Bump `@metamask/profile-sync-controller` from `^32.0.0` to `^32.1.0` ([#10184](https://github.com/MetaMask/core/pull/10184)) diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 2a26409ad03..d3289872fe6 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -53,6 +53,7 @@ "dependencies": { "@metamask/base-controller": "^10.0.0", "@metamask/controller-utils": "^13.0.0", + "@metamask/kyc-controller": "^0.2.0", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^32.1.0", "@metamask/remote-feature-flag-controller": "^7.0.0", diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 59443821394..7600cb5588c 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -402,6 +402,25 @@ export type RampsControllerMarkAutorampAsNotifiedAction = { handler: RampsController['markAutorampAsNotified']; }; +/** + * Refreshes authoritative Money Account / NeoBank onboarding signals and + * writes a single Mobile-routable stage to `state.neobank`. + * + * Safe to call twice. Does **not** navigate and does **not** auto-submit + * wallet registration or autoramp creation (see TRAM-3924). Clients must not + * persist UKYC `sessionId` or re-register; this method is lookup-then-derive + * only. + * + * @param params - Hydration parameters. + * @param params.walletAddress - Money Account wallet address used for + * registration / autoramp checks after KYC is complete. + * @returns The derived {@link NeobankOnboardingStage}. + */ +export type RampsControllerHydrateNeobankStoreAction = { + type: `RampsController:hydrateNeobankStore`; + handler: RampsController['hydrateNeobankStore']; +}; + /** * Applies a remote autoramp snapshot from a websocket / webhook push. * @@ -854,6 +873,7 @@ export type RampsControllerMethodActions = | RampsControllerRegisterMoneyAccountWalletAction | RampsControllerRemoveAutorampAction | RampsControllerMarkAutorampAsNotifiedAction + | RampsControllerHydrateNeobankStoreAction | RampsControllerApplyAutorampStatusFromPushAction | RampsControllerRefreshAutorampAction | RampsControllerRefreshAutorampsAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 47357384730..a5ca7283f06 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -13,6 +13,7 @@ import * as path from 'path'; import { AutorampStatus } from './autorampAccount.js'; import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; +import { NeobankOnboardingStage } from './neobank-onboarding.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -171,6 +172,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -248,6 +254,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -2281,6 +2292,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -2325,6 +2341,11 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -2362,6 +2383,11 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` { "autoramps": [], + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "providerAutoSelected": false, "userRegion": null, @@ -2410,6 +2436,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -9840,6 +9871,425 @@ describe('RampsController', () => { }); }); + describe('hydrateNeobankStore', () => { + const defaultKycState = { + phase: 'idle' as const, + statusMessage: '', + error: null, + email: null, + vendorDisclaimersAccepted: { moonpay: null, iron: null }, + providerDisclaimersAccepted: { sumsub: null }, + idosDisclaimersAccepted: null, + credentialReusabilityConsentGiven: null, + vendorDisclaimers: [], + vendorError: null, + sessionDisclaimers: null, + geoCountry: null, + moonpaySessionToken: null, + moonpayAccessToken: null, + moonpayCustomerId: null, + activeVendor: 'iron' as const, + activeProduct: 'money' as const, + kycRequiredByProduct: {}, + lastCheckedAt: null, + userStatus: null, + userStatusSumsubSessionId: null, + userStatusErrorCode: null, + sumsub: { + status: 'idle' as const, + result: null, + sessionId: null, + applicantAccessToken: null, + sessionStatus: null, + }, + }; + + type HydrateHandlers = { + refreshKycStatus: jest.Mock; + getState: jest.Mock; + getCustomerIdentity: jest.Mock; + getSessionProfile: jest.Mock; + getCustomerByExternalId: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + getAutoramp: jest.Mock; + }; + + /** + * Registers KYC + NeoBank handlers used by hydrateNeobankStore. + * + * @param rootMessenger - Root messenger for the controller under test. + * @param kycState - KYC controller state returned by getState. + * @returns Handler mocks for per-test overrides. + */ + function registerHydrateHandlers( + rootMessenger: RootMessenger, + kycState: typeof defaultKycState = defaultKycState, + ): HydrateHandlers { + const handlers: HydrateHandlers = { + refreshKycStatus: jest.fn().mockResolvedValue({ + status: kycState.userStatus ?? 'not-started', + sumsubSessionId: null, + errorCode: null, + }), + getState: jest.fn().mockReturnValue(kycState), + getCustomerIdentity: jest.fn().mockReturnValue(null), + getSessionProfile: jest.fn().mockResolvedValue({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + canonicalProfileId: 'canonical-1', + }), + getCustomerByExternalId: jest + .fn() + .mockResolvedValue({ id: 'iron-customer-1' }), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + getAutoramp: jest.fn(), + }; + rootMessenger.registerActionHandler( + 'KycController:refreshKycStatus', + handlers.refreshKycStatus, + ); + rootMessenger.registerActionHandler( + 'KycController:getState', + handlers.getState, + ); + rootMessenger.registerActionHandler( + 'KycController:getCustomerIdentity', + handlers.getCustomerIdentity, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + handlers.getSessionProfile, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + handlers.getCustomerByExternalId, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + handlers.getAutoramp, + ); + return handlers; + } + + it('returns NoUser and persists neobank state when KYC has no identity', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerHydrateHandlers(rootMessenger); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.NoUser); + expect(controller.state.neobank.stage).toBe( + NeobankOnboardingStage.NoUser, + ); + expect(controller.state.neobank.lastHydratedAt).toStrictEqual( + expect.any(String), + ); + expect(controller.state.neobank.lastError).toBeNull(); + }); + }); + + it('returns LookupFailed when KYC refresh fails with no local signal', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger); + handlers.refreshKycStatus.mockRejectedValue(new Error('network down')); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastError).toMatch(/network down/u); + }); + }); + + it('returns LookupFailed instead of routing from stale KYC state', async () => { + const staleKycState = { + ...defaultKycState, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + }; + + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, staleKycState); + handlers.refreshKycStatus.mockRejectedValue(new Error('network down')); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastHydratedAt).toBeNull(); + expect(controller.state.neobank.lastError).toMatch(/network down/u); + }); + }); + + it('returns LookupFailed for a missing wallet address', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: ' ', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastError).toMatch( + /walletAddress is required/u, + ); + expect(handlers.refreshKycStatus).not.toHaveBeenCalled(); + }); + }); + + it('returns WalletNotSigned after completed KYC when registration is absent', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-1', + address: '0xabc', + }); + expect(stage).toBe(NeobankOnboardingStage.WalletNotSigned); + }); + }); + + it('returns AutorampCreated when wallet is registered and autoramp is Approved', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController( + { + options: { + state: { + autoramps: [ + { + id: 'ar-1', + customerId: 'iron-customer-1', + walletAddress: '0xAbC', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + updatedAt: 1, + }, + ], + }, + }, + }, + async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + handlers.getAutoramp.mockResolvedValue({ + id: 'ar-1', + customerId: 'iron-customer-1', + walletAddress: '0xAbC', + status: AutorampStatus.Approved, + }); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.AutorampCreated); + expect(controller.state.neobank.stage).toBe( + NeobankOnboardingStage.AutorampCreated, + ); + }, + ); + }); + + it('is idempotent when called twice with the same signals', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerHydrateHandlers(rootMessenger); + + const first = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + const second = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(first).toBe(NeobankOnboardingStage.NoUser); + expect(second).toBe(NeobankOnboardingStage.NoUser); + }); + }); + + it('returns LookupFailed when customer resolution fails after KYC complete', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + handlers.getSessionProfile.mockResolvedValue({ + identifierId: 'id-1', + profileId: '', + metaMetricsId: 'mm-1', + }); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastError).toMatch( + /Cannot resolve MoonPay customer id/u, + ); + }); + }); + + it('still derives autoramp stage when refreshAutoramps fails', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController( + { + options: { + state: { + autoramps: [ + { + id: 'ar-1', + customerId: 'iron-customer-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + updatedAt: 1, + }, + ], + }, + }, + }, + async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + handlers.getAutoramp.mockRejectedValue(new Error('autoramp down')); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.AutorampPending); + }, + ); + }); + }); + describe('registerMoneyAccountWallet', () => { const registration = { id: 'wallet-1', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 931de06a048..a5eda5c7ea0 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,6 +6,12 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { BrokenCircuitError } from '@metamask/controller-utils'; +import type { + KycControllerGetCustomerIdentityAction, + KycControllerGetStateAction, + KycControllerRefreshKycStatusAction, + KycControllerState, +} from '@metamask/kyc-controller'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController, @@ -30,6 +36,14 @@ import { isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; +import type { NeobankState } from './neobank-onboarding.js'; +import { + NeobankOnboardingStage, + deriveNeobankOnboardingStage, + getDefaultNeobankState, + summarizeAutorampsForWallet, +} from './neobank-onboarding.js'; +import type { NeobankOnboardingDerivationInput } from './neobank-onboarding.js'; import type { NeoBankServiceCreateAutorampAction, NeoBankServiceGetAutorampAction, @@ -235,17 +249,50 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ * the EIP-191 ownership proof for Money Account self-hosted wallet * registration; both are only exercised by the autoramp paths. User Storage * and authentication actions support cross-client order syncing. + * `KycController:*` actions power {@link RampsController.hydrateNeobankStore}. */ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'AuthenticationController:getSessionProfile', 'AuthenticationController:isSignedIn', 'KeyringController:signPersonalMessage', + 'KycController:getCustomerIdentity', + 'KycController:getState', + 'KycController:refreshKycStatus', 'RemoteFeatureFlagController:getState', 'UserStorageController:getState', 'UserStorageController:performGetStorageAllFeatureEntries', 'UserStorageController:performBatchSetStorage', ] as const; +/** + * Whether persisted vendor T&C1 acceptance exists for the active vendor. + * + * @param state - KYC controller state. + * @returns Whether vendor terms are accepted. + */ +function hasKycVendorTerms(state: KycControllerState): boolean { + if (state.activeVendor === 'moonpay') { + return Boolean(state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt); + } + if (state.activeVendor === 'iron') { + return Boolean(state.vendorDisclaimersAccepted.iron?.disclaimerIds.length); + } + return false; +} + +/** + * Whether SumSub + idOS T&C2 were accepted as a batch. + * + * @param state - KYC controller state. + * @returns Whether provider terms are accepted. + */ +function hasKycProviderTerms(state: KycControllerState): boolean { + return ( + state.providerDisclaimersAccepted.sumsub !== null && + state.idosDisclaimersAccepted !== null + ); +} + /** * Structural type for the keyring controller's `signPersonalMessage` messenger * action (EIP-191). Declared locally to avoid a package dependency for a single @@ -543,6 +590,13 @@ export type RampsControllerState = { * notifications after refresh or push. */ autoramps: AutorampAccount[]; + /** + * Money Account / NeoBank onboarding stage derived by + * {@link RampsController.hydrateNeobankStore}. Mobile reads `stage` to route + * after cold start or missed KYC events. Lookup failures are persisted with + * their error while the last successful hydration time is retained. + */ + neobank: NeobankState; /** * Whether the currently selected provider was auto-selected by the system * (no order history, no Transak) rather than chosen by the user or derived @@ -610,6 +664,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + neobank: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providerAutoSelected: { persist: true, includeInDebugSnapshot: true, @@ -677,6 +737,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { }, orders: [], autoramps: [], + neobank: getDefaultNeobankState(), providerAutoSelected: false, }; } @@ -812,7 +873,10 @@ type AllowedActions = | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction - | AuthenticationController.AuthenticationControllerIsSignedInAction; + | AuthenticationController.AuthenticationControllerIsSignedInAction + | KycControllerGetCustomerIdentityAction + | KycControllerGetStateAction + | KycControllerRefreshKycStatusAction; /** * Published when the state of {@link RampsController} changes. @@ -1023,6 +1087,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'createAutoramp', 'removeAutoramp', 'registerMoneyAccountWallet', + 'hydrateNeobankStore', 'markAutorampAsNotified', 'applyAutorampStatusFromPush', 'refreshAutoramp', @@ -3574,6 +3639,127 @@ export class RampsController extends BaseController< }); } + /** + * Refreshes authoritative Money Account / NeoBank onboarding signals and + * writes a single Mobile-routable stage to `state.neobank`. + * + * Safe to call twice. Does **not** navigate and does **not** auto-submit + * wallet registration or autoramp creation (see TRAM-3924). Clients must not + * persist UKYC `sessionId` or re-register; this method is lookup-then-derive + * only. + * + * @param params - Hydration parameters. + * @param params.walletAddress - Money Account wallet address used for + * registration / autoramp checks after KYC is complete. + * @returns The derived {@link NeobankOnboardingStage}. + */ + async hydrateNeobankStore({ + walletAddress, + }: { + walletAddress: string; + }): Promise { + if (!walletAddress.trim()) { + return this.#commitNeobankStage( + NeobankOnboardingStage.LookupFailed, + 'walletAddress is required.', + ); + } + + try { + await this.messenger.call('KycController:refreshKycStatus'); + } catch (error) { + return this.#commitNeobankStage( + NeobankOnboardingStage.LookupFailed, + String(error), + ); + } + + const kycState = this.messenger.call('KycController:getState'); + const identity = this.messenger.call('KycController:getCustomerIdentity'); + const hasVendorTerms = hasKycVendorTerms(kycState); + const hasProviderTerms = hasKycProviderTerms(kycState); + const hasCustomerIdentity = + Boolean(identity?.id ?? kycState.email) || kycState.userStatus !== null; + + const kycInput: NeobankOnboardingDerivationInput['kyc'] = { + phase: kycState.phase, + userStatus: kycState.userStatus, + sumsubStatus: kycState.sumsub.status, + hasCustomerIdentity, + hasVendorTerms, + hasProviderTerms, + }; + + let wallet: NeobankOnboardingDerivationInput['wallet'] = { + status: 'skipped', + }; + let autoramp: NeobankOnboardingDerivationInput['autoramp'] = { + status: 'skipped', + }; + let lookupError: string | null = null; + + if (kycState.userStatus === 'completed') { + try { + const customerId = await this.resolveAutorampCustomerId(); + const registration = await this.messenger.call( + 'NeoBankService:getWalletRegistrationStatus', + { customerId, address: walletAddress }, + ); + wallet = { status: 'resolved', registration }; + try { + await this.refreshAutoramps(); + } catch { + // Keep the local autoramp cursor; summarize whatever we have. + } + autoramp = summarizeAutorampsForWallet( + this.state.autoramps, + walletAddress, + ); + } catch (error) { + wallet = { + status: 'lookupUnavailable', + message: String(error), + }; + lookupError = String(error); + } + } + + const stage = deriveNeobankOnboardingStage({ + kyc: kycInput, + wallet, + autoramp, + }); + return this.#commitNeobankStage( + stage, + stage === NeobankOnboardingStage.LookupFailed ? lookupError : null, + ); + } + + /** + * Writes the NeoBank onboarding stage into controller state. + * + * @param stage - Derived stage. + * @param lastError - Optional error message for lookup failures. + * @returns The same stage. + */ + #commitNeobankStage( + stage: NeobankOnboardingStage, + lastError: string | null, + ): NeobankOnboardingStage { + this.update((state) => { + state.neobank = { + stage, + lastHydratedAt: + stage === NeobankOnboardingStage.LookupFailed + ? state.neobank.lastHydratedAt + : new Date().toISOString(), + lastError: + stage === NeobankOnboardingStage.LookupFailed ? lastError : null, + }; + }); + return stage; + } + /** * Applies a remote autoramp snapshot from a websocket / webhook push. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 6aeecbcb6cd..8b2d56e7045 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -38,6 +38,7 @@ export type { RampsControllerCreateAutorampAction, RampsControllerRemoveAutorampAction, RampsControllerRegisterMoneyAccountWalletAction, + RampsControllerHydrateNeobankStoreAction, RampsControllerMarkAutorampAsNotifiedAction, RampsControllerApplyAutorampStatusFromPushAction, RampsControllerRefreshAutorampAction, @@ -83,6 +84,18 @@ export { RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, } from './RampsController.js'; +export type { + NeobankOnboardingDerivationInput, + NeobankState, +} from './neobank-onboarding.js'; +export { + NeobankOnboardingStage, + deriveNeobankOnboardingStage, + getDefaultNeobankState, + isAutorampCreatedStatus, + isAutorampPendingStatus, + summarizeAutorampsForWallet, +} from './neobank-onboarding.js'; export type { RampsServiceActions, RampsServiceEvents, diff --git a/packages/ramps-controller/src/neobank-onboarding.test.ts b/packages/ramps-controller/src/neobank-onboarding.test.ts new file mode 100644 index 00000000000..6636b31e49a --- /dev/null +++ b/packages/ramps-controller/src/neobank-onboarding.test.ts @@ -0,0 +1,443 @@ +import { AutorampStatus } from './autorampAccount.js'; +import type { AutorampAccount } from './autorampAccount.js'; +import { + NeobankOnboardingStage, + deriveNeobankOnboardingStage, + getDefaultNeobankState, + summarizeAutorampsForWallet, +} from './neobank-onboarding.js'; +import type { NeobankOnboardingDerivationInput } from './neobank-onboarding.js'; + +/** + * Builds a derivation input with KYC complete and wallet/autoramp skipped by + * default so each test can override the branch under assertion. + * + * @param overrides - Partial input overrides. + * @param overrides.kyc - KYC input fields to override. + * @param overrides.wallet - Wallet lookup result to override. + * @param overrides.autoramp - Autoramp summary to override. + * @returns A complete derivation input. + */ +function buildInput( + overrides: { + kyc?: Partial; + wallet?: NeobankOnboardingDerivationInput['wallet']; + autoramp?: NeobankOnboardingDerivationInput['autoramp']; + } = {}, +): NeobankOnboardingDerivationInput { + return { + kyc: { + phase: 'done', + userStatus: 'completed', + sumsubStatus: 'complete', + hasCustomerIdentity: true, + hasVendorTerms: true, + hasProviderTerms: true, + ...overrides.kyc, + }, + wallet: overrides.wallet ?? { status: 'skipped' }, + autoramp: overrides.autoramp ?? { status: 'skipped' }, + }; +} + +describe('getDefaultNeobankState', () => { + it('returns an empty neobank slice', () => { + expect(getDefaultNeobankState()).toStrictEqual({ + stage: null, + lastHydratedAt: null, + lastError: null, + }); + }); +}); + +describe('summarizeAutorampsForWallet', () => { + const base: AutorampAccount = { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xAbC', + status: AutorampStatus.Created, + lastSeenStatus: AutorampStatus.Created, + updatedAt: 1, + }; + + it('returns none when no autoramp matches the wallet', () => { + expect(summarizeAutorampsForWallet([base], '0xdef')).toStrictEqual({ + status: 'none', + }); + }); + + it('returns created when any matching autoramp is Approved', () => { + expect( + summarizeAutorampsForWallet( + [ + { ...base, status: AutorampStatus.Authorized }, + { + ...base, + id: 'ar-2', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + }, + ], + '0xabc', + ), + ).toStrictEqual({ status: 'created' }); + }); + + it('returns pending when a matching autoramp is still in progress', () => { + expect( + summarizeAutorampsForWallet( + [{ ...base, status: AutorampStatus.DepositAccountAdded }], + '0xabc', + ), + ).toStrictEqual({ status: 'pending' }); + }); + + it('returns none when matching autoramps are only rejected or cancelled', () => { + expect( + summarizeAutorampsForWallet( + [ + { + ...base, + status: AutorampStatus.Rejected, + lastSeenStatus: AutorampStatus.Rejected, + }, + ], + '0xabc', + ), + ).toStrictEqual({ status: 'none' }); + }); +}); + +describe('deriveNeobankOnboardingStage', () => { + it('returns EmailOtpRequired while the MoonPay auth phase is active', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'auth', + userStatus: null, + hasVendorTerms: false, + hasProviderTerms: false, + hasCustomerIdentity: false, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.EmailOtpRequired); + }); + + it('returns NoUser when there is no identity, no terms, and no status', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'idle', + userStatus: null, + hasVendorTerms: false, + hasProviderTerms: false, + hasCustomerIdentity: false, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.NoUser); + }); + + it('returns VendorTermsRequired when vendor T&C1 is missing', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'terms', + userStatus: 'not-started', + hasVendorTerms: false, + hasProviderTerms: false, + hasCustomerIdentity: true, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.VendorTermsRequired); + }); + + it('returns ProviderTermsRequired when T&C2 batch is incomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'terms', + userStatus: 'not-started', + hasVendorTerms: true, + hasProviderTerms: false, + hasCustomerIdentity: true, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.ProviderTermsRequired); + }); + + it('maps userStatus need-more-information to KycNeedsReview', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { userStatus: 'need-more-information', sumsubStatus: 'failed' }, + }), + ), + ).toBe(NeobankOnboardingStage.KycNeedsReview); + }); + + it('maps userStatus terminal-failure to KycRejected', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { userStatus: 'terminal-failure', sumsubStatus: 'failed' }, + }), + ), + ).toBe(NeobankOnboardingStage.KycRejected); + }); + + it('maps userStatus pending to KycPending', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { userStatus: 'pending', sumsubStatus: 'polling' }, + }), + ), + ).toBe(NeobankOnboardingStage.KycPending); + }); + + it('maps in-progress SumSub to KycStartedIncomplete before completion', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'inProgress', + phase: 'submit', + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + }); + + it('maps idle not-started KYC to KycNotStarted', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'done', + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycNotStarted); + }); + + it('returns WalletNotSigned when KYC is complete and wallet is absent', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { status: 'resolved', registration: { type: 'absent' } }, + autoramp: { status: 'none' }, + }), + ), + ).toBe(NeobankOnboardingStage.WalletNotSigned); + }); + + it('returns LookupFailed when wallet lookup is unavailable', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { status: 'lookupUnavailable', message: 'down' }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); + + it('returns AutorampNotCreated when the wallet is registered but no route exists', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }, + }, + autoramp: { status: 'none' }, + }), + ), + ).toBe(NeobankOnboardingStage.AutorampNotCreated); + }); + + it('returns AutorampPending when a standing route is not Approved', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }, + }, + autoramp: { status: 'pending' }, + }), + ), + ).toBe(NeobankOnboardingStage.AutorampPending); + }); + + it('returns AutorampCreated when a standing route is Approved', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'disabled', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: true, + isSelf: true, + }, + }, + }, + autoramp: { status: 'created' }, + }), + ), + ).toBe(NeobankOnboardingStage.AutorampCreated); + }); + + it('maps submit/session/form/check phases to KycStartedIncomplete', () => { + for (const phase of ['submit', 'session', 'form', 'check'] as const) { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase, + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + } + }); + + it('maps error phase to LookupFailed when status is incomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'error', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); + + it('maps retryable SumSub failure to KycStartedIncomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'failed', + phase: 'done', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + }); + + it('maps abandoned SumSub to KycStartedIncomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'abandoned', + phase: 'done', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + }); + + it('maps vendorProcessing SumSub to KycPending so the SDK is not relaunched', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'vendorProcessing', + phase: 'done', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycPending); + }); + + it('returns LookupFailed when KYC is complete but wallet was skipped', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { status: 'skipped' }, + autoramp: { status: 'skipped' }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); + + it('returns LookupFailed when wallet is registered but autoramp was skipped', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }, + }, + autoramp: { status: 'skipped' }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); +}); diff --git a/packages/ramps-controller/src/neobank-onboarding.ts b/packages/ramps-controller/src/neobank-onboarding.ts new file mode 100644 index 00000000000..d7ee165b220 --- /dev/null +++ b/packages/ramps-controller/src/neobank-onboarding.ts @@ -0,0 +1,294 @@ +/** + * Money Account / NeoBank onboarding stage derivation for Mobile routing. + * + * {@link RampsController.hydrateNeobankStore} refreshes authoritative KYC / + * wallet / autoramp signals, then uses {@link deriveNeobankOnboardingStage} to + * pick a single stage. This ticket returns state only — it never navigates and + * does not auto-submit wallet registration or autoramp creation (TRAM-3924). + */ + +import type { + KycPhase, + KycSumSubStatus, + KycUserStatus, +} from '@metamask/kyc-controller'; + +import { AutorampStatus } from './autorampAccount.js'; +import type { AutorampAccount } from './autorampAccount.js'; +import type { RegistrationStatus } from './wallet-registration-service.js'; + +/** + * Single Mobile-routable stage for NeoBank / Money Account onboarding. + * + * Ordered roughly by funnel position. Terms 1 = vendor disclaimers; Terms 2 = + * SumSub + idOS provider disclaimers (submitted as a batch — if either is + * missing after a partial accept, Mobile routes back to Terms 2). + */ +export enum NeobankOnboardingStage { + /** No vendor customer / identity yet. */ + NoUser = 'NoUser', + /** MoonPay Auth frame: email OTP required. */ + EmailOtpRequired = 'EmailOtpRequired', + /** Vendor T&C (Terms 1) not accepted. */ + VendorTermsRequired = 'VendorTermsRequired', + /** SumSub + idOS T&C (Terms 2) not accepted as a batch. */ + ProviderTermsRequired = 'ProviderTermsRequired', + /** KYC not started. Mobile mounts KYCPage; the page hook may launch SumSub. */ + KycNotStarted = 'KycNotStarted', + /** + * Document flow started, abandoned, or retryable SumSub failure. + * Mobile mounts KYCPage; the page hook should resume / relaunch SumSub. + * Do not launch the SDK from the router. + */ + KycStartedIncomplete = 'KycStartedIncomplete', + /** Terminal KYC failure / rejection (`userStatus === 'terminal-failure'`). */ + KycRejected = 'KycRejected', + /** Needs review / more information (EDD). Mobile mounts KYCPage, not a dead error. */ + KycNeedsReview = 'KycNeedsReview', + /** + * KYC submitted or vendor is still deciding (`userStatus === 'pending'` or + * SumSub `vendorProcessing`). Processing UI — do not launch the SDK. + */ + KycPending = 'KycPending', + /** Money Account wallet ownership proof not registered. */ + WalletNotSigned = 'WalletNotSigned', + /** No autoramp standing route yet. */ + AutorampNotCreated = 'AutorampNotCreated', + /** Autoramp exists but is not yet Approved. */ + AutorampPending = 'AutorampPending', + /** Autoramp Approved (account ready). */ + AutorampCreated = 'AutorampCreated', + /** Authoritative lookup failed; Mobile should show a retryable error. */ + LookupFailed = 'LookupFailed', +} + +/** + * Persisted / UI-facing NeoBank slice on {@link RampsController} state. + */ +export type NeobankState = { + /** Latest derived onboarding stage. */ + stage: NeobankOnboardingStage | null; + /** ISO-8601 timestamp of the last successful hydrate, or `null`. */ + lastHydratedAt: string | null; + /** Last hydrate error message when stage is {@link NeobankOnboardingStage.LookupFailed}. */ + lastError: string | null; +}; + +/** + * Inputs for {@link deriveNeobankOnboardingStage}. Built by + * {@link RampsController.hydrateNeobankStore} after refreshing remotes. + */ +export type NeobankOnboardingDerivationInput = { + kyc: { + phase: KycPhase; + userStatus: KycUserStatus | null; + sumsubStatus: KycSumSubStatus; + hasCustomerIdentity: boolean; + hasVendorTerms: boolean; + /** + * True only when both SumSub provider disclaimers and idOS disclaimers + * were accepted together (batch T&C2). + */ + hasProviderTerms: boolean; + }; + /** + * Wallet registration lookup for the Money Account address. + * `skipped` when KYC is not complete yet. + */ + wallet: + | { status: 'skipped' } + | { status: 'lookupUnavailable'; message?: string } + | { status: 'resolved'; registration: RegistrationStatus }; + /** + * Autoramp readiness for the Money Account address. + * `skipped` when KYC or wallet registration is incomplete. + */ + autoramp: + | { status: 'skipped' } + | { status: 'none' } + | { status: 'pending' } + | { status: 'created' }; +}; + +/** + * Default NeoBank slice for {@link RampsController} state. + * + * @returns Empty neobank state. + */ +export function getDefaultNeobankState(): NeobankState { + return { + stage: null, + lastHydratedAt: null, + lastError: null, + }; +} + +/** + * Whether a SumSub sub-flow status means the applicant started documents but + * has not reached a terminal outcome yet. + * + * @param status - SumSub sub-flow status. + * @returns Whether the flow is in progress. + */ +function isSumSubInProgress(status: KycSumSubStatus): boolean { + return ( + status === 'creatingSession' || + status === 'fetchingToken' || + status === 'launching' || + status === 'inProgress' || + status === 'polling' + ); +} + +/** + * Whether SumSub was started but the applicant can still resume (including a + * retryable SDK failure). Distinct from `vendorProcessing`, which must not + * relaunch the SDK. + * + * @param status - SumSub sub-flow status. + * @returns Whether Mobile should mount KYCPage and let the page hook launch. + */ +function isSumSubResumeRequired(status: KycSumSubStatus): boolean { + return ( + isSumSubInProgress(status) || status === 'abandoned' || status === 'failed' + ); +} + +/** + * Whether an autoramp status counts as "created / ready" for Mobile routing. + * + * @param status - Autoramp status. + * @returns Whether the autoramp is Approved. + */ +export function isAutorampCreatedStatus(status: AutorampStatus): boolean { + return status === AutorampStatus.Approved; +} + +/** + * Whether an autoramp status counts as still pending (exists but not ready). + * + * @param status - Autoramp status. + * @returns Whether Mobile should show a pending autoramp stage. + */ +export function isAutorampPendingStatus(status: AutorampStatus): boolean { + return ( + status !== AutorampStatus.Approved && + status !== AutorampStatus.Rejected && + status !== AutorampStatus.Cancelled + ); +} + +/** + * Summarizes local autoramp accounts for a wallet into a derivation input. + * + * @param autoramps - Local last-seen autoramp cache. + * @param walletAddress - Money Account wallet address (case-insensitive). + * @returns Autoramp branch for {@link NeobankOnboardingDerivationInput}. + */ +export function summarizeAutorampsForWallet( + autoramps: readonly AutorampAccount[], + walletAddress: string, +): NeobankOnboardingDerivationInput['autoramp'] { + const normalized = walletAddress.toLowerCase(); + const matching = autoramps.filter( + (autoramp) => autoramp.walletAddress.toLowerCase() === normalized, + ); + if (matching.length === 0) { + return { status: 'none' }; + } + if (matching.some((autoramp) => isAutorampCreatedStatus(autoramp.status))) { + return { status: 'created' }; + } + if (matching.some((autoramp) => isAutorampPendingStatus(autoramp.status))) { + return { status: 'pending' }; + } + // Only rejected/cancelled remain — treat as needing a new autoramp. + return { status: 'none' }; +} + +/** + * Derives the single Mobile-routable NeoBank onboarding stage. + * + * Priority follows the product funnel: identity / terms / KYC, then wallet + * registration, then autoramp. Safe to call twice with the same inputs. + * This returns a stage only — Mobile mounts KYCPage when terms are done and + * documents are still needed; the page hook launches SumSub. Do not launch + * the SDK from a router method call. + * + * @param input - Refreshed KYC + wallet + autoramp signals. + * @returns The onboarding stage. + */ +export function deriveNeobankOnboardingStage( + input: NeobankOnboardingDerivationInput, +): NeobankOnboardingStage { + const { kyc, wallet, autoramp } = input; + + if (kyc.phase === 'auth') { + return NeobankOnboardingStage.EmailOtpRequired; + } + + if (!kyc.hasVendorTerms) { + if (!kyc.hasCustomerIdentity && kyc.userStatus === null) { + return NeobankOnboardingStage.NoUser; + } + return NeobankOnboardingStage.VendorTermsRequired; + } + + if (!kyc.hasProviderTerms) { + return NeobankOnboardingStage.ProviderTermsRequired; + } + + if (kyc.userStatus === 'need-more-information') { + return NeobankOnboardingStage.KycNeedsReview; + } + if (kyc.userStatus === 'terminal-failure') { + return NeobankOnboardingStage.KycRejected; + } + if (kyc.userStatus === 'pending') { + return NeobankOnboardingStage.KycPending; + } + + if (kyc.userStatus !== 'completed') { + if (kyc.sumsubStatus === 'vendorProcessing') { + return NeobankOnboardingStage.KycPending; + } + if (isSumSubResumeRequired(kyc.sumsubStatus)) { + return NeobankOnboardingStage.KycStartedIncomplete; + } + if (kyc.phase === 'error') { + return NeobankOnboardingStage.LookupFailed; + } + if ( + kyc.phase === 'submit' || + kyc.phase === 'session' || + kyc.phase === 'form' || + kyc.phase === 'check' + ) { + return NeobankOnboardingStage.KycStartedIncomplete; + } + return NeobankOnboardingStage.KycNotStarted; + } + + // KYC completed — wallet / autoramp stages. + if (wallet.status === 'lookupUnavailable') { + return NeobankOnboardingStage.LookupFailed; + } + if (wallet.status === 'skipped') { + return NeobankOnboardingStage.LookupFailed; + } + if (wallet.registration.type === 'absent') { + return NeobankOnboardingStage.WalletNotSigned; + } + // active or disabled registrations both count as "signed"; disabled is a + // downstream product concern, not a missing ownership proof. + if (autoramp.status === 'skipped') { + return NeobankOnboardingStage.LookupFailed; + } + if (autoramp.status === 'none') { + return NeobankOnboardingStage.AutorampNotCreated; + } + if (autoramp.status === 'pending') { + return NeobankOnboardingStage.AutorampPending; + } + return NeobankOnboardingStage.AutorampCreated; +} diff --git a/packages/ramps-controller/tsconfig.build.json b/packages/ramps-controller/tsconfig.build.json index 4031cf9ae57..459f977c7d9 100644 --- a/packages/ramps-controller/tsconfig.build.json +++ b/packages/ramps-controller/tsconfig.build.json @@ -12,6 +12,9 @@ { "path": "../controller-utils/tsconfig.build.json" }, + { + "path": "../kyc-controller/tsconfig.build.json" + }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/packages/ramps-controller/tsconfig.json b/packages/ramps-controller/tsconfig.json index 6e32c0cd68b..10784e74996 100644 --- a/packages/ramps-controller/tsconfig.json +++ b/packages/ramps-controller/tsconfig.json @@ -7,6 +7,12 @@ { "path": "../base-controller" }, + { + "path": "../controller-utils" + }, + { + "path": "../kyc-controller" + }, { "path": "../messenger" }, @@ -15,9 +21,6 @@ }, { "path": "../remote-feature-flag-controller" - }, - { - "path": "../controller-utils" } ], "include": ["../../types", "./src"] diff --git a/yarn.lock b/yarn.lock index 43f86c63a0c..06760ceb93f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7649,7 +7649,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@npm:^0.2.0, @metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: @@ -8703,6 +8703,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^10.0.0" "@metamask/controller-utils": "npm:^13.0.0" + "@metamask/kyc-controller": "npm:^0.2.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/profile-sync-controller": "npm:^32.1.0" "@metamask/remote-feature-flag-controller": "npm:^7.0.0"