diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index a8035e8777..027babd6ed 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -177,14 +177,12 @@ classDiagram +string statusMessage +string error +string email - +string termsAcceptedAt [persisted] - +string[] acceptedDisclaimerIds [persisted] - +KycVendor termsAcceptedVendor [persisted] - +KycDisclaimer[] disclaimers - +string disclaimersError + +KycVendorDisclaimersAccepted vendorDisclaimersAccepted [persisted] + +KycDisclaimer[] vendorDisclaimers + +string vendorError +string geoCountry - +string sessionToken [secret] - +string accessToken [secret] + +string moonpaySessionToken [secret] + +string moonpayAccessToken [secret] +string moonpayCustomerId +KycProduct activeProduct +Record kycRequiredByProduct [persisted] @@ -200,14 +198,14 @@ classDiagram KycControllerState --> SumSubState : sumsub ``` -> Note: nullable fields (`error`, `email`, `sessionToken`, …) are typed as +> Note: nullable fields (`error`, `email`, `moonpaySessionToken`, …) are typed as > `T | null` in the source; `Record` is `Partial>`. > Types are simplified above for diagram readability. State metadata highlights (`kycControllerMetadata`): -- **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, - `termsAcceptedVendor`, `sumsubTncAccepted`, `idosTncAccepted`, +- **Persisted** (`persist: true`): `vendorDisclaimersAccepted`, + `providerDisclaimersAccepted`, `idosDisclaimersAccepted`, `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow can skip already-accepted terms and reuse cached results. Session-scoped `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory @@ -218,8 +216,8 @@ State metadata highlights (`kycControllerMetadata`): vendor switch commits (`createVendorCustomer` succeeds, or the MoonPay path proceeds); a failed or reset switch leaves the previous vendor's acceptance in place. -- **Secrets, never persisted / never logged**: `sessionToken`, `accessToken`, - `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. +- **Secrets, never persisted / never logged**: `moonpaySessionToken`, `moonpayAccessToken`, + `moonpayCustomerId`, `email`, `vendorDisclaimers`, and the whole `sumsub` sub-tree. Switching away from MoonPay (`initialize` / `createVendorCustomer`) drops these MoonPay Check/Auth artifacts immediately so `buildCheckFrameUrl` cannot return a MoonPay URL while `activeVendor` is a consents-path vendor. @@ -241,7 +239,7 @@ stateDiagram-v2 idle --> terms : initialize() (no saved terms) idle --> session : initialize() (saved terms + email) - terms --> session : acceptTermsAndStartSession({ sumsubTncSigned, idosTncSigned }) + terms --> session : acceptTermsAndStartSession({ providerDisclaimersAccepted, idosDisclaimersAccepted }) session --> check : createSession() ok session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) @@ -279,7 +277,7 @@ stateDiagram-v2 > or SDK close without completion) rewinds to `terms` instead of forcing `done`. > A terminal UKYC rejection after the SDK reported `Completed` still finishes as > `done` so `refreshKycStatus` can surface the decision. -> `acceptTermsAndStartSession` requires `sumsubTncSigned` and `idosTncSigned` +> `acceptTermsAndStartSession` requires `providerDisclaimersAccepted` and `idosDisclaimersAccepted` > (T&C2) for every vendor; omitted flags fail the flow instead of defaulting to > `true`. Those flags are mapped onto the session catalog's `idOS` / > `kycProvider` document records; `credentialReusabilityConsentGiven` is @@ -290,8 +288,8 @@ stateDiagram-v2 > `form`, `submit`), a repeat `initialize` or `createVendorCustomer` is a > **no-op** — it will not create a new session, switch `activeVendor`, clear > tokens, or reset `activeProduct`. Call `reset()` first to start over. -> When a switch away from MoonPay is allowed, leftover `sessionToken`, -> `accessToken`, `moonpayCustomerId`, and `#authClientToken` are cleared so +> When a switch away from MoonPay is allowed, leftover `moonpaySessionToken`, +> `moonpayAccessToken`, `moonpayCustomerId`, and `#authClientToken` are cleared so > Check/Auth URLs cannot outlive the MoonPay session. Check/Auth `complete` > messages are also ignored unless `activeVendor` is `moonpay`, so a > still-mounted MoonPay frame cannot recapture `moonpayCustomerId` under @@ -345,12 +343,12 @@ sequenceDiagram Note over Svc: map alpha-2 → alpha-3 locally Ctrl->>Svc: fetchVendorDisclaimers({ country }) Svc->>API: GET /vendors/moonpay/disclaimers?country= - Ctrl-->>UI: phase = terms (+ disclaimers) + Ctrl-->>UI: phase = terms (+ vendorDisclaimers) - User->>Ctrl: acceptTermsAndStartSession({ email, sumsubTncSigned, idosTncSigned }) + User->>Ctrl: acceptTermsAndStartSession({ email, providerDisclaimersAccepted, idosDisclaimersAccepted }) Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) Svc->>API: POST /sessions - Ctrl-->>UI: phase = check (+ sessionToken) + Ctrl-->>UI: phase = check (+ moonpaySessionToken) UI->>Ctrl: buildCheckFrameUrl() Ctrl-->>UI: URL (sessionToken + publicKey) @@ -370,7 +368,7 @@ sequenceDiagram UI->>Ctrl: handleFrameMessage(complete) end - Ctrl-->>UI: phase = form (accessToken set) + Ctrl-->>UI: phase = form (moonpayAccessToken set) Note over Ctrl: activeProduct set at initialize →
continue automatically (no user action) Ctrl->>Svc: checkKycRequired({ accessToken, country, capabilities }) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 8797bd955f..893ef6ee44 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -30,6 +30,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Rename `KycController` state field `accessToken` to `moonpayAccessToken` (MoonPay Auth-frame access token; `KycService.checkKycRequired` still accepts an `accessToken` parameter). ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Rename `KycController` state field `sessionToken` to `moonpaySessionToken` (MoonPay Check-frame session token; the Check-frame URL query parameter remains `sessionToken`). ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Replace persisted `termsAcceptedAt`, `acceptedDisclaimerIds`, and `termsAcceptedVendor` with `vendorDisclaimersAccepted`: a fixed map with `moonpay: { termsAcceptedAt } | null` and `iron: { disclaimerIds } | null` keys (default `{ moonpay: null, iron: null }`). ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Rename persisted `sumsubTncAccepted` to `providerDisclaimersAccepted` (`{ sumsub: KycConsentRecord[] | null }`) and change `acceptTermsAndStartSession` to take `providerDisclaimersAccepted: KycConsentRecord[]` instead of `sumsubTncSigned: boolean`. ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Rename `KycController` state field `disclaimers` to `vendorDisclaimers` (vendor T&Cs fetched for the current country) and `disclaimersError` to `vendorError`. ([#10062](https://github.com/MetaMask/core/pull/10062)) - Add `KycService.fetchDisclaimersCatalog` / `KycService:fetchDisclaimersCatalog` (`GET /disclaimers?country=`, ISO 3166-1 alpha-3) for the pre-session idOS + KYC-provider catalog, plus the `KycDisclaimersCatalog` type (no `credentialReusabilityConsentGiven`). `fetchSessionDisclaimers` remains session-scoped (`GET /sessions/{sessionId}/disclaimers` → `KycSessionDisclaimers`). ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `KycService.fetchDisclaimers` / `KycService:fetchDisclaimers` / `KycServiceFetchDisclaimersAction` to `fetchVendorDisclaimers` / `KycService:fetchVendorDisclaimers` / `KycServiceFetchVendorDisclaimersAction`. ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `fractalEncryptionBaseUrl` to `idosEnclaveBaseUrl`, `KycService.fetchJwks` / `KycService:fetchJwks` / `KycServiceFetchJwksAction` to `fetchIdosEnclaveJwks` / `KycService:fetchIdosEnclaveJwks` / `KycServiceFetchIdosEnclaveJwksAction`, and related Fractal encryption naming to idOS enclave. ([#10008](https://github.com/MetaMask/core/pull/10008)) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index dd4614d1a3..18d0f49778 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -62,10 +62,12 @@ export type KycControllerLoadDisclaimersAction = { * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. - * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). - * Required for every vendor so callers explicitly declare acceptance. - * @param params.idosTncSigned - Whether idOS T&C were accepted (T&C2). - * Required for every vendor so callers explicitly declare acceptance. + * @param params.providerDisclaimersAccepted - Sumsub disclaimer documents the + * customer accepted (`{ key, version }` records). Required for every vendor + * so callers explicitly declare acceptance. + * @param params.idosDisclaimersAccepted - idOS disclaimer documents the + * customer accepted (`{ key, version }` records). Required for every vendor + * so callers explicitly declare acceptance. * @param params.credentialReusabilityConsentGiven - Whether the customer * consented to reuse existing idOS credentials. Used when recording * session-scoped disclaimers on the consents path. Defaults to `false`. diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 6ecc2d0b7c..ac2cd4cb49 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -18,7 +18,12 @@ import { KycController, } from './KycController.js'; import type { KycControllerMessenger } from './KycController.js'; -import type { KycSessionDisclaimers, KycSumSubLauncher } from './types.js'; +import type { + KycConsentRecord, + KycDisclaimer, + KycSessionDisclaimers, + KycSumSubLauncher, +} from './types.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; @@ -72,6 +77,48 @@ const MOCK_SESSION_DISCLAIMERS: KycSessionDisclaimers = { credentialReusabilityConsentGiven: false, }; +const MOCK_IDOS_DISCLAIMERS_ACCEPTED: KycConsentRecord[] = + MOCK_SESSION_DISCLAIMERS.idOS.map(({ key, version }) => ({ key, version })); + +const MOCK_SUMSUB_DISCLAIMERS_ACCEPTED: KycConsentRecord[] = + MOCK_SESSION_DISCLAIMERS.kycProvider.map(({ key, version }) => ({ + key, + version, + })); + +const DEFAULT_VENDOR_DISCLAIMERS_ACCEPTED = { + moonpay: null, + iron: null, +}; + +const VENDOR_TERMS_MOONPAY = { + vendorDisclaimersAccepted: { + moonpay: { termsAcceptedAt: 't' }, + iron: null, + }, +}; + +const VENDOR_TERMS_MOONPAY_D1 = { + vendorDisclaimersAccepted: { + moonpay: { termsAcceptedAt: 't' }, + iron: null, + }, +}; + +const VENDOR_TERMS_IRON = { + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, +}; + +const VENDOR_TERMS_IRON_D1 = { + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['iron-d1'] }, + }, +}; + /** * Builds an encrypted envelope for a recipient's X25519 public key. * @@ -153,20 +200,45 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY, }, }, }, async ({ controller, handlers }) => { handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchVendorDisclaimers.mockResolvedValue([ + { id: '1', display_name: 'T', url: 'u' }, + ]); handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); await controller.initialize({ email: 'a@b.co' }); expect(controller.state.geoCountry).toBe('USA'); - expect(controller.state.sessionToken).toBe('sess'); + expect(controller.state.moonpaySessionToken).toBe('sess'); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + + it('auto-creates a session without reloading disclaimers when they are already present', async () => { + await withController( + { + options: { + state: { + ...VENDOR_TERMS_MOONPAY, + email: 'a@b.co', + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.initialize(); + + expect(handlers.fetchVendorDisclaimers).not.toHaveBeenCalled(); + expect(controller.state.moonpaySessionToken).toBe('sess'); expect(controller.state.phase).toBe('check'); }, ); @@ -179,7 +251,7 @@ describe('KycController', () => { await controller.initialize(); expect(controller.state.phase).toBe('terms'); - expect(controller.state.disclaimersError).toMatch(/Failed to load/u); + expect(controller.state.vendorError).toMatch(/Failed to load/u); }); }); @@ -215,9 +287,8 @@ describe('KycController', () => { state: { phase: 'check', email: 'a@b.co', - sessionToken: 'live-session', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], + moonpaySessionToken: 'live-session', + ...VENDOR_TERMS_MOONPAY, activeProduct: 'ramps', activeVendor: 'moonpay', moonpayCustomerId: 'cust-1', @@ -238,7 +309,7 @@ describe('KycController', () => { expect(handlers.getGeoCountry).not.toHaveBeenCalled(); expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); expect(controller.state.phase).toBe('check'); - expect(controller.state.sessionToken).toBe('live-session'); + expect(controller.state.moonpaySessionToken).toBe('live-session'); expect(controller.state.activeProduct).toBe('ramps'); expect(controller.state.email).toBe('a@b.co'); expect(controller.state.activeVendor).toBe('moonpay'); @@ -251,7 +322,7 @@ describe('KycController', () => { await withController( { options: { - state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + state: { ...VENDOR_TERMS_MOONPAY }, }, }, async ({ controller, handlers }) => { @@ -264,6 +335,72 @@ describe('KycController', () => { }, ); }); + + it('does not auto-create a session when reset() lands during disclaimer loading', async () => { + await withController( + { + options: { + state: { + ...VENDOR_TERMS_MOONPAY, + email: 'a@b.co', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + let release: (disclaimers: KycDisclaimer[]) => void = () => { + // placeholder + }; + handlers.fetchVendorDisclaimers.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.initialize(); + while (handlers.fetchVendorDisclaimers.mock.calls.length === 0) { + await Promise.resolve(); + } + controller.reset(); + release([{ id: '1', display_name: 'T', url: 'u' }]); + await pending; + + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('idle'); + }, + ); + }); + + it('requires reacceptance when Iron T&C2 flags were not persisted', async () => { + await withController( + { + options: { + state: { + ...VENDOR_TERMS_IRON, + email: 'a@b.co', + providerDisclaimersAccepted: { sumsub: null }, + idosDisclaimersAccepted: null, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.createVendorCustomer.mockResolvedValue({ + id: '1', + email: 'a@b.co', + status: 'SigningsRequired', + }); + handlers.fetchVendorDisclaimers.mockResolvedValue([ + { id: 'd1', display_name: 'T', url: 'u' }, + ]); + + await controller.initialize({ vendor: 'iron' }); + + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); }); describe('loadDisclaimers', () => { @@ -274,7 +411,7 @@ describe('KycController', () => { await controller.loadDisclaimers({ country: 'USA' }); - expect(controller.state.disclaimers).toStrictEqual(disclaimers); + expect(controller.state.vendorDisclaimers).toStrictEqual(disclaimers); expect(handlers.getGeoCountry).not.toHaveBeenCalled(); }); }); @@ -291,7 +428,7 @@ describe('KycController', () => { it('lets a later checkKycRequired reuse the overridden country without an override', async () => { await withController( - { options: { state: { accessToken: 'a' } } }, + { options: { state: { moonpayAccessToken: 'a' } } }, async ({ controller, handlers }) => { handlers.fetchVendorDisclaimers.mockResolvedValue([]); handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); @@ -348,7 +485,7 @@ describe('KycController', () => { await controller.loadDisclaimers({ country: 'USA' }); - expect(controller.state.disclaimersError).toMatch(/boom/u); + expect(controller.state.vendorError).toMatch(/boom/u); }); }); }); @@ -358,7 +495,9 @@ describe('KycController', () => { await withController( { options: { - state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + state: { + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, }, }, async ({ controller, handlers }) => { @@ -367,12 +506,16 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'ramps', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); - expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBeDefined(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); expect(controller.state.activeProduct).toBe('ramps'); expect(controller.state.phase).toBe('check'); }, @@ -385,7 +528,7 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -395,7 +538,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('error'); expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); expect(handlers.createSession).not.toHaveBeenCalled(); }, ); @@ -407,7 +550,7 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -415,14 +558,22 @@ describe('KycController', () => { handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); - expect(controller.state.sumsubTncAccepted).toBe(true); - expect(controller.state.idosTncAccepted).toBe(true); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBeDefined(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toStrictEqual(MOCK_SUMSUB_DISCLAIMERS_ACCEPTED); + expect(controller.state.idosDisclaimersAccepted).toStrictEqual( + MOCK_IDOS_DISCLAIMERS_ACCEPTED, + ); expect(controller.state.phase).toBe('check'); expect(handlers.submitVendorDisclaimers).not.toHaveBeenCalled(); }, @@ -436,9 +587,9 @@ describe('KycController', () => { state: { phase: 'check', email: 'a@b.co', - sessionToken: 'old-session', - accessToken: 'stale-access', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + moonpaySessionToken: 'old-session', + moonpayAccessToken: 'stale-access', + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -464,13 +615,13 @@ describe('KycController', () => { // Creating a new session must invalidate the carried-over auth. await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.buildAuthFrameUrl()).toBeNull(); - expect(controller.state.sessionToken).toBe('new-session'); + expect(controller.state.moonpaySessionToken).toBe('new-session'); }, ); }); @@ -481,8 +632,8 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - sessionToken: 'old-session', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + moonpaySessionToken: 'old-session', + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -499,20 +650,20 @@ describe('KycController', () => { ); const pending = controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // While the request is in flight (phase `session`) the stale token // must already be gone so no Check frame URL can be built for it. expect(controller.state.phase).toBe('session'); - expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); releaseSession({ sessionToken: 'new-session' }); await pending; - expect(controller.state.sessionToken).toBe('new-session'); + expect(controller.state.moonpaySessionToken).toBe('new-session'); await controller.initialize({ vendor: 'moonpay' }); expect(controller.buildCheckFrameUrl()).toContain( 'sessionToken=new-session', @@ -527,8 +678,8 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - sessionToken: 'old-session', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + moonpaySessionToken: 'old-session', + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -537,16 +688,16 @@ describe('KycController', () => { handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); expect(controller.state.error).toMatch(/Session creation failed/u); // A failed creation must not leave the old session token behind, so // the Check frame cannot be built against an invalid session. - expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); }, ); @@ -558,8 +709,8 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - sessionToken: 'old-session', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + moonpaySessionToken: 'old-session', + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -574,8 +725,8 @@ describe('KycController', () => { ); const pending = controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // Reset while the create request is in flight, then let it fail. The @@ -599,7 +750,7 @@ describe('KycController', () => { state: { email: 'a@b.co', activeProduct: 'card', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -609,8 +760,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ product: 'ramps', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // The failed flow must not leave a lingering product behind that a @@ -625,13 +776,15 @@ describe('KycController', () => { await withController( { options: { - state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + state: { + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, }, }, async ({ controller }) => { await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -644,8 +797,8 @@ describe('KycController', () => { await withController(async ({ controller }) => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -659,13 +812,68 @@ describe('KycController', () => { await withController( { options: { - state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + state: { ...VENDOR_TERMS_MOONPAY }, }, }, ({ controller }) => { controller.clearSavedTerms(); - expect(controller.state.termsAcceptedAt).toBeNull(); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted).toStrictEqual( + DEFAULT_VENDOR_DISCLAIMERS_ACCEPTED, + ); + }, + ); + }); + }); + + describe('acceptTermsAndStartSession (iron)', () => { + it('persists Iron disclaimer ids for vendor disclaimer submission', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.submitVendorDisclaimers.mockRejectedValue(new Error('stop')); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }); + + expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + disclaimerIds: ['d1'], + }); + }, + ); + }); + + it('clears Iron acceptance when session creation fails', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.submitVendorDisclaimers.mockRejectedValue(new Error('down')); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }); + + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); }, ); }); @@ -729,7 +937,7 @@ describe('KycController', () => { // to an idle phase) means the Check frame is no longer active; a late or // duplicate `ch_1` completion must not resurrect tokens or rewind phase. await withController( - { options: { state: { phase: 'done', sessionToken: 'tok' } } }, + { options: { state: { phase: 'done', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { const result = await controller.handleFrameMessage({ message: { @@ -744,7 +952,7 @@ describe('KycController', () => { }); expect(result).toStrictEqual({}); expect(controller.state.phase).toBe('done'); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.state.moonpayCustomerId).toBeNull(); }, ); @@ -757,7 +965,7 @@ describe('KycController', () => { state: { phase: 'check', activeVendor: 'iron', - sessionToken: 'tok', + moonpaySessionToken: 'tok', }, }, }, @@ -776,7 +984,7 @@ describe('KycController', () => { expect(result).toStrictEqual({}); expect(controller.state.phase).toBe('check'); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.state.moonpayCustomerId).toBeNull(); expect(controller.getCustomerIdentity()).toBeNull(); }, @@ -785,7 +993,7 @@ describe('KycController', () => { it('fails when credential decryption throws', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { await controller.initialize({ vendor: 'moonpay' }); await controller.handleFrameMessage({ @@ -804,7 +1012,9 @@ describe('KycController', () => { describe('check frame', () => { it('moves to form on an active status with an access token', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { + options: { state: { phase: 'check', moonpaySessionToken: 'tok' } }, + }, async ({ controller }) => { const envelope = await envelopeFor(controller, { accessToken: 'access-1', @@ -817,14 +1027,16 @@ describe('KycController', () => { }, }); expect(controller.state.phase).toBe('form'); - expect(controller.state.accessToken).toBe('access-1'); + expect(controller.state.moonpayAccessToken).toBe('access-1'); }, ); }); it('moves to auth on connectionRequired and enables the auth frame URL', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { + options: { state: { phase: 'check', moonpaySessionToken: 'tok' } }, + }, async ({ controller }) => { const envelope = await envelopeFor(controller, { clientToken: 'client-1', @@ -853,9 +1065,8 @@ describe('KycController', () => { options: { state: { phase: 'check', - sessionToken: 'tok', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], + moonpaySessionToken: 'tok', + ...VENDOR_TERMS_MOONPAY, }, }, }, @@ -868,14 +1079,18 @@ describe('KycController', () => { }, }); expect(controller.state.phase).toBe('terms'); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeNull(); }, ); }); it('fails on an unexpected status', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { + options: { state: { phase: 'check', moonpaySessionToken: 'tok' } }, + }, async ({ controller }) => { await controller.handleFrameMessage({ message: { @@ -893,7 +1108,7 @@ describe('KycController', () => { describe('auth frame', () => { it('moves to form on an active status with an access token', async () => { await withController( - { options: { state: { phase: 'auth', sessionToken: 'tok' } } }, + { options: { state: { phase: 'auth', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { const envelope = await envelopeFor(controller, { accessToken: 'access-2', @@ -906,7 +1121,7 @@ describe('KycController', () => { }, }); expect(controller.state.phase).toBe('form'); - expect(controller.state.accessToken).toBe('access-2'); + expect(controller.state.moonpayAccessToken).toBe('access-2'); }, ); }); @@ -950,7 +1165,11 @@ describe('KycController', () => { await withController( { options: { - state: { phase: 'check', sessionToken: 'tok', geoCountry: 'USA' }, + state: { + phase: 'check', + moonpaySessionToken: 'tok', + geoCountry: 'USA', + }, }, }, async ({ controller, handlers }) => { @@ -978,7 +1197,7 @@ describe('KycController', () => { options: { state: { phase: 'check', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', }, @@ -1016,7 +1235,7 @@ describe('KycController', () => { options: { state: { phase: 'auth', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'card', geoCountry: 'FRA', }, @@ -1053,7 +1272,7 @@ describe('KycController', () => { options: { state: { phase: 'check', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', }, @@ -1086,7 +1305,7 @@ describe('KycController', () => { options: { state: { phase: 'auth', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'card', geoCountry: 'FRA', }, @@ -1138,14 +1357,12 @@ describe('KycController', () => { state: { phase: 'check', email: 'a@b.co', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', // Persisted terms so a post-reset `initialize` auto-recreates the // session (reaching phase `check`) for the second completion. - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY, }, }, }, @@ -1190,6 +1407,10 @@ describe('KycController', () => { // Re-establish a product-scoped flow (auto-creates a session and // returns to phase `check`) and confirm the next completion continues // again rather than being blocked forever by a stuck guard. + handlers.fetchVendorDisclaimers.mockResolvedValue([ + { id: '1', display_name: 'T', url: 'u' }, + ]); + handlers.createSession.mockResolvedValue({ sessionToken: 'tok-2' }); await controller.initialize({ product: 'ramps' }); const envelope2 = await envelopeFor(controller, { accessToken: 'access-2', @@ -1210,7 +1431,7 @@ describe('KycController', () => { options: { state: { phase: 'check', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', }, @@ -1246,7 +1467,7 @@ describe('KycController', () => { it('builds the check frame URL with a session', async () => { await withController( - { options: { state: { sessionToken: 'tok' } } }, + { options: { state: { moonpaySessionToken: 'tok' } } }, async ({ controller }) => { await controller.initialize({ vendor: 'moonpay' }); const url = controller.buildCheckFrameUrl() as string; @@ -1261,7 +1482,7 @@ describe('KycController', () => { await withController( { options: { - state: { sessionToken: 'tok', activeVendor: 'iron' }, + state: { moonpaySessionToken: 'tok', activeVendor: 'iron' }, }, }, ({ controller }) => { @@ -1289,13 +1510,13 @@ describe('KycController', () => { expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( false, ); - expect(controller.state.error).toMatch(/Missing accessToken/u); + expect(controller.state.error).toMatch(/Missing moonpayAccessToken/u); }); }); it('fails without a country', async () => { await withController( - { options: { state: { accessToken: 'a' } } }, + { options: { state: { moonpayAccessToken: 'a' } } }, async ({ controller }) => { expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( false, @@ -1307,7 +1528,7 @@ describe('KycController', () => { it('caches the result on success (cached country)', async () => { await withController( - { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + { options: { state: { moonpayAccessToken: 'a', geoCountry: 'USA' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); @@ -1322,7 +1543,7 @@ describe('KycController', () => { it('accepts a country override', async () => { await withController( - { options: { state: { accessToken: 'a' } } }, + { options: { state: { moonpayAccessToken: 'a' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); @@ -1342,7 +1563,7 @@ describe('KycController', () => { it('fails when the service throws', async () => { await withController( - { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + { options: { state: { moonpayAccessToken: 'a', geoCountry: 'USA' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockRejectedValue(new Error('down')); @@ -1356,7 +1577,7 @@ describe('KycController', () => { it('discards a successful result when reset() runs while the check is in flight', async () => { await withController( - { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + { options: { state: { moonpayAccessToken: 'a', geoCountry: 'USA' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockImplementation(async () => { // Simulate a reset() landing while the HTTP call is in flight. @@ -1378,7 +1599,7 @@ describe('KycController', () => { it('discards an error when reset() runs while the check is in flight', async () => { await withController( - { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + { options: { state: { moonpayAccessToken: 'a', geoCountry: 'USA' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockImplementation(async () => { controller.reset(); @@ -1453,8 +1674,8 @@ describe('KycController', () => { state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', - sessionToken: 'tok', - accessToken: 'access-1', + moonpaySessionToken: 'tok', + moonpayAccessToken: 'access-1', }, }, }, @@ -1462,8 +1683,8 @@ describe('KycController', () => { await controller.initialize({ vendor: 'iron' }); expect(controller.state.moonpayCustomerId).toBeNull(); - expect(controller.state.sessionToken).toBeNull(); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); expect(controller.getCustomerIdentity()).toBeNull(); }, @@ -1477,8 +1698,8 @@ describe('KycController', () => { state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', - sessionToken: 'tok', - accessToken: 'access-1', + moonpaySessionToken: 'tok', + moonpayAccessToken: 'access-1', }, }, }, @@ -1486,8 +1707,8 @@ describe('KycController', () => { await controller.initialize({ vendor: 'moonpay' }); expect(controller.state.moonpayCustomerId).toBe('cust-1'); - expect(controller.state.sessionToken).toBe('tok'); - expect(controller.state.accessToken).toBe('access-1'); + expect(controller.state.moonpaySessionToken).toBe('tok'); + expect(controller.state.moonpayAccessToken).toBe('access-1'); expect(controller.buildCheckFrameUrl()).toContain('sessionToken=tok'); }, ); @@ -1500,8 +1721,8 @@ describe('KycController', () => { state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', - sessionToken: 'tok', - accessToken: 'access-1', + moonpaySessionToken: 'tok', + moonpayAccessToken: 'access-1', }, }, }, @@ -1512,8 +1733,8 @@ describe('KycController', () => { }); expect(controller.state.moonpayCustomerId).toBeNull(); - expect(controller.state.sessionToken).toBeNull(); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); expect(controller.getCustomerIdentity()).toBeNull(); }, @@ -1540,8 +1761,8 @@ describe('KycController', () => { state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', - sessionToken: 'tok', - accessToken: 'access-1', + moonpaySessionToken: 'tok', + moonpayAccessToken: 'access-1', }, }, }, @@ -1558,8 +1779,8 @@ describe('KycController', () => { }); expect(controller.state.moonpayCustomerId).toBe('cust-1'); - expect(controller.state.sessionToken).toBe('tok'); - expect(controller.state.accessToken).toBe('access-1'); + expect(controller.state.moonpaySessionToken).toBe('tok'); + expect(controller.state.moonpayAccessToken).toBe('access-1'); }, ); }); @@ -2343,11 +2564,10 @@ describe('KycController', () => { options: { state: { phase: 'form', - sessionToken: 'tok', - accessToken: 'a', + moonpaySessionToken: 'tok', + moonpayAccessToken: 'a', activeProduct: 'ramps', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], + ...VENDOR_TERMS_MOONPAY, kycRequiredByProduct: { ramps: true }, }, }, @@ -2355,10 +2575,12 @@ describe('KycController', () => { ({ controller }) => { controller.reset(); expect(controller.state.phase).toBe('idle'); - expect(controller.state.sessionToken).toBeNull(); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.state.activeProduct).toBeNull(); - expect(controller.state.termsAcceptedAt).toBe('t'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); expect(controller.state.kycRequiredByProduct.ramps).toBe(true); }, ); @@ -2396,16 +2618,19 @@ describe('KycController', () => { statusMessage: 'Review to submit.', error: 'stale error', email: 'a@b.co', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], - termsAcceptedVendor: 'iron', - sumsubTncAccepted: true, - idosTncAccepted: true, - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], - disclaimersError: 'stale disclaimers error', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['1'] }, + }, + providerDisclaimersAccepted: { + sumsub: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + }, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorError: 'stale disclaimers error', geoCountry: 'USA', - sessionToken: 'tok', - accessToken: 'a', + moonpaySessionToken: 'tok', + moonpayAccessToken: 'a', moonpayCustomerId: 'cus-1', activeVendor: 'iron', activeProduct: 'ramps', @@ -2458,7 +2683,7 @@ describe('KycController', () => { it('drops the auth-frame client token', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { const envelope = await envelopeFor(controller, { clientToken: 'client-1', @@ -2580,7 +2805,7 @@ describe('KycController', () => { expect(controller.state.activeVendor).toBe('iron'); expect(controller.state.activeProduct).toBe('money'); expect(controller.state.phase).toBe('terms'); - expect(controller.state.disclaimers).toHaveLength(1); + expect(controller.state.vendorDisclaimers).toHaveLength(1); }); }); @@ -2602,9 +2827,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2616,11 +2839,12 @@ describe('KycController', () => { await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); expect(controller.state.phase).toBe('error'); - expect(controller.state.termsAcceptedAt).toBe('t'); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ - 'moonpay-d1', - ]); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); }, ); }); @@ -2630,9 +2854,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2648,11 +2870,12 @@ describe('KycController', () => { }); expect(controller.state.phase).toBe('idle'); - expect(controller.state.termsAcceptedAt).toBe('t'); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ - 'moonpay-d1', - ]); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); }, ); }); @@ -2691,11 +2914,11 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['d1'], - termsAcceptedVendor: 'iron', - sumsubTncAccepted: true, - idosTncAccepted: true, + ...VENDOR_TERMS_IRON, + providerDisclaimersAccepted: { + sumsub: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + }, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }, userStatusPollIntervalMs: 60_000, }, @@ -2730,9 +2953,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2744,9 +2965,12 @@ describe('KycController', () => { await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); - expect(controller.state.termsAcceptedAt).toBeNull(); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); - expect(controller.state.termsAcceptedVendor).toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toStrictEqual( + VENDOR_TERMS_MOONPAY_D1.vendorDisclaimersAccepted.moonpay, + ); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); expect(handlers.fetchVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', country: 'USA', @@ -2761,12 +2985,10 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['d1'], - termsAcceptedVendor: 'iron', + ...VENDOR_TERMS_IRON, // T&C2 flags are null, simulating pre-migration state - sumsubTncAccepted: null, - idosTncAccepted: null, + providerDisclaimersAccepted: { sumsub: null }, + idosDisclaimersAccepted: null, }, }, }, @@ -2779,9 +3001,11 @@ describe('KycController', () => { // T&C2 flags were null; reacceptance required. expect(controller.state.phase).toBe('terms'); - expect(controller.state.termsAcceptedAt).toBeNull(); - expect(controller.state.sumsubTncAccepted).toBeNull(); - expect(controller.state.idosTncAccepted).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toBeNull(); + expect(controller.state.idosDisclaimersAccepted).toBeNull(); }, ); }); @@ -2791,9 +3015,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['iron-d1'], - termsAcceptedVendor: 'iron', + ...VENDOR_TERMS_IRON_D1, }, }, }, @@ -2801,20 +3023,21 @@ describe('KycController', () => { await controller.initialize({ email: 'a@b.co', vendor: 'moonpay' }); expect(handlers.createSession).not.toHaveBeenCalled(); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect(controller.state.vendorDisclaimersAccepted.iron).toStrictEqual( + VENDOR_TERMS_IRON_D1.vendorDisclaimersAccepted.iron, + ); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); expect(controller.state.phase).toBe('terms'); }, ); }); - it('drops another vendor terms acceptance when createVendorCustomer switches vendor', async () => { + it('preserves another vendor disclaimer acceptance when createVendorCustomer switches vendor', async () => { await withController( { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2824,9 +3047,12 @@ describe('KycController', () => { email: 'a@b.co', }); - expect(controller.state.termsAcceptedAt).toBeNull(); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); - expect(controller.state.termsAcceptedVendor).toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toStrictEqual( + VENDOR_TERMS_MOONPAY_D1.vendorDisclaimersAccepted.moonpay, + ); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); }, ); }); @@ -2837,9 +3063,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['iron-d1'], - termsAcceptedVendor: 'iron', + ...VENDOR_TERMS_IRON_D1, }, }, }, @@ -2849,10 +3073,10 @@ describe('KycController', () => { email: 'a@b.co', }); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ - 'iron-d1', - ]); - expect(controller.state.termsAcceptedVendor).toBe('iron'); + expect( + controller.state.vendorDisclaimersAccepted.iron?.disclaimerIds, + ).toStrictEqual(['iron-d1']); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeDefined(); }, ); }); @@ -2867,7 +3091,7 @@ describe('KycController', () => { phase, activeVendor: 'moonpay', moonpayCustomerId: 'cust-1', - sessionToken: 'tok', + moonpaySessionToken: 'tok', }, }, }, @@ -2896,7 +3120,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -2909,13 +3133,17 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); - expect(controller.state.termsAcceptedVendor).toBe('iron'); - expect(controller.state.sumsubTncAccepted).toBe(true); - expect(controller.state.idosTncAccepted).toBe(true); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeDefined(); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toStrictEqual(MOCK_SUMSUB_DISCLAIMERS_ACCEPTED); + expect(controller.state.idosDisclaimersAccepted).toStrictEqual( + MOCK_IDOS_DISCLAIMERS_ACCEPTED, + ); controller.reset(); }, ); @@ -2941,9 +3169,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2956,11 +3182,12 @@ describe('KycController', () => { }); expect(controller.state.phase).toBe('error'); - expect(controller.state.termsAcceptedAt).toBe('t'); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ - 'moonpay-d1', - ]); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); }, ); }); @@ -2970,9 +3197,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2999,11 +3224,12 @@ describe('KycController', () => { await pending; expect(controller.state.phase).toBe('idle'); - expect(controller.state.termsAcceptedAt).toBe('t'); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ - 'moonpay-d1', - ]); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); }, ); }); @@ -3038,7 +3264,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3068,8 +3294,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(handlers.createSession).not.toHaveBeenCalled(); @@ -3123,7 +3349,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3137,8 +3363,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, }); @@ -3160,7 +3386,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3195,8 +3421,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(handlers.fetchSessionDisclaimers).toHaveBeenCalledTimes(2); @@ -3213,7 +3439,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3243,8 +3469,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, - idosTncSigned: false, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: [], }); expect(controller.state.phase).toBe('done'); @@ -3260,7 +3486,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3288,8 +3514,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, }); @@ -3306,7 +3532,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3325,8 +3551,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -3342,7 +3568,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3376,8 +3602,11 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: [ + { key: 'idos-tos', version: '1' }, + { key: 'idos-privacy', version: '2' }, + ], }); expect(handlers.submitSessionDisclaimers).toHaveBeenCalledWith({ @@ -3397,7 +3626,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3422,8 +3651,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); @@ -3439,11 +3668,11 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, - async ({ controller, handlers }) => { + async ({ controller }) => { // @ts-expect-error T&C2 flags are required await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3452,8 +3681,9 @@ describe('KycController', () => { expect(controller.state.phase).toBe('error'); expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); - expect(controller.state.termsAcceptedAt).toBeNull(); - expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + expect(controller.state.vendorDisclaimersAccepted).toStrictEqual( + DEFAULT_VENDOR_DISCLAIMERS_ACCEPTED, + ); }, ); }); @@ -3464,7 +3694,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3472,7 +3702,7 @@ describe('KycController', () => { // @ts-expect-error both T&C2 flags are required await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -3488,7 +3718,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3502,13 +3732,15 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: false, - idosTncSigned: false, + providerDisclaimersAccepted: [], + idosDisclaimersAccepted: [], }); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); - expect(controller.state.sumsubTncAccepted).toBe(false); - expect(controller.state.idosTncAccepted).toBe(false); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toStrictEqual([]); + expect(controller.state.idosDisclaimersAccepted).toStrictEqual([]); expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', disclaimerIds: ['d1'], @@ -3525,14 +3757,14 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, async ({ controller }) => { await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -3548,15 +3780,15 @@ describe('KycController', () => { state: { activeVendor: 'iron', email: 'a@b.co', - disclaimers: [], + vendorDisclaimers: [], }, }, }, async ({ controller }) => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -3573,7 +3805,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3585,12 +3817,12 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); expect(controller.state.error).toMatch(/Consents session failed/u); }, ); @@ -3602,7 +3834,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3612,8 +3844,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -3628,7 +3860,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3642,14 +3874,14 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); expect(controller.state.sumsub.status).toBe('idle'); expect(controller.state.sumsub.sessionId).toBeNull(); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); expect(controller.state.error).toMatch(/Consents session failed/u); expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); }, @@ -3662,7 +3894,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3681,8 +3913,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('done'); @@ -3690,7 +3922,9 @@ describe('KycController', () => { expect(controller.state.sumsub.sessionStatus).toStrictEqual( sessionStatus('rejected'), ); - expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.iron?.disclaimerIds, + ).toStrictEqual(['d1']); expect(controller.state.error).toBeNull(); expect(handlers.fetchKycStatus).toHaveBeenCalled(); expect(controller.state.userStatus).toBe('terminal-failure'); @@ -3705,7 +3939,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3719,8 +3953,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('done'); @@ -3736,7 +3970,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3754,8 +3988,8 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); controller.reset(); release(); @@ -3773,7 +4007,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3790,8 +4024,8 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // Session create + session disclaimers run first; wait until launch // is pending so reset races with an in-flight SDK presentation. @@ -3814,7 +4048,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3830,8 +4064,8 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); while (handlers.createUkycSession.mock.calls.length === 0) { await Promise.resolve(); @@ -3852,7 +4086,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3868,8 +4102,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(handlers.fetchSessionDisclaimers).toHaveBeenCalledWith({ @@ -3889,7 +4123,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3909,8 +4143,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -3925,7 +4159,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3937,8 +4171,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -3953,7 +4187,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3965,8 +4199,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -3984,7 +4218,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3996,8 +4230,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -4012,7 +4246,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4027,8 +4261,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -4046,7 +4280,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4060,8 +4294,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -4081,7 +4315,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4095,8 +4329,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -4115,7 +4349,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4140,8 +4374,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -4158,7 +4392,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4170,8 +4404,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -4186,7 +4420,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4198,8 +4432,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -4214,7 +4448,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4226,8 +4460,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -4591,7 +4825,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -4604,8 +4838,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('done'); @@ -4621,7 +4855,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -4633,8 +4867,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, - idosTncSigned: true, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('done'); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 700a490a59..38c8dc3a0d 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -25,16 +25,19 @@ import type { import { controllerLog } from './logger.js'; import type { KycConsentDocument, + KycConsentRecord, KycCustomerIdentity, KycDisclaimer, KycPhase, KycProduct, + KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, KycSumSubLauncher, KycSumSubStatus, KycUserStatus, KycVendor, + KycVendorDisclaimersAccepted, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; @@ -46,6 +49,12 @@ import { signStorageAccessToken, } from './ukyc/storageAccessToken.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; +import { + clearVendorDisclaimerAcceptance, + hasVendorDisclaimerAcceptance, + ironDisclaimerIds, + recordVendorDisclaimerAcceptance, +} from './vendorDisclaimerAcceptance.js'; // === GENERAL === @@ -149,30 +158,27 @@ export type KycControllerState = { /** Email associated with the session (sourced from the account). */ email: string | null; - /** ISO-8601 timestamp of the customer's terms acceptance (persisted). */ - termsAcceptedAt: string | null; - /** IDs of the disclaimers the customer accepted (persisted). */ - acceptedDisclaimerIds: string[]; /** - * The vendor whose disclaimers `acceptedDisclaimerIds` belong to (persisted). - * Each vendor serves its own disclaimer set, so acceptance recorded for one - * vendor must not be reused for another. `null` when nothing is accepted. + * Persisted vendor-disclaimer acceptance (T&C1) with fixed `moonpay` and + * `iron` keys. MoonPay stores only `termsAcceptedAt`; Iron stores + * `disclaimerIds`. */ - termsAcceptedVendor: KycVendor | null; + vendorDisclaimersAccepted: KycVendorDisclaimersAccepted; /** - * Whether the customer accepted the SumSub T&C (T&C2) during the last - * terms acceptance (persisted). Consents-path vendors require this flag - * when resuming a session. `null` for acceptance recorded before this - * field existed (treated as requiring reacceptance). + * KYC-provider disclaimer documents the customer accepted during the last + * terms acceptance (persisted `{ key, version }` records under `sumsub`). + * Consents-path vendors require this when resuming a session. `null` for + * acceptance recorded before this field existed (treated as requiring + * reacceptance). */ - sumsubTncAccepted: boolean | null; + providerDisclaimersAccepted: KycProviderDisclaimersAccepted; /** - * Whether the customer accepted the idOS T&C (T&C2) during the last - * terms acceptance (persisted). Consents-path vendors require this flag - * when resuming a session. `null` for acceptance recorded before this - * field existed (treated as requiring reacceptance). + * idOS disclaimer documents the customer accepted during the last terms + * acceptance (persisted `{ key, version }` records). Consents-path vendors + * require this when resuming a session. `null` for acceptance recorded + * before this field existed (treated as requiring reacceptance). */ - idosTncAccepted: boolean | null; + idosDisclaimersAccepted: KycConsentRecord[] | null; /** * Whether the customer consented to reuse existing idOS credentials * during this session. Applied when recording session-scoped disclaimers. @@ -181,10 +187,10 @@ export type KycControllerState = { */ credentialReusabilityConsentGiven: boolean | null; - /** Disclaimers fetched for the current country. */ - disclaimers: KycDisclaimer[]; - /** Error encountered while loading disclaimers, or `null`. */ - disclaimersError: string | null; + /** Vendor disclaimers fetched for the current country. */ + vendorDisclaimers: KycDisclaimer[]; + /** Error encountered while loading vendor disclaimers, or `null`. */ + vendorError: string | null; /** * idOS / KYC-provider disclaimer catalog from `GET /disclaimers` or * `GET /sessions/{sessionId}/disclaimers`. `null` until the catalog has @@ -195,10 +201,10 @@ export type KycControllerState = { /** Resolved ISO 3166-1 alpha-3 country code. */ geoCountry: string | null; - /** Vendor session token (not persisted, not logged). */ - sessionToken: string | null; - /** Vendor access token (not persisted, not logged). */ - accessToken: string | null; + /** MoonPay session token (not persisted, not logged). */ + moonpaySessionToken: string | null; + /** MoonPay access token (not persisted, not logged). */ + moonpayAccessToken: string | null; /** Vendor customer id, used for the SumSub hand-off. */ moonpayCustomerId: string | null; @@ -273,31 +279,19 @@ const kycControllerMetadata = { persist: false, usedInUi: false, }, - termsAcceptedAt: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - acceptedDisclaimerIds: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - termsAcceptedVendor: { + vendorDisclaimersAccepted: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: true, usedInUi: false, }, - sumsubTncAccepted: { + providerDisclaimersAccepted: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: true, usedInUi: false, }, - idosTncAccepted: { + idosDisclaimersAccepted: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: true, @@ -309,13 +303,13 @@ const kycControllerMetadata = { persist: false, usedInUi: false, }, - disclaimers: { + vendorDisclaimers: { includeInDebugSnapshot: false, includeInStateLogs: false, persist: false, usedInUi: true, }, - disclaimersError: { + vendorError: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: false, @@ -333,13 +327,13 @@ const kycControllerMetadata = { persist: false, usedInUi: true, }, - sessionToken: { + moonpaySessionToken: { includeInDebugSnapshot: false, includeInStateLogs: false, persist: false, usedInUi: false, }, - accessToken: { + moonpayAccessToken: { includeInDebugSnapshot: false, includeInStateLogs: false, persist: false, @@ -401,6 +395,19 @@ const kycControllerMetadata = { }, } satisfies StateMetadata; +/** + * Constructs the default {@link KycVendorDisclaimersAccepted} value. + * + * @returns The default vendor-disclaimer acceptance map. + */ +export function getDefaultKycVendorDisclaimersAccepted(): KycVendorDisclaimersAccepted { + return { moonpay: null, iron: null }; +} + +export function getDefaultKycProviderDisclaimersAccepted(): KycProviderDisclaimersAccepted { + return { sumsub: null }; +} + /** * Constructs the default {@link KycController} state. * @@ -412,18 +419,16 @@ export function getDefaultKycControllerState(): KycControllerState { statusMessage: '', error: null, email: null, - termsAcceptedAt: null, - acceptedDisclaimerIds: [], - termsAcceptedVendor: null, - sumsubTncAccepted: null, - idosTncAccepted: null, + vendorDisclaimersAccepted: getDefaultKycVendorDisclaimersAccepted(), + providerDisclaimersAccepted: getDefaultKycProviderDisclaimersAccepted(), + idosDisclaimersAccepted: null, credentialReusabilityConsentGiven: null, - disclaimers: [], - disclaimersError: null, + vendorDisclaimers: [], + vendorError: null, sessionDisclaimers: null, geoCountry: null, - sessionToken: null, - accessToken: null, + moonpaySessionToken: null, + moonpayAccessToken: null, moonpayCustomerId: null, activeVendor: 'moonpay', activeProduct: null, @@ -472,57 +477,89 @@ function isConsentConflictError(error: unknown): boolean { } /** - * Maps a session-disclaimer catalog into the `{ key, version }` records the - * record-consents API expects, or an empty list when the user declined that - * category. + * + * @param value - The value to validate. + * @returns `true` when `value` is a valid consent record list. + */ +function isValidConsentRecordList(value: unknown): value is KycConsentRecord[] { + return ( + Array.isArray(value) && + value.every( + (item) => + typeof item === 'object' && + item !== null && + typeof (item as KycConsentRecord).key === 'string' && + typeof (item as KycConsentRecord).version === 'string', + ) + ); +} + +/** + * Maps accepted disclaimer records onto unconsented catalog documents. * * @param documents - Catalog documents for one consent category. - * @param accepted - Whether the user accepted that category. - * @returns Consent records, or `[]` when not accepted. + * @param accepted - Accepted `{ key, version }` records from the caller. + * @returns Consent records to POST, omitting already-consented documents. */ -function consentRecordsFromCatalog( +function consentRecordsFromAcceptedList( documents: KycConsentDocument[], - accepted: boolean, -): { key: string; version: string }[] { - if (!accepted) { + accepted: KycConsentRecord[], +): KycConsentRecord[] { + if (accepted.length === 0) { return []; } + const acceptedKeys = new Set( + accepted.map((record) => `${record.key}:${record.version}`), + ); return documents - .filter((document) => !document.consented) + .filter( + (document) => + !document.consented && + acceptedKeys.has(`${document.key}:${document.version}`), + ) .map(({ key, version }) => ({ key, version })); } /** - * Whether an accepted T&C2 category has no catalog documents. An empty list - * would otherwise skip the POST and count as success. + * Whether accepted disclaimers reference a missing catalog category. * * @param documents - Catalog documents for one consent category. - * @param accepted - Whether the user accepted that category. - * @returns `true` when the user accepted and the catalog is empty. + * @param accepted - Accepted `{ key, version }` records from the caller. + * @returns `true` when the caller accepted docs but the catalog is empty. */ function isAcceptedCategoryEmpty( documents: KycConsentDocument[], - accepted: boolean, + accepted: KycConsentRecord[], ): boolean { - return accepted && documents.length === 0; + return accepted.length > 0 && documents.length === 0; } /** - * Whether an accepted category is still missing consent after a 409 re-GET: - * empty catalog or any document still unconsented. + * Whether accepted disclaimers are still missing consent after a 409 re-GET: + * empty catalog or any accepted document still unconsented. * * @param documents - Latest catalog documents for one consent category. - * @param accepted - Whether the user accepted that category. + * @param accepted - Accepted `{ key, version }` records from the caller. * @returns `true` when accepted documents are not fully consented. */ function acceptedCategoryStillMissing( documents: KycConsentDocument[], - accepted: boolean, + accepted: KycConsentRecord[], ): boolean { + if (accepted.length === 0) { + return false; + } + if (documents.length === 0) { + return true; + } + const acceptedKeys = new Set( + accepted.map((record) => `${record.key}:${record.version}`), + ); + const relevant = documents.filter((document) => + acceptedKeys.has(`${document.key}:${document.version}`), + ); return ( - accepted && - (documents.length === 0 || - documents.some((document) => !document.consented)) + relevant.length === 0 || relevant.some((document) => !document.consented) ); } @@ -828,8 +865,8 @@ export class KycController extends BaseController< } state.activeVendor = vendor; // MoonPay Check/Auth artifacts must not survive a switch to another - // vendor: leftover `sessionToken` would keep `buildCheckFrameUrl` alive, - // leftover `accessToken` / `#authClientToken` would keep Auth / KYC + // vendor: leftover `moonpaySessionToken` would keep `buildCheckFrameUrl` alive, + // leftover `moonpayAccessToken` / `#authClientToken` would keep Auth / KYC // calls bound to MoonPay, and leftover `moonpayCustomerId` would make // `getCustomerIdentity` report a MoonPay id under the wrong vendor. if (vendor !== 'moonpay') { @@ -885,19 +922,21 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return; } - this.#dropTermsUnlessForVendor(vendor); - - const hasTerms = - Boolean(this.state.termsAcceptedAt) && - this.state.acceptedDisclaimerIds.length > 0; + const hasTerms = hasVendorDisclaimerAcceptance( + this.state.vendorDisclaimersAccepted, + vendor, + ); if (hasTerms && this.state.email) { if (usesConsentsFlow(vendor)) { // Consents-path vendors require T&C2 flags; if they weren't persisted // (i.e. null from pre-migration state), require reacceptance. - const sumsubTncSigned = this.state.sumsubTncAccepted; - const idosTncSigned = this.state.idosTncAccepted; - if (sumsubTncSigned === null || idosTncSigned === null) { + const { providerDisclaimersAccepted, idosDisclaimersAccepted } = + this.state; + if ( + providerDisclaimersAccepted.sumsub === null || + idosDisclaimersAccepted === null + ) { this.#applyUpdate((state) => { this.#clearAcceptedTerms(state); state.phase = 'terms'; @@ -906,12 +945,19 @@ export class KycController extends BaseController< return; } await this.#startConsentsSession({ - sumsubTncSigned, - idosTncSigned, + providerDisclaimersAccepted: providerDisclaimersAccepted.sumsub, + idosDisclaimersAccepted, credentialReusabilityConsentGiven: this.state.credentialReusabilityConsentGiven ?? false, }); } else { + // TODO: should this be here? or should it exist at all? + if (vendor === 'moonpay' && this.state.vendorDisclaimers.length === 0) { + await this.loadDisclaimers(); + if (this.#generation !== generation) { + return; + } + } await this.#createSession(); } return; @@ -962,10 +1008,6 @@ export class KycController extends BaseController< vendor: params.vendor, email: params.email, }); - if (this.#generation !== generation) { - return; - } - this.#dropTermsUnlessForVendor(params.vendor); } catch (error) { if (this.#generation !== generation) { return; @@ -1003,12 +1045,12 @@ export class KycController extends BaseController< }, ); this.#updateIfCurrent(generation, (state) => { - state.disclaimers = disclaimers; - state.disclaimersError = null; + state.vendorDisclaimers = disclaimers; + state.vendorError = null; }); } catch (error) { this.#updateIfCurrent(generation, (state) => { - state.disclaimersError = `Failed to load disclaimers: ${String(error)}`; + state.vendorError = `Failed to load disclaimers: ${String(error)}`; }); } } @@ -1022,56 +1064,63 @@ export class KycController extends BaseController< * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. - * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). - * Required for every vendor so callers explicitly declare acceptance. - * @param params.idosTncSigned - Whether idOS T&C were accepted (T&C2). - * Required for every vendor so callers explicitly declare acceptance. + * @param params.providerDisclaimersAccepted - Sumsub disclaimer documents the + * customer accepted (`{ key, version }` records). Required for every vendor + * so callers explicitly declare acceptance. + * @param params.idosDisclaimersAccepted - idOS disclaimer documents the + * customer accepted (`{ key, version }` records). Required for every vendor + * so callers explicitly declare acceptance. * @param params.credentialReusabilityConsentGiven - Whether the customer * consented to reuse existing idOS credentials. Used when recording * session-scoped disclaimers on the consents path. Defaults to `false`. */ - async acceptTermsAndStartSession(params: { + async acceptTermsAndStartSession(params?: { email?: string; product?: KycProduct; - sumsubTncSigned: boolean; - idosTncSigned: boolean; + providerDisclaimersAccepted: KycConsentRecord[]; + idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven?: boolean; }): Promise { - const sumsubTncSigned = params?.sumsubTncSigned; - const idosTncSigned = params?.idosTncSigned; + const providerDisclaimersAccepted = params?.providerDisclaimersAccepted; + const idosDisclaimersAccepted = params?.idosDisclaimersAccepted; if ( - typeof sumsubTncSigned !== 'boolean' || - typeof idosTncSigned !== 'boolean' + !isValidConsentRecordList(providerDisclaimersAccepted) || + !isValidConsentRecordList(idosDisclaimersAccepted) ) { this.#fail('Missing T&C2 acceptance flags.'); return; } const credentialReusabilityConsentGiven = - params.credentialReusabilityConsentGiven ?? false; + params?.credentialReusabilityConsentGiven ?? false; const termsAcceptedAt = new Date().toISOString(); - const disclaimerIds = this.state.disclaimers.map( + const disclaimerIds = this.state.vendorDisclaimers.map( (disclaimer) => disclaimer.id, ); this.#applyUpdate((state) => { - if (params.email) { + if (params?.email) { state.email = params.email; } - if (params.product) { + if (params?.product) { state.activeProduct = params.product; } - state.termsAcceptedAt = termsAcceptedAt; - state.acceptedDisclaimerIds = disclaimerIds; - state.termsAcceptedVendor = state.activeVendor; - state.sumsubTncAccepted = sumsubTncSigned; - state.idosTncAccepted = idosTncSigned; + state.vendorDisclaimersAccepted = recordVendorDisclaimerAcceptance( + state.vendorDisclaimersAccepted, + state.activeVendor, + { termsAcceptedAt, disclaimerIds }, + ); + state.providerDisclaimersAccepted = { + ...state.providerDisclaimersAccepted, + sumsub: providerDisclaimersAccepted, + }; + state.idosDisclaimersAccepted = idosDisclaimersAccepted; state.credentialReusabilityConsentGiven = credentialReusabilityConsentGiven; }); if (usesConsentsFlow(this.state.activeVendor)) { await this.#startConsentsSession({ - sumsubTncSigned, - idosTncSigned, + providerDisclaimersAccepted, + idosDisclaimersAccepted, credentialReusabilityConsentGiven, }); return; @@ -1085,17 +1134,20 @@ export class KycController extends BaseController< * SumSub — skipping MoonPay Check/Auth frames. * * @param consents - T&C2 flags mapped onto the session disclaimer catalog. - * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. - * @param consents.idosTncSigned - Whether idOS T&C were accepted. + * @param consents.providerDisclaimersAccepted - Accepted Sumsub disclaimer records. + * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param consents.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. */ async #startConsentsSession(consents: { - sumsubTncSigned: boolean; - idosTncSigned: boolean; + providerDisclaimersAccepted: KycConsentRecord[]; + idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }): Promise { - const { email, acceptedDisclaimerIds } = this.state; + const { email } = this.state; + const acceptedDisclaimerIds = ironDisclaimerIds( + this.state.vendorDisclaimersAccepted, + ); if (!email) { this.#fail('Missing email for consents session.'); return; @@ -1242,8 +1294,8 @@ export class KycController extends BaseController< * * @param sessionId - The UKYC session id. * @param consents - T&C2 flags mapped onto catalog documents. - * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. - * @param consents.idosTncSigned - Whether idOS T&C were accepted. + * @param consents.providerDisclaimersAccepted - Accepted Sumsub disclaimer records. + * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param consents.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. * @param generation - Flow generation captured by the caller. @@ -1251,8 +1303,8 @@ export class KycController extends BaseController< async #recordSessionDisclaimers( sessionId: string, consents: { - sumsubTncSigned: boolean; - idosTncSigned: boolean; + providerDisclaimersAccepted: KycConsentRecord[]; + idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }, generation: number, @@ -1270,21 +1322,24 @@ export class KycController extends BaseController< }); if ( - isAcceptedCategoryEmpty(catalog.idOS, consents.idosTncSigned) || - isAcceptedCategoryEmpty(catalog.kycProvider, consents.sumsubTncSigned) + isAcceptedCategoryEmpty(catalog.idOS, consents.idosDisclaimersAccepted) || + isAcceptedCategoryEmpty( + catalog.kycProvider, + consents.providerDisclaimersAccepted, + ) ) { throw new Error( 'Session disclaimer catalog is missing documents for an accepted category.', ); } - const idOS = consentRecordsFromCatalog( + const idOS = consentRecordsFromAcceptedList( catalog.idOS, - consents.idosTncSigned, + consents.idosDisclaimersAccepted, ); - const kycProvider = consentRecordsFromCatalog( + const kycProvider = consentRecordsFromAcceptedList( catalog.kycProvider, - consents.sumsubTncSigned, + consents.providerDisclaimersAccepted, ); const reuseUnchanged = catalog.credentialReusabilityConsentGiven === @@ -1324,13 +1379,14 @@ export class KycController extends BaseController< this.#applyUpdate((state) => { state.sessionDisclaimers = latest; }); + // TODO: Should we really be doing client side validation of these? const stillMissingIdos = acceptedCategoryStillMissing( latest.idOS, - consents.idosTncSigned, + consents.idosDisclaimersAccepted, ); const stillMissingProvider = acceptedCategoryStillMissing( latest.kycProvider, - consents.sumsubTncSigned, + consents.providerDisclaimersAccepted, ); const stillMissingReuse = consents.credentialReusabilityConsentGiven && @@ -1345,7 +1401,12 @@ export class KycController extends BaseController< * Creates a vendor session from the currently stored terms + email. */ async #createSession(): Promise { - const { email, termsAcceptedAt, acceptedDisclaimerIds } = this.state; + const { email } = this.state; + const termsAcceptedAt = + this.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt; + const acceptedDisclaimerIds = this.state.vendorDisclaimers.map( + (disclaimer) => disclaimer.id, + ); if (!email) { this.#fail('Missing email for session creation.'); return; @@ -1361,7 +1422,7 @@ export class KycController extends BaseController< // (or, on failure, invalid) session token, `buildAuthFrameUrl` cannot // return a URL tied to an old client token, and `checkKycRequired` cannot // run with an access token from an earlier authentication. The Check/Auth - // frames re-populate these for the new session. Because `sessionToken` is + // frames re-populate these for the new session. Because `moonpaySessionToken` is // cleared here and only re-set on success, a failed creation leaves it // `null` rather than resurrecting the previous session. // Capture the flow generation so a `reset()` landing while the create @@ -1374,8 +1435,8 @@ export class KycController extends BaseController< state.error = null; state.phase = 'session'; state.statusMessage = 'Creating session...'; - state.sessionToken = null; - state.accessToken = null; + state.moonpaySessionToken = null; + state.moonpayAccessToken = null; }); try { @@ -1384,7 +1445,7 @@ export class KycController extends BaseController< { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds }, ); this.#updateIfCurrent(generation, (state) => { - state.sessionToken = sessionToken; + state.moonpaySessionToken = sessionToken; state.phase = 'check'; state.statusMessage = 'Authenticating via Check frame...'; }); @@ -1416,7 +1477,12 @@ export class KycController extends BaseController< */ clearSavedTerms(): void { this.#applyUpdate((state) => { - this.#clearAcceptedTerms(state); + state.vendorDisclaimersAccepted = + getDefaultKycVendorDisclaimersAccepted(); + state.providerDisclaimersAccepted = + getDefaultKycProviderDisclaimersAccepted(); + state.idosDisclaimersAccepted = null; + state.credentialReusabilityConsentGiven = null; }); } @@ -1428,13 +1494,18 @@ export class KycController extends BaseController< * rest of the flow (geolocation, disclaimers, phase) untouched. * * @param state - The state to mutate. + * @param vendor - Vendor whose acceptance to clear. Defaults to + * `state.activeVendor`. */ - #clearAcceptedTerms(state: KycControllerState): void { - state.termsAcceptedAt = null; - state.acceptedDisclaimerIds = []; - state.termsAcceptedVendor = null; - state.sumsubTncAccepted = null; - state.idosTncAccepted = null; + #clearAcceptedTerms(state: KycControllerState, vendor?: KycVendor): void { + const targetVendor = vendor ?? state.activeVendor; + state.vendorDisclaimersAccepted = clearVendorDisclaimerAcceptance( + state.vendorDisclaimersAccepted, + targetVendor, + ); + state.providerDisclaimersAccepted = + getDefaultKycProviderDisclaimersAccepted(); + state.idosDisclaimersAccepted = null; state.credentialReusabilityConsentGiven = null; } @@ -1448,41 +1519,8 @@ export class KycController extends BaseController< */ #clearMoonPaySession(state: KycControllerState): void { state.moonpayCustomerId = null; - state.sessionToken = null; - state.accessToken = null; - } - - /** - * Drops persisted terms acceptance when it does not belong to `vendor`. - * Callers must invoke this only after the vendor switch has committed - * (e.g. `createVendorCustomer` succeeded) so a failed or reset switch - * cannot erase another vendor's stored acceptance. - * - * @param vendor - The vendor that now owns the flow. - */ - #dropTermsUnlessForVendor(vendor: KycVendor): void { - if (this.#hasTermsForVendor(vendor)) { - return; - } - this.#applyUpdate((state) => { - this.#clearAcceptedTerms(state); - }); - } - - /** - * Determines whether the stored terms acceptance belongs to the given - * vendor. Acceptance persisted before `termsAcceptedVendor` existed - * (indicated by `null`) is invalidated to force reacceptance, ensuring users - * re-review vendor terms after the multi-vendor upgrade. - * - * @param vendor - The vendor about to drive the flow. - * @returns `true` when the stored acceptance can be reused for `vendor`. - */ - #hasTermsForVendor(vendor: KycVendor): boolean { - if (this.state.termsAcceptedVendor === null) { - return false; - } - return this.state.termsAcceptedVendor === vendor; + state.moonpaySessionToken = null; + state.moonpayAccessToken = null; } /** @@ -1590,7 +1628,7 @@ export class KycController extends BaseController< ): Promise { if (status === 'active' && accessToken) { this.#applyUpdate((state) => { - state.accessToken = accessToken; + state.moonpayAccessToken = accessToken; state.phase = 'form'; state.statusMessage = 'Already authenticated. Review to submit.'; }); @@ -1624,7 +1662,7 @@ export class KycController extends BaseController< ): Promise { if (status === 'active' && accessToken) { this.#applyUpdate((state) => { - state.accessToken = accessToken; + state.moonpayAccessToken = accessToken; state.phase = 'form'; state.statusMessage = 'Authenticated. Review to submit.'; }); @@ -1699,13 +1737,13 @@ export class KycController extends BaseController< buildCheckFrameUrl(): string | null { if ( this.state.activeVendor !== 'moonpay' || - !this.state.sessionToken || + !this.state.moonpaySessionToken || !this.#moonpayFrameKeypair ) { return null; } const url = new URL(`${FRAMES_BASE_URL}/check-connection`); - url.searchParams.set('sessionToken', this.state.sessionToken); + url.searchParams.set('sessionToken', this.state.moonpaySessionToken); url.searchParams.set('publicKey', this.#moonpayFrameKeypair.publicKeyHex); url.searchParams.set('channelId', CHANNEL_CHECK); url.searchParams.set('skipKyc', 'true'); @@ -1755,9 +1793,11 @@ export class KycController extends BaseController< product: KycProduct; country?: string; }): Promise { - const { accessToken } = this.state; - if (!accessToken) { - this.#fail('Missing accessToken — repeat the authentication step.'); + const { moonpayAccessToken } = this.state; + if (!moonpayAccessToken) { + this.#fail( + 'Missing moonpayAccessToken — repeat the authentication step.', + ); return false; } const country = params.country ?? this.state.geoCountry; @@ -1778,7 +1818,11 @@ export class KycController extends BaseController< try { const { kycRequired } = await this.messenger.call( 'KycService:checkKycRequired', - { accessToken, country, capabilities: [{ product: params.product }] }, + { + accessToken: moonpayAccessToken, + country, + capabilities: [{ product: params.product }], + }, ); // The flow was reset while the check was in flight; discard the result // rather than resurrecting a done/cached state on an idle controller. @@ -1855,7 +1899,7 @@ export class KycController extends BaseController< return { vendor: 'moonpay', vendorMetadata: { - moonPayAccessToken: this.state.accessToken, + moonPayAccessToken: this.state.moonpayAccessToken, moonPayUserId: this.state.moonpayCustomerId, }, }; @@ -2458,12 +2502,12 @@ export class KycController extends BaseController< state.phase = 'idle'; state.statusMessage = ''; state.error = null; - state.disclaimers = []; - state.disclaimersError = null; + state.vendorDisclaimers = []; + state.vendorError = null; state.sessionDisclaimers = null; state.credentialReusabilityConsentGiven = null; - state.sessionToken = null; - state.accessToken = null; + state.moonpaySessionToken = null; + state.moonpayAccessToken = null; state.moonpayCustomerId = null; state.activeVendor = 'moonpay'; state.activeProduct = null; diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 484e59a807..44c0501602 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -492,7 +492,7 @@ export class KycService extends BaseDataService< // Guard nullish/empty geolocation with the documented domain error rather // than letting `assert(location, string())` surface a superstruct // assertion error (which would change how the failure reads in - // `disclaimersError`). + // `vendorError`). const alpha2 = typeof location === 'string' ? location.split('-')[0].toUpperCase() : ''; if (!alpha2 || alpha2 === 'UNKNOWN') { diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index fdfb9d3fe7..6992d8dcfc 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -1,6 +1,8 @@ export { KycController, getDefaultKycControllerState, + getDefaultKycProviderDisclaimersAccepted, + getDefaultKycVendorDisclaimersAccepted, controllerName, } from './KycController.js'; export type { @@ -101,6 +103,7 @@ export type { KycDisclaimersCatalog, KycPhase, KycProduct, + KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, KycSumSubLaunchParams, @@ -109,6 +112,9 @@ export type { KycUserStatus, KycUserStatusResponse, KycVendor, + KycIronVendorDisclaimersAccepted, + KycMoonpayVendorDisclaimersAccepted, + KycVendorDisclaimersAccepted, KycVendorSigning, } from './types.js'; diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 240f8d6eb0..db2b340f35 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -192,6 +192,40 @@ export type KycConsentRecord = { version: string; }; +/** + * MoonPay vendor T&C1 acceptance persisted under + * {@link KycVendorDisclaimersAccepted.moonpay}. + */ +export type KycMoonpayVendorDisclaimersAccepted = { + /** ISO-8601 timestamp of terms acceptance for MoonPay. */ + termsAcceptedAt: string; +}; + +/** + * Iron vendor T&C1 acceptance persisted under + * {@link KycVendorDisclaimersAccepted.iron}. + */ +export type KycIronVendorDisclaimersAccepted = { + /** IDs of Iron vendor disclaimers the customer accepted. */ + disclaimerIds: string[]; +}; + +/** + * Persisted KYC-provider disclaimer acceptance (T&C2) with a fixed `sumsub` + * key. + */ +export type KycProviderDisclaimersAccepted = { + sumsub: KycConsentRecord[] | null; +}; + +/** + * Persisted vendor-disclaimer acceptance with fixed `moonpay` and `iron` keys. + */ +export type KycVendorDisclaimersAccepted = { + moonpay: KycMoonpayVendorDisclaimersAccepted | null; + iron: KycIronVendorDisclaimersAccepted | null; +}; + /** * idOS / KYC-provider disclaimer catalog returned by * `GET /disclaimers?country=` (no session — no credential-reuse consent state). diff --git a/packages/kyc-controller/src/vendorDisclaimerAcceptance.test.ts b/packages/kyc-controller/src/vendorDisclaimerAcceptance.test.ts new file mode 100644 index 0000000000..3458b3d8cc --- /dev/null +++ b/packages/kyc-controller/src/vendorDisclaimerAcceptance.test.ts @@ -0,0 +1,131 @@ +import { + clearVendorDisclaimerAcceptance, + hasVendorDisclaimerAcceptance, + ironDisclaimerIds, + recordVendorDisclaimerAcceptance, +} from './vendorDisclaimerAcceptance.js'; + +describe('vendorDisclaimerAcceptance', () => { + describe('hasVendorDisclaimerAcceptance', () => { + it('returns true when MoonPay terms are persisted', () => { + expect( + hasVendorDisclaimerAcceptance( + { moonpay: { termsAcceptedAt: 't' }, iron: null }, + 'moonpay', + ), + ).toBe(true); + }); + + it('returns false when MoonPay terms are missing', () => { + expect( + hasVendorDisclaimerAcceptance({ moonpay: null, iron: null }, 'moonpay'), + ).toBe(false); + }); + + it('returns true when Iron disclaimer ids are persisted', () => { + expect( + hasVendorDisclaimerAcceptance( + { moonpay: null, iron: { disclaimerIds: ['d1'] } }, + 'iron', + ), + ).toBe(true); + }); + + it('returns false when Iron disclaimer ids are empty', () => { + expect( + hasVendorDisclaimerAcceptance( + { moonpay: null, iron: { disclaimerIds: [] } }, + 'iron', + ), + ).toBe(false); + }); + + it('returns false for an unknown vendor at runtime', () => { + expect( + hasVendorDisclaimerAcceptance( + { moonpay: null, iron: null }, + 'unknown' as 'moonpay', + ), + ).toBe(false); + }); + }); + + describe('ironDisclaimerIds', () => { + it('returns persisted Iron disclaimer ids', () => { + expect( + ironDisclaimerIds({ + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }), + ).toStrictEqual(['d1']); + }); + + it('returns an empty array when Iron acceptance is missing', () => { + expect( + ironDisclaimerIds({ + moonpay: null, + iron: null, + }), + ).toStrictEqual([]); + }); + }); + + describe('recordVendorDisclaimerAcceptance', () => { + it('records MoonPay acceptance', () => { + expect( + recordVendorDisclaimerAcceptance( + { moonpay: null, iron: null }, + 'moonpay', + { termsAcceptedAt: 't', disclaimerIds: [] }, + ), + ).toStrictEqual({ moonpay: { termsAcceptedAt: 't' }, iron: null }); + }); + + it('records Iron acceptance', () => { + expect( + recordVendorDisclaimerAcceptance( + { moonpay: null, iron: null }, + 'iron', + { termsAcceptedAt: 't', disclaimerIds: ['d1'] }, + ), + ).toStrictEqual({ moonpay: null, iron: { disclaimerIds: ['d1'] } }); + }); + + it('leaves acceptance unchanged for an unknown vendor at runtime', () => { + const accepted = { moonpay: null, iron: null }; + expect( + recordVendorDisclaimerAcceptance(accepted, 'unknown' as 'moonpay', { + termsAcceptedAt: 't', + disclaimerIds: ['d1'], + }), + ).toBe(accepted); + }); + }); + + describe('clearVendorDisclaimerAcceptance', () => { + it('clears MoonPay acceptance', () => { + expect( + clearVendorDisclaimerAcceptance( + { moonpay: { termsAcceptedAt: 't' }, iron: null }, + 'moonpay', + ), + ).toStrictEqual({ moonpay: null, iron: null }); + }); + + it('clears Iron acceptance', () => { + expect( + clearVendorDisclaimerAcceptance( + { moonpay: null, iron: { disclaimerIds: ['d1'] } }, + 'iron', + ), + ).toStrictEqual({ moonpay: null, iron: null }); + }); + + it('leaves acceptance unchanged for an unknown vendor at runtime', () => { + const accepted = { moonpay: null, iron: { disclaimerIds: ['d1'] } }; + expect( + clearVendorDisclaimerAcceptance(accepted, 'unknown' as 'moonpay'), + ).toBe(accepted); + }); + }); +}); diff --git a/packages/kyc-controller/src/vendorDisclaimerAcceptance.ts b/packages/kyc-controller/src/vendorDisclaimerAcceptance.ts new file mode 100644 index 0000000000..4ded773efe --- /dev/null +++ b/packages/kyc-controller/src/vendorDisclaimerAcceptance.ts @@ -0,0 +1,83 @@ +import type { KycVendor, KycVendorDisclaimersAccepted } from './types.js'; + +/** + * Returns whether persisted vendor disclaimer acceptance exists for `vendor`. + * + * @param accepted - Vendor-disclaimer acceptance map. + * @param vendor - Identity vendor to inspect. + * @returns Whether acceptance is present for the vendor. + */ +export function hasVendorDisclaimerAcceptance( + accepted: KycVendorDisclaimersAccepted, + vendor: KycVendor, +): boolean { + if (vendor === 'moonpay') { + return Boolean(accepted.moonpay?.termsAcceptedAt); + } + if (vendor === 'iron') { + return Boolean(accepted.iron?.disclaimerIds.length); + } + return false; +} + +/** + * Returns persisted Iron disclaimer ids, if any. + * + * @param accepted - Vendor-disclaimer acceptance map. + * @returns The accepted disclaimer ids, or an empty array. + */ +export function ironDisclaimerIds( + accepted: KycVendorDisclaimersAccepted, +): string[] { + return accepted.iron?.disclaimerIds ?? []; +} + +/** + * Records vendor disclaimer acceptance for the active vendor. + * + * @param accepted - Existing vendor-disclaimer acceptance map. + * @param vendor - Identity vendor being accepted. + * @param params - Acceptance payload for the vendor. + * @param params.termsAcceptedAt - MoonPay acceptance timestamp. + * @param params.disclaimerIds - Iron disclaimer ids. + * @returns The updated acceptance map. + */ +export function recordVendorDisclaimerAcceptance( + accepted: KycVendorDisclaimersAccepted, + vendor: KycVendor, + params: { termsAcceptedAt: string; disclaimerIds: string[] }, +): KycVendorDisclaimersAccepted { + if (vendor === 'moonpay') { + return { + ...accepted, + moonpay: { termsAcceptedAt: params.termsAcceptedAt }, + }; + } + if (vendor === 'iron') { + return { + ...accepted, + iron: { disclaimerIds: params.disclaimerIds }, + }; + } + return accepted; +} + +/** + * Clears persisted vendor disclaimer acceptance for one vendor. + * + * @param accepted - Existing vendor-disclaimer acceptance map. + * @param vendor - Identity vendor whose acceptance should be cleared. + * @returns The updated acceptance map. + */ +export function clearVendorDisclaimerAcceptance( + accepted: KycVendorDisclaimersAccepted, + vendor: KycVendor, +): KycVendorDisclaimersAccepted { + if (vendor === 'moonpay') { + return { ...accepted, moonpay: null }; + } + if (vendor === 'iron') { + return { ...accepted, iron: null }; + } + return accepted; +}