From 66c7070e2af11ff983f30fdae0937dca11ceb4aa Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 29 Sep 2026 18:47:30 +0700 Subject: [PATCH 1/3] feat: correct subscription response and statuses --- packages/subscription-controller/CHANGELOG.md | 10 + .../src/SubscriptionController.test.ts | 135 ++++++++--- .../src/SubscriptionController.ts | 1 + .../src/SubscriptionService-structs.ts | 9 +- .../src/SubscriptionService.test.ts | 223 +++++++++++++++++- .../src/SubscriptionService.ts | 6 +- .../SubscriptionDelegationService.test.ts | 25 +- packages/subscription-controller/src/types.ts | 17 +- 8 files changed, 353 insertions(+), 73 deletions(-) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 0fc7b90ac3a..0bbc91f0911 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -15,9 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bind recurring permissions to the token, amount, period, start, and zero native value, and verify reusable delegation hashes with CHOMP. - Derive trial timing from authoritative subscription state. - Add optional `skipApproval` to `StartSubscriptionWithDelegationRequest` to bypass the `ApprovalController:addRequest` consent and funding step; the caller is then responsible for consent and funding. +- Add `SUBSCRIPTION_STATUSES.awaitingFunds` (`awaiting_funds`) for crypto subscriptions that are waiting for the payer to fund the first invoice + - `SubscriptionController:submitSubscriptionCryptoApproval` treats it like `past_due` / `unpaid`: a new approval updates the existing subscription's payment method. ### Changed +- **BREAKING:** `StartCryptoSubscriptionResponse` (returned by `SubscriptionService:startSubscriptionWithCrypto`, `SubscriptionController:startSubscriptionWithCrypto`, and `SubscriptionDelegationService:startSubscriptionWithDelegation`) is now the created `Subscription` instead of `{ subscriptionId, status }`, matching what `POST /subscriptions/crypto` actually returns + - Read `response.id` instead of `response.subscriptionId`. `response.status` is unchanged. + - Previously the response failed validation on every successful call, so the crypto start flow always threw. - **BREAKING:** Widen `SubscriptionDelegationServiceMessenger` so `SubscriptionDelegationService:startSubscriptionWithDelegation` can call the actions it needs ([#10339](https://github.com/MetaMask/core/pull/10339)) - The messenger must now allow `ApprovalController:addRequest`, `MoneyAccountUpgradeController:forceUpgradeAccount`, `SubscriptionController:getState`, `SubscriptionController:getSubscriptions`, and `SubscriptionController:startSubscriptionWithCrypto`. - Clients that construct this messenger must delegate those actions before calling `startSubscriptionWithDelegation`. A messenger typed against the previous `SubscriptionDelegationServiceMessenger` is no longer assignable. @@ -36,6 +41,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/money-account-utils` from `^2.0.1` to `^2.1.0` ([#10505](https://github.com/MetaMask/core/pull/10505)) - Bump `@tanstack/query-core` from `^5.89.0` to `^5.103.2` ([#10511](https://github.com/MetaMask/core/pull/10511)) +### Fixed + +- Stop rejecting Subscription API responses whose `lastInvoice` omits `updatedAt` or whose card payment method omits `displayBrand`; both fields are optional in the API and are now optional on `SubscriptionInvoice` and `SubscriptionCardPaymentMethod` + - Previously a subscription with a `lastInvoice` failed validation and `getSubscriptions` threw. + ## [10.0.1] ### Changed diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index b5e402d461f..3c0bc38a2ff 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -115,6 +115,19 @@ const MOCK_MONEY_ACCOUNT_SUBSCRIPTION: Subscription = { }, }; +const MOCK_CRYPTO_SUBSCRIPTION: Subscription = { + ...MOCK_SUBSCRIPTION, + id: 'sub_crypto_123', + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: '0x0000000000000000000000000000000000000001', + chainId: '0x1', + tokenSymbol: 'USDC', + }, + }, +}; + const MOCK_PRODUCT_PRICE: ProductPricing = { name: PRODUCT_TYPES.SHIELD, prices: [ @@ -2688,10 +2701,8 @@ describe('SubscriptionController', () => { rawTransaction: '0xdeadbeef', }; - const response: StartCryptoSubscriptionResponse = { - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }; + const response: StartCryptoSubscriptionResponse = + MOCK_CRYPTO_SUBSCRIPTION; mockService.startSubscriptionWithCrypto.mockResolvedValue(response); mockService.getSubscriptions @@ -2824,10 +2835,9 @@ describe('SubscriptionController', () => { delegationHash: '0xabc', }; - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_money_account', - status: SUBSCRIPTION_STATUSES.active, - }); + mockService.startSubscriptionWithCrypto.mockResolvedValue( + MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + ); mockService.getSubscriptions .mockResolvedValueOnce({ subscriptions: [], @@ -2971,10 +2981,9 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); + mockService.startSubscriptionWithCrypto.mockResolvedValue( + MOCK_CRYPTO_SUBSCRIPTION, + ); mockService.getSubscriptions .mockResolvedValueOnce(MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE) .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); @@ -3051,10 +3060,9 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); + mockService.startSubscriptionWithCrypto.mockResolvedValue( + MOCK_CRYPTO_SUBSCRIPTION, + ); mockService.getSubscriptions .mockResolvedValueOnce({ customerId: 'cus_1', @@ -3099,10 +3107,9 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_money_account', - status: SUBSCRIPTION_STATUSES.active, - }); + mockService.startSubscriptionWithCrypto.mockResolvedValue( + MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + ); mockService.getSubscriptions.mockResolvedValue({ customerId: 'cus_1', subscriptions: [], @@ -3146,10 +3153,9 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); + mockService.startSubscriptionWithCrypto.mockResolvedValue( + MOCK_CRYPTO_SUBSCRIPTION, + ); mockService.getSubscriptions .mockResolvedValueOnce(MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE) .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); @@ -3187,10 +3193,9 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); + mockService.startSubscriptionWithCrypto.mockResolvedValue( + MOCK_CRYPTO_SUBSCRIPTION, + ); mockService.getSubscriptions .mockResolvedValueOnce({ customerId: 'cus_1', @@ -3233,10 +3238,9 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); + mockService.startSubscriptionWithCrypto.mockResolvedValue( + MOCK_CRYPTO_SUBSCRIPTION, + ); mockService.getSubscriptions .mockResolvedValueOnce({ customerId: 'cus_1', @@ -3281,10 +3285,9 @@ describe('SubscriptionController', () => { }, }, async ({ controller, rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_money_account', - status: SUBSCRIPTION_STATUSES.active, - }); + mockService.startSubscriptionWithCrypto.mockResolvedValue( + MOCK_MONEY_ACCOUNT_SUBSCRIPTION, + ); mockService.getSubscriptions .mockResolvedValueOnce({ customerId: 'cus_1', @@ -5990,6 +5993,66 @@ describe('SubscriptionController', () => { ); }); + it('should update payment method when the subscription is awaiting funds', async () => { + await withController( + { + state: { + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [], + subscriptions: [], + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }, + }, + }, + async ({ rootMessenger, mockService }) => { + mockService.updatePaymentMethodCrypto.mockResolvedValue(undefined); + mockService.getSubscriptions.mockResolvedValue({ + subscriptions: [ + { + ...MOCK_CRYPTO_SUBSCRIPTION, + status: SUBSCRIPTION_STATUSES.awaitingFunds, + }, + ], + trialedProducts: [], + }); + + const txMeta = { + ...generateMockTxMeta(), + type: TransactionType.shieldSubscriptionApprove, + chainId: '0x1' as Hex, + rawTx: '0x123', + txParams: { + data: '0x456', + from: '0x1234567890123456789012345678901234567890', + to: '0xtoken', + }, + status: TransactionStatus.submitted, + }; + + await rootMessenger.call( + 'SubscriptionController:submitSubscriptionCryptoApproval', + { + productType: PRODUCT_TYPES.SHIELD, + txMeta, + }, + ); + + expect(mockService.updatePaymentMethodCrypto).toHaveBeenCalledTimes( + 1, + ); + expect( + mockService.startSubscriptionWithCrypto, + ).not.toHaveBeenCalled(); + }, + ); + }); + it('should throw error when subscription status is not valid for crypto approval', async () => { await withController( { diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index df852250135..9eb8347acf8 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -1150,6 +1150,7 @@ export class SubscriptionController extends StaticIntervalPollingController()< !subscription || ( [ + SUBSCRIPTION_STATUSES.awaitingFunds, SUBSCRIPTION_STATUSES.pastDue, SUBSCRIPTION_STATUSES.unpaid, SUBSCRIPTION_STATUSES.paused, diff --git a/packages/subscription-controller/src/SubscriptionService-structs.ts b/packages/subscription-controller/src/SubscriptionService-structs.ts index 998233cdce9..edeec8dd8ba 100644 --- a/packages/subscription-controller/src/SubscriptionService-structs.ts +++ b/packages/subscription-controller/src/SubscriptionService-structs.ts @@ -76,7 +76,7 @@ const SubscriptionCardPaymentMethodStruct = type({ type: enums([PAYMENT_TYPES.byCard]), card: type({ brand: string(), - displayBrand: string(), + displayBrand: optional(string()), last4: string(), }), }); @@ -119,7 +119,7 @@ export const SubscriptionStruct = type({ id: string(), status: InvoicePaymentStatusStruct, errorCode: optional(CryptoPaymentErrorStruct), - updatedAt: string(), + updatedAt: optional(string()), }), ), }); @@ -172,11 +172,6 @@ export const StartSubscriptionResponseStruct = type({ checkoutSessionUrl: string(), }); -export const StartCryptoSubscriptionResponseStruct = type({ - subscriptionId: string(), - status: SubscriptionStatusStruct, -}); - export const UpdatePaymentMethodCardResponseStruct = type({ redirectUrl: string(), }); diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 89e06e2a33c..abe64094d01 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -700,6 +700,111 @@ describe('SubscriptionService', () => { }); }); + it('accepts a last invoice without updatedAt', 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.active, + interval: RECURRING_INTERVALS.month, + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: + '0x123456789012345678901234567890123456abcd', + chainId: '0x1', + tokenSymbol: 'pvmUSD', + }, + }, + lastInvoice: { + id: 'in_123', + status: 'SUCCEEDED', + }, + }, + ], + }, + }), + ); + + const result = await service.getSubscriptions(); + + expect(result.subscriptions[0].lastInvoice).toStrictEqual({ + id: 'in_123', + status: 'SUCCEEDED', + }); + }); + }); + + it('accepts a card payment method without displayBrand', async () => { + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + trialedProducts: [], + subscriptions: [ + { + ...MOCK_SUBSCRIPTION, + paymentMethod: { + type: PAYMENT_TYPES.byCard, + card: { + brand: 'visa', + last4: '1234', + }, + }, + }, + ], + }, + }), + ); + + const result = await service.getSubscriptions(); + + expect(result.subscriptions[0].paymentMethod).toStrictEqual({ + type: PAYMENT_TYPES.byCard, + card: { + brand: 'visa', + last4: '1234', + }, + }); + }); + }); + + it('accepts the awaiting_funds subscription status', async () => { + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + trialedProducts: [], + subscriptions: [ + { + ...MOCK_SUBSCRIPTION, + status: 'awaiting_funds', + }, + ], + }, + }), + ); + + const result = await service.getSubscriptions(); + + expect(result.subscriptions[0].status).toBe( + SUBSCRIPTION_STATUSES.awaitingFunds, + ); + }); + }); + it('rejects unsupported payment execution error codes', async () => { await withMockSubscriptionService(async ({ service, fetchMock }) => { fetchMock.mockResolvedValue( @@ -1404,9 +1509,34 @@ describe('SubscriptionService', () => { it('should start crypto subscription successfully', async () => { await withMockSubscriptionService(async ({ service, fetchMock }) => { - const response = { - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, + // The Subscription API returns the created subscription (same shape + // as `GET /subscriptions` items), not a `{ subscriptionId, status }` + // envelope. + const response: Subscription = { + id: 'sub_crypto_123', + products: [ + { + name: PRODUCT_TYPES.SHIELD, + currency: 'usd', + unitAmount: 900, + unitDecimals: 2, + }, + ], + status: SUBSCRIPTION_STATUSES.provisional, + interval: RECURRING_INTERVALS.month, + currentPeriodStart: '2024-01-01T00:00:00Z', + currentPeriodEnd: '2024-02-01T00:00:00Z', + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: '0x0000000000000000000000000000000000000001', + chainId: '0x1', + tokenSymbol: 'USDC', + }, + }, + billingCycles: 3, + isEligibleForSupport: true, + cancelType: CANCEL_TYPES.ALLOWED_IMMEDIATE, }; fetchMock.mockResolvedValue(createMockResponse({ jsonData: response })); @@ -1418,6 +1548,73 @@ describe('SubscriptionService', () => { }); }); + it('passes through additional subscription fields returned by the API', async () => { + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + id: 'sub_crypto_123', + products: [ + { + name: PRODUCT_TYPES.SHIELD, + currency: 'usd', + unitAmount: 900, + unitDecimals: 2, + }, + ], + status: SUBSCRIPTION_STATUSES.awaitingFunds, + interval: RECURRING_INTERVALS.month, + currentPeriodStart: '2024-01-01T00:00:00Z', + currentPeriodEnd: '2024-02-01T00:00:00Z', + originalPeriodStart: '2024-01-01T00:00:00Z', + renewalCount: 0, + createdAt: '2024-01-01T00:00:00Z', + rewardAccountId: + 'eip155:1:0x0000000000000000000000000000000000000001', + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: '0x0000000000000000000000000000000000000001', + chainId: '0x1', + tokenSymbol: 'USDC', + tokenAddress: '0x0000000000000000000000000000000000000002', + authMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, + isVaultShareToken: false, + }, + }, + isEligibleForSupport: true, + cancelType: CANCEL_TYPES.ALLOWED_IMMEDIATE, + }, + }), + ); + + const result = + await service.startSubscriptionWithCrypto(MOCK_CRYPTO_REQUEST); + + expect(result).toMatchObject({ + id: 'sub_crypto_123', + status: SUBSCRIPTION_STATUSES.awaitingFunds, + }); + }); + }); + + it('rejects a response that is not a subscription', async () => { + await withMockSubscriptionService(async ({ service, fetchMock }) => { + fetchMock.mockResolvedValue( + createMockResponse({ + jsonData: { + subscriptionId: 'sub_crypto_123', + status: SUBSCRIPTION_STATUSES.active, + }, + }), + ); + + await expect( + service.startSubscriptionWithCrypto(MOCK_CRYPTO_REQUEST), + ).rejects.toThrow('id'); + }); + }); + it('throws when products array is empty', async () => { const fetchMock = jest.fn(); const { service } = createService({ fetchMock }); @@ -2322,8 +2519,24 @@ describe('SubscriptionService', () => { fetchMock.mockResolvedValue( createMockResponse({ jsonData: { - subscriptionId: 'sub_money_account', - status: SUBSCRIPTION_STATUSES.active, + ...MOCK_SUBSCRIPTION, + id: 'sub_money_account', + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + currency: 'usd', + unitAmount: 499, + unitDecimals: 2, + }, + ], + paymentMethod: { + type: PAYMENT_TYPES.byCrypto, + crypto: { + payerAddress: '0x0000000000000000000000000000000000000001', + chainId: '0x8f', + tokenSymbol: 'pvmUSD', + }, + }, }, }), ); diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index db542ecf7cf..aebf93245ae 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -29,7 +29,6 @@ import { BillingPortalResponseStruct, GetSubscriptionsResponseStruct, PricingResponseStruct, - StartCryptoSubscriptionResponseStruct, StartSubscriptionResponseStruct, SubscriptionApiGeneralResponseStruct, SubscriptionBenefitsResponseStruct, @@ -321,7 +320,8 @@ export class SubscriptionService extends BaseDataService< * Starts a subscription with a crypto payment method. * * @param request - The start crypto subscription request. - * @returns The created subscription response. + * @returns The created subscription. Unlike card checkout, the Subscription + * API creates the subscription immediately and returns it in full. * @throws If `products` is empty. * @throws If the request does not use exactly one of `rawTransaction` * (ERC-20 approval) or `delegationHash` (delegation). @@ -350,7 +350,7 @@ export class SubscriptionService extends BaseDataService< SubscriptionServiceErrorMessage.FailedToStartSubscriptionWithCrypto, }); - return create(jsonResponse, StartCryptoSubscriptionResponseStruct); + return create(jsonResponse, SubscriptionStruct); } /** diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts index 8b84da87139..cbc4d972384 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -163,6 +163,11 @@ function buildSubscription( }; } +const STARTED_SUBSCRIPTION: Subscription = { + ...buildSubscription(SUBSCRIPTION_STATUSES.provisional), + id: 'subscription-id', +}; + const PERIOD_AMOUNT = calculatePeriodAmount({ unitAmount: PRICE.unitAmount, unitDecimals: PRICE.unitDecimals, @@ -292,10 +297,7 @@ function setup( forceUpgradeAccount: jest.fn().mockResolvedValue(undefined), startSubscriptionWithCrypto: jest .fn() - .mockResolvedValue({ - subscriptionId: 'subscription-id', - status: 'provisional', - }), + .mockResolvedValue(STARTED_SUBSCRIPTION), getIntentsByAddress: jest.fn(), }; mocks.getIntentsByAddress.mockImplementation(async () => { @@ -1248,10 +1250,7 @@ describe('SubscriptionDelegationService', () => { assertTrialEligibility: true, }); expect(mocks.fetchBalanceWithFallback).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ - subscriptionId: 'subscription-id', - status: 'provisional', - }); + expect(result).toStrictEqual(STARTED_SUBSCRIPTION); }); it('reuses an active payment permission without signing or persisting it again', async () => { @@ -1461,10 +1460,7 @@ describe('SubscriptionDelegationService', () => { payerAddress: PAYER, }), ); - expect(result).toStrictEqual({ - subscriptionId: 'subscription-id', - status: 'provisional', - }); + expect(result).toStrictEqual(STARTED_SUBSCRIPTION); }); it('propagates downstream errors when skipApproval is true', async () => { @@ -1505,10 +1501,7 @@ describe('SubscriptionDelegationService', () => { 'SubscriptionDelegationService:startSubscriptionWithDelegation', START_REQUEST, ), - ).toStrictEqual({ - subscriptionId: 'subscription-id', - status: 'provisional', - }); + ).toStrictEqual(STARTED_SUBSCRIPTION); }); it('rejects unsupported products before resolving configuration', async () => { diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index 3f2706a9cff..56bed603971 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -105,6 +105,8 @@ export const SUBSCRIPTION_STATUSES = { // Initial states incomplete: 'incomplete', incompleteExpired: 'incomplete_expired', + /** Crypto subscription created, waiting for the payer to fund the first invoice. */ + awaitingFunds: 'awaiting_funds', // Active states provisional: 'provisional', trialing: 'trialing', @@ -224,7 +226,7 @@ export type SubscriptionInvoice = { id: string; status: InvoicePaymentStatus; errorCode?: CryptoPaymentError; - updatedAt: string; // ISO 8601 + updatedAt?: string; // ISO 8601 }; export type SubscriptionCardPaymentMethod = { @@ -232,7 +234,7 @@ export type SubscriptionCardPaymentMethod = { card: { brand: string; /** display brand account for dual brand card */ - displayBrand: string; + displayBrand?: string; last4: string; }; }; @@ -398,10 +400,13 @@ export type StartCryptoSubscriptionRequest = | StartErc20CryptoSubscriptionRequest | StartDelegationCryptoSubscriptionRequest; -export type StartCryptoSubscriptionResponse = { - subscriptionId: string; - status: SubscriptionStatus; -}; +/** + * Response of `POST /subscriptions/crypto`. + * + * Unlike card checkout, a crypto start creates the subscription immediately, + * so the Subscription API returns the created {@link Subscription} itself. + */ +export type StartCryptoSubscriptionResponse = Subscription; /** * General response type for the subscription API requests From 5704015e6132f36f9608d183b6081aed7de13c49 Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 29 Sep 2026 18:49:37 +0700 Subject: [PATCH 2/3] chore: update changelog --- packages/subscription-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 0bbc91f0911..697824e2672 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -15,12 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bind recurring permissions to the token, amount, period, start, and zero native value, and verify reusable delegation hashes with CHOMP. - Derive trial timing from authoritative subscription state. - Add optional `skipApproval` to `StartSubscriptionWithDelegationRequest` to bypass the `ApprovalController:addRequest` consent and funding step; the caller is then responsible for consent and funding. -- Add `SUBSCRIPTION_STATUSES.awaitingFunds` (`awaiting_funds`) for crypto subscriptions that are waiting for the payer to fund the first invoice +- Add `SUBSCRIPTION_STATUSES.awaitingFunds` (`awaiting_funds`) for crypto subscriptions that are waiting for the payer to fund the first invoice ([#10566](https://github.com/MetaMask/core/pull/10566)) - `SubscriptionController:submitSubscriptionCryptoApproval` treats it like `past_due` / `unpaid`: a new approval updates the existing subscription's payment method. ### Changed -- **BREAKING:** `StartCryptoSubscriptionResponse` (returned by `SubscriptionService:startSubscriptionWithCrypto`, `SubscriptionController:startSubscriptionWithCrypto`, and `SubscriptionDelegationService:startSubscriptionWithDelegation`) is now the created `Subscription` instead of `{ subscriptionId, status }`, matching what `POST /subscriptions/crypto` actually returns +- **BREAKING:** `StartCryptoSubscriptionResponse` (returned by `SubscriptionService:startSubscriptionWithCrypto`, `SubscriptionController:startSubscriptionWithCrypto`, and `SubscriptionDelegationService:startSubscriptionWithDelegation`) is now the created `Subscription` instead of `{ subscriptionId, status }`, matching what `POST /subscriptions/crypto` actually returns ([#10566](https://github.com/MetaMask/core/pull/10566)) - Read `response.id` instead of `response.subscriptionId`. `response.status` is unchanged. - Previously the response failed validation on every successful call, so the crypto start flow always threw. - **BREAKING:** Widen `SubscriptionDelegationServiceMessenger` so `SubscriptionDelegationService:startSubscriptionWithDelegation` can call the actions it needs ([#10339](https://github.com/MetaMask/core/pull/10339)) @@ -43,7 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Stop rejecting Subscription API responses whose `lastInvoice` omits `updatedAt` or whose card payment method omits `displayBrand`; both fields are optional in the API and are now optional on `SubscriptionInvoice` and `SubscriptionCardPaymentMethod` +- Stop rejecting Subscription API responses whose `lastInvoice` omits `updatedAt` or whose card payment method omits `displayBrand`; both fields are optional in the API and are now optional on `SubscriptionInvoice` and `SubscriptionCardPaymentMethod` ([#10566](https://github.com/MetaMask/core/pull/10566)) - Previously a subscription with a `lastInvoice` failed validation and `getSubscriptions` threw. ## [10.0.1] From 82a2b98427f244b092d1614efd4f17d66d989286 Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 29 Sep 2026 18:56:54 +0700 Subject: [PATCH 3/3] fix: messenger type --- .../src/SubscriptionService-method-action-types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/subscription-controller/src/SubscriptionService-method-action-types.ts b/packages/subscription-controller/src/SubscriptionService-method-action-types.ts index 1761f58ad6c..0481729789e 100644 --- a/packages/subscription-controller/src/SubscriptionService-method-action-types.ts +++ b/packages/subscription-controller/src/SubscriptionService-method-action-types.ts @@ -64,7 +64,8 @@ export type SubscriptionServiceStartSubscriptionWithCardAction = { * Starts a subscription with a crypto payment method. * * @param request - The start crypto subscription request. - * @returns The created subscription response. + * @returns The created subscription. Unlike card checkout, the Subscription + * API creates the subscription immediately and returns it in full. * @throws If `products` is empty. * @throws If the request does not use exactly one of `rawTransaction` * (ERC-20 approval) or `delegationHash` (delegation).