diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index a30b8011da..aacfa88c2d 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -7,8 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed +### Added +- Add multi-product and delegation-based crypto subscription types and pricing fields. ([#9866](https://github.com/MetaMask/core/pull/9866)) + - `PRODUCT_TYPES.MONEY_ACCOUNT_PLUS` + - `CRYPTO_AUTH_METHODS` (`erc20_approval`, `delegation`) and `CryptoAuthMethod` + - `StartErc20CryptoSubscriptionRequest` and `StartDelegationCryptoSubscriptionRequest` + - `PricingCardPaymentMethod` and `PricingCryptoPaymentMethod` variants, with optional `products` and (crypto only) `cryptoAuthMethod` + - Optional `ChainPaymentInfo.delegateAddress` + - `SpotTokenPaymentInfo` and `VaultTokenPaymentInfo` variants, with optional `sources`; vault shares require `accountantAddress` + +### Changed + +- **BREAKING:** Model `PricingPaymentMethod` as a discriminated union of card vs crypto. `chains` and `cryptoAuthMethod` exist only on the crypto variant; narrow with `type === 'crypto'` before reading them. ([#9866](https://github.com/MetaMask/core/pull/9866)) +- **BREAKING:** Model `TokenPaymentInfo` as a discriminated union of vault vs spot. `accountantAddress` is required when `isVaultShare` is true and is not present on spot tokens; narrow with `isVaultShare === true` before reading `accountantAddress`. ([#9866](https://github.com/MetaMask/core/pull/9866)) +- **BREAKING:** Rename `startShieldSubscriptionWithCard` to `startSubscriptionWithCard`. ([#9866](https://github.com/MetaMask/core/pull/9866)) + - Rename `SubscriptionController.startShieldSubscriptionWithCard` to `startSubscriptionWithCard`. + - Rename the messenger action `SubscriptionController:startShieldSubscriptionWithCard` to `SubscriptionController:startSubscriptionWithCard`. + - Rename the exported action type `SubscriptionControllerStartShieldSubscriptionWithCardAction` to `SubscriptionControllerStartSubscriptionWithCardAction`. +- **BREAKING:** Rename `submitShieldSubscriptionCryptoApproval` to `submitSubscriptionCryptoApproval` and take a request object instead of positional arguments. ([#9866](https://github.com/MetaMask/core/pull/9866)) + - Rename `SubscriptionController.submitShieldSubscriptionCryptoApproval` to `submitSubscriptionCryptoApproval`. + - Rename the messenger action `SubscriptionController:submitShieldSubscriptionCryptoApproval` to `SubscriptionController:submitSubscriptionCryptoApproval`. + - Rename the exported action type `SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction` to `SubscriptionControllerSubmitSubscriptionCryptoApprovalAction`. + - Callers pass `{ productType, txMeta, isSponsored?, rewardAccountId? }` (`SubmitSubscriptionCryptoApprovalRequest`). + - This handler is Shield ERC-20 approve only. `productType` is typed as `typeof PRODUCT_TYPES.SHIELD` (not `ProductType`); `txMeta.type` must be `TransactionType.shieldSubscriptionApprove`. Other products should use `startSubscriptionWithCrypto`. +- **BREAKING:** Make `TokenPaymentInfo.conversionRate` optional. Consumers that access `.conversionRate.usd` without optional chaining will fail typecheck. ([#9866](https://github.com/MetaMask/core/pull/9866)) +- **BREAKING:** Change `SubscriptionControllerState.lastSelectedPaymentMethod` from `Record` to `Partial>`. Product keys may be absent; consumers must handle missing entries. ([#9866](https://github.com/MetaMask/core/pull/9866)) +- **BREAKING:** `SubscriptionController.cacheLastSelectedPaymentMethod` now takes a request object instead of positional arguments. ([#9866](https://github.com/MetaMask/core/pull/9866)) + - Callers pass `{ product, paymentMethod }` (`CacheLastSelectedPaymentMethodRequest`). +- **BREAKING:** Model `StartCryptoSubscriptionRequest` as a discriminated union of ERC-20 approval vs delegation. ([#9866](https://github.com/MetaMask/core/pull/9866)) + - ERC-20: required `rawTransaction`; optional `cryptoAuthMethod: 'erc20_approval'` (the default when omitted). + - Delegation: required `cryptoAuthMethod: 'delegation'` and `delegationHash`. + - Combining or omitting both auth fields is a type error. Runtime validation in `startSubscriptionWithCrypto` remains for unsound callers. + - New exports: `StartErc20CryptoSubscriptionRequest`, `StartDelegationCryptoSubscriptionRequest`. +- Generalize subscription controller flows for multiple products: product-scoped crypto payment-method lookup, and trial requests derived from `trialPeriodDays` plus `trialedProducts`. ([#9866](https://github.com/MetaMask/core/pull/9866)) - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) ## [7.0.0] diff --git a/packages/subscription-controller/src/SubscriptionController-method-action-types.ts b/packages/subscription-controller/src/SubscriptionController-method-action-types.ts index d2ad7f4c3d..7e96ff6d48 100644 --- a/packages/subscription-controller/src/SubscriptionController-method-action-types.ts +++ b/packages/subscription-controller/src/SubscriptionController-method-action-types.ts @@ -52,29 +52,64 @@ export type SubscriptionControllerUnCancelSubscriptionAction = { handler: SubscriptionController['unCancelSubscription']; }; -export type SubscriptionControllerStartShieldSubscriptionWithCardAction = { - type: `SubscriptionController:startShieldSubscriptionWithCard`; - handler: SubscriptionController['startShieldSubscriptionWithCard']; +/** + * Starts a card-paid subscription checkout session for the requested products + * (e.g. Shield or Money Account Plus). + * + * `isTrialRequested` on the request is ignored and overwritten from pricing + * (`trialPeriodDays > 0`) and `trialedProducts`. + * + * @param request - The start subscription request. + * @returns The checkout session response. + */ +export type SubscriptionControllerStartSubscriptionWithCardAction = { + type: `SubscriptionController:startSubscriptionWithCard`; + handler: SubscriptionController['startSubscriptionWithCard']; }; +/** + * Starts a crypto-paid subscription for the requested products + * (e.g. Shield or Money Account Plus). Unlike card checkout, this + * creates the subscription immediately, so local state is refreshed + * afterwards. + * + * `isTrialRequested` on the request is ignored and overwritten from pricing + * (`trialPeriodDays > 0`) and `trialedProducts`. + * + * @param request - The start crypto subscription request. + * @returns The start crypto subscription response. + * @throws If `products` is empty. + */ export type SubscriptionControllerStartSubscriptionWithCryptoAction = { type: `SubscriptionController:startSubscriptionWithCrypto`; handler: SubscriptionController['startSubscriptionWithCrypto']; }; /** - * Handles shield subscription crypto approval transactions. + * Submits a Shield ERC-20 crypto approval transaction to start or update a + * crypto subscription. * - * @param txMeta - The transaction metadata. - * @param isSponsored - Whether the transaction is sponsored. - * @param rewardAccountId - The account ID of the reward subscription to link to the shield subscription. + * This handler is Shield / `TransactionType.shieldSubscriptionApprove` only. + * Delegation-based products (e.g. Money Account) must call + * `startSubscriptionWithCrypto` instead. + * + * @param request - The crypto approval request. + * @param request.productType - The subscription product. Typed as + * `typeof PRODUCT_TYPES.SHIELD` only at the moment (future might support more + * product). + * @param request.txMeta - The transaction metadata. Must have type + * `TransactionType.shieldSubscriptionApprove`. + * @param request.isSponsored - Whether the transaction is sponsored. + * @param request.rewardAccountId - The account ID of the reward subscription + * to link. + * @throws If `productType` is not Shield or `txMeta.type` is not + * `shieldSubscriptionApprove`. * @returns void */ -export type SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction = - { - type: `SubscriptionController:submitShieldSubscriptionCryptoApproval`; - handler: SubscriptionController['submitShieldSubscriptionCryptoApproval']; - }; +export type SubscriptionControllerSubmitSubscriptionCryptoApprovalAction = { + type: `SubscriptionController:submitSubscriptionCryptoApproval`; + handler: SubscriptionController['submitSubscriptionCryptoApproval']; +}; /** * Get transaction params to create crypto approve transaction for subscription payment @@ -109,12 +144,12 @@ export type SubscriptionControllerGetBillingPortalUrlAction = { /** * Cache the last selected payment method for a specific product. * - * @param product - The product to cache the payment method for. - * @param paymentMethod - The payment method to cache. - * @param paymentMethod.type - The type of the payment method. - * @param paymentMethod.paymentTokenAddress - The payment token address. - * @param paymentMethod.plan - The plan of the payment method. - * @param paymentMethod.product - The product of the payment method. + * @param request - The request object. + * @param request.product - The product to cache the payment method for. + * @param request.paymentMethod - The payment method to cache. + * @param request.paymentMethod.type - The type of the payment method. + * @param request.paymentMethod.paymentTokenAddress - The payment token address. + * @param request.paymentMethod.plan - The plan of the payment method. */ export type SubscriptionControllerCacheLastSelectedPaymentMethodAction = { type: `SubscriptionController:cacheLastSelectedPaymentMethod`; @@ -144,7 +179,8 @@ export type SubscriptionControllerClearLastSelectedPaymentMethodAction = { * recurringInterval: RecurringInterval.Month, * billingCycles: 1, * } - * @returns resolves to true if the sponsorship is supported and intents were submitted successfully, false otherwise + * @returns resolves to true if the sponsorship is supported and intents were submitted successfully, false if the chain does not support sponsorship or the user has already trialed + * @throws If the crypto payment method or chain is missing from pricing */ export type SubscriptionControllerSubmitSponsorshipIntentsAction = { type: `SubscriptionController:submitSponsorshipIntents`; @@ -242,9 +278,9 @@ export type SubscriptionControllerMethodActions = | SubscriptionControllerGetSubscriptionsEligibilitiesAction | SubscriptionControllerCancelSubscriptionAction | SubscriptionControllerUnCancelSubscriptionAction - | SubscriptionControllerStartShieldSubscriptionWithCardAction + | SubscriptionControllerStartSubscriptionWithCardAction | SubscriptionControllerStartSubscriptionWithCryptoAction - | SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction + | SubscriptionControllerSubmitSubscriptionCryptoApprovalAction | SubscriptionControllerGetCryptoApproveTransactionParamsAction | SubscriptionControllerUpdatePaymentMethodAction | SubscriptionControllerGetBillingPortalUrlAction diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 250a0e48ba..178d09d090 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -31,7 +31,7 @@ import type { Subscription, PricingResponse, ProductPricing, - PricingPaymentMethod, + PricingCryptoPaymentMethod, StartCryptoSubscriptionRequest, StartCryptoSubscriptionResponse, UpdatePaymentMethodOpts, @@ -85,6 +85,27 @@ const MOCK_SUBSCRIPTION: Subscription = { cancelType: CANCEL_TYPES.ALLOWED_AT_PERIOD_END, }; +const MOCK_MONEY_ACCOUNT_SUBSCRIPTION: Subscription = { + ...MOCK_SUBSCRIPTION, + id: 'sub_money_account', + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + currency: 'usd', + unitAmount: 499, + unitDecimals: 2, + }, + ], + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: '0x1234567890123456789012345678901234567890', + chainId: '0x8f', + tokenSymbol: 'pvmUSD', + }, + }, +}; + const MOCK_PRODUCT_PRICE: ProductPricing = { name: PRODUCT_TYPES.SHIELD, prices: [ @@ -93,7 +114,7 @@ const MOCK_PRODUCT_PRICE: ProductPricing = { currency: 'usd', unitAmount: 900, unitDecimals: 2, - trialPeriodDays: 0, + trialPeriodDays: 14, minBillingCycles: 12, minBillingCyclesForBalance: 1, }, @@ -109,12 +130,35 @@ const MOCK_PRODUCT_PRICE: ProductPricing = { ], }; -const MOCK_PRICING_PAYMENT_METHOD: PricingPaymentMethod = { +const MOCK_PRODUCT_PRICE_WITHOUT_TRIAL: ProductPricing = { + ...MOCK_PRODUCT_PRICE, + prices: MOCK_PRODUCT_PRICE.prices.map((price) => ({ + ...price, + trialPeriodDays: 0, + })), +}; + +const MOCK_MONEY_ACCOUNT_PRODUCT_PRICE: ProductPricing = { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + prices: [ + { + interval: RECURRING_INTERVALS.month, + currency: 'usd', + unitAmount: 499, + unitDecimals: 2, + trialPeriodDays: 0, + minBillingCycles: 12, + minBillingCyclesForBalance: 1, + }, + ], +}; + +const MOCK_PRICING_PAYMENT_METHOD: PricingCryptoPaymentMethod = { type: PAYMENT_TYPES.byCrypto, chains: [ { chainId: '0x1', - paymentAddress: '0xspender', + paymentAddress: '0x00000000000000000000000000000000000000a2', isSponsorshipSupported: true, tokens: [ { @@ -139,6 +183,12 @@ const MOCK_GET_SUBSCRIPTIONS_RESPONSE = { trialedProducts: [], }; +const MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE = { + customerId: 'cus_1', + subscriptions: [] as Subscription[], + trialedProducts: [] as ProductType[], +}; + const MOCK_COHORTS = [ { cohort: 'post_tx', @@ -766,6 +816,46 @@ describe('SubscriptionController', () => { }, ); }); + + it('should fetch and store active Shield and Money Account subscriptions together', async () => { + await withController( + async ({ controller, rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION, MOCK_MONEY_ACCOUNT_SUBSCRIPTION], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }); + + const result = await rootMessenger.call( + 'SubscriptionController:getSubscriptions', + ); + + expect(result).toStrictEqual([ + MOCK_SUBSCRIPTION, + MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + ]); + expect(controller.state.subscriptions).toStrictEqual([ + MOCK_SUBSCRIPTION, + MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + ]); + expect(controller.state.trialedProducts).toStrictEqual([ + PRODUCT_TYPES.SHIELD, + ]); + expect( + rootMessenger.call( + 'SubscriptionController:getSubscriptionByProduct', + PRODUCT_TYPES.SHIELD, + ), + ).toStrictEqual(MOCK_SUBSCRIPTION); + expect( + rootMessenger.call( + 'SubscriptionController:getSubscriptionByProduct', + PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + ), + ).toStrictEqual(MOCK_MONEY_ACCOUNT_SUBSCRIPTION); + }, + ); + }); }); describe('getSubscriptionByProduct', () => { @@ -799,6 +889,146 @@ describe('SubscriptionController', () => { }); }); + describe('multi-product subscriptions', () => { + it('should hold active Shield and Money Account subscriptions simultaneously', async () => { + await withController( + { + state: { + subscriptions: [MOCK_SUBSCRIPTION, MOCK_MONEY_ACCOUNT_SUBSCRIPTION], + trialedProducts: [PRODUCT_TYPES.SHIELD], + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xmoneytoken', + paymentTokenSymbol: 'pvmUSD', + plan: RECURRING_INTERVALS.month, + cryptoAuthMethod: 'delegation', + }, + }, + }, + }, + async ({ controller, rootMessenger }) => { + expect( + rootMessenger.call( + 'SubscriptionController:getSubscriptionByProduct', + PRODUCT_TYPES.SHIELD, + ), + ).toStrictEqual(MOCK_SUBSCRIPTION); + expect( + rootMessenger.call( + 'SubscriptionController:getSubscriptionByProduct', + PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + ), + ).toStrictEqual(MOCK_MONEY_ACCOUNT_SUBSCRIPTION); + expect(controller.state.subscriptions).toHaveLength(2); + expect(controller.state.trialedProducts).toStrictEqual([ + PRODUCT_TYPES.SHIELD, + ]); + expect( + controller.state.lastSelectedPaymentMethod?.[PRODUCT_TYPES.SHIELD], + ).toBeDefined(); + expect( + controller.state.lastSelectedPaymentMethod?.[ + PRODUCT_TYPES.MONEY_ACCOUNT_PLUS + ], + ).toBeDefined(); + }, + ); + }); + + it('should allow starting Money Account card checkout while Shield is active', async () => { + const checkoutResponse = { + checkoutSessionUrl: 'https://checkout.example.com/money-account', + }; + + await withController( + { + state: { + subscriptions: [MOCK_SUBSCRIPTION], + pricing: { + products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], + paymentMethods: [], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION], + trialedProducts: [], + }); + mockService.startSubscriptionWithCard.mockResolvedValue( + checkoutResponse, + ); + + const result = await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCard', + { + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + }, + ); + + expect(result).toStrictEqual(checkoutResponse); + expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + }); + }, + ); + }); + + it('should sync lastSubscription from API without using it as active product lookup', async () => { + const canceledShieldSubscription = { + ...MOCK_SUBSCRIPTION, + status: SUBSCRIPTION_STATUSES.canceled, + }; + + await withController( + { + state: { + subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION], + lastSubscription: undefined, + }, + }, + async ({ controller, rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION], + trialedProducts: [], + lastSubscription: canceledShieldSubscription, + }); + + await rootMessenger.call('SubscriptionController:getSubscriptions'); + + expect(controller.state.lastSubscription).toStrictEqual( + canceledShieldSubscription, + ); + expect( + rootMessenger.call( + 'SubscriptionController:getSubscriptionByProduct', + PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + ), + ).toStrictEqual(MOCK_MONEY_ACCOUNT_SUBSCRIPTION); + expect( + rootMessenger.call( + 'SubscriptionController:getSubscriptionByProduct', + PRODUCT_TYPES.SHIELD, + ), + ).toBeUndefined(); + }, + ); + }); + }); + describe('cancelSubscription', () => { it('should cancel subscription successfully', async () => { const mockSubscription2 = { ...MOCK_SUBSCRIPTION, id: 'sub_2' }; @@ -1005,7 +1235,7 @@ describe('SubscriptionController', () => { }); }); - describe('startShieldSubscriptionWithCard', () => { + describe('startSubscriptionWithCard', () => { const MOCK_START_SUBSCRIPTION_RESPONSE = { checkoutSessionUrl: 'https://checkout.example.com/session/123', }; @@ -1015,15 +1245,19 @@ describe('SubscriptionController', () => { { state: { subscriptions: [], + pricing: MOCK_PRICE_INFO_RESPONSE, }, }, async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, + ); mockService.startSubscriptionWithCard.mockResolvedValue( MOCK_START_SUBSCRIPTION_RESPONSE, ); const result = await rootMessenger.call( - 'SubscriptionController:startShieldSubscriptionWithCard', + 'SubscriptionController:startSubscriptionWithCard', { products: [PRODUCT_TYPES.SHIELD], isTrialRequested: true, @@ -1049,9 +1283,15 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION], + trialedProducts: [], + }); + await expect( rootMessenger.call( - 'SubscriptionController:startShieldSubscriptionWithCard', + 'SubscriptionController:startSubscriptionWithCard', { products: [PRODUCT_TYPES.SHIELD], isTrialRequested: true, @@ -1073,9 +1313,13 @@ describe('SubscriptionController', () => { { state: { subscriptions: [], + pricing: MOCK_PRICE_INFO_RESPONSE, }, }, async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, + ); const errorMessage = 'Failed to start subscription'; mockService.startSubscriptionWithCard.mockRejectedValue( new SubscriptionServiceError(errorMessage), @@ -1083,7 +1327,7 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:startShieldSubscriptionWithCard', + 'SubscriptionController:startSubscriptionWithCard', { products: [PRODUCT_TYPES.SHIELD], isTrialRequested: true, @@ -1100,59 +1344,879 @@ describe('SubscriptionController', () => { }, ); }); - }); - describe('startCryptoSubscription', () => { - it('should start crypto subscription successfully when user is not subscribed', async () => { + it('overwrites client-supplied isTrialRequested from pricing and trialedProducts', async () => { await withController( { state: { subscriptions: [], + trialedProducts: [], + pricing: MOCK_PRICE_INFO_RESPONSE, }, }, async ({ rootMessenger, mockService }) => { - const request: StartCryptoSubscriptionRequest = { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 3, - chainId: '0x1', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'USDC', - rawTransaction: '0xdeadbeef', - }; - - const response: StartCryptoSubscriptionResponse = { - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }; - - mockService.startSubscriptionWithCrypto.mockResolvedValue(response); - - const result = await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - request, + mockService.getSubscriptions.mockResolvedValue( + MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, + ); + mockService.startSubscriptionWithCard.mockResolvedValue( + MOCK_START_SUBSCRIPTION_RESPONSE, ); - expect(result).toStrictEqual(response); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - request, + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCard', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + }, ); + + expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + }); }, ); }); - }); - - describe('startPolling', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - afterEach(() => { - jest.useRealTimers(); - }); + it('does not request a trial when the product has already been trialed', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }); + mockService.startSubscriptionWithCard.mockResolvedValue( + MOCK_START_SUBSCRIPTION_RESPONSE, + ); - it('should call getSubscriptions with the correct interval', async () => { + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCard', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + }, + ); + + expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + }); + }, + ); + }); + + it('does not request a trial when pricing has no trial period', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [], + pricing: { + products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], + paymentMethods: [], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, + ); + mockService.startSubscriptionWithCard.mockResolvedValue( + MOCK_START_SUBSCRIPTION_RESPONSE, + ); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCard', + { + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + }, + ); + + expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + }); + }, + ); + }); + + it('does not request a trial when Shield pricing has trialPeriodDays of 0', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [], + pricing: { + products: [MOCK_PRODUCT_PRICE_WITHOUT_TRIAL], + paymentMethods: [], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, + ); + mockService.startSubscriptionWithCard.mockResolvedValue( + MOCK_START_SUBSCRIPTION_RESPONSE, + ); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCard', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + }, + ); + + expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + }); + }, + ); + }); + + it('throws when product pricing is not available', async () => { + await withController( + { + state: { + subscriptions: [], + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, + ); + + await expect( + rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCard', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + }, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.ProductPriceNotFound, + ); + + expect(mockService.startSubscriptionWithCard).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not request a trial when refreshed subscriptions show the product was already trialed', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [], + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }); + mockService.startSubscriptionWithCard.mockResolvedValue( + MOCK_START_SUBSCRIPTION_RESPONSE, + ); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCard', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + }, + ); + + expect(mockService.getSubscriptions).toHaveBeenCalledTimes(1); + expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + }); + }, + ); + }); + + it('requests a trial when refreshed subscriptions show the product has not been trialed', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [], + }); + mockService.startSubscriptionWithCard.mockResolvedValue( + MOCK_START_SUBSCRIPTION_RESPONSE, + ); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCard', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + }, + ); + + expect(mockService.getSubscriptions).toHaveBeenCalledTimes(1); + expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + }); + }, + ); + }); + }); + + describe('startCryptoSubscription', () => { + it('should start crypto subscription successfully when user is not subscribed', async () => { + await withController( + { + state: { + subscriptions: [], + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + const request: StartCryptoSubscriptionRequest = { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }; + + const response: StartCryptoSubscriptionResponse = { + subscriptionId: 'sub_crypto_123', + status: SUBSCRIPTION_STATUSES.active, + }; + + mockService.startSubscriptionWithCrypto.mockResolvedValue(response); + mockService.getSubscriptions + .mockResolvedValueOnce(MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + const result = await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + request, + ); + + expect(result).toStrictEqual(response); + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + request, + ); + expect(mockService.getSubscriptions).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('should throw error when products array is empty', async () => { + await withController(async ({ rootMessenger, mockService }) => { + await expect( + rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, + ); + + expect(mockService.startSubscriptionWithCrypto).not.toHaveBeenCalled(); + expect(mockService.getSubscriptions).not.toHaveBeenCalled(); + }); + }); + + it('should refresh subscriptions after a successful Money Account crypto start', async () => { + const moneyAccountSubscription: Subscription = { + ...MOCK_SUBSCRIPTION, + id: 'sub_money_account', + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + currency: 'usd', + unitAmount: 499, + unitDecimals: 2, + }, + ], + }; + + await withController( + { + state: { + subscriptions: [], + pricing: { + products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], + paymentMethods: [], + }, + }, + }, + async ({ controller, rootMessenger, mockService }) => { + const request: StartCryptoSubscriptionRequest = { + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + chainId: '0x8f', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'pvmUSD', + cryptoAuthMethod: 'delegation', + delegationHash: '0xabc', + }; + + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_money_account', + status: SUBSCRIPTION_STATUSES.active, + }); + mockService.getSubscriptions + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [], + }) + .mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [moneyAccountSubscription], + trialedProducts: [], + }); + + const triggerAccessTokenRefreshSpy = jest.spyOn( + controller, + 'triggerAccessTokenRefresh', + ); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + request, + ); + + expect(mockService.getSubscriptions).toHaveBeenCalledTimes(2); + expect( + rootMessenger.call( + 'SubscriptionController:getSubscriptionByProduct', + PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + ), + ).toStrictEqual(moneyAccountSubscription); + expect(triggerAccessTokenRefreshSpy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should not refresh subscriptions after a failed crypto start', async () => { + await withController( + { + state: { + subscriptions: [], + pricing: { + products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], + paymentMethods: [], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, + ); + mockService.startSubscriptionWithCrypto.mockRejectedValue( + new SubscriptionServiceError('Failed to start crypto subscription'), + ); + + await expect( + rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + chainId: '0x8f', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'pvmUSD', + cryptoAuthMethod: 'delegation', + delegationHash: '0xabc', + }, + ), + ).rejects.toThrow(SubscriptionServiceError); + + expect(mockService.getSubscriptions).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('overwrites client-supplied isTrialRequested from pricing and trialedProducts', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [], + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_crypto_123', + status: SUBSCRIPTION_STATUSES.active, + }); + mockService.getSubscriptions + .mockResolvedValueOnce(MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }, + ); + + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + isTrialRequested: true, + }), + ); + }, + ); + }); + + it('does not request a trial when the product has already been trialed', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_crypto_123', + status: SUBSCRIPTION_STATUSES.active, + }); + mockService.getSubscriptions + .mockResolvedValueOnce({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }, + ); + + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + isTrialRequested: false, + }), + ); + }, + ); + }); + + it('does not request a trial when pricing has no trial period', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [], + pricing: { + products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], + paymentMethods: [], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_money_account', + status: SUBSCRIPTION_STATUSES.active, + }); + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [], + }); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + chainId: '0x8f', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'pvmUSD', + cryptoAuthMethod: 'delegation', + delegationHash: '0xabc', + }, + ); + + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + isTrialRequested: false, + }), + ); + }, + ); + }); + + it('does not request a trial when Shield pricing has trialPeriodDays of 0', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [], + pricing: { + products: [MOCK_PRODUCT_PRICE_WITHOUT_TRIAL], + paymentMethods: [], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_crypto_123', + status: SUBSCRIPTION_STATUSES.active, + }); + mockService.getSubscriptions + .mockResolvedValueOnce(MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }, + ); + + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + isTrialRequested: false, + }), + ); + }, + ); + }); + + it('does not request a trial when refreshed subscriptions show the product was already trialed', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [], + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_crypto_123', + status: SUBSCRIPTION_STATUSES.active, + }); + mockService.getSubscriptions + .mockResolvedValueOnce({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }, + ); + + expect(mockService.getSubscriptions).toHaveBeenCalled(); + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + isTrialRequested: false, + }), + ); + }, + ); + }); + + it('requests a trial when refreshed subscriptions show the product has not been trialed', async () => { + await withController( + { + state: { + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_crypto_123', + status: SUBSCRIPTION_STATUSES.active, + }); + mockService.getSubscriptions + .mockResolvedValueOnce({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [], + }) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }, + ); + + expect(mockService.getSubscriptions).toHaveBeenCalled(); + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + isTrialRequested: true, + }), + ); + }, + ); + }); + + it('should start Money Account crypto subscription while Shield is active', async () => { + await withController( + { + state: { + subscriptions: [MOCK_SUBSCRIPTION], + pricing: { + products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], + paymentMethods: [], + }, + }, + }, + async ({ controller, rootMessenger, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_money_account', + status: SUBSCRIPTION_STATUSES.active, + }); + mockService.getSubscriptions + .mockResolvedValueOnce({ + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }) + .mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [ + MOCK_SUBSCRIPTION, + MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + ], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }); + + await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + chainId: '0x8f', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'pvmUSD', + cryptoAuthMethod: 'delegation', + delegationHash: '0xabc', + }, + ); + + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + cryptoAuthMethod: 'delegation', + isTrialRequested: false, + }), + ); + expect(controller.state.subscriptions).toHaveLength(2); + expect( + rootMessenger.call( + 'SubscriptionController:getSubscriptionByProduct', + PRODUCT_TYPES.SHIELD, + ), + ).toStrictEqual(MOCK_SUBSCRIPTION); + expect( + rootMessenger.call( + 'SubscriptionController:getSubscriptionByProduct', + PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + ), + ).toStrictEqual(MOCK_MONEY_ACCOUNT_SUBSCRIPTION); + }, + ); + }); + + it('should throw when Money Account is already active', async () => { + await withController( + { + state: { + subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION], + pricing: { + products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], + paymentMethods: [], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION], + trialedProducts: [], + }); + + await expect( + rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + chainId: '0x8f', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'pvmUSD', + cryptoAuthMethod: 'delegation', + delegationHash: '0xabc', + }, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.UserAlreadySubscribed, + ); + + expect( + mockService.startSubscriptionWithCrypto, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('throws when product pricing is not available', async () => { + await withController( + { + state: { + subscriptions: [], + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, + ); + + await expect( + rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + { + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + chainId: '0x8f', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'pvmUSD', + cryptoAuthMethod: 'delegation', + delegationHash: '0xabc', + }, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.ProductPriceNotFound, + ); + + expect( + mockService.startSubscriptionWithCrypto, + ).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('startPolling', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should call getSubscriptions with the correct interval', async () => { await withController(async ({ controller }) => { const getSubscriptionsSpy = jest.spyOn(controller, 'getSubscriptions'); controller.startPolling({}); @@ -1161,92 +2225,404 @@ describe('SubscriptionController', () => { }); }); - it('should call `triggerAccessTokenRefresh` when the state changes', async () => { - await withController(async ({ controller, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_GET_SUBSCRIPTIONS_RESPONSE, - ); - const triggerAccessTokenRefreshSpy = jest.spyOn( - controller, - 'triggerAccessTokenRefresh', - ); - controller.startPolling({}); - await jestAdvanceTime({ duration: 0 }); - expect(triggerAccessTokenRefreshSpy).toHaveBeenCalledTimes(1); - }); + it('should call `triggerAccessTokenRefresh` when the state changes', async () => { + await withController(async ({ controller, mockService }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_GET_SUBSCRIPTIONS_RESPONSE, + ); + const triggerAccessTokenRefreshSpy = jest.spyOn( + controller, + 'triggerAccessTokenRefresh', + ); + controller.startPolling({}); + await jestAdvanceTime({ duration: 0 }); + expect(triggerAccessTokenRefreshSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('integration scenarios', () => { + it('should handle complete subscription lifecycle with updated logic', async () => { + await withController( + async ({ controller, rootMessenger, mockService }) => { + // 1. Initially no subscription + expect(controller.state.subscriptions).toStrictEqual([]); + + // 2. Try to cancel subscription (should fail - user not subscribed) + await expect( + rootMessenger.call('SubscriptionController:cancelSubscription', { + subscriptionId: 'sub_123456789', + }), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.UserNotSubscribed, + ); + + // 3. Fetch subscription + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION], + trialedProducts: [], + }); + const subscriptions = await rootMessenger.call( + 'SubscriptionController:getSubscriptions', + ); + + expect(subscriptions).toStrictEqual([MOCK_SUBSCRIPTION]); + expect(controller.state.subscriptions).toStrictEqual([ + MOCK_SUBSCRIPTION, + ]); + + // 4. Now cancel should work (user is subscribed) + mockService.cancelSubscription.mockResolvedValue(MOCK_SUBSCRIPTION); + expect( + await rootMessenger.call( + 'SubscriptionController:cancelSubscription', + { + subscriptionId: 'sub_123456789', + }, + ), + ).toBeUndefined(); + + expect(mockService.cancelSubscription).toHaveBeenCalledWith({ + subscriptionId: 'sub_123456789', + }); + }, + ); + }); + }); + + describe('getPricing', () => { + const mockPricingResponse: PricingResponse = { + products: [], + paymentMethods: [], + }; + + it('should return pricing response', async () => { + await withController(async ({ rootMessenger, mockService }) => { + mockService.getPricing.mockResolvedValue(mockPricingResponse); + + const result = await rootMessenger.call( + 'SubscriptionController:getPricing', + ); + + expect(result).toStrictEqual(mockPricingResponse); + }); + }); + }); + + describe('getCryptoApproveTransactionParams', () => { + it('selects the erc20 approval payment method for the requested product', async () => { + await withController( + { + state: { + pricing: { + products: [MOCK_PRODUCT_PRICE], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'delegation', + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + chains: [ + { + chainId: '0x8f', + paymentAddress: + '0x00000000000000000000000000000000000000c0', + tokens: [], + }, + ], + }, + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'erc20_approval', + products: [PRODUCT_TYPES.SHIELD], + chains: MOCK_PRICING_PAYMENT_METHOD.chains, + }, + ], + }, + }, + }, + async ({ rootMessenger }) => { + const result = rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, + }, + ); + + expect(result).toStrictEqual({ + approveAmount: '108000000000000000000', + paymentAddress: '0x00000000000000000000000000000000000000a2', + paymentTokenAddress: '0xtoken', + chainId: '0x1', + }); + }, + ); + }); + + it('does not treat omitted products and cryptoAuthMethod as wildcards for Money Account', async () => { + const shieldPaymentAddress = '0x00000000000000000000000000000000000000a2'; + const mapPaymentAddress = '0x00000000000000000000000000000000000000c0'; + const mapProductPrice: ProductPricing = { + ...MOCK_PRODUCT_PRICE, + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + }; + const [legacyShieldChain] = MOCK_PRICING_PAYMENT_METHOD.chains ?? []; + + await withController( + { + state: { + pricing: { + products: [MOCK_PRODUCT_PRICE, mapProductPrice], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + chains: MOCK_PRICING_PAYMENT_METHOD.chains, + }, + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'erc20_approval', + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + chains: [ + { + chainId: '0x1', + paymentAddress: mapPaymentAddress, + tokens: legacyShieldChain.tokens, + }, + ], + }, + ], + }, + }, + }, + async ({ rootMessenger }) => { + const result = rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + interval: RECURRING_INTERVALS.month, + }, + ); + + expect(result.paymentAddress).toBe(mapPaymentAddress); + expect(result.paymentAddress).not.toBe(shieldPaymentAddress); + }, + ); }); - }); - - describe('integration scenarios', () => { - it('should handle complete subscription lifecycle with updated logic', async () => { - await withController( - async ({ controller, rootMessenger, mockService }) => { - // 1. Initially no subscription - expect(controller.state.subscriptions).toStrictEqual([]); - // 2. Try to cancel subscription (should fail - user not subscribed) - await expect( - rootMessenger.call('SubscriptionController:cancelSubscription', { - subscriptionId: 'sub_123456789', - }), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.UserNotSubscribed, - ); + it('does not return Shield spender for Money Account when pricing is legacy crypto-only', async () => { + const mapProductPrice: ProductPricing = { + ...MOCK_PRODUCT_PRICE, + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + }; - // 3. Fetch subscription - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION], - trialedProducts: [], - }); - const subscriptions = await rootMessenger.call( - 'SubscriptionController:getSubscriptions', - ); + await withController( + { + state: { + pricing: { + products: [MOCK_PRODUCT_PRICE, mapProductPrice], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + chains: MOCK_PRICING_PAYMENT_METHOD.chains, + }, + ], + }, + }, + }, + async ({ rootMessenger }) => { + expect(() => + rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + interval: RECURRING_INTERVALS.month, + }, + ), + ).toThrow('Chains payment info not found'); + }, + ); + }); - expect(subscriptions).toStrictEqual([MOCK_SUBSCRIPTION]); - expect(controller.state.subscriptions).toStrictEqual([ - MOCK_SUBSCRIPTION, - ]); + it('does not default omitted cryptoAuthMethod to erc20_approval when products is set', async () => { + const mapProductPrice: ProductPricing = { + ...MOCK_PRODUCT_PRICE, + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + }; + const [legacyShieldChain] = MOCK_PRICING_PAYMENT_METHOD.chains ?? []; - // 4. Now cancel should work (user is subscribed) - mockService.cancelSubscription.mockResolvedValue(MOCK_SUBSCRIPTION); - expect( - await rootMessenger.call( - 'SubscriptionController:cancelSubscription', + await withController( + { + state: { + pricing: { + products: [MOCK_PRODUCT_PRICE, mapProductPrice], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + chains: [ + { + chainId: '0x1', + paymentAddress: + '0x00000000000000000000000000000000000000c0', + tokens: legacyShieldChain.tokens, + }, + ], + }, + ], + }, + }, + }, + async ({ rootMessenger }) => { + expect(() => + rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', { - subscriptionId: 'sub_123456789', + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + interval: RECURRING_INTERVALS.month, }, ), - ).toBeUndefined(); + ).toThrow('Chains payment info not found'); + }, + ); + }); - expect(mockService.cancelSubscription).toHaveBeenCalledWith({ - subscriptionId: 'sub_123456789', - }); + it('does not treat empty products as Shield', async () => { + await withController( + { + state: { + pricing: { + products: [MOCK_PRODUCT_PRICE], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + products: [], + chains: MOCK_PRICING_PAYMENT_METHOD.chains, + }, + ], + }, + }, + }, + async ({ rootMessenger }) => { + expect(() => + rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, + }, + ), + ).toThrow('Chains payment info not found'); }, ); }); - }); - describe('getPricing', () => { - const mockPricingResponse: PricingResponse = { - products: [], - paymentMethods: [], - }; + it('prefers an explicit Shield row over a leftover unscoped crypto row', async () => { + const leftoverPaymentAddress = + '0x00000000000000000000000000000000000000aa'; + const shieldPaymentAddress = '0x00000000000000000000000000000000000000a2'; + const [legacyShieldChain] = MOCK_PRICING_PAYMENT_METHOD.chains ?? []; - it('should return pricing response', async () => { - await withController(async ({ rootMessenger, mockService }) => { - mockService.getPricing.mockResolvedValue(mockPricingResponse); + await withController( + { + state: { + pricing: { + products: [MOCK_PRODUCT_PRICE], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + chains: [ + { + ...legacyShieldChain, + paymentAddress: leftoverPaymentAddress, + }, + ], + }, + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'erc20_approval', + products: [PRODUCT_TYPES.SHIELD], + chains: MOCK_PRICING_PAYMENT_METHOD.chains, + }, + ], + }, + }, + }, + async ({ rootMessenger }) => { + const result = rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, + }, + ); - const result = await rootMessenger.call( - 'SubscriptionController:getPricing', - ); + expect(result.paymentAddress).toBe(shieldPaymentAddress); + expect(result.paymentAddress).not.toBe(leftoverPaymentAddress); + }, + ); + }); - expect(result).toStrictEqual(mockPricingResponse); - }); + it('throws when multiple equally specific crypto payment methods match', async () => { + const [legacyShieldChain] = MOCK_PRICING_PAYMENT_METHOD.chains ?? []; + + await withController( + { + state: { + pricing: { + products: [MOCK_PRODUCT_PRICE], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'erc20_approval', + products: [PRODUCT_TYPES.SHIELD], + chains: MOCK_PRICING_PAYMENT_METHOD.chains, + }, + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'erc20_approval', + products: [PRODUCT_TYPES.SHIELD], + chains: [ + { + chainId: '0x1', + paymentAddress: + '0x00000000000000000000000000000000000000bb', + tokens: legacyShieldChain.tokens, + }, + ], + }, + ], + }, + }, + }, + async ({ rootMessenger }) => { + expect(() => + rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, + }, + ), + ).toThrow('Multiple matching crypto payment methods found'); + }, + ); }); - }); - describe('getCryptoApproveTransactionParams', () => { it('returns transaction params for crypto approve transaction', async () => { await withController( { @@ -1267,7 +2643,7 @@ describe('SubscriptionController', () => { expect(result).toStrictEqual({ approveAmount: '108000000000000000000', - paymentAddress: '0xspender', + paymentAddress: '0x00000000000000000000000000000000000000a2', paymentTokenAddress: '0xtoken', chainId: '0x1', }); @@ -1292,7 +2668,7 @@ describe('SubscriptionController', () => { expect(result).toStrictEqual({ approveAmount: '108000000000000000000', - paymentAddress: '0xspender', + paymentAddress: '0x00000000000000000000000000000000000000a2', paymentTokenAddress: '0xToKeN', chainId: '0x1', }); @@ -1425,7 +2801,8 @@ describe('SubscriptionController', () => { chains: [ { chainId: '0x2', - paymentAddress: '0xspender', + paymentAddress: + '0x00000000000000000000000000000000000000a2', tokens: [], }, ], @@ -1485,7 +2862,8 @@ describe('SubscriptionController', () => { chains: [ { chainId: '0x1', - paymentAddress: '0xspender', + paymentAddress: + '0x00000000000000000000000000000000000000a2', tokens: [ { address: '0xtoken', @@ -1911,20 +3289,70 @@ describe('SubscriptionController', () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.call( 'SubscriptionController:cacheLastSelectedPaymentMethod', - PRODUCT_TYPES.SHIELD, { + product: PRODUCT_TYPES.SHIELD, + paymentMethod: { + type: PAYMENT_TYPES.byCard, + plan: RECURRING_INTERVALS.month, + }, + }, + ); + + expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ + [PRODUCT_TYPES.SHIELD]: { type: PAYMENT_TYPES.byCard, plan: RECURRING_INTERVALS.month, }, - ); + }); + }); + }); + + it('should cache Money Account payment method without clobbering Shield', async () => { + await withController( + { + state: { + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.call( + 'SubscriptionController:cacheLastSelectedPaymentMethod', + { + product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xmoneytoken', + paymentTokenSymbol: 'pvmUSD', + plan: RECURRING_INTERVALS.month, + cryptoAuthMethod: 'delegation', + }, + }, + ); - expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCard, - plan: RECURRING_INTERVALS.month, - }, - }); - }); + expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xmoneytoken', + paymentTokenSymbol: 'pvmUSD', + plan: RECURRING_INTERVALS.month, + cryptoAuthMethod: 'delegation', + }, + }); + }, + ); }); it('should update the last selected payment method for the same product', async () => { @@ -1949,8 +3377,10 @@ describe('SubscriptionController', () => { rootMessenger.call( 'SubscriptionController:cacheLastSelectedPaymentMethod', - PRODUCT_TYPES.SHIELD, - MOCK_CACHED_PAYMENT_METHOD, + { + product: PRODUCT_TYPES.SHIELD, + paymentMethod: MOCK_CACHED_PAYMENT_METHOD, + }, ); expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ @@ -1965,11 +3395,13 @@ describe('SubscriptionController', () => { expect(() => rootMessenger.call( 'SubscriptionController:cacheLastSelectedPaymentMethod', - PRODUCT_TYPES.SHIELD, { - type: PAYMENT_TYPES.byCrypto, - plan: RECURRING_INTERVALS.month, - } as CachedLastSelectedPaymentMethod, + product: PRODUCT_TYPES.SHIELD, + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + plan: RECURRING_INTERVALS.month, + } as CachedLastSelectedPaymentMethod, + }, ), ).toThrow( SubscriptionControllerErrorMessage.PaymentTokenAddressAndSymbolRequiredForCrypto, @@ -2098,254 +3530,677 @@ describe('SubscriptionController', () => { }, ); }); - }); - - describe('submitSponsorshipIntents', () => { - const MOCK_SUBMISSION_INTENTS_REQUEST: SubmitSponsorshipIntentsMethodParams = - { - chainId: '0x1', - address: '0x1234567890123456789012345678901234567890', - products: [PRODUCT_TYPES.SHIELD], - }; - const MOCK_CACHED_PAYMENT_METHOD: Record< - ProductType, - CachedLastSelectedPaymentMethod - > = { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }, - }; + }); + + describe('submitSponsorshipIntents', () => { + const MOCK_SUBMISSION_INTENTS_REQUEST: SubmitSponsorshipIntentsMethodParams = + { + chainId: '0x1', + address: '0x1234567890123456789012345678901234567890', + products: [PRODUCT_TYPES.SHIELD], + }; + const MOCK_CACHED_PAYMENT_METHOD: Record< + ProductType, + CachedLastSelectedPaymentMethod + > = { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }; + + it('should submit sponsorship intents successfully', async () => { + await withController( + { + state: { + lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + const submitSponsorshipIntentsSpy = jest + .spyOn(mockService, 'submitSponsorshipIntents') + .mockResolvedValue(undefined); + + await rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ); + expect(submitSponsorshipIntentsSpy).toHaveBeenCalledWith({ + ...MOCK_SUBMISSION_INTENTS_REQUEST, + paymentTokenSymbol: 'USDT', + billingCycles: 12, + recurringInterval: RECURRING_INTERVALS.month, + }); + }, + ); + }); + + it('should throw error when products array is empty', async () => { + await withController(async ({ rootMessenger }) => { + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + { + ...MOCK_SUBMISSION_INTENTS_REQUEST, + products: [], + }, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, + ); + }); + }); + + it('should throw error when user is already subscribed', async () => { + await withController( + { + state: { + subscriptions: [MOCK_SUBSCRIPTION], + }, + }, + async ({ rootMessenger, mockService }) => { + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.UserAlreadySubscribed, + ); + + // Verify the subscription service was not called + expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); + }, + ); + }); + + it('should not submit sponsorship intents if the user has trailed the products before', async () => { + await withController( + { + state: { + lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, + subscriptions: [ + { + ...MOCK_SUBSCRIPTION, + status: SUBSCRIPTION_STATUSES.canceled, + }, + ], + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [PRODUCT_TYPES.SHIELD], + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.submitSponsorshipIntents.mockResolvedValue(undefined); + + const isSponsored = await rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ); + expect(isSponsored).toBe(false); + expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); + }, + ); + }); + + it('should not submit sponsorship intents if the chain does not support sponsorship', async () => { + await withController( + { + state: { + lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, + pricing: { + ...MOCK_PRICE_INFO_RESPONSE, + paymentMethods: [ + ...MOCK_PRICE_INFO_RESPONSE.paymentMethods.map( + (paymentMethod) => + paymentMethod.type === PAYMENT_TYPES.byCrypto + ? { + ...paymentMethod, + chains: paymentMethod.chains?.map((chain) => ({ + ...chain, + isSponsorshipSupported: false, // <==== Sponsorship not supported + })), + } + : paymentMethod, + ), + ], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + const isSponsored = await rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ); + expect(isSponsored).toBe(false); + expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); + }, + ); + }); + + it('looks up sponsorship on the selected crypto auth method, not always erc20_approval', async () => { + const moneyAccountPrice: ProductPricing = { + ...MOCK_PRODUCT_PRICE, + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + }; + const moneyAccountRequest: SubmitSponsorshipIntentsMethodParams = { + ...MOCK_SUBMISSION_INTENTS_REQUEST, + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + }; + + await withController( + { + state: { + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDC', + plan: RECURRING_INTERVALS.month, + cryptoAuthMethod: 'delegation', + }, + }, + pricing: { + products: [moneyAccountPrice], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'erc20_approval', + products: [PRODUCT_TYPES.SHIELD], + chains: [ + { + chainId: '0x1', + paymentAddress: + '0x00000000000000000000000000000000000000a2', + isSponsorshipSupported: false, + tokens: [], + }, + ], + }, + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'delegation', + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + chains: [ + { + chainId: '0x1', + paymentAddress: + '0x00000000000000000000000000000000000000c0', + isSponsorshipSupported: true, + tokens: [], + }, + ], + }, + ], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.submitSponsorshipIntents.mockResolvedValue(undefined); + + const isSponsored = await rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + moneyAccountRequest, + ); + + expect(isSponsored).toBe(true); + expect(mockService.submitSponsorshipIntents).toHaveBeenCalledWith({ + ...moneyAccountRequest, + paymentTokenSymbol: 'USDC', + billingCycles: 12, + recurringInterval: RECURRING_INTERVALS.month, + }); + }, + ); + }); + + it('throws when the pricing chain row is missing instead of treating it as not sponsored', async () => { + await withController( + { + state: { + lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, + pricing: MOCK_PRICE_INFO_RESPONSE, + }, + }, + async ({ rootMessenger, mockService }) => { + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + { + ...MOCK_SUBMISSION_INTENTS_REQUEST, + chainId: '0x89', + }, + ), + ).rejects.toThrow('Invalid chain id'); + expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); + }, + ); + }); + + it('throws when pricing is missing instead of treating it as not sponsored', async () => { + await withController( + { + state: { + lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, + }, + }, + async ({ rootMessenger, mockService }) => { + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ), + ).rejects.toThrow('Chains payment info not found'); + expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); + }, + ); + }); - it('should submit sponsorship intents successfully', async () => { + it('throws when the crypto payment method row is missing instead of treating it as not sponsored', async () => { await withController( { state: { lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - pricing: MOCK_PRICE_INFO_RESPONSE, + pricing: { + ...MOCK_PRICE_INFO_RESPONSE, + paymentMethods: [ + { + type: PAYMENT_TYPES.byCard, + products: [PRODUCT_TYPES.SHIELD], + }, + ], + }, }, }, async ({ rootMessenger, mockService }) => { - const submitSponsorshipIntentsSpy = jest - .spyOn(mockService, 'submitSponsorshipIntents') - .mockResolvedValue(undefined); - - await rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ); - expect(submitSponsorshipIntentsSpy).toHaveBeenCalledWith({ - ...MOCK_SUBMISSION_INTENTS_REQUEST, - paymentTokenSymbol: 'USDT', - billingCycles: 12, - recurringInterval: RECURRING_INTERVALS.month, - }); + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ), + ).rejects.toThrow('Chains payment info not found'); + expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); }, ); }); - it('should throw error when products array is empty', async () => { + it('should throw error when no cached payment method is found', async () => { await withController(async ({ rootMessenger }) => { await expect( rootMessenger.call( 'SubscriptionController:submitSponsorshipIntents', - { - ...MOCK_SUBMISSION_INTENTS_REQUEST, - products: [], - }, + MOCK_SUBMISSION_INTENTS_REQUEST, ), ).rejects.toThrow( - SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, + SubscriptionControllerErrorMessage.PaymentMethodNotCrypto, ); }); }); - it('should throw error when user is already subscribed', async () => { + it('should throw error when payment method is not crypto', async () => { await withController( { state: { - subscriptions: [MOCK_SUBSCRIPTION], + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCard, + plan: RECURRING_INTERVALS.month, + }, + }, }, }, - async ({ rootMessenger, mockService }) => { + async ({ rootMessenger }) => { await expect( rootMessenger.call( 'SubscriptionController:submitSponsorshipIntents', MOCK_SUBMISSION_INTENTS_REQUEST, ), ).rejects.toThrow( - SubscriptionControllerErrorMessage.UserAlreadySubscribed, + SubscriptionControllerErrorMessage.PaymentMethodNotCrypto, ); - - // Verify the subscription service was not called - expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); }, ); }); - it('should not submit sponsorship intents if the user has trailed the products before', async () => { + it('should throw error when product price is not found', async () => { await withController( { state: { lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - subscriptions: [ - { - ...MOCK_SUBSCRIPTION, - status: SUBSCRIPTION_STATUSES.canceled, + pricing: { + products: [], + paymentMethods: [MOCK_PRICING_PAYMENT_METHOD], + }, + }, + }, + async ({ rootMessenger }) => { + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.ProductPriceNotFound, + ); + }, + ); + }); + + it('should handle subscription service errors', async () => { + await withController( + { + state: { + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + ...MOCK_CACHED_PAYMENT_METHOD[PRODUCT_TYPES.SHIELD], + plan: RECURRING_INTERVALS.year, }, - ], + }, pricing: MOCK_PRICE_INFO_RESPONSE, - trialedProducts: [PRODUCT_TYPES.SHIELD], }, }, async ({ rootMessenger, mockService }) => { - mockService.submitSponsorshipIntents.mockResolvedValue(undefined); + mockService.submitSponsorshipIntents.mockRejectedValue( + new SubscriptionServiceError( + 'Failed to submit sponsorship intents', + ), + ); - const isSponsored = await rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ), + ).rejects.toThrow(SubscriptionServiceError); + expect(mockService.submitSponsorshipIntents).toHaveBeenCalledWith({ + ...MOCK_SUBMISSION_INTENTS_REQUEST, + paymentTokenSymbol: 'USDT', + billingCycles: 1, + recurringInterval: RECURRING_INTERVALS.year, + }); + }, + ); + }); + }); + + describe('submitSubscriptionCryptoApproval', () => { + it('accepts only Shield as productType at compile time', () => { + type ProductArg = Parameters< + SubscriptionController['submitSubscriptionCryptoApproval'] + >[0]['productType']; + + const shield: ProductArg = PRODUCT_TYPES.SHIELD; + expect(shield).toBe(PRODUCT_TYPES.SHIELD); + + // @ts-expect-error only Shield is a valid productType + const moneyAccount: ProductArg = PRODUCT_TYPES.MONEY_ACCOUNT_PLUS; + expect(moneyAccount).toBe(PRODUCT_TYPES.MONEY_ACCOUNT_PLUS); + }); + + it('should handle subscription crypto approval when shield subscription transaction is submitted', async () => { + await withController( + { + state: { + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [], + subscriptions: [], + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_123', + status: SUBSCRIPTION_STATUSES.trialing, + }); + + mockService.getSubscriptions + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [], + }) + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [], + }) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + // Create a shield subscription approval transaction + const txMeta = { + ...generateMockTxMeta(), + type: TransactionType.shieldSubscriptionApprove, + chainId: '0x1' as Hex, + rawTx: '0x123', + txParams: { + data: '0x456', + from: '0x1234567890123456789012345678901234567890', + to: '0xtoken', + }, + status: TransactionStatus.submitted, + }; + + await rootMessenger.call( + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ); - expect(isSponsored).toBe(false); - expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); + + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledTimes( + 1, + ); + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith({ + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: true, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + chainId: '0x1', + payerAddress: '0x1234567890123456789012345678901234567890', + tokenSymbol: 'USDT', + rawTransaction: '0x123', + cryptoAuthMethod: 'erc20_approval', + isSponsored: undefined, + useTestClock: undefined, + rewardAccountId: undefined, + }); }, ); }); - it('should not submit sponsorship intents if the chain does not support sponsorship', async () => { + it('should not request trial when Shield pricing has trialPeriodDays of 0', async () => { await withController( { state: { - lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, pricing: { - ...MOCK_PRICE_INFO_RESPONSE, - paymentMethods: [ - ...MOCK_PRICE_INFO_RESPONSE.paymentMethods.map( - (paymentMethod) => ({ - ...paymentMethod, - chains: paymentMethod.chains?.map((chain) => ({ - ...chain, - isSponsorshipSupported: false, // <==== Sponsorship not supported - })), - }), - ), - ], + products: [MOCK_PRODUCT_PRICE_WITHOUT_TRIAL], + paymentMethods: MOCK_PRICE_INFO_RESPONSE.paymentMethods, + }, + trialedProducts: [], + subscriptions: [], + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_123', + status: SUBSCRIPTION_STATUSES.active, + }); + + mockService.getSubscriptions + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [], + }) + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [], + }) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + const txMeta = { + ...generateMockTxMeta(), + type: TransactionType.shieldSubscriptionApprove, + chainId: '0x1' as Hex, + rawTx: '0x123', + txParams: { + data: '0x456', + from: '0x1234567890123456789012345678901234567890', + to: '0xtoken', + }, + status: TransactionStatus.submitted, + }; + + await rootMessenger.call( + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, }, - }, - }, - async ({ rootMessenger, mockService }) => { - const isSponsored = await rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, ); - expect(isSponsored).toBe(false); - expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); + + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + cryptoAuthMethod: 'erc20_approval', + }), + ); }, ); }); - it('should throw error when no cached payment method is found', async () => { - await withController(async ({ rootMessenger }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.PaymentMethodNotCrypto, - ); - }); - }); - - it('should throw error when payment method is not crypto', async () => { + it('should throw when Shield crypto approval is submitted with only Money Account payment method cached', async () => { await withController( { state: { + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [], + subscriptions: [], lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCard, + [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xmoneytoken', + paymentTokenSymbol: 'pvmUSD', plan: RECURRING_INTERVALS.month, + cryptoAuthMethod: 'delegation', }, }, }, }, - async ({ rootMessenger }) => { + async ({ rootMessenger, mockService }) => { + const txMeta = { + ...generateMockTxMeta(), + type: TransactionType.shieldSubscriptionApprove, + chainId: '0x1' as Hex, + rawTx: '0x123', + txParams: { + data: '0x456', + from: '0x1234567890123456789012345678901234567890', + to: '0xtoken', + }, + status: TransactionStatus.submitted, + }; + await expect( rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ), ).rejects.toThrow( SubscriptionControllerErrorMessage.PaymentMethodNotCrypto, ); - }, - ); - }); - it('should throw error when product price is not found', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - pricing: { - products: [], - paymentMethods: [MOCK_PRICING_PAYMENT_METHOD], - }, - }, - }, - async ({ rootMessenger }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.ProductPriceNotFound, - ); + expect( + mockService.startSubscriptionWithCrypto, + ).not.toHaveBeenCalled(); }, ); }); - it('should handle subscription service errors', async () => { + it('should not request trial when product was already trialed', async () => { await withController( { state: { + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [PRODUCT_TYPES.SHIELD], + subscriptions: [], lastSelectedPaymentMethod: { [PRODUCT_TYPES.SHIELD]: { - ...MOCK_CACHED_PAYMENT_METHOD[PRODUCT_TYPES.SHIELD], - plan: RECURRING_INTERVALS.year, + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, }, }, - pricing: MOCK_PRICE_INFO_RESPONSE, }, }, async ({ rootMessenger, mockService }) => { - mockService.submitSponsorshipIntents.mockRejectedValue( - new SubscriptionServiceError( - 'Failed to submit sponsorship intents', - ), + mockService.startSubscriptionWithCrypto.mockResolvedValue({ + subscriptionId: 'sub_123', + status: SUBSCRIPTION_STATUSES.active, + }); + + mockService.getSubscriptions + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }) + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }) + .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + + const txMeta = { + ...generateMockTxMeta(), + type: TransactionType.shieldSubscriptionApprove, + chainId: '0x1' as Hex, + rawTx: '0x123', + txParams: { + data: '0x456', + from: '0x1234567890123456789012345678901234567890', + to: '0xtoken', + }, + status: TransactionStatus.submitted, + }; + + await rootMessenger.call( + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ); - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow(SubscriptionServiceError); - expect(mockService.submitSponsorshipIntents).toHaveBeenCalledWith({ - ...MOCK_SUBMISSION_INTENTS_REQUEST, - paymentTokenSymbol: 'USDT', - billingCycles: 1, - recurringInterval: RECURRING_INTERVALS.year, - }); + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + isTrialRequested: false, + }), + ); }, ); }); - }); - describe('submitShieldSubscriptionCryptoApproval', () => { - it('should handle subscription crypto approval when shield subscription transaction is submitted', async () => { + it('should not request trial when refreshed subscriptions show the product was already trialed', async () => { await withController( { state: { @@ -2365,17 +4220,20 @@ describe('SubscriptionController', () => { async ({ rootMessenger, mockService }) => { mockService.startSubscriptionWithCrypto.mockResolvedValue({ subscriptionId: 'sub_123', - status: SUBSCRIPTION_STATUSES.trialing, + status: SUBSCRIPTION_STATUSES.active, }); mockService.getSubscriptions .mockResolvedValueOnce({ subscriptions: [], - trialedProducts: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }) + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [PRODUCT_TYPES.SHIELD], }) .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - // Create a shield subscription approval transaction const txMeta = { ...generateMockTxMeta(), type: TransactionType.shieldSubscriptionApprove, @@ -2390,12 +4248,17 @@ describe('SubscriptionController', () => { }; await rootMessenger.call( - 'SubscriptionController:submitShieldSubscriptionCryptoApproval', - txMeta, + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledTimes( - 1, + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + expect.objectContaining({ + isTrialRequested: false, + }), ); }, ); @@ -2425,6 +4288,10 @@ describe('SubscriptionController', () => { }); mockService.getSubscriptions + .mockResolvedValueOnce({ + subscriptions: [], + trialedProducts: [], + }) .mockResolvedValueOnce({ subscriptions: [], trialedProducts: [], @@ -2446,10 +4313,14 @@ describe('SubscriptionController', () => { }; await rootMessenger.call( - 'SubscriptionController:submitShieldSubscriptionCryptoApproval', - txMeta, - false, // isSponsored - 'eip155:1:0x1234567890123456789012345678901234567890', + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + isSponsored: false, + rewardAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890', + }, ); expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith({ @@ -2461,6 +4332,7 @@ describe('SubscriptionController', () => { payerAddress: '0x1234567890123456789012345678901234567890', tokenSymbol: 'USDT', rawTransaction: '0x123', + cryptoAuthMethod: 'erc20_approval', isSponsored: false, useTestClock: undefined, rewardAccountId: @@ -2491,8 +4363,11 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitShieldSubscriptionCryptoApproval', - txMeta, + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ), ).rejects.toThrow('Subscription pricing not found'); @@ -2504,7 +4379,7 @@ describe('SubscriptionController', () => { ); }); - it('should not handle subscription crypto approval for non-shield subscription transactions', async () => { + it('should throw for non-shield-approve transaction types', async () => { await withController( { state: { @@ -2514,7 +4389,6 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { - // Create a non-shield subscription transaction const txMeta = { ...generateMockTxMeta(), type: TransactionType.contractInteraction, @@ -2522,12 +4396,69 @@ describe('SubscriptionController', () => { hash: '0x123', }; - await rootMessenger.call( - 'SubscriptionController:submitShieldSubscriptionCryptoApproval', - txMeta, + await expect( + rootMessenger.call( + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.CryptoApprovalRequiresShieldApprove, + ); + + expect( + mockService.startSubscriptionWithCrypto, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('should throw when productType is not Shield', async () => { + await withController( + { + state: { + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [], + subscriptions: [], + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }, + }, + }, + async ({ rootMessenger, mockService }) => { + const txMeta = { + ...generateMockTxMeta(), + type: TransactionType.shieldSubscriptionApprove, + chainId: '0x1' as Hex, + rawTx: '0x123', + txParams: { + data: '0x456', + from: '0x1234567890123456789012345678901234567890', + to: '0xtoken', + }, + status: TransactionStatus.submitted, + }; + + await expect( + rootMessenger.call( + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + // @ts-expect-error only Shield is a valid productType + productType: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + txMeta, + }, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.CryptoApprovalRequiresShieldApprove, ); - // Verify that decodeTransactionDataHandler was not called expect( mockService.startSubscriptionWithCrypto, ).not.toHaveBeenCalled(); @@ -2562,8 +4493,11 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitShieldSubscriptionCryptoApproval', - txMeta, + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ), ).rejects.toThrow('Chain ID or raw transaction not found'); @@ -2602,8 +4536,11 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitShieldSubscriptionCryptoApproval', - txMeta, + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ), ).rejects.toThrow('Last selected payment method not found'); @@ -2649,8 +4586,11 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitShieldSubscriptionCryptoApproval', - txMeta, + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ), ).rejects.toThrow( SubscriptionControllerErrorMessage.ProductPriceNotFound, @@ -2700,8 +4640,11 @@ describe('SubscriptionController', () => { }; await rootMessenger.call( - 'SubscriptionController:submitShieldSubscriptionCryptoApproval', - txMeta, + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ); expect(mockService.updatePaymentMethodCrypto).toHaveBeenCalledTimes( @@ -2757,8 +4700,11 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitShieldSubscriptionCryptoApproval', - txMeta, + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, ), ).rejects.toThrow( SubscriptionControllerErrorMessage.SubscriptionNotValidForCryptoApproval, diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index 8764e77bd4..a1692a371e 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -6,7 +6,6 @@ import type { import type { Messenger } from '@metamask/messenger'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; import { TransactionType } from '@metamask/transaction-controller'; import type { CaipAccountId, Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -35,6 +34,7 @@ import type { SubscriptionServiceUpdatePaymentMethodCryptoAction, } from './SubscriptionService-method-action-types.js'; import { + CRYPTO_AUTH_METHODS, PAYMENT_TYPES, PRODUCT_TYPES, SUBSCRIPTION_STATUSES, @@ -42,17 +42,20 @@ import { import type { AssignCohortRequest, BillingPortalResponse, + CryptoAuthMethod, GetCryptoApproveTransactionRequest, GetCryptoApproveTransactionResponse, GetSubscriptionsEligibilitiesRequest, ProductPrice, SubscriptionEligibility, StartCryptoSubscriptionRequest, + SubmitSubscriptionCryptoApprovalRequest, SubmitUserEventRequest, TokenPaymentInfo, UpdatePaymentMethodCardResponse, UpdatePaymentMethodOpts, CachedLastSelectedPaymentMethod, + CacheLastSelectedPaymentMethodRequest, SubmitSponsorshipIntentsMethodParams, RecurringInterval, SubscriptionStatus, @@ -60,6 +63,7 @@ import type { StartCryptoSubscriptionResponse, StartSubscriptionResponse, CancelSubscriptionRequest, + PricingCryptoPaymentMethod, } from './types.js'; import type { PricingResponse, @@ -82,9 +86,8 @@ export type SubscriptionControllerState = { * This is used to display the last selected payment method in the UI. * This state is also meant to be used internally to track the last selected payment method for the user. (e.g. for crypto subscriptions) */ - lastSelectedPaymentMethod?: Record< - ProductType, - CachedLastSelectedPaymentMethod + lastSelectedPaymentMethod?: Partial< + Record >; }; @@ -221,10 +224,10 @@ const MESSENGER_EXPOSED_METHODS = [ 'getSubscriptionsEligibilities', 'cancelSubscription', 'unCancelSubscription', - 'startShieldSubscriptionWithCard', + 'startSubscriptionWithCard', 'startSubscriptionWithCrypto', 'stopAllPolling', - 'submitShieldSubscriptionCryptoApproval', + 'submitSubscriptionCryptoApproval', 'getCryptoApproveTransactionParams', 'updatePaymentMethod', 'getBillingPortalUrl', @@ -415,47 +418,114 @@ export class SubscriptionController extends StaticIntervalPollingController()< this.triggerAccessTokenRefresh(); } - async startShieldSubscriptionWithCard( + /** + * Starts a card-paid subscription checkout session for the requested products + * (e.g. Shield or Money Account Plus). + * + * `isTrialRequested` on the request is ignored and overwritten from pricing + * (`trialPeriodDays > 0`) and `trialedProducts`. + * + * @param request - The start subscription request. + * @returns The checkout session response. + */ + async startSubscriptionWithCard( request: StartSubscriptionRequest, ): Promise { + // get the latest subscriptions state before computing trial eligibility + await this.getSubscriptions(); this.#assertIsUserNotSubscribed({ products: request.products }); const response = await this.messenger.call( 'SubscriptionService:startSubscriptionWithCard', - request, + { + ...request, + isTrialRequested: this.#getIsTrialRequested( + request.products, + request.recurringInterval, + ), + }, ); // note: no need to trigger access token refresh after startSubscriptionWithCard request because this only return stripe checkout session url, subscription not created yet return response; } + /** + * Starts a crypto-paid subscription for the requested products + * (e.g. Shield or Money Account Plus). Unlike card checkout, this + * creates the subscription immediately, so local state is refreshed + * afterwards. + * + * `isTrialRequested` on the request is ignored and overwritten from pricing + * (`trialPeriodDays > 0`) and `trialedProducts`. + * + * @param request - The start crypto subscription request. + * @returns The start crypto subscription response. + * @throws If `products` is empty. + */ async startSubscriptionWithCrypto( request: StartCryptoSubscriptionRequest, ): Promise { + if (request.products.length === 0) { + throw new Error( + SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, + ); + } + + // get the latest subscriptions state before computing trial eligibility + await this.getSubscriptions(); this.#assertIsUserNotSubscribed({ products: request.products }); const response = await this.messenger.call( 'SubscriptionService:startSubscriptionWithCrypto', - request, + { + ...request, + isTrialRequested: this.#getIsTrialRequested( + request.products, + request.recurringInterval, + ), + }, ); + // Crypto start creates the subscription immediately (unlike card checkout). + await this.getSubscriptions(); + return response; } /** - * Handles shield subscription crypto approval transactions. + * Submits a Shield ERC-20 crypto approval transaction to start or update a + * crypto subscription. + * + * This handler is Shield / `TransactionType.shieldSubscriptionApprove` only. + * Delegation-based products (e.g. Money Account) must call + * `startSubscriptionWithCrypto` instead. * - * @param txMeta - The transaction metadata. - * @param isSponsored - Whether the transaction is sponsored. - * @param rewardAccountId - The account ID of the reward subscription to link to the shield subscription. + * @param request - The crypto approval request. + * @param request.productType - The subscription product. Typed as + * `typeof PRODUCT_TYPES.SHIELD` only at the moment (future might support more + * product). + * @param request.txMeta - The transaction metadata. Must have type + * `TransactionType.shieldSubscriptionApprove`. + * @param request.isSponsored - Whether the transaction is sponsored. + * @param request.rewardAccountId - The account ID of the reward subscription + * to link. + * @throws If `productType` is not Shield or `txMeta.type` is not + * `shieldSubscriptionApprove`. * @returns void */ - async submitShieldSubscriptionCryptoApproval( - txMeta: TransactionMeta, - isSponsored?: boolean, - rewardAccountId?: CaipAccountId, + async submitSubscriptionCryptoApproval( + request: SubmitSubscriptionCryptoApprovalRequest, ): Promise { - if (txMeta.type !== TransactionType.shieldSubscriptionApprove) { - return; + const { productType, txMeta, isSponsored, rewardAccountId } = request; + if ( + // Widen for the runtime guard: JS / unsound callers may still pass a + // non-Shield product. + (productType as ProductType) !== PRODUCT_TYPES.SHIELD || + txMeta.type !== TransactionType.shieldSubscriptionApprove + ) { + throw new Error( + SubscriptionControllerErrorMessage.CryptoApprovalRequiresShieldApprove, + ); } const { chainId, rawTx } = txMeta; @@ -463,34 +533,33 @@ export class SubscriptionController extends StaticIntervalPollingController()< throw new Error('Chain ID or raw transaction not found'); } - const { pricing, trialedProducts, lastSelectedPaymentMethod } = this.state; + const { pricing, lastSelectedPaymentMethod } = this.state; if (!pricing) { throw new Error('Subscription pricing not found'); } if (!lastSelectedPaymentMethod) { throw new Error('Last selected payment method not found'); } - const lastSelectedPaymentMethodShield = - lastSelectedPaymentMethod[PRODUCT_TYPES.SHIELD]; - this.#assertIsPaymentMethodCrypto(lastSelectedPaymentMethodShield); + const lastSelectedPaymentMethodForProduct = + lastSelectedPaymentMethod[productType]; + this.#assertIsPaymentMethodCrypto(lastSelectedPaymentMethodForProduct); const productPrice = this.#getProductPriceByProductAndPlan( - PRODUCT_TYPES.SHIELD, - lastSelectedPaymentMethodShield.plan, + productType, + lastSelectedPaymentMethodForProduct.plan, ); - const isTrialed = trialedProducts?.includes(PRODUCT_TYPES.SHIELD); - // get the latest subscriptions state to check if the user has an active shield subscription + // get the latest subscriptions state before computing trial eligibility await this.getSubscriptions(); - const currentSubscription = this.state.subscriptions.find((subscription) => - subscription.products.some( - (product) => product.name === PRODUCT_TYPES.SHIELD, - ), + const isTrialRequested = this.#getIsTrialRequested( + [productType], + lastSelectedPaymentMethodForProduct.plan, ); + const currentSubscription = this.getSubscriptionByProduct(productType); this.#assertValidSubscriptionStateForCryptoApproval({ - productType: PRODUCT_TYPES.SHIELD, + productType, }); - // if shield subscription exists, this transaction is for changing payment method + // if subscription exists, this transaction is for changing payment method const isChangePaymentMethod = Boolean(currentSubscription); if (isChangePaymentMethod) { @@ -499,23 +568,24 @@ export class SubscriptionController extends StaticIntervalPollingController()< subscriptionId: (currentSubscription as Subscription).id, chainId, payerAddress: txMeta.txParams.from as Hex, - tokenSymbol: lastSelectedPaymentMethodShield.paymentTokenSymbol, + tokenSymbol: lastSelectedPaymentMethodForProduct.paymentTokenSymbol, rawTransaction: rawTx as Hex, recurringInterval: productPrice.interval, billingCycles: productPrice.minBillingCycles, }); } else { - const params = { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: !isTrialed, + const params: StartCryptoSubscriptionRequest = { + products: [productType], + isTrialRequested, recurringInterval: productPrice.interval, billingCycles: productPrice.minBillingCycles, chainId, payerAddress: txMeta.txParams.from as Hex, - tokenSymbol: lastSelectedPaymentMethodShield.paymentTokenSymbol, + tokenSymbol: lastSelectedPaymentMethodForProduct.paymentTokenSymbol, rawTransaction: rawTx as Hex, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, isSponsored, - useTestClock: lastSelectedPaymentMethodShield.useTestClock, + useTestClock: lastSelectedPaymentMethodForProduct.useTestClock, rewardAccountId, }; await this.startSubscriptionWithCrypto(params); @@ -556,8 +626,9 @@ export class SubscriptionController extends StaticIntervalPollingController()< throw new Error('Price not found'); } - const chainsPaymentInfo = pricing.paymentMethods.find( - (paymentMethod) => paymentMethod.type === PAYMENT_TYPES.byCrypto, + const chainsPaymentInfo = this.#findCryptoPaymentMethod( + request.productType, + CRYPTO_AUTH_METHODS.ERC20_APPROVAL, ); if (!chainsPaymentInfo) { throw new Error('Chains payment info not found'); @@ -622,17 +693,17 @@ export class SubscriptionController extends StaticIntervalPollingController()< /** * Cache the last selected payment method for a specific product. * - * @param product - The product to cache the payment method for. - * @param paymentMethod - The payment method to cache. - * @param paymentMethod.type - The type of the payment method. - * @param paymentMethod.paymentTokenAddress - The payment token address. - * @param paymentMethod.plan - The plan of the payment method. - * @param paymentMethod.product - The product of the payment method. + * @param request - The request object. + * @param request.product - The product to cache the payment method for. + * @param request.paymentMethod - The payment method to cache. + * @param request.paymentMethod.type - The type of the payment method. + * @param request.paymentMethod.paymentTokenAddress - The payment token address. + * @param request.paymentMethod.plan - The plan of the payment method. */ cacheLastSelectedPaymentMethod( - product: ProductType, - paymentMethod: CachedLastSelectedPaymentMethod, + request: CacheLastSelectedPaymentMethodRequest, ): void { + const { product, paymentMethod } = request; if ( paymentMethod.type === PAYMENT_TYPES.byCrypto && (!paymentMethod.paymentTokenAddress || !paymentMethod.paymentTokenSymbol) @@ -678,7 +749,8 @@ export class SubscriptionController extends StaticIntervalPollingController()< * recurringInterval: RecurringInterval.Month, * billingCycles: 1, * } - * @returns resolves to true if the sponsorship is supported and intents were submitted successfully, false otherwise + * @returns resolves to true if the sponsorship is supported and intents were submitted successfully, false if the chain does not support sponsorship or the user has already trialed + * @throws If the crypto payment method or chain is missing from pricing */ async submitSponsorshipIntents( request: SubmitSponsorshipIntentsMethodParams, @@ -695,10 +767,14 @@ export class SubscriptionController extends StaticIntervalPollingController()< this.state.lastSelectedPaymentMethod?.[request.products[0]]; this.#assertIsPaymentMethodCrypto(selectedPaymentMethod); + const cryptoAuthMethod = + selectedPaymentMethod.cryptoAuthMethod ?? + CRYPTO_AUTH_METHODS.ERC20_APPROVAL; const isEligibleForTrialedSponsorship = this.#getIsEligibleForTrialedSponsorship( request.chainId, request.products, + cryptoAuthMethod, ); if (!isEligibleForTrialedSponsorship) { return false; @@ -818,8 +894,8 @@ export class SubscriptionController extends StaticIntervalPollingController()< tokenPaymentInfo: TokenPaymentInfo, ): string { const conversionRate = - tokenPaymentInfo.conversionRate[ - price.currency as keyof typeof tokenPaymentInfo.conversionRate + tokenPaymentInfo.conversionRate?.[ + price.currency as keyof NonNullable ]; if (!conversionRate) { throw new Error('Conversion rate not found'); @@ -846,8 +922,8 @@ export class SubscriptionController extends StaticIntervalPollingController()< tokenPaymentInfo: TokenPaymentInfo, ): string { const conversionRate = - tokenPaymentInfo.conversionRate[ - price.currency as keyof typeof tokenPaymentInfo.conversionRate + tokenPaymentInfo.conversionRate?.[ + price.currency as keyof NonNullable ]; if (!conversionRate) { throw new Error('Conversion rate not found'); @@ -954,12 +1030,19 @@ export class SubscriptionController extends StaticIntervalPollingController()< /** * Asserts that the value is a valid crypto payment method. * + * After this assert, `cryptoAuthMethod` and `useTestClock` remain optional + * because persisted cache entries may omit them. + * * @param value - The value to assert. * @throws an error if the value is not a valid crypto payment method. */ #assertIsPaymentMethodCrypto( value: CachedLastSelectedPaymentMethod | undefined, - ): asserts value is Required { + ): asserts value is CachedLastSelectedPaymentMethod & { + type: typeof PAYMENT_TYPES.byCrypto; + paymentTokenAddress: Hex; + paymentTokenSymbol: string; + } { if ( value?.type !== PAYMENT_TYPES.byCrypto || !value.paymentTokenAddress || @@ -977,13 +1060,19 @@ export class SubscriptionController extends StaticIntervalPollingController()< * * @param chainId - The chain ID * @param products - The products to check eligibility for + * @param cryptoAuthMethod - The crypto authorization method of the selected payment method * @returns True if the user is eligible for trialed sponsorship, false otherwise */ #getIsEligibleForTrialedSponsorship( chainId: Hex, products: ProductType[], + cryptoAuthMethod: CryptoAuthMethod, ): boolean { - const isSponsorshipSupported = this.#getChainSupportsSponsorship(chainId); + const isSponsorshipSupported = this.#getChainSupportsSponsorship( + chainId, + products[0], + cryptoAuthMethod, + ); // verify if the user has trialed the provided products before const hasTrialedBefore = this.state.trialedProducts.some((product) => @@ -993,15 +1082,145 @@ export class SubscriptionController extends StaticIntervalPollingController()< return isSponsorshipSupported && !hasTrialedBefore; } - #getChainSupportsSponsorship(chainId: Hex): boolean { - const cryptoPaymentInfo = this.state.pricing?.paymentMethods.find( - (paymentMethod) => paymentMethod.type === PAYMENT_TYPES.byCrypto, + /** + * Whether a trial should be requested for the given products and plan. + * True only when every product has `trialPeriodDays > 0` and has not + * already been trialed. + * + * @param products - The products to check. + * @param plan - The recurring interval to look up pricing for. + * @returns Whether a trial should be requested. + */ + #getIsTrialRequested( + products: ProductType[], + plan: RecurringInterval, + ): boolean { + return products.every((productType) => { + if (this.state.trialedProducts.includes(productType)) { + return false; + } + const productPrice = this.#getProductPriceByProductAndPlan( + productType, + plan, + ); + return productPrice.trialPeriodDays > 0; + }); + } + + #findCryptoPaymentMethod( + productType: ProductType, + cryptoAuthMethod: CryptoAuthMethod, + ): PricingCryptoPaymentMethod | undefined { + const matches: { + method: PricingCryptoPaymentMethod; + explicit: boolean; + }[] = []; + + for (const paymentMethod of this.state.pricing?.paymentMethods ?? []) { + if (paymentMethod.type !== PAYMENT_TYPES.byCrypto) { + continue; + } + + const resolved = this.#resolveCryptoPaymentMethodDefaults(paymentMethod); + if (!resolved) { + continue; + } + + if ( + resolved.cryptoAuthMethod === cryptoAuthMethod && + resolved.products.includes(productType) + ) { + matches.push({ + method: paymentMethod, + explicit: resolved.explicit, + }); + } + } + + if (matches.length === 0) { + return undefined; + } + + const explicitMatches = matches.filter((match) => match.explicit); + const candidates = explicitMatches.length > 0 ? explicitMatches : matches; + + if (candidates.length > 1) { + throw new Error('Multiple matching crypto payment methods found'); + } + + return candidates[0].method; + } + + /** + * Resolves omitted `products` / `cryptoAuthMethod` on a crypto pricing row. + * Legacy Shield + `erc20_approval` defaults apply only when both fields are + * absent. If `products` is present, the list must be non-empty and + * `cryptoAuthMethod` must be explicit; otherwise the row is ignored. + * + * @param paymentMethod - The crypto pricing row. + * @returns Resolved products and auth method, or `undefined` if the row is + * incomplete. + */ + #resolveCryptoPaymentMethodDefaults( + paymentMethod: PricingCryptoPaymentMethod, + ): + | { + products: ProductType[]; + cryptoAuthMethod: CryptoAuthMethod; + explicit: boolean; + } + | undefined { + const productsPresent = paymentMethod.products !== undefined; + const authPresent = paymentMethod.cryptoAuthMethod !== undefined; + + if (!productsPresent && !authPresent) { + return { + products: [PRODUCT_TYPES.SHIELD], + cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, + explicit: false, + }; + } + + if (!paymentMethod.products?.length || !paymentMethod.cryptoAuthMethod) { + return undefined; + } + + return { + products: paymentMethod.products, + cryptoAuthMethod: paymentMethod.cryptoAuthMethod, + explicit: true, + }; + } + + /** + * Whether the given chain supports sponsorship for the product and auth method. + * + * @param chainId - The chain ID + * @param productType - The product type + * @param cryptoAuthMethod - The crypto authorization method to look up + * @returns True if the chain row has sponsorship enabled, false if it is explicitly not sponsored + * @throws If the crypto payment method or chain row is missing from pricing + */ + #getChainSupportsSponsorship( + chainId: Hex, + productType: ProductType, + cryptoAuthMethod: CryptoAuthMethod, + ): boolean { + const cryptoPaymentInfo = this.#findCryptoPaymentMethod( + productType, + cryptoAuthMethod, ); + if (!cryptoPaymentInfo) { + throw new Error('Chains payment info not found'); + } - const isSponsorshipSupported = cryptoPaymentInfo?.chains?.find( + const chainPaymentInfo = cryptoPaymentInfo.chains?.find( (chain) => chain.chainId === chainId, - )?.isSponsorshipSupported; - return Boolean(isSponsorshipSupported); + ); + if (!chainPaymentInfo) { + throw new Error('Invalid chain id'); + } + return Boolean(chainPaymentInfo.isSponsorshipSupported); } /** diff --git a/packages/subscription-controller/src/SubscriptionService-method-action-types.ts b/packages/subscription-controller/src/SubscriptionService-method-action-types.ts index 8e7cfc874e..e7d3a5b5ec 100644 --- a/packages/subscription-controller/src/SubscriptionService-method-action-types.ts +++ b/packages/subscription-controller/src/SubscriptionService-method-action-types.ts @@ -39,7 +39,8 @@ export type SubscriptionServiceUnCancelSubscriptionAction = { }; /** - * Starts a subscription with a card payment method. + * Starts a card-paid subscription checkout session for the requested products + * (e.g. Shield or Money Account Plus). * * @param request - The start subscription request. * @returns The checkout session response. @@ -54,6 +55,9 @@ export type SubscriptionServiceStartSubscriptionWithCardAction = { * * @param request - The start crypto subscription request. * @returns The created subscription response. + * @throws If `products` is empty. + * @throws If the request does not use exactly one of `rawTransaction` + * (ERC-20 approval) or `delegationHash` (delegation). */ export type SubscriptionServiceStartSubscriptionWithCryptoAction = { type: `SubscriptionService:startSubscriptionWithCrypto`; diff --git a/packages/subscription-controller/src/SubscriptionService-structs.ts b/packages/subscription-controller/src/SubscriptionService-structs.ts index ddebe1ff6d..28fb5c001d 100644 --- a/packages/subscription-controller/src/SubscriptionService-structs.ts +++ b/packages/subscription-controller/src/SubscriptionService-structs.ts @@ -2,26 +2,32 @@ import { array, boolean, enums, + lazy, + literal, nullable, number, + object, optional, string, type, union, } from '@metamask/superstruct'; +import type { Struct } from '@metamask/superstruct'; import { StrictHexStruct, CaipAccountIdStruct } from '@metamask/utils'; import { CANCEL_TYPES, + CRYPTO_AUTH_METHODS, CRYPTO_PAYMENT_METHOD_ERRORS, PAYMENT_TYPES, PRODUCT_TYPES, RECURRING_INTERVALS, SUBSCRIPTION_STATUSES, } from './types.js'; +import type { TokenPaymentInfo } from './types.js'; const ProductTypeStruct = enums(Object.values(PRODUCT_TYPES)); -const PaymentTypeStruct = enums(Object.values(PAYMENT_TYPES)); +const CryptoAuthMethodStruct = enums(Object.values(CRYPTO_AUTH_METHODS)); const RecurringIntervalStruct = enums(Object.values(RECURRING_INTERVALS)); const SubscriptionStatusStruct = enums(Object.values(SUBSCRIPTION_STATUSES)); const CancelTypeStruct = enums(Object.values(CANCEL_TYPES)); @@ -125,26 +131,52 @@ const ProductPricingStruct = type({ prices: array(ProductPriceStruct), }); -const TokenPaymentInfoStruct = type({ - symbol: string(), - address: StrictHexStruct, - decimals: number(), - conversionRate: type({ - usd: string(), - }), -}); +const TokenPaymentInfoConversionRateStruct = type({ + usd: string(), +}); + +const TokenPaymentInfoStruct: Struct = lazy(() => + union([ + object({ + symbol: string(), + address: StrictHexStruct, + decimals: number(), + conversionRate: optional(TokenPaymentInfoConversionRateStruct), + isVaultShare: literal(true), + accountantAddress: StrictHexStruct, + sources: optional(array(TokenPaymentInfoStruct)), + }), + object({ + symbol: string(), + address: StrictHexStruct, + decimals: number(), + conversionRate: optional(TokenPaymentInfoConversionRateStruct), + isVaultShare: optional(literal(false)), + sources: optional(array(TokenPaymentInfoStruct)), + }), + ]), +) as Struct; const ChainPaymentInfoStruct = type({ chainId: StrictHexStruct, paymentAddress: StrictHexStruct, + delegateAddress: optional(StrictHexStruct), tokens: array(TokenPaymentInfoStruct), isSponsorshipSupported: optional(boolean()), }); -const PricingPaymentMethodStruct = type({ - type: PaymentTypeStruct, - chains: optional(array(ChainPaymentInfoStruct)), -}); +const PricingPaymentMethodStruct = union([ + object({ + type: enums([PAYMENT_TYPES.byCard]), + products: optional(array(ProductTypeStruct)), + }), + object({ + type: enums([PAYMENT_TYPES.byCrypto]), + cryptoAuthMethod: optional(CryptoAuthMethodStruct), + products: optional(array(ProductTypeStruct)), + chains: optional(array(ChainPaymentInfoStruct)), + }), +]); export const PricingResponseStruct = type({ products: array(ProductPricingStruct), diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 55ffc65d88..aa8a241f2e 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -757,6 +757,80 @@ describe('SubscriptionService', () => { expect(result).toStrictEqual(response); }); }); + + it('throws when products array is empty', async () => { + const fetchMock = jest.fn(); + const { service } = createService({ fetchMock }); + const request: StartCryptoSubscriptionRequest = { + ...MOCK_CRYPTO_REQUEST, + products: [], + }; + + await expect( + service.startSubscriptionWithCrypto(request), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'delegation without delegationHash', + { cryptoAuthMethod: 'delegation' as const }, + ], + [ + 'ERC-20 without rawTransaction', + { cryptoAuthMethod: 'erc20_approval' as const }, + ], + ['omitted method without rawTransaction', {}], + [ + 'both rawTransaction and delegationHash', + { rawTransaction: '0xdeadbeef' as const, delegationHash: '0xabc' }, + ], + [ + 'delegation with both fields', + { + cryptoAuthMethod: 'delegation' as const, + rawTransaction: '0xdeadbeef' as const, + delegationHash: '0xabc' as const, + }, + ], + [ + 'ERC-20 with only delegationHash', + { + cryptoAuthMethod: 'erc20_approval' as const, + delegationHash: '0xabc' as const, + }, + ], + ])( + 'rejects %s without posting', + async ( + _case: string, + overrides: { + cryptoAuthMethod?: string; + rawTransaction?: string; + delegationHash?: string; + }, + ) => { + const fetchMock = jest.fn(); + const { service } = createService({ fetchMock }); + const { rawTransaction: _rawTransaction, ...base } = + MOCK_CRYPTO_REQUEST; + // Intentionally invalid combos: runtime still rejects unsound callers. + const request = { + ...base, + ...overrides, + } as StartCryptoSubscriptionRequest; + + await expect( + service.startSubscriptionWithCrypto(request), + ).rejects.toThrow( + SubscriptionServiceErrorMessage.InvalidCryptoAuthCombo, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); }); describe('getPricing', () => { @@ -765,6 +839,19 @@ describe('SubscriptionService', () => { paymentMethods: [], }; + const mockSpotToken = { + symbol: 'USDC', + address: '0xa9f2867708c727fe250fb0d1fbeb4b4c8e1818e8', + decimals: 9, + conversionRate: { usd: '1.0' }, + }; + + const mockCryptoChain = { + chainId: '0x1', + paymentAddress: '0x00000000000000000000000000000000000000a2', + tokens: [mockSpotToken], + }; + it('should fetch pricing successfully', async () => { const fetchMock = jest.fn(); const { service } = createService({ fetchMock }); @@ -777,6 +864,61 @@ describe('SubscriptionService', () => { expect(result).toStrictEqual(mockPricingResponse); }); + + it('rejects vault share tokens that omit accountantAddress', async () => { + const fetchMock = jest.fn(); + const { service } = createService({ fetchMock }); + + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + products: [], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + chains: [ + { + ...mockCryptoChain, + tokens: [ + { + symbol: 'pvmUSD', + address: '0x1C8a336051D2024E318A229d01F9F6CF96efD316', + decimals: 6, + isVaultShare: true, + }, + ], + }, + ], + }, + ], + }, + }), + ); + + await expect(service.getPricing()).rejects.toThrow(/union/u); + }); + + it('rejects card payment methods that include crypto-only fields', async () => { + const fetchMock = jest.fn(); + const { service } = createService({ fetchMock }); + + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + products: [], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCard, + cryptoAuthMethod: 'delegation', + chains: [mockCryptoChain], + }, + ], + }, + }), + ); + + await expect(service.getPricing()).rejects.toThrow(/union/u); + }); }); describe('updatePaymentMethodCard', () => { @@ -1266,6 +1408,213 @@ describe('SubscriptionService', () => { }); }); + describe('multi-product support', () => { + const MOCK_MONEY_ACCOUNT_PRICING_RESPONSE: PricingResponse = { + products: [ + { + name: PRODUCT_TYPES.SHIELD, + prices: [ + { + interval: RECURRING_INTERVALS.month, + unitAmount: 900, + unitDecimals: 2, + currency: 'usd', + trialPeriodDays: 14, + minBillingCycles: 12, + minBillingCyclesForBalance: 1, + }, + ], + }, + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + prices: [ + { + interval: RECURRING_INTERVALS.month, + unitAmount: 499, + unitDecimals: 2, + currency: 'usd', + trialPeriodDays: 0, + minBillingCycles: 12, + minBillingCyclesForBalance: 1, + }, + ], + }, + ], + paymentMethods: [ + { + type: PAYMENT_TYPES.byCard, + products: [PRODUCT_TYPES.SHIELD], + }, + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'erc20_approval', + products: [PRODUCT_TYPES.SHIELD], + chains: [ + { + chainId: '0x1', + paymentAddress: '0x00000000000000000000000000000000000000a2', + tokens: [ + { + symbol: 'USDC', + address: '0xa9f2867708c727fe250fb0d1fbeb4b4c8e1818e8', + decimals: 9, + conversionRate: { usd: '1.0' }, + }, + ], + }, + ], + }, + { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: 'delegation', + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + chains: [ + { + chainId: '0x8f', + paymentAddress: '0x00000000000000000000000000000000000000a1', + delegateAddress: '0x00000000000000000000000000000000000000c0', + tokens: [ + { + symbol: 'pvmUSD', + address: '0x1C8a336051D2024E318A229d01F9F6CF96efD316', + decimals: 6, + isVaultShare: true, + accountantAddress: + '0x98A45D90E81849a5743241d3ff765F9Fd788206a', + sources: [ + { + symbol: 'mUSD', + address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + decimals: 6, + conversionRate: { usd: '1.0' }, + }, + ], + }, + ], + }, + ], + }, + ], + }; + + it('should validate Money Account pricing responses', async () => { + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ jsonData: MOCK_MONEY_ACCOUNT_PRICING_RESPONSE }), + ); + + const result = await service.getPricing(); + + expect(result).toStrictEqual(MOCK_MONEY_ACCOUNT_PRICING_RESPONSE); + }); + }); + + it('should forward delegation-based Money Account crypto subscriptions', async () => { + const delegationRequest: StartCryptoSubscriptionRequest = { + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + chainId: '0x8f', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'pvmUSD', + cryptoAuthMethod: 'delegation', + delegationHash: '0xabc', + }; + + await withMockSubscriptionService(async ({ service, fetchMock, env }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + subscriptionId: 'sub_money_account', + status: SUBSCRIPTION_STATUSES.active, + }, + }), + ); + + await service.startSubscriptionWithCrypto(delegationRequest); + + expect(fetchMock).toHaveBeenCalledWith( + SUBSCRIPTION_URL(env, 'subscriptions/crypto'), + { + method: 'POST', + headers: MOCK_HEADERS, + body: JSON.stringify(delegationRequest), + }, + ); + }); + }); + + it('should return dual-product subscription responses', async () => { + const moneyAccountSubscription: Subscription = { + ...MOCK_SUBSCRIPTION, + id: 'sub_money_account', + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + currency: 'usd', + unitAmount: 499, + unitDecimals: 2, + }, + ], + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: '0x0000000000000000000000000000000000000001', + chainId: '0x8f', + tokenSymbol: 'pvmUSD', + }, + }, + }; + + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION, moneyAccountSubscription], + trialedProducts: [PRODUCT_TYPES.SHIELD], + }, + }), + ); + + const result = await service.getSubscriptions(); + + expect(result.subscriptions).toHaveLength(2); + expect(result.trialedProducts).toStrictEqual([PRODUCT_TYPES.SHIELD]); + }); + }); + + it('should return Money Account eligibility responses', async () => { + const moneyAccountEligibility: SubscriptionEligibility = { + product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + canSubscribe: true, + canViewEntryModal: false, + cohorts: [], + assignedCohort: null, + hasAssignedCohortExpired: false, + }; + + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: [ + createMockEligibilityResponse(), + moneyAccountEligibility, + ], + }), + ); + + const results = await service.getSubscriptionsEligibilities(); + + expect(results).toStrictEqual([ + createMockEligibilityResponse(), + moneyAccountEligibility, + ]); + }); + }); + }); + describe('error handling', () => { it('rethrows SubscriptionServiceError thrown by fetchQuery without wrapping', async () => { const fetchMock = jest.fn(); diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index 4a73388e68..1514874262 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -36,6 +36,7 @@ import { SubscriptionStruct, UpdatePaymentMethodCardResponseStruct, } from './SubscriptionService-structs.js'; +import { CRYPTO_AUTH_METHODS } from './types.js'; import type { AssignCohortRequest, BillingPortalResponse, @@ -255,7 +256,8 @@ export class SubscriptionService extends BaseDataService< } /** - * Starts a subscription with a card payment method. + * Starts a card-paid subscription checkout session for the requested products + * (e.g. Shield or Money Account Plus). * * @param request - The start subscription request. * @returns The checkout session response. @@ -290,10 +292,21 @@ export class SubscriptionService extends BaseDataService< * * @param request - The start crypto subscription request. * @returns The created subscription response. + * @throws If `products` is empty. + * @throws If the request does not use exactly one of `rawTransaction` + * (ERC-20 approval) or `delegationHash` (delegation). */ async startSubscriptionWithCrypto( request: StartCryptoSubscriptionRequest, ): Promise { + if (request.products.length === 0) { + throw new SubscriptionServiceError( + SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, + ); + } + + this.#assertValidCryptoAuthCombo(request); + const { profileKey, bearerToken } = await this.#getAuthenticatedContext(); const jsonResponse = await this.#fetchJson({ profileKey, @@ -611,6 +624,34 @@ export class SubscriptionService extends BaseDataService< } } + /** + * Ensures the request uses exactly one crypto auth method: ERC-20 approval + * (`rawTransaction`, default) or delegation (`delegationHash`). + * + * @param request - The start crypto subscription request. + * @throws If both, neither, or a mismatched combo of auth fields is provided. + */ + #assertValidCryptoAuthCombo(request: StartCryptoSubscriptionRequest): void { + const method = + request.cryptoAuthMethod ?? CRYPTO_AUTH_METHODS.ERC20_APPROVAL; + const hasRawTransaction = Boolean(request.rawTransaction); + const hasDelegationHash = Boolean(request.delegationHash); + const isValidErc20Approval = + method === CRYPTO_AUTH_METHODS.ERC20_APPROVAL && + hasRawTransaction && + !hasDelegationHash; + const isValidDelegation = + method === CRYPTO_AUTH_METHODS.DELEGATION && + hasDelegationHash && + !hasRawTransaction; + + if (!isValidErc20Approval && !isValidDelegation) { + throw new SubscriptionServiceError( + SubscriptionServiceErrorMessage.InvalidCryptoAuthCombo, + ); + } + } + async #getAuthenticatedContext(): Promise<{ profileKey: string; bearerToken: string; diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index eba4888352..147daadf6f 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -46,6 +46,7 @@ export enum SubscriptionControllerErrorMessage { PaymentMethodNotCrypto = `${controllerName} - Payment method is not crypto`, ProductPriceNotFound = `${controllerName} - Product price not found`, SubscriptionNotValidForCryptoApproval = `${controllerName} - Subscription is not valid for crypto approval`, + CryptoApprovalRequiresShieldApprove = `${controllerName} - Crypto approval is only supported for Shield ERC-20 approve transactions`, LinkRewardsFailed = `${controllerName} - Failed to link rewards`, } @@ -55,6 +56,7 @@ export enum SubscriptionServiceErrorMessage { FailedToUncancelSubscription = 'Failed to uncancel subscription', FailedToStartSubscriptionWithCard = 'Failed to start subscription with card', FailedToStartSubscriptionWithCrypto = 'Failed to start subscription with crypto', + InvalidCryptoAuthCombo = 'Crypto subscription requires exactly one of rawTransaction (erc20_approval) or delegationHash (delegation)', FailedToUpdatePaymentMethodCard = 'Failed to update payment method card', FailedToUpdatePaymentMethodCrypto = 'Failed to update payment method crypto', FailedToGetSubscriptionsEligibilities = 'Failed to get subscriptions eligibilities', diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index db37dcdee2..c3d531ab00 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -14,9 +14,9 @@ export type { SubscriptionControllerGetSubscriptionsEligibilitiesAction, SubscriptionControllerCancelSubscriptionAction, SubscriptionControllerUnCancelSubscriptionAction, - SubscriptionControllerStartShieldSubscriptionWithCardAction, + SubscriptionControllerStartSubscriptionWithCardAction, SubscriptionControllerStartSubscriptionWithCryptoAction, - SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction, + SubscriptionControllerSubmitSubscriptionCryptoApprovalAction, SubscriptionControllerGetCryptoApproveTransactionParamsAction, SubscriptionControllerUpdatePaymentMethodAction, SubscriptionControllerGetBillingPortalUrlAction, @@ -44,11 +44,14 @@ export type { CancelType, ISubscriptionService, StartCryptoSubscriptionRequest, + StartDelegationCryptoSubscriptionRequest, + StartErc20CryptoSubscriptionRequest, StartCryptoSubscriptionResponse, StartSubscriptionRequest, StartSubscriptionResponse, GetCryptoApproveTransactionRequest, GetCryptoApproveTransactionResponse, + SubmitSubscriptionCryptoApprovalRequest, SubscriptionCardPaymentMethod, SubscriptionCryptoPaymentMethod, SubscriptionPaymentMethod, @@ -64,9 +67,14 @@ export type { ProductPrice, ProductPricing, TokenPaymentInfo, + SpotTokenPaymentInfo, + VaultTokenPaymentInfo, ChainPaymentInfo, Currency, + CryptoAuthMethod, PricingPaymentMethod, + PricingCardPaymentMethod, + PricingCryptoPaymentMethod, PricingResponse, UpdatePaymentMethodOpts, BillingPortalResponse, @@ -75,6 +83,7 @@ export type { UpdatePaymentMethodCardRequest, UpdatePaymentMethodCardResponse, CachedLastSelectedPaymentMethod, + CacheLastSelectedPaymentMethodRequest, SubmitSponsorshipIntentsMethodParams, Cohort, CohortName, @@ -90,6 +99,7 @@ export { PRODUCT_TYPES, RECURRING_INTERVALS, PAYMENT_TYPES, + CRYPTO_AUTH_METHODS, SubscriptionUserEvent, COHORT_NAMES, BALANCE_CATEGORIES, diff --git a/packages/subscription-controller/src/types.test.ts b/packages/subscription-controller/src/types.test.ts new file mode 100644 index 0000000000..b488cfe269 --- /dev/null +++ b/packages/subscription-controller/src/types.test.ts @@ -0,0 +1,102 @@ +import type { Hex } from '@metamask/utils'; + +import { + CRYPTO_AUTH_METHODS, + PRODUCT_TYPES, + RECURRING_INTERVALS, +} from './types.js'; +import type { StartCryptoSubscriptionRequest } from './types.js'; + +const SHARED_CRYPTO_REQUEST = { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 3, + chainId: '0x1' as Hex, + payerAddress: '0x0000000000000000000000000000000000000001' as Hex, + tokenSymbol: 'USDC', +}; + +function assertStartCryptoSubscriptionRequest( + request: StartCryptoSubscriptionRequest, +): StartCryptoSubscriptionRequest { + return request; +} + +describe('StartCryptoSubscriptionRequest', () => { + it('accepts an ERC-20 approval request without cryptoAuthMethod', () => { + const request = assertStartCryptoSubscriptionRequest({ + ...SHARED_CRYPTO_REQUEST, + rawTransaction: '0xdeadbeef', + }); + + expect(request.rawTransaction).toBe('0xdeadbeef'); + }); + + it('accepts an ERC-20 approval request with explicit cryptoAuthMethod', () => { + const request = assertStartCryptoSubscriptionRequest({ + ...SHARED_CRYPTO_REQUEST, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, + rawTransaction: '0xdeadbeef', + }); + + expect(request.cryptoAuthMethod).toBe(CRYPTO_AUTH_METHODS.ERC20_APPROVAL); + }); + + it('accepts a delegation request', () => { + const request = assertStartCryptoSubscriptionRequest({ + ...SHARED_CRYPTO_REQUEST, + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, + delegationHash: '0xabc', + }); + + expect(request.delegationHash).toBe('0xabc'); + }); + + it('rejects invalid auth field combinations at compile time', () => { + const bothFields = { + ...SHARED_CRYPTO_REQUEST, + rawTransaction: '0xdeadbeef' as Hex, + delegationHash: '0xabc' as Hex, + }; + // @ts-expect-error ERC-20 and delegation fields together + assertStartCryptoSubscriptionRequest(bothFields); + + // @ts-expect-error neither auth field + assertStartCryptoSubscriptionRequest({ + ...SHARED_CRYPTO_REQUEST, + }); + + // @ts-expect-error delegation without delegationHash + assertStartCryptoSubscriptionRequest({ + ...SHARED_CRYPTO_REQUEST, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, + }); + + // @ts-expect-error ERC-20 method without rawTransaction + assertStartCryptoSubscriptionRequest({ + ...SHARED_CRYPTO_REQUEST, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, + }); + + const delegationWithRawTransaction = { + ...SHARED_CRYPTO_REQUEST, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, + rawTransaction: '0xdeadbeef' as Hex, + delegationHash: '0xabc' as Hex, + }; + // @ts-expect-error delegation with rawTransaction + assertStartCryptoSubscriptionRequest(delegationWithRawTransaction); + + const erc20WithDelegationHash = { + ...SHARED_CRYPTO_REQUEST, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, + delegationHash: '0xabc' as Hex, + }; + // @ts-expect-error ERC-20 method with only delegationHash + assertStartCryptoSubscriptionRequest(erc20WithDelegationHash); + + expect(true).toBe(true); + }); +}); diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index b7504b2b12..5842d42d60 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -1,3 +1,4 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { CaipAccountId, Hex } from '@metamask/utils'; /** @@ -9,12 +10,42 @@ export type SubscriptionApiError = { statusCode?: number; }; +/** + * Supported subscription products. + */ export const PRODUCT_TYPES = { + /** + * MetaMask Shield. + */ SHIELD: 'shield', + /** + * Money Account Plus (delegation-based crypto billing). + */ + MONEY_ACCOUNT_PLUS: 'money_account_plus', } as const; export type ProductType = (typeof PRODUCT_TYPES)[keyof typeof PRODUCT_TYPES]; +/** + * How a crypto subscription is authorized. + * + * Use `erc20_approval` with `rawTransaction` (e.g. Shield). Use `delegation` + * with `delegationHash` (e.g. Money Account Plus). + */ +export const CRYPTO_AUTH_METHODS = { + /** + * User signs an ERC-20 approve transaction. + */ + ERC20_APPROVAL: 'erc20_approval', + /** + * User authorizes via a stored delegation hash. + */ + DELEGATION: 'delegation', +} as const; + +export type CryptoAuthMethod = + (typeof CRYPTO_AUTH_METHODS)[keyof typeof CRYPTO_AUTH_METHODS]; + export const PAYMENT_TYPES = { byCard: 'card', byCrypto: 'crypto', @@ -158,8 +189,9 @@ export type StartSubscriptionRequest = { useTestClock?: boolean; /** - * The optional ID of the reward subscription to be opt in along with the main `shield` subscription. - * This is required if user wants to opt in to the reward subscription during the `shield` subscription creation. + * Optional CAIP account ID of the rewards account to opt in alongside this + * subscription. Required when the user wants to link rewards during + * subscription creation. * * @example { * rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890', @@ -172,7 +204,7 @@ export type StartSubscriptionResponse = { checkoutSessionUrl: string; }; -export type StartCryptoSubscriptionRequest = { +type StartCryptoSubscriptionRequestBase = { products: ProductType[]; isTrialRequested: boolean; recurringInterval: RecurringInterval; @@ -183,12 +215,12 @@ export type StartCryptoSubscriptionRequest = { * e.g. "USDC" */ tokenSymbol: string; - rawTransaction: Hex; isSponsored?: boolean; useTestClock?: boolean; /** - * The optional ID of the reward subscription to be opt in along with the main `shield` subscription. - * This is required if user wants to opt in to the reward subscription during the `shield` subscription creation. + * Optional CAIP account ID of the rewards account to opt in alongside this + * subscription. Required when the user wants to link rewards during + * subscription creation. * * @example { * rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890', @@ -197,6 +229,42 @@ export type StartCryptoSubscriptionRequest = { rewardAccountId?: CaipAccountId; }; +/** + * ERC-20 approval crypto subscription request (e.g. Shield). + * + * `cryptoAuthMethod` defaults to `CRYPTO_AUTH_METHODS.ERC20_APPROVAL` when + * omitted. + */ +export type StartErc20CryptoSubscriptionRequest = + StartCryptoSubscriptionRequestBase & { + cryptoAuthMethod?: typeof CRYPTO_AUTH_METHODS.ERC20_APPROVAL; + rawTransaction: Hex; + delegationHash?: never; + }; + +/** + * Delegation-based crypto subscription request (e.g. Money Account Plus). + */ +export type StartDelegationCryptoSubscriptionRequest = + StartCryptoSubscriptionRequestBase & { + cryptoAuthMethod: typeof CRYPTO_AUTH_METHODS.DELEGATION; + delegationHash: Hex; + rawTransaction?: never; + }; + +/** + * Request to start a crypto subscription. + * + * Discriminated union of ERC-20 approval vs delegation. Provide + * `rawTransaction` for ERC-20 approval (the default when `cryptoAuthMethod` is + * omitted), or `delegationHash` with `cryptoAuthMethod: 'delegation'`. + * Combining or omitting both is a type error, and is also rejected at runtime + * by `startSubscriptionWithCrypto`. + */ +export type StartCryptoSubscriptionRequest = + | StartErc20CryptoSubscriptionRequest + | StartDelegationCryptoSubscriptionRequest; + export type StartCryptoSubscriptionResponse = { subscriptionId: string; status: SubscriptionStatus; @@ -245,7 +313,7 @@ export type ProductPricing = { prices: ProductPrice[]; }; -export type TokenPaymentInfo = { +type TokenPaymentInfoBase = { symbol: string; address: Hex; decimals: number; @@ -254,14 +322,47 @@ export type TokenPaymentInfo = { usd: '1.0', }, */ - conversionRate: { + conversionRate?: { usd: string; }; + /** + * Source tokens that can be converted into this settlement token. + */ + sources?: TokenPaymentInfo[]; }; +/** + * Spot (non-vault) settlement token. Priced via `conversionRate` when provided. + * `accountantAddress` is not present on this variant. + */ +export type SpotTokenPaymentInfo = TokenPaymentInfoBase & { + isVaultShare?: false; +}; + +/** + * Yield-bearing vault share priced via an accountant rate. + */ +export type VaultTokenPaymentInfo = TokenPaymentInfoBase & { + isVaultShare: true; + /** + * Veda accountant address used to value this vault share. + */ + accountantAddress: Hex; +}; + +/** + * A settlement token in a pricing chain. Discriminated by `isVaultShare`: + * vault shares require `accountantAddress`; spot tokens omit it. + */ +export type TokenPaymentInfo = SpotTokenPaymentInfo | VaultTokenPaymentInfo; + export type ChainPaymentInfo = { chainId: Hex; paymentAddress: Hex; + /** + * Delegate address clients authorize when using the delegation auth method. + */ + delegateAddress?: Hex; tokens: TokenPaymentInfo[]; /** * Whether the chain supports sponsorship for the trialed subscription approval transaction. @@ -270,11 +371,39 @@ export type ChainPaymentInfo = { isSponsorshipSupported?: boolean; }; -export type PricingPaymentMethod = { - type: PaymentType; +export type PricingCardPaymentMethod = { + type: Extract; + /** + * Products that support this payment method. + */ + products?: ProductType[]; +}; + +export type PricingCryptoPaymentMethod = { + type: Extract; + /** + * Crypto authorization method. Omitted together with `products` on persisted + * pre-multi-product pricing rows; those rows are treated as Shield + `erc20_approval`. + * If `products` is set, this field must be explicit. + */ + cryptoAuthMethod?: CryptoAuthMethod; + /** + * Products that support this payment method. Omitted together with + * `cryptoAuthMethod` on persisted pre-multi-product pricing rows (treated as Shield). + * If present, must be non-empty and paired with an explicit `cryptoAuthMethod`. + */ + products?: ProductType[]; chains?: ChainPaymentInfo[]; }; +/** + * A pricing payment-method row. Discriminated by `type`: card rows have no + * crypto fields; crypto rows may include `cryptoAuthMethod` and `chains`. + */ +export type PricingPaymentMethod = + | PricingCardPaymentMethod + | PricingCryptoPaymentMethod; + export type PricingResponse = { products: ProductPricing[]; paymentMethods: PricingPaymentMethod[]; @@ -310,6 +439,30 @@ export type GetCryptoApproveTransactionResponse = { chainId: Hex; }; +/** + * Request to submit a Shield ERC-20 crypto approval transaction. + */ +export type SubmitSubscriptionCryptoApprovalRequest = { + /** + * The subscription product. Typed as `typeof PRODUCT_TYPES.SHIELD` only at + * the moment (future might support more product). + */ + productType: typeof PRODUCT_TYPES.SHIELD; + /** + * The transaction metadata. Must have type + * `TransactionType.shieldSubscriptionApprove`. + */ + txMeta: TransactionMeta; + /** + * Whether the transaction is sponsored. + */ + isSponsored?: boolean; + /** + * The account ID of the reward subscription to link. + */ + rewardAccountId?: CaipAccountId; +}; + export const COHORT_NAMES = { POST_TX: 'post_tx', WALLET_HOME: 'wallet_home', @@ -390,6 +543,13 @@ export type ISubscriptionService = { unCancelSubscription(request: { subscriptionId: string; }): Promise; + /** + * Starts a card-paid subscription checkout session for the requested products + * (e.g. Shield or Money Account Plus). + * + * @param request - The start subscription request. + * @returns The checkout session response. + */ startSubscriptionWithCard( request: StartSubscriptionRequest, ): Promise; @@ -490,6 +650,25 @@ export type CachedLastSelectedPaymentMethod = { paymentTokenSymbol?: string; plan: RecurringInterval; useTestClock?: boolean; + /** + * Crypto authorization method. Omitted on persisted cache entries written + * before this field existed; treat as `erc20_approval` when missing. + */ + cryptoAuthMethod?: CryptoAuthMethod; +}; + +/** + * Request to cache the last selected payment method for a product. + */ +export type CacheLastSelectedPaymentMethodRequest = { + /** + * The product to cache the payment method for. + */ + product: ProductType; + /** + * The payment method to cache. + */ + paymentMethod: CachedLastSelectedPaymentMethod; }; /** diff --git a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts index 7490253394..429990944d 100644 --- a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts +++ b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts @@ -1,7 +1,9 @@ import { Messenger } from '@metamask/messenger'; import { + Env, getDefaultSubscriptionControllerState, SubscriptionController, + SUBSCRIPTION_URL, } from '@metamask/subscription-controller'; import { defaultConfigurations } from '../../defaults.js'; @@ -203,4 +205,176 @@ describe('subscriptionController', () => { getDefaultSubscriptionControllerState(), ); }); + + it('calls generic startSubscriptionWithCard through the root messenger', async () => { + const rootMessenger = getRootMessenger(); + registerActionHandler( + rootMessenger, + 'AuthenticationController', + 'AuthenticationController:getBearerToken', + async () => 'test-bearer-token', + ); + registerActionHandler( + rootMessenger, + 'AuthenticationController', + 'AuthenticationController:getSessionProfile', + async () => ({ + profileId: 'profile-1', + canonicalProfileId: 'canonical-profile-1', + metaMetricsId: 'metametrics-1', + }), + ); + registerActionHandler( + rootMessenger, + 'AuthenticationController', + 'AuthenticationController:performSignOut', + jest.fn(), + ); + const serviceMessenger = subscriptionService.getMessenger(rootMessenger); + const fetchFunction = jest.fn(async (url: string) => { + if (url === SUBSCRIPTION_URL(Env.PRD, 'subscriptions/card')) { + return new globalThis.Response( + JSON.stringify({ + checkoutSessionUrl: 'https://checkout.example.com/session/123', + }), + { status: 200 }, + ); + } + + return new globalThis.Response( + JSON.stringify({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [], + }), + { status: 200 }, + ); + }); + + subscriptionService.init({ + state: undefined, + messenger: serviceMessenger, + options: { + fetchFunction, + }, + }); + + const controllerMessenger = + subscriptionController.getMessenger(rootMessenger); + subscriptionController.init({ + state: { + subscriptions: [], + trialedProducts: [], + pricing: { + products: [ + { + name: 'money_account_plus', + prices: [ + { + interval: 'month', + currency: 'usd', + unitAmount: 499, + unitDecimals: 2, + trialPeriodDays: 0, + minBillingCycles: 12, + minBillingCyclesForBalance: 1, + }, + ], + }, + ], + paymentMethods: [], + }, + }, + messenger: controllerMessenger, + options: {}, + }); + + const result = await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCard', + { + products: ['money_account_plus'], + isTrialRequested: false, + recurringInterval: 'month', + }, + ); + + expect(result).toStrictEqual({ + checkoutSessionUrl: 'https://checkout.example.com/session/123', + }); + expect(fetchFunction).toHaveBeenCalledWith( + SUBSCRIPTION_URL(Env.PRD, 'subscriptions'), + expect.objectContaining({ method: 'GET' }), + ); + expect(fetchFunction).toHaveBeenCalledWith( + SUBSCRIPTION_URL(Env.PRD, 'subscriptions/card'), + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('forwards dual-product initial state to the controller', () => { + const messenger = subscriptionController.getMessenger(getRootMessenger()); + + const instance = subscriptionController.init({ + state: { + subscriptions: [ + { + id: 'sub_shield', + products: [ + { + name: 'shield', + currency: 'usd', + unitAmount: 900, + unitDecimals: 2, + }, + ], + currentPeriodStart: '2024-01-01T00:00:00Z', + currentPeriodEnd: '2024-02-01T00:00:00Z', + status: 'active', + interval: 'month', + paymentMethod: { + type: 'card', + card: { + brand: 'visa', + displayBrand: 'visa', + last4: '1234', + }, + }, + isEligibleForSupport: true, + cancelType: 'allowed_at_period_end', + }, + { + id: 'sub_money_account', + products: [ + { + name: 'money_account_plus', + currency: 'usd', + unitAmount: 499, + unitDecimals: 2, + }, + ], + currentPeriodStart: '2024-01-01T00:00:00Z', + currentPeriodEnd: '2024-02-01T00:00:00Z', + status: 'active', + interval: 'month', + paymentMethod: { + type: 'crypto', + crypto: { + payerAddress: '0x1234567890123456789012345678901234567890', + chainId: '0x8f', + tokenSymbol: 'pvmUSD', + }, + }, + isEligibleForSupport: false, + cancelType: 'allowed_at_period_end', + }, + ], + trialedProducts: ['shield'], + }, + messenger, + options: {}, + }); + + expect(instance.state.subscriptions).toHaveLength(2); + expect(instance.state.trialedProducts).toStrictEqual(['shield']); + }); });