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
12 changes: 12 additions & 0 deletions packages/subscription-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `INVOICE_PAYMENT_STATUSES` / `InvoicePaymentStatus` for typed invoice
payment status values. ([#10305](https://github.com/MetaMask/core/pull/10305))
- Add `CRYPTO_PAYMENT_ERRORS` / `CryptoPaymentError` and optional `Subscription.lastInvoice` (`SubscriptionInvoice`) for Subscription API crypto payment-execution failures. ([#10305](https://github.com/MetaMask/core/pull/10305))
- Add `selectIsPaymentFailed`, `selectPaymentFailureReason`, `selectIsRenewalNeeded`, and `selectIsDelegationExhausted` selectors keyed by subscription product. ([#10305](https://github.com/MetaMask/core/pull/10305))
- Add `UpdateDelegationPaymentMethodCryptoRequest` so `updatePaymentMethod` can rotate an active crypto subscription with `cryptoAuthMethod` and `delegationHash`. ([#10305](https://github.com/MetaMask/core/pull/10305))
- Add optional `forceNew` to `SubscriptionDelegationService:prepareDelegation` to create a replacement delegation instead of reusing a matching stored one. ([#10305](https://github.com/MetaMask/core/pull/10305))

### Changed

- **BREAKING:** Make `Subscription.currentPeriodStart`, `currentPeriodEnd`, `cancelType`, and `isEligibleForSupport` optional so paused or failed crypto subscriptions can validate. ([#10305](https://github.com/MetaMask/core/pull/10305))
- Refresh the access token when Money Account Plus subscription snapshots change, including payment-failure state while status remains active. ([#10305](https://github.com/MetaMask/core/pull/10305))
- Prefer the latest period `startDate` when `prepareDelegation` reuses a matching stored cash-subscription delegation, so a `forceNew` replacement is chosen over an older equivalent record. ([#10305](https://github.com/MetaMask/core/pull/10305))
- Bump `@metamask/profile-sync-controller` from `^32.1.1` to `^32.2.0` ([#10348](https://github.com/MetaMask/core/pull/10348))
- Bump `@metamask/transaction-controller` from `^70.1.0` to `^71.0.0` ([#10386](https://github.com/MetaMask/core/pull/10386))

Expand Down
182 changes: 182 additions & 0 deletions packages/subscription-controller/src/SubscriptionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type {
} from './types.js';
import {
CANCEL_TYPES,
CRYPTO_AUTH_METHODS,
MODAL_TYPE,
PAYMENT_TYPES,
PRODUCT_TYPES,
Expand Down Expand Up @@ -1099,6 +1100,145 @@ describe('SubscriptionController', () => {
);
});

it.each([
{
name: 'created',
currentSubscriptions: [],
nextSubscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION],
},
{
name: 'payment failed while status remains active',
currentSubscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION],
nextSubscriptions: [
{
...MOCK_MONEY_ACCOUNT_SUBSCRIPTION,
lastInvoice: {
id: 'in_payment_failed',
status: 'FAILED',
errorCode: 'internal_server_error',
updatedAt: '2026-09-20T12:00:00.000Z',
},
},
],
},
{
name: 'renewal needed',
currentSubscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION],
nextSubscriptions: [
{
...MOCK_MONEY_ACCOUNT_SUBSCRIPTION,
lastInvoice: {
id: 'in_renewal_needed',
status: 'FAILED',
errorCode: 'delegation_not_found',
updatedAt: '2026-09-20T12:00:00.000Z',
},
},
],
},
{
name: 'delegation exhausted',
currentSubscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION],
nextSubscriptions: [
{
...MOCK_MONEY_ACCOUNT_SUBSCRIPTION,
lastInvoice: {
id: 'in_exhausted',
status: 'FAILED',
errorCode: 'exceeds_delegation_allowance',
updatedAt: '2026-09-20T12:00:00.000Z',
},
},
],
},
{
name: 'cancelled',
currentSubscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION],
nextSubscriptions: [
{
...MOCK_MONEY_ACCOUNT_SUBSCRIPTION,
status: SUBSCRIPTION_STATUSES.canceled,
},
],
},
{
name: 'expired',
currentSubscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION],
nextSubscriptions: [
{
...MOCK_MONEY_ACCOUNT_SUBSCRIPTION,
status: SUBSCRIPTION_STATUSES.incompleteExpired,
},
],
},
])(
'refreshes the access token when a Money Account subscription is $name',
async ({ currentSubscriptions, nextSubscriptions }) => {
await withController(
{
state: {
subscriptions: currentSubscriptions,
},
},
async ({ rootMessenger, mockService, mockPerformSignOut }) => {
mockService.getSubscriptions.mockResolvedValue({
subscriptions: nextSubscriptions,
trialedProducts: [],
});

await rootMessenger.call('SubscriptionController:getSubscriptions');

expect(mockPerformSignOut).toHaveBeenCalledTimes(1);
},
);
},
);

it('does not refresh the access token when the Money Account subscription is unchanged', async () => {
await withController(
{
state: {
subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION],
},
},
async ({ rootMessenger, mockService, mockPerformSignOut }) => {
mockService.getSubscriptions.mockResolvedValue({
subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION],
trialedProducts: [],
});

await rootMessenger.call('SubscriptionController:getSubscriptions');

expect(mockPerformSignOut).not.toHaveBeenCalled();
},
);
});

it('preserves Shield refresh behavior when Shield subscription state changes', async () => {
const cancelledShieldSubscription = {
...MOCK_SUBSCRIPTION,
status: SUBSCRIPTION_STATUSES.canceled,
};

await withController(
{
state: {
subscriptions: [MOCK_SUBSCRIPTION],
},
},
async ({ rootMessenger, mockService, mockPerformSignOut }) => {
mockService.getSubscriptions.mockResolvedValue({
subscriptions: [cancelledShieldSubscription],
trialedProducts: [],
});

await rootMessenger.call('SubscriptionController:getSubscriptions');

expect(mockPerformSignOut).toHaveBeenCalledTimes(1);
},
);
});

it('should fetch and store subscription successfully', async () => {
await withController(
async ({ controller, rootMessenger, mockService }) => {
Expand Down Expand Up @@ -4193,6 +4333,48 @@ describe('SubscriptionController', () => {
);
});

it('should update crypto payment method with a delegation hash and refresh state', async () => {
await withController(
async ({ controller, rootMessenger, mockService }) => {
mockService.updatePaymentMethodCrypto.mockResolvedValue(undefined);
mockService.getSubscriptions.mockResolvedValue(
MOCK_GET_SUBSCRIPTIONS_RESPONSE,
);

const opts: UpdatePaymentMethodOpts = {
paymentType: PAYMENT_TYPES.byCrypto,
subscriptionId: 'sub_123456789',
chainId: '0x1',
payerAddress: '0x0000000000000000000000000000000000000001',
tokenSymbol: 'pvmUSD',
recurringInterval: RECURRING_INTERVALS.month,
billingCycles: 12,
cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION,
delegationHash: '0xabcdef1234567890',
};

await rootMessenger.call(
'SubscriptionController:updatePaymentMethod',
opts,
);

expect(mockService.updatePaymentMethodCrypto).toHaveBeenCalledWith({
subscriptionId: 'sub_123456789',
chainId: '0x1',
payerAddress: '0x0000000000000000000000000000000000000001',
tokenSymbol: 'pvmUSD',
recurringInterval: RECURRING_INTERVALS.month,
billingCycles: 12,
cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION,
delegationHash: '0xabcdef1234567890',
});
expect(controller.state.subscriptions).toStrictEqual([
MOCK_SUBSCRIPTION,
]);
},
);
});

it('throws when invalid payment type', async () => {
await withController(async ({ rootMessenger }) => {
const opts = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import { StrictHexStruct, CaipAccountIdStruct } from '@metamask/utils';
import {
CANCEL_TYPES,
CRYPTO_AUTH_METHODS,
CRYPTO_PAYMENT_ERRORS,
CRYPTO_PAYMENT_METHOD_ERRORS,
INVOICE_PAYMENT_STATUSES,
PAYMENT_TYPES,
PRODUCT_TYPES,
RECURRING_INTERVALS,
Expand Down Expand Up @@ -55,10 +57,14 @@ const ProductEntitlementsStruct = type({
});
const RecurringIntervalStruct = enums(Object.values(RECURRING_INTERVALS));
const SubscriptionStatusStruct = enums(Object.values(SUBSCRIPTION_STATUSES));
const InvoicePaymentStatusStruct = enums(
Object.values(INVOICE_PAYMENT_STATUSES),
);
const CancelTypeStruct = enums(Object.values(CANCEL_TYPES));
const CryptoPaymentMethodErrorStruct = enums(
Object.values(CRYPTO_PAYMENT_METHOD_ERRORS),
);
const CryptoPaymentErrorStruct = enums(Object.values(CRYPTO_PAYMENT_ERRORS));

const ProductStruct = type({
name: ProductTypeStruct,
Expand Down Expand Up @@ -94,8 +100,8 @@ const SubscriptionPaymentMethodStruct = union([
export const SubscriptionStruct = type({
id: string(),
products: array(ProductStruct),
currentPeriodStart: string(),
currentPeriodEnd: string(),
currentPeriodStart: optional(string()),
currentPeriodEnd: optional(string()),
cancelAtPeriodEnd: optional(boolean()),
status: SubscriptionStatusStruct,
interval: RecurringIntervalStruct,
Expand All @@ -105,10 +111,18 @@ export const SubscriptionStruct = type({
trialEnd: optional(string()),
endDate: optional(string()),
canceledAt: optional(string()),
cancelType: CancelTypeStruct,
cancelType: optional(CancelTypeStruct),
inactiveAt: optional(string()),
isEligibleForSupport: boolean(),
isEligibleForSupport: optional(boolean()),
billingCycles: optional(number()),
lastInvoice: optional(
type({
id: string(),
status: InvoicePaymentStatusStruct,
errorCode: optional(CryptoPaymentErrorStruct),
updatedAt: string(),
}),
),
});

export const GetSubscriptionsResponseStruct = type({
Expand Down
Loading
Loading