diff --git a/package.json b/package.json index 067536d89c1..9bb296d48ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "667.0.0", + "version": "669.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/claims-controller/CHANGELOG.md b/packages/claims-controller/CHANGELOG.md index 4abf3248faf..f1606a525cb 100644 --- a/packages/claims-controller/CHANGELOG.md +++ b/packages/claims-controller/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] + +### Uncategorized + +- ci: Fix `claims-controller` errors in `tsconfig.json` ([#7092](https://github.com/MetaMask/core/pull/7092)) + +### Added + +- Added new public method, `fetchClaimsConfigurations` to fetch the claims configuration from the Claims backend. ([#7109](https://github.com/MetaMask/core/pull/7109)) +- Added new states fields, `claimsConfigurations` to the controller state. ([#7109](https://github.com/MetaMask/core/pull/7109)) + - `validSubmissionWindowDays` - number of days the claim is valid for submission. + - `supportedNetworks` - supported networks for the claim submission. +- Exported `CreateClaimRequest` and `SubmitClaimConfig` types from the controller. ([#7109](https://github.com/MetaMask/core/pull/7109)) + ## [0.1.0] ### Added @@ -21,5 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `generateMessageForClaimSignature`: generate message to sign for the claim signature. - `verifyClaimSignature`: verify claim signature produced by user. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.2.0...HEAD +[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/claims-controller@0.1.0...@metamask/claims-controller@0.2.0 [0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/claims-controller@0.1.0 diff --git a/packages/claims-controller/package.json b/packages/claims-controller/package.json index efe75aeaa46..0ed0858d759 100644 --- a/packages/claims-controller/package.json +++ b/packages/claims-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/claims-controller", - "version": "0.1.0", + "version": "0.2.0", "description": "Controller handling shield subscription claims logic", "keywords": [ "MetaMask", diff --git a/packages/claims-controller/src/ClaimsController.test.ts b/packages/claims-controller/src/ClaimsController.test.ts index d92f5d4cbd2..b9b3450f6bc 100644 --- a/packages/claims-controller/src/ClaimsController.test.ts +++ b/packages/claims-controller/src/ClaimsController.test.ts @@ -1,10 +1,12 @@ +import { toHex } from '@metamask/controller-utils'; + import { ClaimsController } from './ClaimsController'; -import { - ClaimsControllerErrorMessages, - ClaimStatusEnum, - HttpContentTypeHeader, -} from './constants'; -import type { Claim, CreateClaimRequest } from './types'; +import { ClaimsControllerErrorMessages, ClaimStatusEnum } from './constants'; +import type { + Claim, + ClaimsConfigurationsResponse, + CreateClaimRequest, +} from './types'; import { createMockClaimsControllerMessenger } from '../tests/mocks/messenger'; import type { WithControllerArgs } from '../tests/types'; @@ -13,6 +15,7 @@ const mockClaimServiceGetClaimsApiUrl = jest.fn(); const mockClaimServiceGenerateMessageForClaimSignature = jest.fn(); const mockKeyringControllerSignPersonalMessage = jest.fn(); const mockClaimsServiceGetClaims = jest.fn(); +const mockClaimsServiceFetchClaimsConfigurations = jest.fn(); /** * Builds a controller based on the given options and calls the given function with that controller. @@ -30,6 +33,7 @@ async function withController( mockClaimServiceGenerateMessageForClaimSignature, mockKeyringControllerSignPersonalMessage, mockClaimsServiceGetClaims, + mockClaimsServiceFetchClaimsConfigurations, }); const controller = new ClaimsController({ @@ -52,6 +56,46 @@ describe('ClaimsController', () => { }); }); + describe('fetchClaimsConfigurations', () => { + const MOCK_CONFIGURATIONS_RESPONSE: ClaimsConfigurationsResponse = { + validSubmissionWindowDays: 21, + networks: [1, 5, 11155111], + }; + + beforeEach(() => { + jest.resetAllMocks(); + + mockClaimsServiceFetchClaimsConfigurations.mockResolvedValueOnce( + MOCK_CONFIGURATIONS_RESPONSE, + ); + }); + + it('should fetch claims configurations successfully', async () => { + await withController(async ({ controller }) => { + const initialState = controller.state; + const configurations = await controller.fetchClaimsConfigurations(); + expect(configurations).toBeDefined(); + + const expectedConfigurations = { + validSubmissionWindowDays: + MOCK_CONFIGURATIONS_RESPONSE.validSubmissionWindowDays, + supportedNetworks: MOCK_CONFIGURATIONS_RESPONSE.networks.map( + (network) => toHex(network), + ), + }; + + expect(configurations).toStrictEqual(expectedConfigurations); + expect(controller.state).not.toBe(initialState); + expect( + controller.state.claimsConfigurations.validSubmissionWindowDays, + ).toBe(MOCK_CONFIGURATIONS_RESPONSE.validSubmissionWindowDays); + expect( + controller.state.claimsConfigurations.supportedNetworks, + ).toStrictEqual(expectedConfigurations.supportedNetworks); + }); + }); + }); + describe('getSubmitClaimConfig', () => { const MOCK_CLAIM: CreateClaimRequest = { chainId: '0x1', @@ -65,7 +109,6 @@ describe('ClaimsController', () => { }; const MOCK_CLAIM_API = 'https://claims-api.test.com'; const MOCK_HEADERS = { - 'Content-Type': HttpContentTypeHeader.MULTIPART_FORM_DATA, Authorization: 'Bearer test-token', }; @@ -81,9 +124,7 @@ describe('ClaimsController', () => { const submitClaimConfig = await controller.getSubmitClaimConfig(MOCK_CLAIM); - expect(mockClaimServiceRequestHeaders).toHaveBeenCalledWith( - HttpContentTypeHeader.MULTIPART_FORM_DATA, - ); + expect(mockClaimServiceRequestHeaders).toHaveBeenCalledTimes(1); expect(mockClaimServiceGetClaimsApiUrl).toHaveBeenCalledTimes(1); expect(submitClaimConfig).toBeDefined(); diff --git a/packages/claims-controller/src/ClaimsController.ts b/packages/claims-controller/src/ClaimsController.ts index 56700b8d77f..5208237e4ef 100644 --- a/packages/claims-controller/src/ClaimsController.ts +++ b/packages/claims-controller/src/ClaimsController.ts @@ -4,12 +4,13 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { detectSIWE } from '@metamask/controller-utils'; +import { detectSIWE, toHex } from '@metamask/controller-utils'; import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import { bytesToHex, stringToBytes } from '@metamask/utils'; import type { + ClaimsServiceFetchClaimsConfigurationsAction, ClaimsServiceGenerateMessageForClaimSignatureAction, ClaimsServiceGetClaimByIdAction, ClaimsServiceGetClaimsAction, @@ -19,11 +20,12 @@ import type { import { ClaimsControllerErrorMessages, CONTROLLER_NAME, - HttpContentTypeHeader, + DEFAULT_CLAIMS_CONFIGURATIONS, SERVICE_NAME, } from './constants'; import type { Claim, + ClaimsConfigurations, ClaimsControllerState, CreateClaimRequest, SubmitClaimConfig, @@ -37,6 +39,7 @@ export type ClaimsControllerGetStateAction = ControllerGetStateAction< export type ClaimsControllerActions = ClaimsControllerGetStateAction; export type AllowedActions = + | ClaimsServiceFetchClaimsConfigurationsAction | ClaimsServiceGetClaimsAction | ClaimsServiceGetClaimByIdAction | ClaimsServiceGetRequestHeadersAction @@ -68,6 +71,12 @@ const ClaimsControllerStateMetadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + claimsConfigurations: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, }; /** @@ -77,6 +86,7 @@ const ClaimsControllerStateMetadata: StateMetadata = { */ export function getDefaultClaimsControllerState(): ClaimsControllerState { return { + claimsConfigurations: DEFAULT_CLAIMS_CONFIGURATIONS, claims: [], }; } @@ -95,6 +105,30 @@ export class ClaimsController extends BaseController< }); } + /** + * Fetch the required configurations for the claims service. + * + * @returns The required configurations for the claims service. + */ + async fetchClaimsConfigurations(): Promise { + const configurations = await this.messenger.call( + `${SERVICE_NAME}:fetchClaimsConfigurations`, + ); + + const supportedNetworks = configurations.networks.map((network) => + toHex(network), + ); + const claimsConfigurations = { + validSubmissionWindowDays: configurations.validSubmissionWindowDays, + supportedNetworks, + }; + + this.update((state) => { + state.claimsConfigurations = claimsConfigurations; + }); + return claimsConfigurations; + } + /** * Get required config for submitting a claim. * @@ -109,7 +143,6 @@ export class ClaimsController extends BaseController< const headers = await this.messenger.call( `${SERVICE_NAME}:getRequestHeaders`, - HttpContentTypeHeader.MULTIPART_FORM_DATA, ); const baseUrl = this.messenger.call(`${SERVICE_NAME}:getClaimsApiUrl`); const url = `${baseUrl}/claims`; diff --git a/packages/claims-controller/src/ClaimService.test.ts b/packages/claims-controller/src/ClaimsService.test.ts similarity index 76% rename from packages/claims-controller/src/ClaimService.test.ts rename to packages/claims-controller/src/ClaimsService.test.ts index 0ce5b0c3af2..b730596ed59 100644 --- a/packages/claims-controller/src/ClaimService.test.ts +++ b/packages/claims-controller/src/ClaimsService.test.ts @@ -1,12 +1,15 @@ import { ClaimsService } from './ClaimsService'; import { - CLAIMS_API_URL, + CLAIMS_API_URL_MAP, ClaimsServiceErrorMessages, ClaimStatusEnum, Env, - HttpContentTypeHeader, } from './constants'; -import type { Claim, GenerateSignatureMessageResponse } from './types'; +import type { + Claim, + ClaimsConfigurationsResponse, + GenerateSignatureMessageResponse, +} from './types'; import { createMockClaimsServiceMessenger } from '../tests/mocks/messenger'; const mockAuthenticationControllerGetBearerToken = jest.fn(); @@ -76,6 +79,60 @@ describe('ClaimsService', () => { }); }); + describe('fetchClaimsConfigurations', () => { + const MOCK_CONFIGURATIONS: ClaimsConfigurationsResponse = { + validSubmissionWindowDays: 21, + networks: [1, 5, 11155111], + }; + + beforeEach(() => { + jest.resetAllMocks(); + + mockAuthenticationControllerGetBearerToken.mockResolvedValueOnce( + 'test-token', + ); + mockFetchFunction.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(MOCK_CONFIGURATIONS), + }); + }); + + it('should fetch claims configurations successfully', async () => { + const service = createMockClaimsService(); + + const configurations = await service.fetchClaimsConfigurations(); + + expect(mockAuthenticationControllerGetBearerToken).toHaveBeenCalledTimes( + 1, + ); + expect(mockFetchFunction).toHaveBeenCalledTimes(1); + expect(mockFetchFunction).toHaveBeenCalledWith( + `${CLAIMS_API_URL_MAP[Env.DEV]}/configurations`, + { + headers: { + Authorization: 'Bearer test-token', + }, + }, + ); + expect(configurations).toStrictEqual(MOCK_CONFIGURATIONS); + }); + + it('should throw error if fetch fails', async () => { + mockFetchFunction.mockRestore(); + + mockFetchFunction.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValueOnce(null), + }); + + const service = createMockClaimsService(); + + await expect(service.fetchClaimsConfigurations()).rejects.toThrow( + ClaimsServiceErrorMessages.FAILED_TO_FETCH_CONFIGURATIONS, + ); + }); + }); + describe('getClaims', () => { beforeEach(() => { jest.resetAllMocks(); @@ -99,11 +156,10 @@ describe('ClaimsService', () => { ); expect(mockFetchFunction).toHaveBeenCalledTimes(1); expect(mockFetchFunction).toHaveBeenCalledWith( - `${CLAIMS_API_URL[Env.DEV]}/claims`, + `${CLAIMS_API_URL_MAP[Env.DEV]}/claims`, { headers: { Authorization: 'Bearer test-token', - 'Content-Type': HttpContentTypeHeader.APPLICATION_JSON, }, }, ); @@ -150,11 +206,10 @@ describe('ClaimsService', () => { ); expect(mockFetchFunction).toHaveBeenCalledTimes(1); expect(mockFetchFunction).toHaveBeenCalledWith( - `${CLAIMS_API_URL[Env.DEV]}/claims/byId/1`, + `${CLAIMS_API_URL_MAP[Env.DEV]}/claims/byId/1`, { headers: { Authorization: 'Bearer test-token', - 'Content-Type': HttpContentTypeHeader.APPLICATION_JSON, }, }, ); @@ -212,11 +267,11 @@ describe('ClaimsService', () => { ); expect(mockFetchFunction).toHaveBeenCalledTimes(1); expect(mockFetchFunction).toHaveBeenCalledWith( - `${CLAIMS_API_URL[Env.DEV]}/signature/generateMessage`, + `${CLAIMS_API_URL_MAP[Env.DEV]}/signature/generateMessage`, { headers: { Authorization: 'Bearer test-token', - 'Content-Type': HttpContentTypeHeader.APPLICATION_JSON, + 'Content-Type': 'application/json', }, method: 'POST', body: JSON.stringify({ diff --git a/packages/claims-controller/src/ClaimsService.ts b/packages/claims-controller/src/ClaimsService.ts index 56feff68685..c643a7ee704 100644 --- a/packages/claims-controller/src/ClaimsService.ts +++ b/packages/claims-controller/src/ClaimsService.ts @@ -3,13 +3,21 @@ import type { AuthenticationController } from '@metamask/profile-sync-controller import type { Hex } from '@metamask/utils'; import { - CLAIMS_API_URL, + CLAIMS_API_URL_MAP, ClaimsServiceErrorMessages, type Env, - HttpContentTypeHeader, SERVICE_NAME, } from './constants'; -import type { Claim, GenerateSignatureMessageResponse } from './types'; +import type { + Claim, + ClaimsConfigurationsResponse, + GenerateSignatureMessageResponse, +} from './types'; + +export type ClaimsServiceFetchClaimsConfigurationsAction = { + type: `${typeof SERVICE_NAME}:fetchClaimsConfigurations`; + handler: ClaimsService['fetchClaimsConfigurations']; +}; export type ClaimsServiceGetClaimsAction = { type: `${typeof SERVICE_NAME}:getClaims`; @@ -37,6 +45,7 @@ export type ClaimsServiceGenerateMessageForClaimSignatureAction = { }; export type ClaimsServiceActions = + | ClaimsServiceFetchClaimsConfigurationsAction | ClaimsServiceGetClaimsAction | ClaimsServiceGetClaimByIdAction | ClaimsServiceGetRequestHeadersAction @@ -73,6 +82,10 @@ export class ClaimsService { this.#messenger = messenger; this.#fetch = fetchFunction; + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:fetchClaimsConfigurations`, + this.fetchClaimsConfigurations.bind(this), + ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getClaims`, this.getClaims.bind(this), @@ -95,6 +108,28 @@ export class ClaimsService { ); } + /** + * Fetch required configurations for the claims service. + * + * @returns The required configurations for the claims service. + */ + async fetchClaimsConfigurations(): Promise { + const headers = await this.getRequestHeaders(); + const url = `${this.getClaimsApiUrl()}/configurations`; + const response = await this.#fetch(url, { + headers, + }); + + if (!response.ok) { + throw new Error( + ClaimsServiceErrorMessages.FAILED_TO_FETCH_CONFIGURATIONS, + ); + } + + const configurations = await response.json(); + return configurations; + } + /** * Get the claims for the current user. * @@ -150,8 +185,11 @@ export class ClaimsService { const headers = await this.getRequestHeaders(); const url = `${this.getClaimsApiUrl()}/signature/generateMessage`; const response = await this.#fetch(url, { - headers, method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + }, body: JSON.stringify({ chainId, walletAddress, @@ -171,18 +209,14 @@ export class ClaimsService { /** * Create the headers for the current request. * - * @param contentType - The content type of the request. Defaults to 'application/json'. * @returns The headers for the current request. */ - async getRequestHeaders( - contentType: HttpContentTypeHeader = HttpContentTypeHeader.APPLICATION_JSON, - ): Promise> { + async getRequestHeaders(): Promise> { const bearerToken = await this.#messenger.call( 'AuthenticationController:getBearerToken', ); return { Authorization: `Bearer ${bearerToken}`, - 'Content-Type': contentType, }; } @@ -192,6 +226,6 @@ export class ClaimsService { * @returns The URL for the claims API for the current environment. */ getClaimsApiUrl(): string { - return `${CLAIMS_API_URL[this.#env]}`; + return `${CLAIMS_API_URL_MAP[this.#env]}`; } } diff --git a/packages/claims-controller/src/constants.ts b/packages/claims-controller/src/constants.ts index 22074db7213..a907fc19aad 100644 --- a/packages/claims-controller/src/constants.ts +++ b/packages/claims-controller/src/constants.ts @@ -1,3 +1,5 @@ +import { BuiltInNetworkName, ChainId } from '@metamask/controller-utils'; + export const CONTROLLER_NAME = 'ClaimsController'; export const SERVICE_NAME = 'ClaimsService'; @@ -25,17 +27,12 @@ export enum ClaimStatusEnum { UNKNOWN = 'unknown', } -export const CLAIMS_API_URL: Record = { +export const CLAIMS_API_URL_MAP: Record = { [Env.DEV]: 'https://claims.dev-api.cx.metamask.io', [Env.UAT]: 'https://claims.uat-api.cx.metamask.io', [Env.PRD]: 'https://claims.api.cx.metamask.io', }; -export enum HttpContentTypeHeader { - APPLICATION_JSON = 'application/json', - MULTIPART_FORM_DATA = 'multipart/form-data', -} - export const ClaimsControllerErrorMessages = { CLAIM_ALREADY_SUBMITTED: 'Claim already submitted', INVALID_CLAIM_SIGNATURE: 'Invalid claim signature', @@ -43,6 +40,7 @@ export const ClaimsControllerErrorMessages = { }; export const ClaimsServiceErrorMessages = { + FAILED_TO_FETCH_CONFIGURATIONS: 'Failed to fetch claims configurations', FAILED_TO_GET_CLAIMS: 'Failed to get claims', FAILED_TO_GET_CLAIM_BY_ID: 'Failed to get claim by id', SIGNATURE_MESSAGE_GENERATION_FAILED: @@ -50,3 +48,14 @@ export const ClaimsServiceErrorMessages = { CLAIM_SIGNATURE_VERIFICATION_REQUEST_FAILED: 'Failed to verify claim signature', }; + +/** + * Default claims configurations. + */ +export const DEFAULT_CLAIMS_CONFIGURATIONS = { + validSubmissionWindowDays: 21, + supportedNetworks: [ + ChainId[BuiltInNetworkName.Mainnet], + ChainId[BuiltInNetworkName.LineaMainnet], + ], +}; diff --git a/packages/claims-controller/src/index.ts b/packages/claims-controller/src/index.ts index 660800ba8bb..420473c9b0c 100644 --- a/packages/claims-controller/src/index.ts +++ b/packages/claims-controller/src/index.ts @@ -10,11 +10,19 @@ export type { ClaimsControllerMessenger, } from './ClaimsController'; -export type { Claim, ClaimsControllerState, Attachment } from './types'; +export type { + Claim, + ClaimsControllerState, + Attachment, + ClaimsConfigurations, + CreateClaimRequest, + SubmitClaimConfig, +} from './types'; export { ClaimsService } from './ClaimsService'; export type { + ClaimsServiceFetchClaimsConfigurationsAction, ClaimsServiceGetClaimsAction, ClaimsServiceGetRequestHeadersAction, ClaimsServiceGetClaimsApiUrlAction, @@ -28,4 +36,7 @@ export { ClaimStatusEnum, Env, ClaimsControllerErrorMessages, + DEFAULT_CLAIMS_CONFIGURATIONS, + ClaimsServiceErrorMessages, + CLAIMS_API_URL_MAP, } from './constants'; diff --git a/packages/claims-controller/src/types.ts b/packages/claims-controller/src/types.ts index 05e9db63557..6fc4d59c5b4 100644 --- a/packages/claims-controller/src/types.ts +++ b/packages/claims-controller/src/types.ts @@ -8,6 +8,29 @@ export type Attachment = { originalname: string; }; +export type ClaimsConfigurations = { + /** + * The number of days the claim is valid for submission. + */ + validSubmissionWindowDays: number; + + /** + * List of supported chain IDs in hexadecimal format. + */ + supportedNetworks: `0x${string}`[]; +}; + +export type ClaimsConfigurationsResponse = Omit< + ClaimsConfigurations, + 'supportedNetworks' +> & { + /** + * List of supported chain IDs. + * Claims API response for `supportedNetworks` field (in decimal format). + */ + networks: number[]; +}; + export type Claim = { id: string; shortId: string; @@ -31,7 +54,16 @@ export type CreateClaimRequest = Omit< >; export type ClaimsControllerState = { + /** + * List of claims. + */ claims: Claim[]; + + /** + * The claims configurations. + * This is used to store the claims configurations fetched from the backend. + */ + claimsConfigurations: ClaimsConfigurations; }; export type SubmitClaimConfig = { @@ -57,8 +89,3 @@ export type GenerateSignatureMessageResponse = { message: string; nonce: string; }; - -export type VerifyClaimSignatureResponse = { - message: string; - success: boolean; -}; diff --git a/packages/claims-controller/tests/mocks/messenger.ts b/packages/claims-controller/tests/mocks/messenger.ts index ed50d814df0..3acf3595700 100644 --- a/packages/claims-controller/tests/mocks/messenger.ts +++ b/packages/claims-controller/tests/mocks/messenger.ts @@ -31,6 +31,7 @@ export type RootControllerMessenger = Messenger< * @param params.mockClaimServiceGenerateMessageForClaimSignature - A mock function for the claim service generate message for claim signature. * @param params.mockKeyringControllerSignPersonalMessage - A mock function for the keyring controller sign personal message. * @param params.mockClaimsServiceGetClaims - A mock function for the claim service get claims. + * @param params.mockClaimsServiceFetchClaimsConfigurations - A mock function for the claim service fetch claims configurations. * @returns A mock messenger. */ export function createMockClaimsControllerMessenger({ @@ -39,12 +40,14 @@ export function createMockClaimsControllerMessenger({ mockClaimServiceGenerateMessageForClaimSignature, mockKeyringControllerSignPersonalMessage, mockClaimsServiceGetClaims, + mockClaimsServiceFetchClaimsConfigurations, }: { mockClaimServiceRequestHeaders: jest.Mock; mockClaimServiceGetClaimsApiUrl: jest.Mock; mockClaimServiceGenerateMessageForClaimSignature: jest.Mock; mockKeyringControllerSignPersonalMessage: jest.Mock; mockClaimsServiceGetClaims: jest.Mock; + mockClaimsServiceFetchClaimsConfigurations: jest.Mock; }): { rootMessenger: RootControllerMessenger; messenger: ClaimsControllerMessenger; @@ -57,6 +60,10 @@ export function createMockClaimsControllerMessenger({ namespace: MOCK_ANY_NAMESPACE, }); + rootMessenger.registerActionHandler( + `${SERVICE_NAME}:fetchClaimsConfigurations`, + mockClaimsServiceFetchClaimsConfigurations, + ); rootMessenger.registerActionHandler( `${SERVICE_NAME}:getRequestHeaders`, mockClaimServiceRequestHeaders, @@ -91,6 +98,7 @@ export function createMockClaimsControllerMessenger({ messenger, events: [], actions: [ + `${SERVICE_NAME}:fetchClaimsConfigurations`, `${SERVICE_NAME}:getRequestHeaders`, `${SERVICE_NAME}:getClaimsApiUrl`, `${SERVICE_NAME}:generateMessageForClaimSignature`, diff --git a/packages/claims-controller/tsconfig.build.json b/packages/claims-controller/tsconfig.build.json index 63c9b341f96..e144a55d21a 100644 --- a/packages/claims-controller/tsconfig.build.json +++ b/packages/claims-controller/tsconfig.build.json @@ -14,6 +14,9 @@ }, { "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../keyring-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/claims-controller/tsconfig.json b/packages/claims-controller/tsconfig.json index 72f54050b98..cf63460ba63 100644 --- a/packages/claims-controller/tsconfig.json +++ b/packages/claims-controller/tsconfig.json @@ -1,9 +1,7 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./", - "outDir": "./dist", - "rootDir": "./src" + "baseUrl": "./" }, "references": [ { @@ -14,6 +12,9 @@ }, { "path": "../profile-sync-controller/tsconfig.json" + }, + { + "path": "../keyring-controller/tsconfig.json" } ], "include": ["../../types", "./src", "./tests"] diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 5bd5f4153a4..623f520033c 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.0.0] + +### Added + +- Added `lastSubscription` in state returned from `getSubscriptions` method ([#7110](https://github.com/MetaMask/core/pull/7110)) +- Add `assignUserToCohort` method to assign users to cohorts via backend API ([#7099](https://github.com/MetaMask/core/pull/7099)) +- Add cohort-related types: `Cohort`, `CohortName`, `BalanceCategory`, `AssignCohortRequest`, `GetSubscriptionsEligibilitiesRequest` ([#7099](https://github.com/MetaMask/core/pull/7099)) +- Add cohort-related constants: `COHORT_NAMES`, `BALANCE_CATEGORIES`, `SubscriptionUserEvent` ([#7099](https://github.com/MetaMask/core/pull/7099)) +- Add cohort fields to `SubscriptionEligibility` type: `cohorts`, `assignedCohort`, `hasAssignedCohortExpired` ([#7099](https://github.com/MetaMask/core/pull/7099)) +- Add `ShieldCohortAssigned` event to `SubscriptionUserEvent` ([#7099](https://github.com/MetaMask/core/pull/7099)) +- Add optional `balanceCategory` parameter to `getSubscriptionsEligibilities` for privacy-preserving balance evaluation ([#7099](https://github.com/MetaMask/core/pull/7099)) +- Add optional `cohort` field to `SubmitUserEventRequest` for event tracking ([#7099](https://github.com/MetaMask/core/pull/7099)) + +### Changed + +- Refactor `SubscriptionService.makeRequest` to accept query parameters for cleaner URL construction ([#7099](https://github.com/MetaMask/core/pull/7099)) + ## [3.3.0] ### Changed @@ -149,7 +166,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) - Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@3.3.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@4.0.0...HEAD +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@3.3.0...@metamask/subscription-controller@4.0.0 [3.3.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@3.2.0...@metamask/subscription-controller@3.3.0 [3.2.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@3.1.0...@metamask/subscription-controller@3.2.0 [3.1.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@3.0.0...@metamask/subscription-controller@3.1.0 diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 25e05c70f91..f4b86c5be7b 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/subscription-controller", - "version": "3.3.0", + "version": "4.0.0", "description": "Handle user subscription", "keywords": [ "MetaMask", diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 6b91f7218c8..ec028851abb 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -80,6 +80,7 @@ const MOCK_SUBSCRIPTION: Subscription = { last4: '1234', }, }, + isEligibleForSupport: true, }; const MOCK_PRODUCT_PRICE: ProductPricing = { @@ -134,6 +135,21 @@ const MOCK_GET_SUBSCRIPTIONS_RESPONSE = { trialedProducts: [], }; +const MOCK_COHORTS = [ + { + cohort: 'post_tx', + eligibilityRate: 0.8, + priority: 1, + eligible: true, + }, + { + cohort: 'wallet_home', + eligibilityRate: 0.2, + priority: 2, + eligible: true, + }, +]; + /** * Creates a custom subscription messenger, in case tests need different permissions * @@ -218,6 +234,7 @@ function createMockSubscriptionService() { const mockGetSubscriptionsEligibilities = jest.fn(); const mockSubmitUserEvent = jest.fn(); const mockSubmitSponsorshipIntents = jest.fn(); + const mockAssignUserToCohort = jest.fn(); const mockService = { getSubscriptions: mockGetSubscriptions, @@ -232,6 +249,7 @@ function createMockSubscriptionService() { getSubscriptionsEligibilities: mockGetSubscriptionsEligibilities, submitUserEvent: mockSubmitUserEvent, submitSponsorshipIntents: mockSubmitSponsorshipIntents, + assignUserToCohort: mockAssignUserToCohort, }; return { @@ -245,6 +263,7 @@ function createMockSubscriptionService() { mockUpdatePaymentMethodCard, mockUpdatePaymentMethodCrypto, mockSubmitSponsorshipIntents, + mockAssignUserToCohort, }; } @@ -545,6 +564,30 @@ describe('SubscriptionController', () => { }, ); }); + + it('should update state when lastSubscription changes from undefined to defined', async () => { + await withController( + { + state: { + lastSubscription: undefined, + }, + }, + async ({ controller, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [], + lastSubscription: MOCK_SUBSCRIPTION, + }); + + await controller.getSubscriptions(); + + expect(controller.state.lastSubscription).toStrictEqual( + MOCK_SUBSCRIPTION, + ); + }, + ); + }); }); describe('getSubscriptionByProduct', () => { @@ -1251,7 +1294,6 @@ describe('SubscriptionController', () => { ), ).toMatchInlineSnapshot(` Object { - "subscriptions": Array [], "trialedProducts": Array [], } `); @@ -1384,6 +1426,9 @@ describe('SubscriptionController', () => { canSubscribe: true, minBalanceUSD: 100, canViewEntryModal: true, + cohorts: [], + assignedCohort: null, + hasAssignedCohortExpired: false, }; it('should get the subscriptions eligibilities', async () => { @@ -1397,6 +1442,29 @@ describe('SubscriptionController', () => { }); }); + it('should get the subscriptions eligibilities with balanceCategory parameter', async () => { + await withController(async ({ controller, mockService }) => { + const mockEligibilityWithCohorts: SubscriptionEligibility = { + ...MOCK_SUBSCRIPTION_ELIGIBILITY, + cohorts: MOCK_COHORTS, + assignedCohort: 'post_tx', + }; + + mockService.getSubscriptionsEligibilities.mockResolvedValue([ + mockEligibilityWithCohorts, + ]); + + const balanceCategory = '1k-9.9k'; + const result = await controller.getSubscriptionsEligibilities({ + balanceCategory, + }); + expect(result).toStrictEqual([mockEligibilityWithCohorts]); + expect(mockService.getSubscriptionsEligibilities).toHaveBeenCalledWith({ + balanceCategory, + }); + }); + }); + it('should handle subscription service errors', async () => { await withController(async ({ controller, mockService }) => { const errorMessage = 'Failed to get subscriptions eligibilities'; @@ -1425,6 +1493,26 @@ describe('SubscriptionController', () => { expect(submitUserEventSpy).toHaveBeenCalledWith({ event: SubscriptionUserEvent.ShieldEntryModalViewed, }); + expect(submitUserEventSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('should submit user event with cohort successfully', async () => { + await withController(async ({ controller, mockService }) => { + const submitUserEventSpy = jest + .spyOn(mockService, 'submitUserEvent') + .mockResolvedValue(undefined); + + const result = await controller.submitUserEvent({ + event: SubscriptionUserEvent.ShieldCohortAssigned, + cohort: 'post_tx', + }); + expect(result).toBeUndefined(); + expect(submitUserEventSpy).toHaveBeenCalledWith({ + event: SubscriptionUserEvent.ShieldCohortAssigned, + cohort: 'post_tx', + }); + expect(submitUserEventSpy).toHaveBeenCalledTimes(1); }); }); @@ -1444,6 +1532,38 @@ describe('SubscriptionController', () => { }); }); + describe('assignUserToCohort', () => { + it('should assign user to cohort successfully', async () => { + await withController(async ({ controller, mockService }) => { + const assignUserToCohortSpy = jest + .spyOn(mockService, 'assignUserToCohort') + .mockResolvedValue(undefined); + + const result = await controller.assignUserToCohort({ + cohort: 'post_tx', + }); + expect(result).toBeUndefined(); + expect(assignUserToCohortSpy).toHaveBeenCalledWith({ + cohort: 'post_tx', + }); + expect(assignUserToCohortSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('should handle subscription service errors', async () => { + await withController(async ({ controller, mockService }) => { + const errorMessage = 'Failed to assign user to cohort'; + mockService.assignUserToCohort.mockRejectedValue( + new SubscriptionServiceError(errorMessage), + ); + + await expect( + controller.assignUserToCohort({ cohort: 'post_tx' }), + ).rejects.toThrow(SubscriptionServiceError); + }); + }); + }); + describe('cacheLastSelectedPaymentMethod', () => { const MOCK_CACHED_PAYMENT_METHOD: CachedLastSelectedPaymentMethod = { type: PAYMENT_TYPES.byCrypto, diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index 12d56ad3e9d..6f57a5ac4fc 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -18,9 +18,11 @@ import { SubscriptionControllerErrorMessage, } from './constants'; import type { + AssignCohortRequest, BillingPortalResponse, GetCryptoApproveTransactionRequest, GetCryptoApproveTransactionResponse, + GetSubscriptionsEligibilitiesRequest, ProductPrice, SubscriptionEligibility, StartCryptoSubscriptionRequest, @@ -47,7 +49,8 @@ export type SubscriptionControllerState = { trialedProducts: ProductType[]; subscriptions: Subscription[]; pricing?: PricingResponse; - + /** The last subscription that user has subscribed to if any. */ + lastSubscription?: Subscription; /** * The last selected payment method for the user. * This is used to display the last selected payment method in the UI. @@ -194,7 +197,13 @@ export function getDefaultSubscriptionControllerState(): SubscriptionControllerS const subscriptionControllerMetadata: StateMetadata = { subscriptions: { - includeInStateLogs: true, + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + lastSubscription: { + includeInStateLogs: false, persist: true, includeInDebugSnapshot: false, usedInUi: true, @@ -342,10 +351,12 @@ export class SubscriptionController extends StaticIntervalPollingController()< const currentSubscriptions = this.state.subscriptions; const currentTrialedProducts = this.state.trialedProducts; const currentCustomerId = this.state.customerId; + const currentLastSubscription = this.state.lastSubscription; const { customerId: newCustomerId, subscriptions: newSubscriptions, trialedProducts: newTrialedProducts, + lastSubscription: newLastSubscription, } = await this.#subscriptionService.getSubscriptions(); // check if the new subscriptions are different from the current subscriptions @@ -358,6 +369,11 @@ export class SubscriptionController extends StaticIntervalPollingController()< currentTrialedProducts, newTrialedProducts, ); + // check if the new last subscription is different from the current last subscription + const isLastSubscriptionEqual = this.#isSubscriptionEqual( + currentLastSubscription, + newLastSubscription, + ); const areCustomerIdsEqual = currentCustomerId === newCustomerId; @@ -365,6 +381,7 @@ export class SubscriptionController extends StaticIntervalPollingController()< // this prevents unnecessary state updates events, easier for the clients to handle if ( !areSubscriptionsEqual || + !isLastSubscriptionEqual || !areTrialedProductsEqual || !areCustomerIdsEqual ) { @@ -372,6 +389,7 @@ export class SubscriptionController extends StaticIntervalPollingController()< state.subscriptions = newSubscriptions; state.customerId = newCustomerId; state.trialedProducts = newTrialedProducts; + state.lastSubscription = newLastSubscription; }); this.#shouldCallRefreshAuthToken = true; } @@ -394,10 +412,15 @@ export class SubscriptionController extends StaticIntervalPollingController()< /** * Get the subscriptions eligibilities. * + * @param request - Optional request object containing user balance to check cohort eligibility. * @returns The subscriptions eligibilities. */ - async getSubscriptionsEligibilities(): Promise { - return await this.#subscriptionService.getSubscriptionsEligibilities(); + async getSubscriptionsEligibilities( + request?: GetSubscriptionsEligibilitiesRequest, + ): Promise { + return await this.#subscriptionService.getSubscriptionsEligibilities( + request, + ); } async cancelSubscription(request: { subscriptionId: string }) { @@ -688,12 +711,22 @@ export class SubscriptionController extends StaticIntervalPollingController()< * Submit a user event from the UI. (e.g. shield modal viewed) * * @param request - Request object containing the event to submit. - * @example { event: SubscriptionUserEvent.ShieldEntryModalViewed } + * @example { event: SubscriptionUserEvent.ShieldEntryModalViewed, cohort: 'post_tx' } */ async submitUserEvent(request: SubmitUserEventRequest) { await this.#subscriptionService.submitUserEvent(request); } + /** + * Assign user to a cohort. + * + * @param request - Request object containing the cohort to assign the user to. + * @example { cohort: 'post_tx' } + */ + async assignUserToCohort(request: AssignCohortRequest): Promise { + await this.#subscriptionService.assignUserToCohort(request); + } + async _executePoll(): Promise { await this.getSubscriptions(); if (this.#shouldCallRefreshAuthToken) { @@ -891,13 +924,25 @@ export class SubscriptionController extends StaticIntervalPollingController()< // Check if all subscriptions are equal return sortedOldSubs.every((oldSub, index) => { const newSub = sortedNewSubs[index]; - return ( - this.#stringifySubscription(oldSub) === - this.#stringifySubscription(newSub) - ); + return this.#isSubscriptionEqual(oldSub, newSub); }); } + #isSubscriptionEqual(oldSub?: Subscription, newSub?: Subscription): boolean { + // not equal if one is undefined and the other is defined + if (!oldSub || !newSub) { + if (!oldSub && !newSub) { + return true; + } + return false; + } + + return ( + this.#stringifySubscription(oldSub) === + this.#stringifySubscription(newSub) + ); + } + #stringifySubscription(subscription: Subscription): string { const subsWithSortedProducts = { ...subscription, diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 1a0b26a09cc..82c7faf29db 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -53,6 +53,7 @@ const MOCK_SUBSCRIPTION: Subscription = { last4: '1234', }, }, + isEligibleForSupport: true, }; const MOCK_ACCESS_TOKEN = 'mock-access-token-12345'; @@ -72,6 +73,40 @@ const MOCK_HEADERS = { Authorization: `Bearer ${MOCK_ACCESS_TOKEN}`, }; +const MOCK_COHORTS = [ + { + cohort: 'post_tx', + eligibilityRate: 0.8, + priority: 1, + eligible: true, + }, + { + cohort: 'wallet_home', + eligibilityRate: 0.2, + priority: 2, + eligible: true, + }, +]; + +/** + * Creates a mock subscription eligibility response + * + * @param overrides - Optional overrides for the response + * @returns Mock eligibility response + */ +function createMockEligibilityResponse(overrides = {}) { + return { + product: PRODUCT_TYPES.SHIELD, + canSubscribe: true, + minBalanceUSD: 100, + canViewEntryModal: true, + cohorts: [], + assignedCohort: null, + hasAssignedCohortExpired: false, + ...overrides, + }; +} + /** * Creates a mock subscription service config for testing * @@ -407,25 +442,30 @@ describe('SubscriptionService', () => { describe('getShieldSubscriptionEligibility', () => { it('should get shield subscription eligibility successfully', async () => { await withMockSubscriptionService(async ({ service }) => { - handleFetchMock.mockResolvedValue([ - { - product: PRODUCT_TYPES.SHIELD, - canSubscribe: true, - minBalanceUSD: 100, - canViewEntryModal: true, - }, - ]); + const mockResponse = createMockEligibilityResponse(); + handleFetchMock.mockResolvedValue([mockResponse]); const results = await service.getSubscriptionsEligibilities(); - expect(results).toStrictEqual([ - { - product: PRODUCT_TYPES.SHIELD, - canSubscribe: true, - minBalanceUSD: 100, - canViewEntryModal: true, - }, - ]); + expect(results).toStrictEqual([mockResponse]); + }); + }); + + it('should get shield subscription eligibility with cohort information', async () => { + await withMockSubscriptionService(async ({ service }) => { + const mockResponse = createMockEligibilityResponse({ + cohorts: MOCK_COHORTS, + assignedCohort: 'post_tx', + assignedAt: '2024-01-01T00:00:00Z', + }); + + handleFetchMock.mockResolvedValue([mockResponse]); + + const results = await service.getSubscriptionsEligibilities({ + balanceCategory: '1k-9.9k', + }); + + expect(results).toStrictEqual([mockResponse]); }); }); @@ -442,15 +482,51 @@ describe('SubscriptionService', () => { expect(results).toHaveLength(1); expect(results).toStrictEqual([ - { - product: PRODUCT_TYPES.SHIELD, + createMockEligibilityResponse({ canSubscribe: false, canViewEntryModal: false, - minBalanceUSD: 100, - }, + }), ]); }); }); + + it('should pass balanceCategory as query parameter when provided', async () => { + await withMockSubscriptionService(async ({ service, config }) => { + const mockResponse = createMockEligibilityResponse(); + handleFetchMock.mockResolvedValue([mockResponse]); + + await service.getSubscriptionsEligibilities({ + balanceCategory: '100-999', + }); + + expect(handleFetchMock).toHaveBeenCalledWith( + expect.stringContaining('balanceCategory=100-999'), + expect.objectContaining({ + method: 'GET', + headers: MOCK_HEADERS, + }), + ); + expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); + }); + }); + + it('should not pass balanceCategory query parameter when not provided', async () => { + await withMockSubscriptionService(async ({ service, config }) => { + const mockResponse = createMockEligibilityResponse(); + handleFetchMock.mockResolvedValue([mockResponse]); + + await service.getSubscriptionsEligibilities(); + + expect(handleFetchMock).toHaveBeenCalledWith( + expect.not.stringContaining('balanceCategory'), + expect.objectContaining({ + method: 'GET', + headers: MOCK_HEADERS, + }), + ); + expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); + }); + }); }); describe('submitUserEvent', () => { @@ -474,6 +550,61 @@ describe('SubscriptionService', () => { ); }); }); + + it('should submit user event with cohort successfully', async () => { + await withMockSubscriptionService(async ({ service, config }) => { + handleFetchMock.mockResolvedValue({}); + + await service.submitUserEvent({ + event: SubscriptionUserEvent.ShieldEntryModalViewed, + cohort: 'post_tx', + }); + + expect(handleFetchMock).toHaveBeenCalledWith( + SUBSCRIPTION_URL(config.env, 'user-events'), + { + method: 'POST', + headers: MOCK_HEADERS, + body: JSON.stringify({ + event: SubscriptionUserEvent.ShieldEntryModalViewed, + cohort: 'post_tx', + }), + }, + ); + }); + }); + }); + + describe('assignUserToCohort', () => { + it('should assign user to cohort successfully', async () => { + await withMockSubscriptionService(async ({ service, config }) => { + handleFetchMock.mockResolvedValue({}); + + await service.assignUserToCohort({ cohort: 'post_tx' }); + + expect(handleFetchMock).toHaveBeenCalledWith( + SUBSCRIPTION_URL(config.env, 'cohorts/assign'), + { + method: 'POST', + headers: MOCK_HEADERS, + body: JSON.stringify({ + cohort: 'post_tx', + }), + }, + ); + expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); + }); + }); + + it('should handle cohort assignment errors', async () => { + await withMockSubscriptionService(async ({ service }) => { + handleFetchMock.mockRejectedValue(new Error('Network error')); + + await expect( + service.assignUserToCohort({ cohort: 'wallet_home' }), + ).rejects.toThrow(SubscriptionServiceError); + }); + }); }); describe('submitSponsorshipIntents', () => { diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index e07c467d569..f6122ce2501 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -7,8 +7,10 @@ import { } from './constants'; import { SubscriptionServiceError } from './errors'; import type { + AssignCohortRequest, AuthUtils, BillingPortalResponse, + GetSubscriptionsEligibilitiesRequest, GetSubscriptionsResponse, ISubscriptionService, PricingResponse, @@ -107,15 +109,31 @@ export class SubscriptionService implements ISubscriptionService { /** * Get the eligibility for a shield subscription. * + * @param request - Optional request object containing user balance category to check cohort eligibility * @returns The eligibility for a shield subscription */ - async getSubscriptionsEligibilities(): Promise { + async getSubscriptionsEligibilities( + request?: GetSubscriptionsEligibilitiesRequest, + ): Promise { const path = 'subscriptions/eligibility'; - const results = await this.#makeRequest(path); + let query: Record | undefined; + if (request?.balanceCategory !== undefined) { + query = { balanceCategory: request.balanceCategory }; + } + const results = await this.#makeRequest( + path, + 'GET', + undefined, + query, + ); + return results.map((result) => ({ ...result, canSubscribe: result.canSubscribe || false, canViewEntryModal: result.canViewEntryModal || false, + cohorts: result.cohorts || [], + assignedCohort: result.assignedCohort || null, + hasAssignedCohortExpired: result.hasAssignedCohortExpired || false, })); } @@ -123,13 +141,24 @@ export class SubscriptionService implements ISubscriptionService { * Submit a user event. (e.g. shield modal viewed) * * @param request - Request object containing the event to submit. - * @example { event: SubscriptionUserEvent.ShieldEntryModalViewed } + * @example { event: SubscriptionUserEvent.ShieldEntryModalViewed, cohort: 'post_tx' } */ async submitUserEvent(request: SubmitUserEventRequest): Promise { const path = 'user-events'; await this.#makeRequest(path, 'POST', request); } + /** + * Assign user to a cohort. + * + * @param request - Request object containing the cohort to assign the user to. + * @example { cohort: 'post_tx' } + */ + async assignUserToCohort(request: AssignCohortRequest): Promise { + const path = 'cohorts/assign'; + await this.#makeRequest(path, 'POST', request); + } + /** * Submit sponsorship intents to the Subscription Service backend. * @@ -150,11 +179,18 @@ export class SubscriptionService implements ISubscriptionService { path: string, method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH' = 'GET', body?: Record, + queryParams?: Record, ): Promise { try { const headers = await this.#getAuthorizationHeader(); const url = new URL(SUBSCRIPTION_URL(this.#env, path)); + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + } + const response = await handleFetch(url.toString(), { method, headers: { diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 7805c902a83..62eb17e6eec 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -60,6 +60,11 @@ export type { UpdatePaymentMethodCardResponse, CachedLastSelectedPaymentMethod, SubmitSponsorshipIntentsMethodParams, + Cohort, + CohortName, + BalanceCategory, + AssignCohortRequest, + GetSubscriptionsEligibilitiesRequest, } from './types'; export { CRYPTO_PAYMENT_METHOD_ERRORS, @@ -68,6 +73,8 @@ export { RECURRING_INTERVALS, PAYMENT_TYPES, SubscriptionUserEvent, + COHORT_NAMES, + BALANCE_CATEGORIES, } from './types'; export { SubscriptionServiceError } from './errors'; export { Env, SubscriptionControllerErrorMessage } from './constants'; diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index 518058c6675..a6c15101fde 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -79,6 +79,12 @@ export type Subscription = { trialEnd?: string; // ISO 8601 /** Crypto payment only: next billing cycle date (e.g after 12 months) */ endDate?: string; // ISO 8601 + /** The date the subscription was canceled. */ + canceledAt?: string; // ISO 8601 + /** The date the subscription was marked as inactive (paused/past_due/canceled). */ + inactiveAt?: string; // ISO 8601 + /** Whether the user is eligible for support features (priority support and filing claims). True for active subscriptions and inactive subscriptions within grace period. */ + isEligibleForSupport: boolean; billingCycles?: number; }; @@ -110,6 +116,8 @@ export type GetSubscriptionsResponse = { customerId?: string; subscriptions: Subscription[]; trialedProducts: ProductType[]; + /** The last subscription that user has subscribed to if any. */ + lastSubscription?: Subscription; }; export type StartSubscriptionRequest = { @@ -227,15 +235,45 @@ export type GetCryptoApproveTransactionResponse = { chainId: Hex; }; +export const COHORT_NAMES = { + POST_TX: 'post_tx', + WALLET_HOME: 'wallet_home', +} as const; + +export type CohortName = (typeof COHORT_NAMES)[keyof typeof COHORT_NAMES]; + +export const BALANCE_CATEGORIES = { + RANGE_0_99: '0-99', + RANGE_100_999: '100-999', + RANGE_1K_9_9K: '1k-9.9k', + RANGE_10K_99_9K: '10k-99.9k', + RANGE_100K_999_9K: '100k-999.9k', + RANGE_1M_PLUS: '1M+', +} as const; + +export type BalanceCategory = + (typeof BALANCE_CATEGORIES)[keyof typeof BALANCE_CATEGORIES]; + +export type Cohort = { + cohort: string; + eligibilityRate: number; // 0-1 probability of being assigned to this cohort + priority: number; // lower number = higher priority + eligible: boolean; +}; + export type SubscriptionEligibility = { product: ProductType; canSubscribe: boolean; minBalanceUSD: number; canViewEntryModal: boolean; + cohorts: Cohort[]; + assignedCohort: string | null; + hasAssignedCohortExpired: boolean; }; export const SubscriptionUserEvent = { ShieldEntryModalViewed: 'shield_entry_modal_viewed', + ShieldCohortAssigned: 'shield_cohort_assigned', } as const; export type SubscriptionUserEventType = @@ -243,6 +281,15 @@ export type SubscriptionUserEventType = export type SubmitUserEventRequest = { event: SubscriptionUserEventType; + cohort?: string; +}; + +export type AssignCohortRequest = { + cohort: string; +}; + +export type GetSubscriptionsEligibilitiesRequest = { + balanceCategory?: BalanceCategory; }; /** @@ -284,8 +331,11 @@ export type ISubscriptionService = { updatePaymentMethodCrypto( request: UpdatePaymentMethodCryptoRequest, ): Promise; - getSubscriptionsEligibilities(): Promise; + getSubscriptionsEligibilities( + request?: GetSubscriptionsEligibilitiesRequest, + ): Promise; submitUserEvent(request: SubmitUserEventRequest): Promise; + assignUserToCohort(request: AssignCohortRequest): Promise; /** * Submit sponsorship intents to the Subscription Service backend.