Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c74dd4d
feat: add get price info method
tuna1207 Sep 1, 2025
bc86539
feat: update fetch fn
tuna1207 Sep 4, 2025
55e415d
feat: handle create crypto approve
tuna1207 Sep 4, 2025
ef91e6c
Merge branch 'main' into feat/shield-create-subscription-crypto
tuna1207 Sep 4, 2025
ceb20ac
refactor: simplify test mock data
tuna1207 Sep 4, 2025
a5903c4
feat: register createCryptoApproveTransaction handler
tuna1207 Sep 4, 2025
b8bd772
feat: add start crypto subscription method
tuna1207 Sep 4, 2025
1141428
feat: register start crypto subscription handler
tuna1207 Sep 4, 2025
8d8f0c8
feat: correct type name and export
tuna1207 Sep 4, 2025
ede07ec
chore: update changelog
tuna1207 Sep 4, 2025
f938bb2
fix: tsconfig
tuna1207 Sep 4, 2025
e49aed4
fix: correct tsconfig
tuna1207 Sep 4, 2025
fad2fea
chore: upgrade transaction-controller dependencies version
tuna1207 Sep 4, 2025
15afd77
Merge branch 'main' into feat/shield-create-subscription-crypto
tuna1207 Sep 5, 2025
2634ca8
feat: use getCryptoApproveTransactionParams instead of creating trans…
tuna1207 Sep 5, 2025
c148d80
feat: correct package usage
tuna1207 Sep 5, 2025
3e13fe7
fix: linting
tuna1207 Sep 5, 2025
18ac5a4
fix: update conversion rate scale
tuna1207 Sep 6, 2025
247e2ac
feat: scale subscription price amount
tuna1207 Sep 6, 2025
def4585
feat: update type
tuna1207 Sep 8, 2025
8184712
refactor: rename response type for consistency
tuna1207 Sep 8, 2025
dee0f45
Update packages/subscription-controller/CHANGELOG.md
tuna1207 Sep 8, 2025
b6f696b
feat: use handleFetch internally
tuna1207 Sep 9, 2025
048f5c5
fix: remove redundant test
tuna1207 Sep 9, 2025
d4fa47e
refactor: revert moving
tuna1207 Sep 9, 2025
3f0df4b
refactor: make scale more clear
tuna1207 Sep 9, 2025
3ae5b3f
chore: move package to dep
tuna1207 Sep 9, 2025
c122653
Merge commit '7a9e620105aadd8902abe8c7976f57e6e88bf867' into feat/shi…
tuna1207 Sep 9, 2025
6853156
fix: lint
tuna1207 Sep 9, 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 @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `cancelSubscription`: Cancel user active subscription.
- `startShieldSubscriptionWithCard`: start shield subscription via card (with trial option) ([#6300](https://github.com/MetaMask/core/pull/6300))
- Add `getPricing` method ([#6356](https://github.com/MetaMask/core/pull/6356))
- Add methods `startSubscriptionWithCrypto` and `getCryptoApproveTransactionParams` method ([#6456](https://github.com/MetaMask/core/pull/6456))
- Added `triggerAccessTokenRefresh` to trigger an access token refresh ([#6374](https://github.com/MetaMask/core/pull/6374))

[Unreleased]: https://github.com/MetaMask/core/
2 changes: 1 addition & 1 deletion packages/subscription-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
},
"dependencies": {
"@metamask/base-controller": "^8.3.0",
"@metamask/controller-utils": "^11.12.0",
"@metamask/utils": "^11.4.2"
},
"devDependencies": {
Expand All @@ -56,7 +57,6 @@
"@types/jest": "^27.4.1",
"deepmerge": "^4.2.2",
"jest": "^27.5.1",
"nock": "^13.3.1",
"ts-jest": "^27.1.4",
"typedoc": "^0.24.8",
"typedoc-plugin-missing-exports": "^2.0.0",
Expand Down
271 changes: 267 additions & 4 deletions packages/subscription-controller/src/SubscriptionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ import {
type SubscriptionControllerOptions,
type SubscriptionControllerState,
} from './SubscriptionController';
import type { Subscription, PricingResponse } from './types';
import type {
Subscription,
PricingResponse,
ProductPricing,
PricingPaymentMethod,
StartCryptoSubscriptionRequest,
StartCryptoSubscriptionResponse,
} from './types';
import {
PaymentType,
ProductType,
Expand All @@ -29,8 +36,8 @@ const MOCK_SUBSCRIPTION: Subscription = {
{
name: ProductType.SHIELD,
id: 'prod_shield_basic',
currency: 'USD',
amount: 9.99,
currency: 'usd',
amount: 900,
Comment thread
tuna1207 marked this conversation as resolved.
},
],
currentPeriodStart: '2024-01-01T00:00:00Z',
Expand All @@ -42,6 +49,43 @@ const MOCK_SUBSCRIPTION: Subscription = {
},
};

const MOCK_PRODUCT_PRICE: ProductPricing = {
name: ProductType.SHIELD,
prices: [
{
interval: RecurringInterval.month,
currency: 'usd',
unitAmount: 900,
Comment thread
tuna1207 marked this conversation as resolved.
unitDecimals: 2,
trialPeriodDays: 0,
minBillingCycles: 1,
},
],
};

const MOCK_PRICING_PAYMENT_METHOD: PricingPaymentMethod = {
type: PaymentType.byCrypto,
chains: [
{
chainId: '0x1',
paymentAddress: '0xspender',
tokens: [
{
address: '0xtoken',
symbol: 'USDT',
decimals: 18,
conversionRate: { usd: '1.0' },
},
],
},
],
};

const MOCK_PRICE_INFO_RESPONSE: PricingResponse = {
products: [MOCK_PRODUCT_PRICE],
paymentMethods: [MOCK_PRICING_PAYMENT_METHOD],
};

/**
* Creates a custom subscription messenger, in case tests need different permissions
*
Expand Down Expand Up @@ -113,12 +157,14 @@ function createMockSubscriptionService() {
const mockCancelSubscription = jest.fn();
const mockStartSubscriptionWithCard = jest.fn();
const mockGetPricing = jest.fn();
const mockStartSubscriptionWithCrypto = jest.fn();

const mockService = {
getSubscriptions: mockGetSubscriptions,
cancelSubscription: mockCancelSubscription,
startSubscriptionWithCard: mockStartSubscriptionWithCard,
getPricing: mockGetPricing,
startSubscriptionWithCrypto: mockStartSubscriptionWithCrypto,
};

return {
Expand All @@ -127,6 +173,7 @@ function createMockSubscriptionService() {
mockCancelSubscription,
mockStartSubscriptionWithCard,
mockGetPricing,
mockStartSubscriptionWithCrypto,
};
}

Expand All @@ -137,6 +184,7 @@ type WithControllerCallback<ReturnValue> = (params: {
controller: SubscriptionController;
initialState: SubscriptionControllerState;
messenger: SubscriptionControllerMessenger;
baseMessenger: Messenger<AllowedActions, AllowedEvents>;
mockService: ReturnType<typeof createMockSubscriptionService>['mockService'];
mockPerformSignOut: jest.Mock;
}) => Promise<ReturnValue> | ReturnValue;
Expand All @@ -157,7 +205,8 @@ async function withController<ReturnValue>(
...args: WithControllerArgs<ReturnValue>
) {
const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]];
const { messenger, mockPerformSignOut } = createMockSubscriptionMessenger();
const { messenger, mockPerformSignOut, baseMessenger } =
createMockSubscriptionMessenger();
const { mockService } = createMockSubscriptionService();

const controller = new SubscriptionController({
Expand All @@ -170,6 +219,7 @@ async function withController<ReturnValue>(
controller,
initialState: controller.state,
messenger,
baseMessenger,
mockService,
mockPerformSignOut,
});
Expand Down Expand Up @@ -489,6 +539,44 @@ describe('SubscriptionController', () => {
});
});

describe('startCryptoSubscription', () => {
it('should start crypto subscription successfully when user is not subscribed', async () => {
await withController(
{
state: {
subscriptions: [],
},
},
async ({ controller, mockService }) => {
const request: StartCryptoSubscriptionRequest = {
products: [ProductType.SHIELD],
isTrialRequested: false,
recurringInterval: RecurringInterval.month,
billingCycles: 3,
chainId: '0x1',
payerAddress: '0x0000000000000000000000000000000000000001',
tokenSymbol: 'USDC',
rawTransaction: '0xdeadbeef',
};

const response: StartCryptoSubscriptionResponse = {
subscriptionId: 'sub_crypto_123',
status: SubscriptionStatus.active,
};

mockService.startSubscriptionWithCrypto.mockResolvedValue(response);

const result = await controller.startSubscriptionWithCrypto(request);

expect(result).toStrictEqual(response);
expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith(
request,
);
},
);
});
});

describe('integration scenarios', () => {
it('should handle complete subscription lifecycle with updated logic', async () => {
await withController(async ({ controller, mockService }) => {
Expand Down Expand Up @@ -547,6 +635,181 @@ describe('SubscriptionController', () => {
});
});

describe('getCryptoApproveTransactionParams', () => {
it('returns transaction params for crypto approve transaction', async () => {
await withController(async ({ controller, mockService }) => {
// Provide product pricing and crypto payment info with unitDecimals small to avoid integer div to 0
Comment thread
tuna1207 marked this conversation as resolved.
mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE);

const result = await controller.getCryptoApproveTransactionParams({
chainId: '0x1',
paymentTokenAddress: '0xtoken',
productType: ProductType.SHIELD,
interval: RecurringInterval.month,
});

expect(result).toStrictEqual({
approveAmount: '9000000000000000000',
paymentAddress: '0xspender',
paymentTokenAddress: '0xtoken',
chainId: '0x1',
});
});
});

it('throws when product price not found', async () => {
await withController(async ({ controller, mockService }) => {
mockService.getPricing.mockResolvedValue({
products: [],
paymentMethods: [],
});

await expect(
controller.getCryptoApproveTransactionParams({
chainId: '0x1',
paymentTokenAddress: '0xtoken',
productType: ProductType.SHIELD,
interval: RecurringInterval.month,
}),
).rejects.toThrow('Product price not found');
});
});

it('throws when price not found for interval', async () => {
await withController(async ({ controller, mockService }) => {
mockService.getPricing.mockResolvedValue({
products: [
{
name: ProductType.SHIELD,
prices: [
{
interval: RecurringInterval.year,
currency: 'usd',
unitAmount: 10,
unitDecimals: 18,
trialPeriodDays: 0,
minBillingCycles: 1,
},
],
},
],
paymentMethods: [],
});

await expect(
controller.getCryptoApproveTransactionParams({
chainId: '0x1',
paymentTokenAddress: '0xtoken',
productType: ProductType.SHIELD,
interval: RecurringInterval.month,
}),
).rejects.toThrow('Price not found');
});
});

it('throws when chains payment info not found', async () => {
await withController(async ({ controller, mockService }) => {
mockService.getPricing.mockResolvedValue({
...MOCK_PRICE_INFO_RESPONSE,
paymentMethods: [
{
type: PaymentType.byCard,
},
],
});

await expect(
controller.getCryptoApproveTransactionParams({
chainId: '0x1',
paymentTokenAddress: '0xtoken',
productType: ProductType.SHIELD,
interval: RecurringInterval.month,
}),
).rejects.toThrow('Chains payment info not found');
});
});

it('throws when invalid chain id', async () => {
await withController(async ({ controller, mockService }) => {
mockService.getPricing.mockResolvedValue({
...MOCK_PRICE_INFO_RESPONSE,
paymentMethods: [
{
type: PaymentType.byCrypto,
chains: [
{
chainId: '0x2',
paymentAddress: '0xspender',
tokens: [],
},
],
},
],
});

await expect(
controller.getCryptoApproveTransactionParams({
chainId: '0x1',
paymentTokenAddress: '0xtoken',
productType: ProductType.SHIELD,
interval: RecurringInterval.month,
}),
).rejects.toThrow('Invalid chain id');
});
});

it('throws when invalid token address', async () => {
await withController(async ({ controller, mockService }) => {
mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE);

await expect(
controller.getCryptoApproveTransactionParams({
chainId: '0x1',
paymentTokenAddress: '0xtoken-invalid',
productType: ProductType.SHIELD,
interval: RecurringInterval.month,
}),
).rejects.toThrow('Invalid token address');
});
});

it('throws when conversion rate not found', async () => {
await withController(async ({ controller, mockService }) => {
// Valid product and chain/token, but token lacks conversion rate for currency
mockService.getPricing.mockResolvedValue({
...MOCK_PRICE_INFO_RESPONSE,
paymentMethods: [
{
type: PaymentType.byCrypto,
chains: [
{
chainId: '0x1',
paymentAddress: '0xspender',
tokens: [
{
address: '0xtoken',
decimals: 18,
conversionRate: {},
},
],
},
],
},
],
});

await expect(
controller.getCryptoApproveTransactionParams({
chainId: '0x1',
paymentTokenAddress: '0xtoken',
productType: ProductType.SHIELD,
interval: RecurringInterval.month,
}),
).rejects.toThrow('Conversion rate not found');
});
});
});

describe('triggerAuthTokenRefresh', () => {
it('should trigger auth token refresh', async () => {
await withController(async ({ controller, mockPerformSignOut }) => {
Expand Down
Loading
Loading