diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index be5e286ae56..8475b558343 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.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] diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index d5e7bceb333..ec028851abb 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -135,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 * @@ -219,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, @@ -233,6 +249,7 @@ function createMockSubscriptionService() { getSubscriptionsEligibilities: mockGetSubscriptionsEligibilities, submitUserEvent: mockSubmitUserEvent, submitSponsorshipIntents: mockSubmitSponsorshipIntents, + assignUserToCohort: mockAssignUserToCohort, }; return { @@ -246,6 +263,7 @@ function createMockSubscriptionService() { mockUpdatePaymentMethodCard, mockUpdatePaymentMethodCrypto, mockSubmitSponsorshipIntents, + mockAssignUserToCohort, }; } @@ -1408,6 +1426,9 @@ describe('SubscriptionController', () => { canSubscribe: true, minBalanceUSD: 100, canViewEntryModal: true, + cohorts: [], + assignedCohort: null, + hasAssignedCohortExpired: false, }; it('should get the subscriptions eligibilities', async () => { @@ -1421,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'; @@ -1449,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); }); }); @@ -1468,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 00e6c76665e..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, @@ -410,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 }) { @@ -704,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) { diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 7af56904b30..82c7faf29db 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -73,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 * @@ -408,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]); }); }); @@ -443,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', () => { @@ -475,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 f007d2de68a..a6c15101fde 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -235,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 = @@ -251,6 +281,15 @@ export type SubscriptionUserEventType = export type SubmitUserEventRequest = { event: SubscriptionUserEventType; + cohort?: string; +}; + +export type AssignCohortRequest = { + cohort: string; +}; + +export type GetSubscriptionsEligibilitiesRequest = { + balanceCategory?: BalanceCategory; }; /** @@ -292,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.