Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2322343
feat: submit subscription-sponsorship intents
lwin-kyaw Oct 20, 2025
7bb19c2
test: updated and added new tests
lwin-kyaw Oct 20, 2025
197f62f
fix: updated ChangeLog
lwin-kyaw Oct 20, 2025
12b0b60
resolved conflicts and rebased with main
lwin-kyaw Oct 22, 2025
f18d3aa
feat: update TransactionSponsorshipIntents requeset
lwin-kyaw Oct 23, 2025
f09d390
chore: resolved confilcts and rebased with main
lwin-kyaw Oct 25, 2025
2e34f63
fix: update 'assertIsUserNotSubscribed' method with inactive subs
lwin-kyaw Oct 27, 2025
2bb073d
fix: fixed lint
lwin-kyaw Oct 27, 2025
0447846
feat: added 'smartTransactionId' in 'StartCryptoSubscriptionRequest'
lwin-kyaw Oct 27, 2025
0ac150a
feat: include chainID in sponsorship intents request
lwin-kyaw Oct 27, 2025
238ac7e
Merge branch 'main' into feat/subscriptions-sponsorship-intents
lwin-kyaw Oct 27, 2025
46d2c97
resolved conflicts and rebased with main
lwin-kyaw Oct 28, 2025
4e2613e
feat: include paymentTokenSymbol in intents request and added some va…
lwin-kyaw Oct 28, 2025
de580bc
fix: lint fixed
lwin-kyaw Oct 28, 2025
55f130d
feat: updated CachedLastSelectedPaymentMethod with paymentTokenSymbol
lwin-kyaw Oct 28, 2025
c055fc6
feat: products array validation in intents request
lwin-kyaw Oct 28, 2025
d74d2f9
Merge remote-tracking branch 'origin/main' into feat/subscriptions-sp…
lwin-kyaw Oct 28, 2025
8f24aad
Merge branch 'main' into feat/subscriptions-sponsorship-intents
lwin-kyaw Oct 28, 2025
42e207b
feat: get billing cyles from the PricingResponse
lwin-kyaw Oct 29, 2025
12a2da2
fix: fixed lint
lwin-kyaw Oct 29, 2025
3387604
chore: clean up
lwin-kyaw Oct 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/subscription-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the new controller state, `lastSelectedPaymentMethod`. ([#6946](https://github.com/MetaMask/core/pull/6946))
- We will use this in the UI state persistence between navigation.
- We will use this to query user subscription plan details in subscribe methods internally.
- Added new public method, `submitSponsorshipIntents`, to submit sponsorship intents for the new subscription with crypto. ([#6898](https://github.com/MetaMask/core/pull/6898))

## [3.0.0]

Expand Down
236 changes: 222 additions & 14 deletions packages/subscription-controller/src/SubscriptionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import type {
UpdatePaymentMethodOpts,
Product,
SubscriptionEligibility,
CachedLastSelectedPaymentMethods,
CachedLastSelectedPaymentMethod,
SubmitSponsorshipIntentsMethodParams,
ProductType,
} from './types';
import {
PAYMENT_TYPES,
Expand Down Expand Up @@ -82,6 +84,14 @@ const MOCK_PRODUCT_PRICE: ProductPricing = {
unitAmount: 900,
unitDecimals: 2,
trialPeriodDays: 0,
minBillingCycles: 12,
},
{
interval: 'year',
unitAmount: 8000,
unitDecimals: 2,
currency: 'usd',
trialPeriodDays: 14,
minBillingCycles: 1,
},
],
Expand Down Expand Up @@ -199,6 +209,7 @@ function createMockSubscriptionService() {
const mockGetBillingPortalUrl = jest.fn();
const mockGetSubscriptionsEligibilities = jest.fn();
const mockSubmitUserEvent = jest.fn();
const mockSubmitSponsorshipIntents = jest.fn();

const mockService = {
getSubscriptions: mockGetSubscriptions,
Expand All @@ -212,6 +223,7 @@ function createMockSubscriptionService() {
getBillingPortalUrl: mockGetBillingPortalUrl,
getSubscriptionsEligibilities: mockGetSubscriptionsEligibilities,
submitUserEvent: mockSubmitUserEvent,
submitSponsorshipIntents: mockSubmitSponsorshipIntents,
};

return {
Expand All @@ -224,6 +236,7 @@ function createMockSubscriptionService() {
mockStartSubscriptionWithCrypto,
mockUpdatePaymentMethodCard,
mockUpdatePaymentMethodCrypto,
mockSubmitSponsorshipIntents,
};
}

Expand Down Expand Up @@ -988,7 +1001,7 @@ describe('SubscriptionController', () => {
});

expect(result).toStrictEqual({
approveAmount: '9000000000000000000',
approveAmount: '108000000000000000000',
paymentAddress: '0xspender',
paymentTokenAddress: '0xtoken',
chainId: '0x1',
Expand Down Expand Up @@ -1424,6 +1437,13 @@ describe('SubscriptionController', () => {
});

describe('cacheLastSelectedPaymentMethod', () => {
const MOCK_CACHED_PAYMENT_METHOD: CachedLastSelectedPaymentMethod = {
type: PAYMENT_TYPES.byCrypto,
paymentTokenAddress: '0x123',
paymentTokenSymbol: 'USDT',
plan: RECURRING_INTERVALS.month,
};

it('should cache last selected payment method successfully', async () => {
await withController(async ({ controller }) => {
controller.cacheLastSelectedPaymentMethod(PRODUCT_TYPES.SHIELD, {
Expand Down Expand Up @@ -1460,18 +1480,13 @@ describe('SubscriptionController', () => {
},
});

controller.cacheLastSelectedPaymentMethod(PRODUCT_TYPES.SHIELD, {
type: PAYMENT_TYPES.byCrypto,
paymentTokenAddress: '0x123',
plan: RECURRING_INTERVALS.month,
});
controller.cacheLastSelectedPaymentMethod(
PRODUCT_TYPES.SHIELD,
MOCK_CACHED_PAYMENT_METHOD,
);

expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({
[PRODUCT_TYPES.SHIELD]: {
type: PAYMENT_TYPES.byCrypto,
paymentTokenAddress: '0x123',
plan: RECURRING_INTERVALS.month,
},
[PRODUCT_TYPES.SHIELD]: MOCK_CACHED_PAYMENT_METHOD,
});
},
);
Expand All @@ -1483,11 +1498,204 @@ describe('SubscriptionController', () => {
controller.cacheLastSelectedPaymentMethod(PRODUCT_TYPES.SHIELD, {
type: PAYMENT_TYPES.byCrypto,
plan: RECURRING_INTERVALS.month,
} as CachedLastSelectedPaymentMethods),
} as CachedLastSelectedPaymentMethod),
).toThrow(
SubscriptionControllerErrorMessage.PaymentTokenAddressRequiredForCrypto,
SubscriptionControllerErrorMessage.PaymentTokenAddressAndSymbolRequiredForCrypto,
);
});
});
});

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 ({ controller, mockService }) => {
const submitSponsorshipIntentsSpy = jest
.spyOn(mockService, 'submitSponsorshipIntents')
.mockResolvedValue(undefined);

await controller.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 ({ controller }) => {
await expect(
controller.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 ({ controller, mockService }) => {
await expect(
controller.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: {
subscriptions: [
{
...MOCK_SUBSCRIPTION,
status: SUBSCRIPTION_STATUSES.canceled,
},
],
trialedProducts: [PRODUCT_TYPES.SHIELD],
},
},
async ({ controller, mockService }) => {
mockService.submitSponsorshipIntents.mockResolvedValue(undefined);

await controller.submitSponsorshipIntents(
MOCK_SUBMISSION_INTENTS_REQUEST,
);
expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled();
},
);
});

it('should throw error when no cached payment method is found', async () => {
await withController(async ({ controller }) => {
await expect(
controller.submitSponsorshipIntents(MOCK_SUBMISSION_INTENTS_REQUEST),
).rejects.toThrow(
SubscriptionControllerErrorMessage.PaymentMethodNotCrypto,
);
});
});

it('should throw error when payment method is not crypto', async () => {
await withController(
{
state: {
lastSelectedPaymentMethod: {
[PRODUCT_TYPES.SHIELD]: {
type: PAYMENT_TYPES.byCard,
plan: RECURRING_INTERVALS.month,
},
},
},
},
async ({ controller }) => {
await expect(
controller.submitSponsorshipIntents(
MOCK_SUBMISSION_INTENTS_REQUEST,
),
).rejects.toThrow(
SubscriptionControllerErrorMessage.PaymentMethodNotCrypto,
);
},
);
});

it('should throw error when product price is not found', async () => {
await withController(
{
state: {
lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD,
},
},
async ({ controller }) => {
await expect(
controller.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,
},
},
async ({ controller, mockService }) => {
mockService.submitSponsorshipIntents.mockRejectedValue(
new SubscriptionServiceError(
'Failed to submit sponsorship intents',
),
);

await expect(
controller.submitSponsorshipIntents(
MOCK_SUBMISSION_INTENTS_REQUEST,
),
).rejects.toThrow(SubscriptionServiceError);
expect(mockService.submitSponsorshipIntents).toHaveBeenCalledWith({
...MOCK_SUBMISSION_INTENTS_REQUEST,
paymentTokenSymbol: 'USDT',
billingCycles: 1,
recurringInterval: RECURRING_INTERVALS.year,
});
},
);
});
});
});
Loading
Loading