diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index db47c21a2d0..9311692a7f5 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -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)) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 9cb998908a5..c8c83e1ef5e 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -50,6 +50,7 @@ import type { } from './types.js'; import { CANCEL_TYPES, + CRYPTO_AUTH_METHODS, MODAL_TYPE, PAYMENT_TYPES, PRODUCT_TYPES, @@ -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 }) => { @@ -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 = { diff --git a/packages/subscription-controller/src/SubscriptionService-structs.ts b/packages/subscription-controller/src/SubscriptionService-structs.ts index 0b97cf50a33..134ebf38198 100644 --- a/packages/subscription-controller/src/SubscriptionService-structs.ts +++ b/packages/subscription-controller/src/SubscriptionService-structs.ts @@ -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, @@ -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, @@ -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, @@ -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({ diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index e88acbaa9c9..49081d1a420 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -35,6 +35,7 @@ import type { } from './types.js'; import { CANCEL_TYPES, + CRYPTO_AUTH_METHODS, PAYMENT_TYPES, PRODUCT_TYPES, RECURRING_INTERVALS, @@ -581,6 +582,171 @@ describe('SubscriptionService', () => { }); describe('getSubscriptions', () => { + it('accepts the complete payment-error response shape', async () => { + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + trialedProducts: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + subscriptions: [ + { + id: 'sub_money_account_plus_123', + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + unitAmount: 999, + unitDecimals: 2, + currency: 'usd', + }, + ], + status: SUBSCRIPTION_STATUSES.paused, + interval: RECURRING_INTERVALS.month, + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: + '0x123456789012345678901234567890123456abcd', + chainId: '0x1', + tokenSymbol: 'pvmUSD', + error: 'insufficient_balance', + }, + }, + lastInvoice: { + id: 'in_123', + status: 'FAILED', + errorCode: 'insufficient_balance', + updatedAt: '2026-09-20T12:00:00.000Z', + }, + }, + ], + }, + }), + ); + + const result = await service.getSubscriptions(); + + expect(result.trialedProducts).toStrictEqual([ + PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + ]); + expect(result.subscriptions[0]).toMatchObject({ + status: SUBSCRIPTION_STATUSES.paused, + lastInvoice: { + id: 'in_123', + status: 'FAILED', + errorCode: 'insufficient_balance', + updatedAt: '2026-09-20T12:00:00.000Z', + }, + }); + }); + }); + + it('accepts delegation execution error codes on the last invoice', async () => { + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + trialedProducts: [], + subscriptions: [ + { + id: 'sub_money_account_plus_123', + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + unitAmount: 999, + unitDecimals: 2, + currency: 'usd', + }, + ], + status: SUBSCRIPTION_STATUSES.paused, + interval: RECURRING_INTERVALS.month, + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: + '0x123456789012345678901234567890123456abcd', + chainId: '0x1', + tokenSymbol: 'pvmUSD', + error: 'insufficient_balance', + }, + }, + lastInvoice: { + id: 'in_123', + status: 'FAILED', + errorCode: 'delegation_not_found', + updatedAt: '2026-09-20T12:00:00.000Z', + }, + }, + ], + }, + }), + ); + + const result = await service.getSubscriptions(); + + expect(result).toMatchObject({ + subscriptions: [ + expect.objectContaining({ + paymentMethod: expect.objectContaining({ + crypto: expect.objectContaining({ + error: 'insufficient_balance', + }), + }), + lastInvoice: expect.objectContaining({ + errorCode: 'delegation_not_found', + }), + }), + ], + }); + }); + }); + + it('rejects unsupported payment execution error codes', async () => { + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + trialedProducts: [], + subscriptions: [ + { + id: 'sub_money_account_plus_123', + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + unitAmount: 999, + unitDecimals: 2, + currency: 'usd', + }, + ], + status: SUBSCRIPTION_STATUSES.paused, + interval: RECURRING_INTERVALS.month, + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: + '0x123456789012345678901234567890123456abcd', + chainId: '0x1', + tokenSymbol: 'pvmUSD', + error: 'unknown_payment_error', + }, + }, + lastInvoice: { + id: 'in_123', + status: 'FAILED', + errorCode: 'unknown_payment_error', + updatedAt: '2026-09-20T12:00:00.000Z', + }, + }, + ], + }, + }), + ); + + await expect(service.getSubscriptions()).rejects.toThrow( + 'paymentMethod', + ); + }); + }); + it('returns product entitlements from the subscriptions response', async () => { await withMockSubscriptionService(async ({ service, fetchMock }) => { const productEntitlements = { @@ -1481,6 +1647,45 @@ describe('SubscriptionService', () => { }); }); + it('should update crypto payment method with a delegation hash', async () => { + await withMockSubscriptionService(async ({ service, fetchMock, env }) => { + const request: UpdatePaymentMethodCryptoRequest = { + subscriptionId: 'sub_123456789', + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'pvmUSD', + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, + delegationHash: '0xabcdef1234567890', + }; + + fetchMock.mockResolvedValue(createMockResponse({ jsonData: {} })); + + await service.updatePaymentMethodCrypto(request); + + expect(fetchMock).toHaveBeenCalledWith( + SUBSCRIPTION_URL( + env, + 'subscriptions/sub_123456789/payment-method/crypto', + ), + { + method: 'PATCH', + headers: MOCK_HEADERS, + body: JSON.stringify({ + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'pvmUSD', + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, + delegationHash: '0xabcdef1234567890', + }), + }, + ); + }); + }); + it('should throw SubscriptionServiceError for crypto payment method errors', async () => { await withMockSubscriptionService( async ({ service, fetchMock, captureExceptionMock }) => { diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 1c3f88b0640..ceef6fdc9cc 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -62,6 +62,8 @@ export type { SubscriptionCardPaymentMethod, SubscriptionCryptoPaymentMethod, SubscriptionPaymentMethod, + SubscriptionInvoice, + InvoicePaymentStatus, SubmitUserEventRequest, SubmitSponsorshipIntentsRequest, SubscriptionEligibility, @@ -86,6 +88,9 @@ export type { UpdatePaymentMethodOpts, BillingPortalResponse, CryptoPaymentMethodError, + CryptoPaymentError, + UpdateErc20PaymentMethodCryptoRequest, + UpdateDelegationPaymentMethodCryptoRequest, UpdatePaymentMethodCryptoRequest, UpdatePaymentMethodCardRequest, UpdatePaymentMethodCardResponse, @@ -107,6 +112,8 @@ export type { export { CANCEL_TYPES, CRYPTO_PAYMENT_METHOD_ERRORS, + CRYPTO_PAYMENT_ERRORS, + INVOICE_PAYMENT_STATUSES, SUBSCRIPTION_STATUSES, PRODUCT_TYPES, RECURRING_INTERVALS, @@ -124,6 +131,10 @@ export { selectHasEntitlement, selectIsActiveSubscriber, selectIsUsageAvailable, + selectIsPaymentFailed, + selectPaymentFailureReason, + selectIsRenewalNeeded, + selectIsDelegationExhausted, } from './selectors.js'; export { SubscriptionServiceError } from './errors.js'; export { diff --git a/packages/subscription-controller/src/selectors.test.ts b/packages/subscription-controller/src/selectors.test.ts index 55392eff1a6..2bea8f04c31 100644 --- a/packages/subscription-controller/src/selectors.test.ts +++ b/packages/subscription-controller/src/selectors.test.ts @@ -1,7 +1,11 @@ import { selectHasEntitlement, selectIsActiveSubscriber, + selectIsDelegationExhausted, + selectIsPaymentFailed, + selectIsRenewalNeeded, selectIsUsageAvailable, + selectPaymentFailureReason, } from './selectors.js'; import { getDefaultSubscriptionControllerState } from './SubscriptionController.js'; import type { SubscriptionControllerState } from './SubscriptionController.js'; @@ -42,6 +46,35 @@ const MOCK_SHIELD_SUBSCRIPTION: Subscription = { cancelType: CANCEL_TYPES.ALLOWED_AT_PERIOD_END, }; +const MOCK_MONEY_ACCOUNT_SUBSCRIPTION: Subscription = { + id: 'sub_money_account_plus', + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + currency: 'usd', + unitAmount: 999, + unitDecimals: 2, + }, + ], + status: SUBSCRIPTION_STATUSES.paused, + interval: RECURRING_INTERVALS.month, + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: '0x123456789012345678901234567890123456abcd', + chainId: '0x1', + tokenSymbol: 'pvmUSD', + error: 'insufficient_balance', + }, + }, + lastInvoice: { + id: 'in_money_account', + status: 'FAILED', + errorCode: 'insufficient_balance', + updatedAt: '2026-09-20T12:00:00.000Z', + }, +}; + const STATE_WITH_PRODUCT_ENTITLEMENTS: SubscriptionControllerState = { ...getDefaultSubscriptionControllerState(), productEntitlements: { @@ -299,4 +332,142 @@ describe('subscription selectors', () => { ).toBe(false); }); }); + + describe('payment state selectors', () => { + it('returns payment failure details for a Money Account subscription', () => { + const state: SubscriptionControllerState = { + ...getDefaultSubscriptionControllerState(), + subscriptions: [ + MOCK_SHIELD_SUBSCRIPTION, + MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + ], + }; + + expect( + selectIsPaymentFailed(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(true); + expect( + selectPaymentFailureReason(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe('insufficient_balance'); + expect( + selectIsRenewalNeeded(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(true); + expect( + selectIsDelegationExhausted(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(false); + }); + + it.each(['insufficient_balance', 'delegation_not_found'] as const)( + 'treats %s as renewal-needed', + (errorCode) => { + const state: SubscriptionControllerState = { + ...getDefaultSubscriptionControllerState(), + subscriptions: [ + { + ...MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + lastInvoice: { + id: 'in_money_account', + status: 'FAILED', + updatedAt: '2026-09-20T12:00:00.000Z', + errorCode, + }, + }, + ], + }; + + expect( + selectIsRenewalNeeded(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(true); + expect( + selectIsDelegationExhausted(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(false); + }, + ); + + it('treats an exceeded delegation allowance as exhausted', () => { + const state: SubscriptionControllerState = { + ...getDefaultSubscriptionControllerState(), + subscriptions: [ + { + ...MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + lastInvoice: { + id: 'in_money_account', + status: 'FAILED', + updatedAt: '2026-09-20T12:00:00.000Z', + errorCode: 'exceeds_delegation_allowance', + }, + }, + ], + }; + + expect( + selectIsPaymentFailed(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(true); + expect( + selectIsRenewalNeeded(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(false); + expect( + selectIsDelegationExhausted(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(true); + }); + + it('fails closed for Shield, missing subscriptions, and missing invoices', () => { + const mixedState: SubscriptionControllerState = { + ...getDefaultSubscriptionControllerState(), + subscriptions: [MOCK_SHIELD_SUBSCRIPTION], + }; + + expect(selectIsPaymentFailed(mixedState, PRODUCT_TYPES.SHIELD)).toBe( + false, + ); + expect( + selectPaymentFailureReason(mixedState, PRODUCT_TYPES.SHIELD), + ).toBeUndefined(); + expect(selectIsRenewalNeeded(mixedState, PRODUCT_TYPES.SHIELD)).toBe( + false, + ); + expect( + selectIsDelegationExhausted(mixedState, PRODUCT_TYPES.SHIELD), + ).toBe(false); + + const noInvoiceState: SubscriptionControllerState = { + ...getDefaultSubscriptionControllerState(), + subscriptions: [ + { + ...MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + lastInvoice: undefined, + }, + ], + }; + + expect( + selectIsPaymentFailed(noInvoiceState, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(false); + expect( + selectPaymentFailureReason( + noInvoiceState, + PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + ), + ).toBeUndefined(); + }); + + it('does not interpret a payment-method error as an execution failure', () => { + const state: SubscriptionControllerState = { + ...getDefaultSubscriptionControllerState(), + subscriptions: [ + { + ...MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + lastInvoice: undefined, + }, + ], + }; + + expect( + selectIsRenewalNeeded(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(false); + expect( + selectIsDelegationExhausted(state, PRODUCT_TYPES.MONEY_ACCOUNT_PLUS), + ).toBe(false); + }); + }); }); diff --git a/packages/subscription-controller/src/selectors.ts b/packages/subscription-controller/src/selectors.ts index 01069b74639..cab805ce365 100644 --- a/packages/subscription-controller/src/selectors.ts +++ b/packages/subscription-controller/src/selectors.ts @@ -1,6 +1,41 @@ import { ACTIVE_SUBSCRIPTION_STATUSES } from './constants.js'; import type { SubscriptionControllerState } from './SubscriptionController.js'; -import type { ProductEntitlementFeatureMap, ProductType } from './types.js'; +import { + CRYPTO_PAYMENT_ERRORS, + INVOICE_PAYMENT_STATUSES, + PAYMENT_TYPES, +} from './types.js'; +import type { + CryptoPaymentError, + ProductEntitlementFeatureMap, + ProductType, + Subscription, +} from './types.js'; + +function getSubscriptionByProduct( + state: SubscriptionControllerState, + productType: ProductType, +): Subscription | undefined { + return state.subscriptions.find((subscription) => + subscription.products.some((product) => product.name === productType), + ); +} + +function getPaymentExecutionError( + state: SubscriptionControllerState, + productType: ProductType, +): CryptoPaymentError | undefined { + const subscription = getSubscriptionByProduct(state, productType); + + if ( + subscription?.paymentMethod.type !== PAYMENT_TYPES.byCrypto || + subscription.lastInvoice?.status !== INVOICE_PAYMENT_STATUSES.FAILED + ) { + return undefined; + } + + return subscription.lastInvoice?.errorCode; +} /** * Returns whether a specific product feature entitlement is enabled. @@ -65,3 +100,80 @@ export function selectIsActiveSubscriber( subscription.products.some((product) => product.name === productType), ); } + +/** + * Returns whether the product's latest crypto payment failed. + * + * The result is based on the latest invoice status. Payment-method setup + * errors in `paymentMethod.crypto.error` are intentionally not treated as + * invoice execution failures by this selector. + * + * @param state - The subscription controller state. + * @param productType - The product whose payment state is queried. + * @returns Whether the latest crypto payment failed. + */ +export function selectIsPaymentFailed( + state: SubscriptionControllerState, + productType: ProductType, +): boolean { + const subscription = getSubscriptionByProduct(state, productType); + + return Boolean( + subscription?.paymentMethod.type === PAYMENT_TYPES.byCrypto && + subscription.lastInvoice?.status === INVOICE_PAYMENT_STATUSES.FAILED, + ); +} + +/** + * Returns the raw Subscription API execution error for the latest crypto + * payment. + * + * @param state - The subscription controller state. + * @param productType - The product whose payment failure is queried. + * @returns The raw payment execution error, if present. + */ +export function selectPaymentFailureReason( + state: SubscriptionControllerState, + productType: ProductType, +): CryptoPaymentError | undefined { + return getPaymentExecutionError(state, productType); +} + +/** + * Returns whether the latest payment needs renewal or delegation recovery. + * + * The Subscription API signals this through `lastInvoice.errorCode` rather + * than a derived boolean. + * + * @param state - The subscription controller state. + * @param productType - The product whose renewal state is queried. + * @returns Whether renewal/recovery can be offered. + */ +export function selectIsRenewalNeeded( + state: SubscriptionControllerState, + productType: ProductType, +): boolean { + const errorCode = getPaymentExecutionError(state, productType); + + return ( + errorCode === CRYPTO_PAYMENT_ERRORS.INSUFFICIENT_BALANCE || + errorCode === CRYPTO_PAYMENT_ERRORS.DELEGATION_NOT_FOUND + ); +} + +/** + * Returns whether the delegation has exhausted its allowance. + * + * @param state - The subscription controller state. + * @param productType - The product whose delegation state is queried. + * @returns Whether the delegation allowance is exhausted. + */ +export function selectIsDelegationExhausted( + state: SubscriptionControllerState, + productType: ProductType, +): boolean { + return ( + getPaymentExecutionError(state, productType) === + CRYPTO_PAYMENT_ERRORS.EXCEEDS_DELEGATION_ALLOWANCE + ); +} diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts index fc5646c87d0..0a24cb8ed7b 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts @@ -24,6 +24,9 @@ export type SubscriptionDelegationServiceCheckMoneyAccountBalanceAction = { * one exists (ensuring a CHOMP intent is active for its hash, unless * `skipChompInteractions` is true). Reuse classifies period `startDate` as * trial-deferred (`> now`) vs immediately redeemable, matching creation. + * When several records match, the latest period `startDate` is reused + * so a `forceNew` replacement is preferred over an older equivalent + * permission. * If there is no match, builds, signs, optionally verifies with CHOMP, * persists, and optionally registers a new delegation. * @@ -33,6 +36,8 @@ export type SubscriptionDelegationServiceCheckMoneyAccountBalanceAction = { * that accepts `'cash-subscription'` intent metadata. * * @param request - Authoritative pricing and payer details for the delegation. + * @param forceNew - Whether to create a replacement instead of reusing a + * matching stored delegation. * @returns The delegation hash (CHOMP-verified unless skipped) and whether it * was created or reused. */ diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts index 377dd0336e3..9fd178af1fb 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -488,6 +488,78 @@ describe('SubscriptionDelegationService', () => { expect(mocks.createIntents).not.toHaveBeenCalled(); }); + it('reuses the matching stored delegation with the latest period startDate', async () => { + const older = buildStoredDelegation({ + delegationHash: `0x${'11'.repeat(32)}`, + startDate: 1_700_000_000, + }); + const newer = buildStoredDelegation({ + delegationHash: `0x${'22'.repeat(32)}`, + startDate: 1_700_086_400, + }); + const { service, mocks } = setup({ + listDelegations: [older, newer], + intents: [ + { + account: PAYER, + delegationHash: newer.metadata.delegationHash, + chainId: CHAIN_ID, + status: 'active', + metadata: newer.metadata, + }, + ], + }); + + const result = await service.prepareDelegation(REQUEST); + + expect(result).toStrictEqual({ + delegationHash: newer.metadata.delegationHash, + disposition: 'reused', + }); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('creates and registers a replacement when forceNew is requested', async () => { + const stored = buildStoredDelegation(); + const { service, mocks } = setup({ + listDelegations: [stored], + intents: [ + { + account: PAYER, + delegationHash: stored.metadata.delegationHash, + chainId: CHAIN_ID, + status: 'active', + metadata: stored.metadata, + }, + ], + }); + + const result = await service.prepareDelegation( + { + ...REQUEST, + }, + true, + ); + + expect(result.disposition).toBe('created'); + expect(result.delegationHash).not.toBe(stored.metadata.delegationHash); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + delegationHash: result.delegationHash, + }), + }), + ); + expect(mocks.createIntents).toHaveBeenCalledWith([ + expect.objectContaining({ + delegationHash: result.delegationHash, + }), + ]); + }); + it('reuses a matching delegation and registers an intent when missing', async () => { const stored = buildStoredDelegation(); const { service, mocks } = setup({ diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts index ac84e42bdd0..8d79e0a132c 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -1,4 +1,5 @@ import type { + DelegationResponse, AuthenticatedUserStorageServiceCreateDelegationAction, AuthenticatedUserStorageServiceListDelegationsAction, } from '@metamask/authenticated-user-storage'; @@ -40,6 +41,7 @@ import { buildUnsignedSubscriptionDelegation } from './caveats.js'; import { equalsIgnoreCase, makeMatchesSubscriptionDelegation, + pickLatestMatchingSubscriptionDelegation, } from './fingerprint.js'; import type { SubscriptionDelegationServiceMethodActions } from './SubscriptionDelegationService-method-action-types.js'; import type { @@ -227,6 +229,9 @@ export class SubscriptionDelegationService { * one exists (ensuring a CHOMP intent is active for its hash, unless * `skipChompInteractions` is true). Reuse classifies period `startDate` as * trial-deferred (`> now`) vs immediately redeemable, matching creation. + * When several records match, the latest period `startDate` is reused + * so a `forceNew` replacement is preferred over an older equivalent + * permission. * If there is no match, builds, signs, optionally verifies with CHOMP, * persists, and optionally registers a new delegation. * @@ -236,11 +241,14 @@ export class SubscriptionDelegationService { * that accepts `'cash-subscription'` intent metadata. * * @param request - Authoritative pricing and payer details for the delegation. + * @param forceNew - Whether to create a replacement instead of reusing a + * matching stored delegation. * @returns The delegation hash (CHOMP-verified unless skipped) and whether it * was created or reused. */ async prepareDelegation( request: PrepareSubscriptionDelegationRequest, + forceNew = false, ): Promise { if (request.product !== PRODUCT_TYPES.MONEY_ACCOUNT_PLUS) { throw new Error( @@ -283,22 +291,19 @@ export class SubscriptionDelegationService { }); const isTrialDeferred = startDate > nowSeconds; - const matches = makeMatchesSubscriptionDelegation({ - delegatorAddress: request.payerAddress, - delegateAddress, - chainId, - tokenAddress: token.address, - periodAmount, - periodDuration, - nowSeconds, - isTrialDeferred, - enforcers, - }); - - const existingDelegations = await this.#messenger.call( - 'AuthenticatedUserStorageService:listDelegations', - ); - const reusable = existingDelegations.find(matches); + const reusable = forceNew + ? undefined + : await this.#findReusableDelegation({ + request, + chainId, + delegateAddress, + tokenAddress: token.address, + periodAmount, + periodDuration, + nowSeconds, + isTrialDeferred, + enforcers, + }); if (reusable) { if (!skipChomp) { await this.#ensureIntent({ @@ -402,6 +407,49 @@ export class SubscriptionDelegationService { }; } + async #findReusableDelegation({ + request, + chainId, + delegateAddress, + tokenAddress, + periodAmount, + periodDuration, + nowSeconds, + isTrialDeferred, + enforcers, + }: { + request: PrepareSubscriptionDelegationRequest; + chainId: Hex; + delegateAddress: Hex; + tokenAddress: Hex; + periodAmount: bigint; + periodDuration: number; + nowSeconds: number; + isTrialDeferred: boolean; + enforcers: SubscriptionDelegationEnforcers; + }): Promise { + const matches = makeMatchesSubscriptionDelegation({ + delegatorAddress: request.payerAddress, + delegateAddress, + chainId, + tokenAddress, + periodAmount, + periodDuration, + nowSeconds, + isTrialDeferred, + enforcers, + }); + + const existingDelegations = await this.#messenger.call( + 'AuthenticatedUserStorageService:listDelegations', + ); + + return pickLatestMatchingSubscriptionDelegation( + existingDelegations.filter(matches), + enforcers, + ); + } + async #resolveConfiguration( product: ProductType, recurringInterval: RecurringInterval, diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts index 99790cf1cb2..5bc0f7f3cc5 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts @@ -9,6 +9,7 @@ import type { Hex } from '@metamask/utils'; import { equalsIgnoreCase, makeMatchesSubscriptionDelegation, + pickLatestMatchingSubscriptionDelegation, } from './fingerprint.js'; import { CASH_SUBSCRIPTION_DELEGATION_TYPE } from './types.js'; @@ -210,3 +211,52 @@ describe('makeMatchesSubscriptionDelegation', () => { expect(matches(entry)).toBe(false); }); }); + +describe('pickLatestMatchingSubscriptionDelegation', () => { + it('returns undefined when there are no matches', () => { + expect( + pickLatestMatchingSubscriptionDelegation([], expected.enforcers), + ).toBeUndefined(); + }); + + it('prefers the matching delegation with the latest period startDate', () => { + const older = buildEntry({ startDate: NOW_SECONDS - 86_400 }); + older.metadata.delegationHash = `0x${'11'.repeat(32)}`; + const newer = buildEntry({ startDate: NOW_SECONDS }); + newer.metadata.delegationHash = `0x${'22'.repeat(32)}`; + + expect( + pickLatestMatchingSubscriptionDelegation( + [older, newer], + expected.enforcers, + )?.metadata.delegationHash, + ).toBe(newer.metadata.delegationHash); + + expect( + pickLatestMatchingSubscriptionDelegation( + [newer, older], + expected.enforcers, + )?.metadata.delegationHash, + ).toBe(newer.metadata.delegationHash); + }); + + it('deprioritizes delegations whose period startDate cannot be read', () => { + const withoutPeriodCaveat = buildEntry({ startDate: NOW_SECONDS + 86_400 }); + withoutPeriodCaveat.signedDelegation.caveats.pop(); + withoutPeriodCaveat.metadata.delegationHash = `0x${'11'.repeat(32)}`; + + const malformedTerms = buildEntry({ startDate: NOW_SECONDS + 86_400 }); + malformedTerms.signedDelegation.caveats[1].terms = '0x'; + malformedTerms.metadata.delegationHash = `0x${'22'.repeat(32)}`; + + const readable = buildEntry({ startDate: NOW_SECONDS - 86_400 }); + readable.metadata.delegationHash = `0x${'33'.repeat(32)}`; + + expect( + pickLatestMatchingSubscriptionDelegation( + [withoutPeriodCaveat, malformedTerms, readable], + expected.enforcers, + )?.metadata.delegationHash, + ).toBe(readable.metadata.delegationHash); + }); +}); diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts index e84174c38bd..4c5610f1592 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts @@ -122,3 +122,58 @@ export function makeMatchesSubscriptionDelegation( } }; } + +/** + * Reads the ERC-20 period-transfer `startDate` from a stored delegation. + * + * @param entry - Stored AUS delegation. + * @param enforcers - Enforcer addresses used to locate the period caveat. + * @returns Unix timestamp in seconds, or `undefined` when the caveat is + * missing or malformed. + */ +function getSubscriptionDelegationStartDate( + entry: DelegationResponse, + enforcers: SubscriptionDelegationEnforcers, +): number | undefined { + const periodCaveat = entry.signedDelegation.caveats.find((caveat) => + equalsIgnoreCase(caveat.enforcer, enforcers.erc20TokenPeriodTransfer), + ); + if (!periodCaveat) { + return undefined; + } + + try { + return Number( + decodeERC20TokenPeriodTransferTerms(periodCaveat.terms).startDate, + ); + } catch { + return undefined; + } +} + +/** + * Returns the fingerprint match with the latest period `startDate`. + * + * Equal `startDate`s keep list order. A delegation whose `startDate` cannot + * be read sorts last. + * + * @param matches - Fingerprint-matching AUS delegations, in list order. + * @param enforcers - Enforcer addresses used to read `startDate`. + * @returns The preferred match, or `undefined` when `matches` is empty. + */ +export function pickLatestMatchingSubscriptionDelegation( + matches: readonly DelegationResponse[], + enforcers: SubscriptionDelegationEnforcers, +): DelegationResponse | undefined { + const byStartDateDescending = [...matches].sort((left, right) => { + const leftStart = + getSubscriptionDelegationStartDate(left, enforcers) ?? + Number.NEGATIVE_INFINITY; + const rightStart = + getSubscriptionDelegationStartDate(right, enforcers) ?? + Number.NEGATIVE_INFINITY; + return rightStart - leftStart; + }); + + return byStartDateDescending[0]; +} diff --git a/packages/subscription-controller/src/types.test.ts b/packages/subscription-controller/src/types.test.ts index 91c7a94df27..959bada4517 100644 --- a/packages/subscription-controller/src/types.test.ts +++ b/packages/subscription-controller/src/types.test.ts @@ -11,6 +11,7 @@ import type { MoneyAccountEntitlements, ShieldEntitlements, StartCryptoSubscriptionRequest, + UpdatePaymentMethodCryptoRequest, } from './types.js'; const SHARED_CRYPTO_REQUEST = { @@ -29,6 +30,12 @@ function assertStartCryptoSubscriptionRequest( return request; } +function assertUpdatePaymentMethodCryptoRequest( + request: UpdatePaymentMethodCryptoRequest, +): UpdatePaymentMethodCryptoRequest { + return request; +} + function assertMoneyAccountEntitlements( entitlements: MoneyAccountEntitlements, ): MoneyAccountEntitlements { @@ -146,3 +153,52 @@ describe('StartCryptoSubscriptionRequest', () => { expect(true).toBe(true); }); }); + +describe('UpdatePaymentMethodCryptoRequest', () => { + const sharedRequest = { + subscriptionId: 'sub_123', + chainId: '0x1' as Hex, + payerAddress: '0x0000000000000000000000000000000000000001' as Hex, + tokenSymbol: 'pvmUSD', + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 12, + }; + + it('accepts the existing ERC-20 approval request', () => { + const request = assertUpdatePaymentMethodCryptoRequest({ + ...sharedRequest, + rawTransaction: '0xdeadbeef', + }); + + expect(request.rawTransaction).toBe('0xdeadbeef'); + }); + + it('accepts a delegation rotation request', () => { + const request = assertUpdatePaymentMethodCryptoRequest({ + ...sharedRequest, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, + delegationHash: '0xabcdef1234567890', + }); + + expect(request.cryptoAuthMethod).toBe(CRYPTO_AUTH_METHODS.DELEGATION); + expect(request.delegationHash).toBe('0xabcdef1234567890'); + }); + + it('rejects mutually exclusive or incomplete crypto update fields', () => { + // @ts-expect-error Delegation updates cannot include an ERC-20 transaction. + assertUpdatePaymentMethodCryptoRequest({ + ...sharedRequest, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, + delegationHash: '0xabcdef1234567890' as Hex, + rawTransaction: '0xdeadbeef' as Hex, + }); + + // @ts-expect-error Delegation updates require a delegation hash. + assertUpdatePaymentMethodCryptoRequest({ + ...sharedRequest, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, + }); + + expect(true).toBe(true); + }); +}); diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index b955d965ca5..d93cec256b7 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -121,6 +121,16 @@ export const SUBSCRIPTION_STATUSES = { export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[keyof typeof SUBSCRIPTION_STATUSES]; +export const INVOICE_PAYMENT_STATUSES = { + PROCESSING: 'PROCESSING', + SUCCEEDED: 'SUCCEEDED', + FAILED: 'FAILED', + UNKNOWN: 'UNKNOWN', +} as const; + +export type InvoicePaymentStatus = + (typeof INVOICE_PAYMENT_STATUSES)[keyof typeof INVOICE_PAYMENT_STATUSES]; + export const CANCEL_TYPES = { ALLOWED_IMMEDIATE: 'allowed_immediate', ALLOWED_AT_PERIOD_END: 'allowed_at_period_end', @@ -145,6 +155,25 @@ export const CRYPTO_PAYMENT_METHOD_ERRORS = { export type CryptoPaymentMethodError = (typeof CRYPTO_PAYMENT_METHOD_ERRORS)[keyof typeof CRYPTO_PAYMENT_METHOD_ERRORS]; +/** + * Errors returned by the Subscription API after crypto payment execution. + * + * These are distinct from {@link CRYPTO_PAYMENT_METHOD_ERRORS}, which describe + * approval/payment-method failures. + */ +export const CRYPTO_PAYMENT_ERRORS = { + INSUFFICIENT_BALANCE: 'insufficient_balance', + INSUFFICIENT_ALLOWANCE: 'insufficient_allowance', + EXCEEDS_DELEGATION_ALLOWANCE: 'exceeds_delegation_allowance', + DELEGATION_NOT_FOUND: 'delegation_not_found', + DELEGATION_REVOKED: 'delegation_revoked', + RECIPIENT_NOT_ALLOWLISTED: 'recipient_not_allowlisted', + INTERNAL_SERVER_ERROR: 'internal_server_error', +} as const; + +export type CryptoPaymentError = + (typeof CRYPTO_PAYMENT_ERRORS)[keyof typeof CRYPTO_PAYMENT_ERRORS]; + export const MODAL_TYPE = { A: 'A', B: 'B', @@ -166,8 +195,8 @@ export type Product = { export type Subscription = { id: string; products: Product[]; - currentPeriodStart: string; // ISO 8601 - currentPeriodEnd: string; // ISO 8601 + currentPeriodStart?: string; // ISO 8601 + currentPeriodEnd?: string; // ISO 8601 /** is subscription scheduled for cancellation */ cancelAtPeriodEnd?: boolean; status: SubscriptionStatus; @@ -181,12 +210,21 @@ export type Subscription = { /** The date the subscription was canceled. */ canceledAt?: string; // ISO 8601 /** The cancellation type indicating what cancellation options are available for this subscription. */ - cancelType: CancelType; + cancelType?: CancelType; /** The date the subscription was marked as inactive (paused/past_due/canceled). */ inactiveAt?: string; // ISO 8601 /** Whether the user is eligible for support features (priority support and filing claims). True for active subscriptions and inactive subscriptions within grace period. */ - isEligibleForSupport: boolean; + isEligibleForSupport?: boolean; billingCycles?: number; + /** The most recent invoice associated with the subscription. */ + lastInvoice?: SubscriptionInvoice; +}; + +export type SubscriptionInvoice = { + id: string; + status: InvoicePaymentStatus; + errorCode?: CryptoPaymentError; + updatedAt: string; // ISO 8601 }; export type SubscriptionCardPaymentMethod = { @@ -718,20 +756,51 @@ export type UpdatePaymentMethodCardResponse = { redirectUrl: string; }; -export type UpdatePaymentMethodCryptoRequest = { +type UpdatePaymentMethodCryptoRequestBase = { subscriptionId: string; chainId: Hex; payerAddress: Hex; tokenSymbol: string; - /** - * The raw transaction to pay for the subscription - * Can be empty if retry after topping up balance - */ - rawTransaction?: Hex; recurringInterval: RecurringInterval; billingCycles: number; }; +/** + * ERC-20 approval crypto payment-method update request. + * + * `rawTransaction` may be omitted when retrying after a balance top-up. + */ +export type UpdateErc20PaymentMethodCryptoRequest = + UpdatePaymentMethodCryptoRequestBase & { + cryptoAuthMethod?: typeof CRYPTO_AUTH_METHODS.ERC20_APPROVAL; + /** + * The raw transaction to pay for the subscription. + */ + rawTransaction?: Hex; + delegationHash?: never; + }; + +/** + * Delegation crypto payment-method update request used to rotate an active + * subscription to a replacement delegation. + */ +export type UpdateDelegationPaymentMethodCryptoRequest = + UpdatePaymentMethodCryptoRequestBase & { + cryptoAuthMethod: typeof CRYPTO_AUTH_METHODS.DELEGATION; + delegationHash: Hex; + rawTransaction?: never; + }; + +/** + * Request to update a subscription's crypto payment method. + * + * Provide `rawTransaction` for the existing ERC-20 approval path, or provide + * `cryptoAuthMethod: 'delegation'` and `delegationHash` to rotate a delegation. + */ +export type UpdatePaymentMethodCryptoRequest = + | UpdateErc20PaymentMethodCryptoRequest + | UpdateDelegationPaymentMethodCryptoRequest; + export type BillingPortalResponse = { url: string; };