Skip to content

Commit c25c554

Browse files
authored
feat: subscription sponsorship intents (#6898)
## Explanation <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> Added new method, `submitSponsorshipIntents`, which submits the user's sponsorship intents for new subscription with crypto. ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces submitSponsorshipIntents flow for crypto subscriptions, adds/validates cached crypto payment method data (including token symbol), and updates approve amount/pricing handling with new helpers and errors. > > - **Controller (`src/SubscriptionController.ts`)** > - **New API**: `submitSponsorshipIntents` (registered via messenger) to submit gas-sponsorship intents using cached crypto payment method and pricing-derived `billingCycles`/`recurringInterval`. > - **Validation/Helpers**: Add `#assertIsPaymentMethodCrypto`, `#getProductPriceByProductAndPlan`, and refine `#assertIsUserNotSubscribed` using `ACTIVE_SUBSCRIPTION_STATUSES`. > - **State**: `cacheLastSelectedPaymentMethod` now requires `paymentTokenSymbol` for crypto; persists `lastSelectedPaymentMethod` per product. > - **Approve Params**: `getTokenApproveAmount` uses price `minBillingCycles`, affecting `approveAmount`. > - **Service (`src/SubscriptionService.ts`)** > - **New Endpoint**: `submitSponsorshipIntents` POST to `transaction-sponsorship/intents`. > - **Types/Constants (`src/types.ts`, `src/constants.ts`, `src/index.ts`)** > - Add `SubmitSponsorshipIntentsRequest`/`SubmitSponsorshipIntentsMethodParams`. > - Add `CachedLastSelectedPaymentMethod` (includes optional `paymentTokenSymbol`). > - Add errors: `PaymentTokenAddressAndSymbolRequiredForCrypto`, `PaymentMethodNotCrypto`, `ProductPriceNotFound`; export `ACTIVE_SUBSCRIPTION_STATUSES`. > - Extend `StartCryptoSubscriptionRequest` with optional `isSponsored`, `smartTransactionId`. > - **Tests** > - Add coverage for sponsorship intents flow and new validations; update pricing mocks to include `minBillingCycles` and yearly price; adjust expected `approveAmount`. > - **Changelog** > - Document new `submitSponsorshipIntents` method. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3387604. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a7a5588 commit c25c554

File tree

8 files changed

+448
-33
lines changed

8 files changed

+448
-33
lines changed

packages/subscription-controller/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Added the new controller state, `lastSelectedPaymentMethod`. ([#6946](https://github.com/MetaMask/core/pull/6946))
1313
- We will use this in the UI state persistence between navigation.
1414
- We will use this to query user subscription plan details in subscribe methods internally.
15+
- Added new public method, `submitSponsorshipIntents`, to submit sponsorship intents for the new subscription with crypto. ([#6898](https://github.com/MetaMask/core/pull/6898))
1516

1617
## [3.0.0]
1718

packages/subscription-controller/src/SubscriptionController.test.ts

Lines changed: 222 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import type {
3131
UpdatePaymentMethodOpts,
3232
Product,
3333
SubscriptionEligibility,
34-
CachedLastSelectedPaymentMethods,
34+
CachedLastSelectedPaymentMethod,
35+
SubmitSponsorshipIntentsMethodParams,
36+
ProductType,
3537
} from './types';
3638
import {
3739
PAYMENT_TYPES,
@@ -82,6 +84,14 @@ const MOCK_PRODUCT_PRICE: ProductPricing = {
8284
unitAmount: 900,
8385
unitDecimals: 2,
8486
trialPeriodDays: 0,
87+
minBillingCycles: 12,
88+
},
89+
{
90+
interval: 'year',
91+
unitAmount: 8000,
92+
unitDecimals: 2,
93+
currency: 'usd',
94+
trialPeriodDays: 14,
8595
minBillingCycles: 1,
8696
},
8797
],
@@ -199,6 +209,7 @@ function createMockSubscriptionService() {
199209
const mockGetBillingPortalUrl = jest.fn();
200210
const mockGetSubscriptionsEligibilities = jest.fn();
201211
const mockSubmitUserEvent = jest.fn();
212+
const mockSubmitSponsorshipIntents = jest.fn();
202213

203214
const mockService = {
204215
getSubscriptions: mockGetSubscriptions,
@@ -212,6 +223,7 @@ function createMockSubscriptionService() {
212223
getBillingPortalUrl: mockGetBillingPortalUrl,
213224
getSubscriptionsEligibilities: mockGetSubscriptionsEligibilities,
214225
submitUserEvent: mockSubmitUserEvent,
226+
submitSponsorshipIntents: mockSubmitSponsorshipIntents,
215227
};
216228

217229
return {
@@ -224,6 +236,7 @@ function createMockSubscriptionService() {
224236
mockStartSubscriptionWithCrypto,
225237
mockUpdatePaymentMethodCard,
226238
mockUpdatePaymentMethodCrypto,
239+
mockSubmitSponsorshipIntents,
227240
};
228241
}
229242

@@ -988,7 +1001,7 @@ describe('SubscriptionController', () => {
9881001
});
9891002

9901003
expect(result).toStrictEqual({
991-
approveAmount: '9000000000000000000',
1004+
approveAmount: '108000000000000000000',
9921005
paymentAddress: '0xspender',
9931006
paymentTokenAddress: '0xtoken',
9941007
chainId: '0x1',
@@ -1424,6 +1437,13 @@ describe('SubscriptionController', () => {
14241437
});
14251438

14261439
describe('cacheLastSelectedPaymentMethod', () => {
1440+
const MOCK_CACHED_PAYMENT_METHOD: CachedLastSelectedPaymentMethod = {
1441+
type: PAYMENT_TYPES.byCrypto,
1442+
paymentTokenAddress: '0x123',
1443+
paymentTokenSymbol: 'USDT',
1444+
plan: RECURRING_INTERVALS.month,
1445+
};
1446+
14271447
it('should cache last selected payment method successfully', async () => {
14281448
await withController(async ({ controller }) => {
14291449
controller.cacheLastSelectedPaymentMethod(PRODUCT_TYPES.SHIELD, {
@@ -1460,18 +1480,13 @@ describe('SubscriptionController', () => {
14601480
},
14611481
});
14621482

1463-
controller.cacheLastSelectedPaymentMethod(PRODUCT_TYPES.SHIELD, {
1464-
type: PAYMENT_TYPES.byCrypto,
1465-
paymentTokenAddress: '0x123',
1466-
plan: RECURRING_INTERVALS.month,
1467-
});
1483+
controller.cacheLastSelectedPaymentMethod(
1484+
PRODUCT_TYPES.SHIELD,
1485+
MOCK_CACHED_PAYMENT_METHOD,
1486+
);
14681487

14691488
expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({
1470-
[PRODUCT_TYPES.SHIELD]: {
1471-
type: PAYMENT_TYPES.byCrypto,
1472-
paymentTokenAddress: '0x123',
1473-
plan: RECURRING_INTERVALS.month,
1474-
},
1489+
[PRODUCT_TYPES.SHIELD]: MOCK_CACHED_PAYMENT_METHOD,
14751490
});
14761491
},
14771492
);
@@ -1483,11 +1498,204 @@ describe('SubscriptionController', () => {
14831498
controller.cacheLastSelectedPaymentMethod(PRODUCT_TYPES.SHIELD, {
14841499
type: PAYMENT_TYPES.byCrypto,
14851500
plan: RECURRING_INTERVALS.month,
1486-
} as CachedLastSelectedPaymentMethods),
1501+
} as CachedLastSelectedPaymentMethod),
14871502
).toThrow(
1488-
SubscriptionControllerErrorMessage.PaymentTokenAddressRequiredForCrypto,
1503+
SubscriptionControllerErrorMessage.PaymentTokenAddressAndSymbolRequiredForCrypto,
1504+
);
1505+
});
1506+
});
1507+
});
1508+
1509+
describe('submitSponsorshipIntents', () => {
1510+
const MOCK_SUBMISSION_INTENTS_REQUEST: SubmitSponsorshipIntentsMethodParams =
1511+
{
1512+
chainId: '0x1',
1513+
address: '0x1234567890123456789012345678901234567890',
1514+
products: [PRODUCT_TYPES.SHIELD],
1515+
};
1516+
const MOCK_CACHED_PAYMENT_METHOD: Record<
1517+
ProductType,
1518+
CachedLastSelectedPaymentMethod
1519+
> = {
1520+
[PRODUCT_TYPES.SHIELD]: {
1521+
type: PAYMENT_TYPES.byCrypto,
1522+
paymentTokenAddress: '0xtoken',
1523+
paymentTokenSymbol: 'USDT',
1524+
plan: RECURRING_INTERVALS.month,
1525+
},
1526+
};
1527+
1528+
it('should submit sponsorship intents successfully', async () => {
1529+
await withController(
1530+
{
1531+
state: {
1532+
lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD,
1533+
pricing: MOCK_PRICE_INFO_RESPONSE,
1534+
},
1535+
},
1536+
async ({ controller, mockService }) => {
1537+
const submitSponsorshipIntentsSpy = jest
1538+
.spyOn(mockService, 'submitSponsorshipIntents')
1539+
.mockResolvedValue(undefined);
1540+
1541+
await controller.submitSponsorshipIntents(
1542+
MOCK_SUBMISSION_INTENTS_REQUEST,
1543+
);
1544+
expect(submitSponsorshipIntentsSpy).toHaveBeenCalledWith({
1545+
...MOCK_SUBMISSION_INTENTS_REQUEST,
1546+
paymentTokenSymbol: 'USDT',
1547+
billingCycles: 12,
1548+
recurringInterval: RECURRING_INTERVALS.month,
1549+
});
1550+
},
1551+
);
1552+
});
1553+
1554+
it('should throw error when products array is empty', async () => {
1555+
await withController(async ({ controller }) => {
1556+
await expect(
1557+
controller.submitSponsorshipIntents({
1558+
...MOCK_SUBMISSION_INTENTS_REQUEST,
1559+
products: [],
1560+
}),
1561+
).rejects.toThrow(
1562+
SubscriptionControllerErrorMessage.SubscriptionProductsEmpty,
1563+
);
1564+
});
1565+
});
1566+
1567+
it('should throw error when user is already subscribed', async () => {
1568+
await withController(
1569+
{
1570+
state: {
1571+
subscriptions: [MOCK_SUBSCRIPTION],
1572+
},
1573+
},
1574+
async ({ controller, mockService }) => {
1575+
await expect(
1576+
controller.submitSponsorshipIntents(
1577+
MOCK_SUBMISSION_INTENTS_REQUEST,
1578+
),
1579+
).rejects.toThrow(
1580+
SubscriptionControllerErrorMessage.UserAlreadySubscribed,
1581+
);
1582+
1583+
// Verify the subscription service was not called
1584+
expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled();
1585+
},
1586+
);
1587+
});
1588+
1589+
it('should not submit sponsorship intents if the user has trailed the products before', async () => {
1590+
await withController(
1591+
{
1592+
state: {
1593+
subscriptions: [
1594+
{
1595+
...MOCK_SUBSCRIPTION,
1596+
status: SUBSCRIPTION_STATUSES.canceled,
1597+
},
1598+
],
1599+
trialedProducts: [PRODUCT_TYPES.SHIELD],
1600+
},
1601+
},
1602+
async ({ controller, mockService }) => {
1603+
mockService.submitSponsorshipIntents.mockResolvedValue(undefined);
1604+
1605+
await controller.submitSponsorshipIntents(
1606+
MOCK_SUBMISSION_INTENTS_REQUEST,
1607+
);
1608+
expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled();
1609+
},
1610+
);
1611+
});
1612+
1613+
it('should throw error when no cached payment method is found', async () => {
1614+
await withController(async ({ controller }) => {
1615+
await expect(
1616+
controller.submitSponsorshipIntents(MOCK_SUBMISSION_INTENTS_REQUEST),
1617+
).rejects.toThrow(
1618+
SubscriptionControllerErrorMessage.PaymentMethodNotCrypto,
14891619
);
14901620
});
14911621
});
1622+
1623+
it('should throw error when payment method is not crypto', async () => {
1624+
await withController(
1625+
{
1626+
state: {
1627+
lastSelectedPaymentMethod: {
1628+
[PRODUCT_TYPES.SHIELD]: {
1629+
type: PAYMENT_TYPES.byCard,
1630+
plan: RECURRING_INTERVALS.month,
1631+
},
1632+
},
1633+
},
1634+
},
1635+
async ({ controller }) => {
1636+
await expect(
1637+
controller.submitSponsorshipIntents(
1638+
MOCK_SUBMISSION_INTENTS_REQUEST,
1639+
),
1640+
).rejects.toThrow(
1641+
SubscriptionControllerErrorMessage.PaymentMethodNotCrypto,
1642+
);
1643+
},
1644+
);
1645+
});
1646+
1647+
it('should throw error when product price is not found', async () => {
1648+
await withController(
1649+
{
1650+
state: {
1651+
lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD,
1652+
},
1653+
},
1654+
async ({ controller }) => {
1655+
await expect(
1656+
controller.submitSponsorshipIntents(
1657+
MOCK_SUBMISSION_INTENTS_REQUEST,
1658+
),
1659+
).rejects.toThrow(
1660+
SubscriptionControllerErrorMessage.ProductPriceNotFound,
1661+
);
1662+
},
1663+
);
1664+
});
1665+
1666+
it('should handle subscription service errors', async () => {
1667+
await withController(
1668+
{
1669+
state: {
1670+
lastSelectedPaymentMethod: {
1671+
[PRODUCT_TYPES.SHIELD]: {
1672+
...MOCK_CACHED_PAYMENT_METHOD[PRODUCT_TYPES.SHIELD],
1673+
plan: RECURRING_INTERVALS.year,
1674+
},
1675+
},
1676+
pricing: MOCK_PRICE_INFO_RESPONSE,
1677+
},
1678+
},
1679+
async ({ controller, mockService }) => {
1680+
mockService.submitSponsorshipIntents.mockRejectedValue(
1681+
new SubscriptionServiceError(
1682+
'Failed to submit sponsorship intents',
1683+
),
1684+
);
1685+
1686+
await expect(
1687+
controller.submitSponsorshipIntents(
1688+
MOCK_SUBMISSION_INTENTS_REQUEST,
1689+
),
1690+
).rejects.toThrow(SubscriptionServiceError);
1691+
expect(mockService.submitSponsorshipIntents).toHaveBeenCalledWith({
1692+
...MOCK_SUBMISSION_INTENTS_REQUEST,
1693+
paymentTokenSymbol: 'USDT',
1694+
billingCycles: 1,
1695+
recurringInterval: RECURRING_INTERVALS.year,
1696+
});
1697+
},
1698+
);
1699+
});
14921700
});
14931701
});

0 commit comments

Comments
 (0)