Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
"version": "524.0.0",
"version": "525.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
Expand Down
5 changes: 4 additions & 1 deletion packages/assets-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [74.3.2]

### Changed

- Refactor `AccountTrackerController` to eliminate duplicate code by replacing custom `AccountTrackerRpcBalanceFetcher` with existing `RpcBalanceFetcher` ([#6425](https://github.com/MetaMask/core/pull/6425))
Expand Down Expand Up @@ -1952,7 +1954,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845))

[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@74.3.1...HEAD
[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@74.3.2...HEAD
[74.3.2]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@74.3.1...@metamask/assets-controllers@74.3.2
[74.3.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@74.3.0...@metamask/assets-controllers@74.3.1
[74.3.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@74.2.0...@metamask/assets-controllers@74.3.0
[74.2.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@74.1.1...@metamask/assets-controllers@74.2.0
Expand Down
2 changes: 1 addition & 1 deletion packages/assets-controllers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@metamask/assets-controllers",
"version": "74.3.1",
"version": "74.3.2",
"description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)",
"keywords": [
"MetaMask",
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
},
"devDependencies": {
"@metamask/accounts-controller": "^33.0.0",
"@metamask/assets-controllers": "^74.3.1",
"@metamask/assets-controllers": "^74.3.2",
"@metamask/auto-changelog": "^3.4.4",
"@metamask/eth-json-rpc-provider": "^4.1.8",
"@metamask/network-controller": "^24.1.0",
Expand Down
1 change: 1 addition & 0 deletions packages/subscription-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release of the subscription controller ([#6233](https://github.com/MetaMask/core/pull/6233))
- `getSubscription`: Retrieve current user subscription info if exist.
- `cancelSubscription`: Cancel user active subscription.
- `startShieldSubscriptionWithCard`: start shield subscription via card (with trial option) ([#6300](https://github.com/MetaMask/core/pull/6300))

[Unreleased]: https://github.com/MetaMask/core/
106 changes: 101 additions & 5 deletions packages/subscription-controller/src/SubscriptionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
type SubscriptionControllerState,
} from './SubscriptionController';
import type { Subscription } from './types';
import { PaymentType, ProductType } from './types';
import {
PaymentType,
ProductType,
RecurringInterval,
SubscriptionStatus,
} from './types';

// Mock data
const MOCK_SUBSCRIPTION: Subscription = {
Expand All @@ -30,10 +35,10 @@ const MOCK_SUBSCRIPTION: Subscription = {
],
currentPeriodStart: '2024-01-01T00:00:00Z',
currentPeriodEnd: '2024-02-01T00:00:00Z',
status: 'active',
interval: 'month',
status: SubscriptionStatus.active,
interval: RecurringInterval.month,
paymentMethod: {
type: PaymentType.CARD,
type: PaymentType.byCard,
},
};

Expand Down Expand Up @@ -108,16 +113,19 @@ function createMockSubscriptionMessenger(): {
function createMockSubscriptionService() {
const mockGetSubscriptions = jest.fn().mockImplementation();
const mockCancelSubscription = jest.fn();
const mockStartSubscriptionWithCard = jest.fn();

const mockService = {
getSubscriptions: mockGetSubscriptions,
cancelSubscription: mockCancelSubscription,
startSubscriptionWithCard: mockStartSubscriptionWithCard,
};

return {
mockService,
mockGetSubscriptions,
mockCancelSubscription,
mockStartSubscriptionWithCard,
};
}

Expand Down Expand Up @@ -310,7 +318,7 @@ describe('SubscriptionController', () => {
}),
).toBeUndefined();
expect(controller.state.subscriptions).toStrictEqual([
{ ...MOCK_SUBSCRIPTION, status: 'cancelled' },
{ ...MOCK_SUBSCRIPTION, status: SubscriptionStatus.canceled },
mockSubscription2,
]);
expect(mockService.cancelSubscription).toHaveBeenCalledWith({
Expand Down Expand Up @@ -390,6 +398,94 @@ describe('SubscriptionController', () => {
});
});

describe('startShieldSubscriptionWithCard', () => {
const MOCK_START_SUBSCRIPTION_RESPONSE = {
checkoutSessionUrl: 'https://checkout.example.com/session/123',
};

it('should start shield subscription successfully when user is not subscribed', async () => {
await withController(
{
state: {
subscriptions: [],
},
},
async ({ controller, mockService }) => {
mockService.startSubscriptionWithCard.mockResolvedValue(
MOCK_START_SUBSCRIPTION_RESPONSE,
);

const result = await controller.startShieldSubscriptionWithCard({
products: [ProductType.SHIELD],
isTrialRequested: true,
recurringInterval: RecurringInterval.month,
});

expect(result).toStrictEqual(MOCK_START_SUBSCRIPTION_RESPONSE);
expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({
products: [ProductType.SHIELD],
isTrialRequested: true,
recurringInterval: RecurringInterval.month,
});
},
);
});

it('should throw error when user is already subscribed', async () => {
await withController(
{
state: {
subscriptions: [MOCK_SUBSCRIPTION],
},
},
async ({ controller, mockService }) => {
await expect(
controller.startShieldSubscriptionWithCard({
products: [ProductType.SHIELD],
isTrialRequested: true,
recurringInterval: RecurringInterval.month,
}),
).rejects.toThrow(
SubscriptionControllerErrorMessage.UserAlreadySubscribed,
);

// Verify the subscription service was not called
expect(mockService.startSubscriptionWithCard).not.toHaveBeenCalled();
},
);
});

it('should handle subscription service errors during start subscription', async () => {
await withController(
{
state: {
subscriptions: [],
},
},
async ({ controller, mockService }) => {
const errorMessage = 'Failed to start subscription';
mockService.startSubscriptionWithCard.mockRejectedValue(
new SubscriptionServiceError(errorMessage),
);

await expect(
controller.startShieldSubscriptionWithCard({
products: [ProductType.SHIELD],
isTrialRequested: true,
recurringInterval: RecurringInterval.month,
}),
).rejects.toThrow(SubscriptionServiceError);

expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({
products: [ProductType.SHIELD],
isTrialRequested: true,
recurringInterval: RecurringInterval.month,
});
},
);
});
});

describe('integration scenarios', () => {
it('should handle complete subscription lifecycle with updated logic', async () => {
await withController(async ({ controller, mockService }) => {
Expand Down
52 changes: 43 additions & 9 deletions packages/subscription-controller/src/SubscriptionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,40 @@ import {
controllerName,
SubscriptionControllerErrorMessage,
} from './constants';
import type { ISubscriptionService, Subscription } from './types';
import {
SubscriptionStatus,
type ISubscriptionService,
type ProductType,
type StartSubscriptionRequest,
type Subscription,
} from './types';

export type SubscriptionControllerState = {
subscriptions: Subscription[];
};

// Messenger Actions
type CreateActionsObj<Controller extends keyof SubscriptionController> = {
[K in Controller]: {
type: `${typeof controllerName}:${K}`;
handler: SubscriptionController[K];
};
export type SubscriptionControllerGetSubscriptionsAction = {
type: `${typeof controllerName}:getSubscriptions`;
handler: SubscriptionController['getSubscriptions'];
};
export type SubscriptionControllerCancelSubscriptionAction = {
type: `${typeof controllerName}:cancelSubscription`;
handler: SubscriptionController['cancelSubscription'];
};
export type SubscriptionControllerStartShieldSubscriptionWithCardAction = {
type: `${typeof controllerName}:startShieldSubscriptionWithCard`;
handler: SubscriptionController['startShieldSubscriptionWithCard'];
};
type ActionsObj = CreateActionsObj<'getSubscriptions' | 'cancelSubscription'>;

export type SubscriptionControllerGetStateAction = ControllerGetStateAction<
typeof controllerName,
SubscriptionControllerState
>;
export type SubscriptionControllerActions =
| ActionsObj[keyof ActionsObj]
| SubscriptionControllerGetSubscriptionsAction
| SubscriptionControllerCancelSubscriptionAction
| SubscriptionControllerStartShieldSubscriptionWithCardAction
| SubscriptionControllerGetStateAction;

export type AllowedActions =
Expand Down Expand Up @@ -149,6 +162,11 @@ export class SubscriptionController extends BaseController<
'SubscriptionController:cancelSubscription',
this.cancelSubscription.bind(this),
);

this.messagingSystem.registerActionHandler(
'SubscriptionController:startShieldSubscriptionWithCard',
this.startShieldSubscriptionWithCard.bind(this),
);
}

async getSubscriptions() {
Expand All @@ -172,12 +190,28 @@ export class SubscriptionController extends BaseController<
this.update((state) => {
state.subscriptions = state.subscriptions.map((subscription) =>
subscription.id === request.subscriptionId
? { ...subscription, status: 'cancelled' }
? { ...subscription, status: SubscriptionStatus.canceled }
: subscription,
);
});
}

async startShieldSubscriptionWithCard(request: StartSubscriptionRequest) {
this.#assertIsUserNotSubscribed({ products: request.products });

return await this.#subscriptionService.startSubscriptionWithCard(request);
}

#assertIsUserNotSubscribed({ products }: { products: ProductType[] }) {
if (
this.state.subscriptions.find((subscription) =>
subscription.products.some((p) => products.includes(p.name)),
)
) {
throw new Error(SubscriptionControllerErrorMessage.UserAlreadySubscribed);
}
}

#assertIsUserSubscribed(request: { subscriptionId: string }) {
if (
!this.state.subscriptions.find(
Expand Down
Loading
Loading