From 28fdf2c54448787a4418ea04d5c3719349ce7734 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:54:25 +0800 Subject: [PATCH 1/5] feat: implement cohort-based gating system for subscriptions --- .../src/SubscriptionController.test.ts | 96 ++++++++++ .../src/SubscriptionController.ts | 36 +++- .../src/SubscriptionService.test.ts | 168 +++++++++++++++--- .../src/SubscriptionService.ts | 42 ++++- packages/subscription-controller/src/index.ts | 6 + packages/subscription-controller/src/types.ts | 32 +++- 6 files changed, 352 insertions(+), 28 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 6b91f7218c8..5286c380ece 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -134,6 +134,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 +233,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 +248,7 @@ function createMockSubscriptionService() { getSubscriptionsEligibilities: mockGetSubscriptionsEligibilities, submitUserEvent: mockSubmitUserEvent, submitSponsorshipIntents: mockSubmitSponsorshipIntents, + assignUserToCohort: mockAssignUserToCohort, }; return { @@ -245,6 +262,7 @@ function createMockSubscriptionService() { mockUpdatePaymentMethodCard, mockUpdatePaymentMethodCrypto, mockSubmitSponsorshipIntents, + mockAssignUserToCohort, }; } @@ -1384,6 +1402,9 @@ describe('SubscriptionController', () => { canSubscribe: true, minBalanceUSD: 100, canViewEntryModal: true, + cohorts: [], + assignedCohort: null, + hasAssignedCohortExpired: false, }; it('should get the subscriptions eligibilities', async () => { @@ -1397,6 +1418,29 @@ describe('SubscriptionController', () => { }); }); + it('should get the subscriptions eligibilities with balanceUsd parameter', async () => { + await withController(async ({ controller, mockService }) => { + const mockEligibilityWithCohorts: SubscriptionEligibility = { + ...MOCK_SUBSCRIPTION_ELIGIBILITY, + cohorts: MOCK_COHORTS, + assignedCohort: 'post_tx', + }; + + mockService.getSubscriptionsEligibilities.mockResolvedValue([ + mockEligibilityWithCohorts, + ]); + + const balanceUsd = 1500; + const result = await controller.getSubscriptionsEligibilities({ + balanceUsd, + }); + expect(result).toStrictEqual([mockEligibilityWithCohorts]); + expect(mockService.getSubscriptionsEligibilities).toHaveBeenCalledWith({ + balanceUsd, + }); + }); + }); + it('should handle subscription service errors', async () => { await withController(async ({ controller, mockService }) => { const errorMessage = 'Failed to get subscriptions eligibilities'; @@ -1425,6 +1469,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 +1508,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..00c3ac4a450 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, @@ -108,6 +110,11 @@ export type SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction = handler: SubscriptionController['submitShieldSubscriptionCryptoApproval']; }; +export type SubscriptionControllerAssignUserToCohortAction = { + type: `${typeof controllerName}:assignUserToCohort`; + handler: SubscriptionController['assignUserToCohort']; +}; + export type SubscriptionControllerGetStateAction = ControllerGetStateAction< typeof controllerName, SubscriptionControllerState @@ -124,7 +131,8 @@ export type SubscriptionControllerActions = | SubscriptionControllerUpdatePaymentMethodAction | SubscriptionControllerGetBillingPortalUrlAction | SubscriptionControllerSubmitSponsorshipIntentsAction - | SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction; + | SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction + | SubscriptionControllerAssignUserToCohortAction; export type AllowedActions = | AuthenticationController.AuthenticationControllerGetBearerToken @@ -323,6 +331,11 @@ export class SubscriptionController extends StaticIntervalPollingController()< `${controllerName}:submitShieldSubscriptionCryptoApproval`, this.submitShieldSubscriptionCryptoApproval.bind(this), ); + + this.messenger.registerActionHandler( + `${controllerName}:assignUserToCohort`, + this.assignUserToCohort.bind(this), + ); } /** @@ -394,10 +407,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 +706,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 1a0b26a09cc..0b2334cf0f8 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -72,6 +72,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 +441,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({ + balanceUsd: 1500, + }); + + expect(results).toStrictEqual([mockResponse]); }); }); @@ -442,15 +481,49 @@ describe('SubscriptionService', () => { expect(results).toHaveLength(1); expect(results).toStrictEqual([ - { - product: PRODUCT_TYPES.SHIELD, + createMockEligibilityResponse({ canSubscribe: false, canViewEntryModal: false, - minBalanceUSD: 100, - }, + }), ]); }); }); + + it('should pass balanceUsd as query parameter when provided', async () => { + await withMockSubscriptionService(async ({ service, config }) => { + const mockResponse = createMockEligibilityResponse(); + handleFetchMock.mockResolvedValue([mockResponse]); + + await service.getSubscriptionsEligibilities({ balanceUsd: 100 }); + + expect(handleFetchMock).toHaveBeenCalledWith( + expect.stringContaining('balanceUsd=100'), + expect.objectContaining({ + method: 'GET', + headers: MOCK_HEADERS, + }), + ); + expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); + }); + }); + + it('should not pass balanceUsd 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('balanceUsd'), + expect.objectContaining({ + method: 'GET', + headers: MOCK_HEADERS, + }), + ); + expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); + }); + }); }); describe('submitUserEvent', () => { @@ -474,6 +547,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..123993841c6 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 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?.balanceUsd !== undefined) { + query = { balanceUsd: String(request.balanceUsd) }; + } + 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..380e53d7619 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -16,6 +16,7 @@ export type { SubscriptionControllerOptions, SubscriptionControllerStateChangeEvent, SubscriptionControllerSubmitSponsorshipIntentsAction, + SubscriptionControllerAssignUserToCohortAction, AllowedActions, AllowedEvents, } from './SubscriptionController'; @@ -60,6 +61,10 @@ export type { UpdatePaymentMethodCardResponse, CachedLastSelectedPaymentMethod, SubmitSponsorshipIntentsMethodParams, + Cohort, + CohortName, + AssignCohortRequest, + GetSubscriptionsEligibilitiesRequest, } from './types'; export { CRYPTO_PAYMENT_METHOD_ERRORS, @@ -68,6 +73,7 @@ export { RECURRING_INTERVALS, PAYMENT_TYPES, SubscriptionUserEvent, + COHORT_NAMES, } 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..21b47926d78 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -227,15 +227,33 @@ 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 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 +261,15 @@ export type SubscriptionUserEventType = export type SubmitUserEventRequest = { event: SubscriptionUserEventType; + cohort?: string; +}; + +export type AssignCohortRequest = { + cohort: string; +}; + +export type GetSubscriptionsEligibilitiesRequest = { + balanceUsd?: number; }; /** @@ -284,8 +311,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. From 7ff8602dd96b95fe61f4f06ffba2c8edfdea2596 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:40:12 +0800 Subject: [PATCH 2/5] refactor: remove unused SubscriptionControllerAssignUserToCohortAction type and related code --- .../src/SubscriptionController.ts | 13 +------------ packages/subscription-controller/src/index.ts | 1 - 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index 00c3ac4a450..2b901cb0501 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -110,11 +110,6 @@ export type SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction = handler: SubscriptionController['submitShieldSubscriptionCryptoApproval']; }; -export type SubscriptionControllerAssignUserToCohortAction = { - type: `${typeof controllerName}:assignUserToCohort`; - handler: SubscriptionController['assignUserToCohort']; -}; - export type SubscriptionControllerGetStateAction = ControllerGetStateAction< typeof controllerName, SubscriptionControllerState @@ -131,8 +126,7 @@ export type SubscriptionControllerActions = | SubscriptionControllerUpdatePaymentMethodAction | SubscriptionControllerGetBillingPortalUrlAction | SubscriptionControllerSubmitSponsorshipIntentsAction - | SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction - | SubscriptionControllerAssignUserToCohortAction; + | SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction; export type AllowedActions = | AuthenticationController.AuthenticationControllerGetBearerToken @@ -331,11 +325,6 @@ export class SubscriptionController extends StaticIntervalPollingController()< `${controllerName}:submitShieldSubscriptionCryptoApproval`, this.submitShieldSubscriptionCryptoApproval.bind(this), ); - - this.messenger.registerActionHandler( - `${controllerName}:assignUserToCohort`, - this.assignUserToCohort.bind(this), - ); } /** diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 380e53d7619..2f7e1b52a7f 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -16,7 +16,6 @@ export type { SubscriptionControllerOptions, SubscriptionControllerStateChangeEvent, SubscriptionControllerSubmitSponsorshipIntentsAction, - SubscriptionControllerAssignUserToCohortAction, AllowedActions, AllowedEvents, } from './SubscriptionController'; From 54bc9ec6a938fea014d6beb5aef1265fc34b865c Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:01:48 +0800 Subject: [PATCH 3/5] chore: Update changelog --- packages/subscription-controller/CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 5bd5f4153a4..b565578515a 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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`, `AssignCohortRequest`, `GetSubscriptionsEligibilitiesRequest` ([#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 optional `cohort` parameter to `submitSubscriptionUserEvents` for event tracking ([#7099](https://github.com/MetaMask/core/pull/7099)) + +### Changed + +- Update `getSubscriptionsEligibilities` to accept optional `balanceUsd` parameter for cohort eligibility evaluation ([#7099](https://github.com/MetaMask/core/pull/7099)) +- Refactor `SubscriptionService` to use `makeRequest` helper for cleaner query parameter handling ([#7099](https://github.com/MetaMask/core/pull/7099)) + ## [3.3.0] ### Changed From 7f5161b8a23ef1be1bfada1e049bdce44e092096 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:26:10 +0800 Subject: [PATCH 4/5] fix: replace balanceUsd with balanceCategory for privacy-preserving cohort eligibility --- packages/subscription-controller/CHANGELOG.md | 10 ++++++---- .../src/SubscriptionController.test.ts | 8 ++++---- .../src/SubscriptionService.test.ts | 14 ++++++++------ .../src/SubscriptionService.ts | 6 +++--- packages/subscription-controller/src/constants.ts | 2 +- packages/subscription-controller/src/index.ts | 2 ++ packages/subscription-controller/src/types.ts | 14 +++++++++++++- 7 files changed, 37 insertions(+), 19 deletions(-) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index b565578515a..1df0e7a6947 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -10,14 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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`, `AssignCohortRequest`, `GetSubscriptionsEligibilitiesRequest` ([#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 optional `cohort` parameter to `submitSubscriptionUserEvents` for event tracking ([#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 -- Update `getSubscriptionsEligibilities` to accept optional `balanceUsd` parameter for cohort eligibility evaluation ([#7099](https://github.com/MetaMask/core/pull/7099)) -- Refactor `SubscriptionService` to use `makeRequest` helper for cleaner query parameter handling ([#7099](https://github.com/MetaMask/core/pull/7099)) +- 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 5286c380ece..bb1518ea2be 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -1418,7 +1418,7 @@ describe('SubscriptionController', () => { }); }); - it('should get the subscriptions eligibilities with balanceUsd parameter', async () => { + it('should get the subscriptions eligibilities with balanceCategory parameter', async () => { await withController(async ({ controller, mockService }) => { const mockEligibilityWithCohorts: SubscriptionEligibility = { ...MOCK_SUBSCRIPTION_ELIGIBILITY, @@ -1430,13 +1430,13 @@ describe('SubscriptionController', () => { mockEligibilityWithCohorts, ]); - const balanceUsd = 1500; + const balanceCategory = '1k-9.9k'; const result = await controller.getSubscriptionsEligibilities({ - balanceUsd, + balanceCategory, }); expect(result).toStrictEqual([mockEligibilityWithCohorts]); expect(mockService.getSubscriptionsEligibilities).toHaveBeenCalledWith({ - balanceUsd, + balanceCategory, }); }); }); diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 0b2334cf0f8..c6e57fdcc49 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -461,7 +461,7 @@ describe('SubscriptionService', () => { handleFetchMock.mockResolvedValue([mockResponse]); const results = await service.getSubscriptionsEligibilities({ - balanceUsd: 1500, + balanceCategory: '1k-9.9k', }); expect(results).toStrictEqual([mockResponse]); @@ -489,15 +489,17 @@ describe('SubscriptionService', () => { }); }); - it('should pass balanceUsd as query parameter when provided', async () => { + it('should pass balanceCategory as query parameter when provided', async () => { await withMockSubscriptionService(async ({ service, config }) => { const mockResponse = createMockEligibilityResponse(); handleFetchMock.mockResolvedValue([mockResponse]); - await service.getSubscriptionsEligibilities({ balanceUsd: 100 }); + await service.getSubscriptionsEligibilities({ + balanceCategory: '100-999', + }); expect(handleFetchMock).toHaveBeenCalledWith( - expect.stringContaining('balanceUsd=100'), + expect.stringContaining('balanceCategory=100-999'), expect.objectContaining({ method: 'GET', headers: MOCK_HEADERS, @@ -507,7 +509,7 @@ describe('SubscriptionService', () => { }); }); - it('should not pass balanceUsd query parameter when not provided', async () => { + it('should not pass balanceCategory query parameter when not provided', async () => { await withMockSubscriptionService(async ({ service, config }) => { const mockResponse = createMockEligibilityResponse(); handleFetchMock.mockResolvedValue([mockResponse]); @@ -515,7 +517,7 @@ describe('SubscriptionService', () => { await service.getSubscriptionsEligibilities(); expect(handleFetchMock).toHaveBeenCalledWith( - expect.not.stringContaining('balanceUsd'), + expect.not.stringContaining('balanceCategory'), expect.objectContaining({ method: 'GET', headers: MOCK_HEADERS, diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index 123993841c6..f6122ce2501 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -109,7 +109,7 @@ export class SubscriptionService implements ISubscriptionService { /** * Get the eligibility for a shield subscription. * - * @param request - Optional request object containing user balance to check cohort eligibility + * @param request - Optional request object containing user balance category to check cohort eligibility * @returns The eligibility for a shield subscription */ async getSubscriptionsEligibilities( @@ -117,8 +117,8 @@ export class SubscriptionService implements ISubscriptionService { ): Promise { const path = 'subscriptions/eligibility'; let query: Record | undefined; - if (request?.balanceUsd !== undefined) { - query = { balanceUsd: String(request.balanceUsd) }; + if (request?.balanceCategory !== undefined) { + query = { balanceCategory: request.balanceCategory }; } const results = await this.#makeRequest( path, diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index d5bdc8ed0f7..a9ac502649c 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -14,7 +14,7 @@ type EnvUrlsEntry = { const ENV_URLS: Record = { dev: { - subscriptionApiUrl: 'https://subscription.dev-api.cx.metamask.io', + subscriptionApiUrl: 'http://localhost:3000', }, uat: { subscriptionApiUrl: 'https://subscription.uat-api.cx.metamask.io', diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 2f7e1b52a7f..62eb17e6eec 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -62,6 +62,7 @@ export type { SubmitSponsorshipIntentsMethodParams, Cohort, CohortName, + BalanceCategory, AssignCohortRequest, GetSubscriptionsEligibilitiesRequest, } from './types'; @@ -73,6 +74,7 @@ export { 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 21b47926d78..2308a96ee05 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -234,6 +234,18 @@ export const COHORT_NAMES = { 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 @@ -269,7 +281,7 @@ export type AssignCohortRequest = { }; export type GetSubscriptionsEligibilitiesRequest = { - balanceUsd?: number; + balanceCategory?: BalanceCategory; }; /** From 98bfffd6feceb927cb4df0299c42d5b51e66740f Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Mon, 10 Nov 2025 22:31:16 +0800 Subject: [PATCH 5/5] fix: update subscription API URL for development environment --- packages/subscription-controller/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index a9ac502649c..d5bdc8ed0f7 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -14,7 +14,7 @@ type EnvUrlsEntry = { const ENV_URLS: Record = { dev: { - subscriptionApiUrl: 'http://localhost:3000', + subscriptionApiUrl: 'https://subscription.dev-api.cx.metamask.io', }, uat: { subscriptionApiUrl: 'https://subscription.uat-api.cx.metamask.io',