Skip to content

Commit 5ab20fd

Browse files
Merge pull request #20328 from mozilla/PAY-3602
feat(payments-next): Cancel free trial on upgrade
2 parents 365b387 + 3589556 commit 5ab20fd

8 files changed

Lines changed: 299 additions & 15 deletions

File tree

apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(startLayout)/start/en.ftl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ upgrade-page-payment-information = Payment Information
44
55
# $nextInvoiceDate (number) - The date of the next invoice
66
upgrade-page-acknowledgment = Your plan will change immediately, and you’ll be charged a prorated amount today for the rest of this billing cycle. Starting { $nextInvoiceDate } you’ll be charged the full amount.
7+
8+
upgrade-page-acknowledgment-from-trial = By upgrading, your active free trial will end immediately and you will be charged for your new plan today.

apps/payments/next/app/[locale]/[offeringId]/[interval]/upgrade/[cartId]/(startLayout)/start/page.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -150,20 +150,26 @@ export default async function Upgrade({
150150
className="leading-5 text-sm"
151151
data-testid="sub-update-acknowledgment"
152152
>
153-
{l10n.getString(
154-
'upgrade-page-acknowledgment',
155-
{
156-
nextInvoiceDate: l10n.getLocalizedDate(
157-
cart.upcomingInvoicePreview.nextInvoiceDate
158-
),
159-
},
160-
`Your plan will change immediately, and you’ll be charged a prorated
153+
{cart.isUpgradeFromTrial
154+
? l10n.getString(
155+
'upgrade-page-acknowledgment-from-trial',
156+
{},
157+
`By upgrading, your active free trial will end immediately and you will be charged for your new plan today.`
158+
)
159+
: l10n.getString(
160+
'upgrade-page-acknowledgment',
161+
{
162+
nextInvoiceDate: l10n.getLocalizedDate(
163+
cart.upcomingInvoicePreview.nextInvoiceDate
164+
),
165+
},
166+
`Your plan will change immediately, and you’ll be charged a prorated
161167
amount today for the rest of this billing cycle. Starting
162168
${l10n.getLocalizedDateString(
163169
cart.upcomingInvoicePreview.nextInvoiceDate
164170
)}
165171
you’ll be charged the full amount.`
166-
)}
172+
)}
167173
</p>
168174

169175
<div

libs/payments/cart/src/lib/cart.service.spec.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,6 +2013,7 @@ describe('CartService', () => {
20132013
},
20142014
hasActiveSubscriptions: true,
20152015
freeTrialEligibility: null,
2016+
isUpgradeFromTrial: false,
20162017
});
20172018

20182019
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
@@ -2029,6 +2030,123 @@ describe('CartService', () => {
20292030
});
20302031
});
20312032

2033+
it('returns cart with isUpgradeFromTrial true and uses previewUpcomingForUpgrade with trialEnd when subscription is trialing with payment method', async () => {
2034+
const mockCart = ResultCartFactory({
2035+
state: CartState.START,
2036+
stripeSubscriptionId: null,
2037+
eligibilityStatus: CartEligibilityStatus.UPGRADE,
2038+
});
2039+
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
2040+
const mockPrice = StripePriceFactory({ currency: mockCart.currency });
2041+
const mockInvoicePreviewForUpgrade = InvoicePreviewFactory();
2042+
const mockFromOfferingId = faker.string.uuid();
2043+
const mockFromPrice = StripePriceFactory({
2044+
recurring: StripePriceRecurringFactory({ interval: 'month' }),
2045+
});
2046+
const mockPricingForCurrency = PricingForCurrencyFactory({
2047+
price: mockFromPrice,
2048+
});
2049+
const mockTrialingSubscription = StripeSubscriptionFactory({
2050+
status: 'trialing',
2051+
});
2052+
2053+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
2054+
jest
2055+
.spyOn(productConfigurationManager, 'retrieveStripePrice')
2056+
.mockResolvedValue(mockPrice);
2057+
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
2058+
jest
2059+
.spyOn(invoiceManager, 'previewUpcomingForUpgrade')
2060+
.mockResolvedValue(mockInvoicePreviewForUpgrade);
2061+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
2062+
subscriptionEligibilityResult: EligibilityStatus.UPGRADE,
2063+
fromOfferingConfigId: mockFromOfferingId,
2064+
fromPrice: mockFromPrice,
2065+
});
2066+
jest
2067+
.spyOn(subscriptionManager, 'retrieveForCustomerAndPrice')
2068+
.mockResolvedValue(mockTrialingSubscription);
2069+
jest
2070+
.spyOn(priceManager, 'retrievePricingForCurrency')
2071+
.mockResolvedValue(mockPricingForCurrency);
2072+
2073+
const result = await cartService.getCart(mockCart.id);
2074+
expect(invoiceManager.previewUpcomingForUpgrade).toHaveBeenCalledWith(
2075+
expect.objectContaining({
2076+
trialEnd: expect.any(Number),
2077+
})
2078+
);
2079+
expect(result).toEqual(
2080+
expect.objectContaining({
2081+
isUpgradeFromTrial: true,
2082+
})
2083+
);
2084+
});
2085+
2086+
it('uses previewUpcoming for trialing upgrade with no payment method', async () => {
2087+
const mockCart = ResultCartFactory({
2088+
state: CartState.START,
2089+
stripeSubscriptionId: null,
2090+
eligibilityStatus: CartEligibilityStatus.UPGRADE,
2091+
});
2092+
const mockCustomerNoPaymentMethod = StripeResponseFactory(
2093+
StripeCustomerFactory({
2094+
invoice_settings: {
2095+
custom_fields: null,
2096+
default_payment_method: null,
2097+
footer: null,
2098+
rendering_options: null,
2099+
},
2100+
})
2101+
);
2102+
const mockPrice = StripePriceFactory({ currency: mockCart.currency });
2103+
const mockInvoicePreview = InvoicePreviewFactory();
2104+
const mockFromOfferingId = faker.string.uuid();
2105+
const mockFromPrice = StripePriceFactory({
2106+
recurring: StripePriceRecurringFactory({ interval: 'month' }),
2107+
});
2108+
const mockPricingForCurrency = PricingForCurrencyFactory({
2109+
price: mockFromPrice,
2110+
});
2111+
const mockTrialingSubscription = StripeSubscriptionFactory({
2112+
status: 'trialing',
2113+
});
2114+
2115+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
2116+
jest
2117+
.spyOn(productConfigurationManager, 'retrieveStripePrice')
2118+
.mockResolvedValue(mockPrice);
2119+
jest
2120+
.spyOn(customerManager, 'retrieve')
2121+
.mockResolvedValue(mockCustomerNoPaymentMethod);
2122+
const previewUpcomingSpy = jest
2123+
.spyOn(invoiceManager, 'previewUpcoming')
2124+
.mockResolvedValue(mockInvoicePreview);
2125+
const previewUpgradespy = jest
2126+
.spyOn(invoiceManager, 'previewUpcomingForUpgrade')
2127+
.mockResolvedValue(mockInvoicePreview);
2128+
jest.spyOn(eligibilityService, 'checkEligibility').mockResolvedValue({
2129+
subscriptionEligibilityResult: EligibilityStatus.UPGRADE,
2130+
fromOfferingConfigId: mockFromOfferingId,
2131+
fromPrice: mockFromPrice,
2132+
});
2133+
jest
2134+
.spyOn(subscriptionManager, 'retrieveForCustomerAndPrice')
2135+
.mockResolvedValue(mockTrialingSubscription);
2136+
jest
2137+
.spyOn(priceManager, 'retrievePricingForCurrency')
2138+
.mockResolvedValue(mockPricingForCurrency);
2139+
2140+
const result = await cartService.getCart(mockCart.id);
2141+
expect(previewUpcomingSpy).toHaveBeenCalled();
2142+
expect(previewUpgradespy).not.toHaveBeenCalled();
2143+
expect(result).toEqual(
2144+
expect.objectContaining({
2145+
isUpgradeFromTrial: true,
2146+
})
2147+
);
2148+
});
2149+
20322150
it('throws error if offeringPrice could not be retrieved', async () => {
20332151
const mockCart = ResultCartFactory({
20342152
state: CartState.START,

libs/payments/cart/src/lib/cart.service.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,7 @@ export class CartService {
925925
}
926926

927927
let upcomingInvoicePreview: InvoicePreview | undefined;
928+
let isUpgradeFromTrial: boolean | undefined;
928929
if (
929930
cartEligibilityStatus === CartEligibilityStatus.UPGRADE &&
930931
cart.state !== CartState.FAIL
@@ -940,13 +941,29 @@ export class CartService {
940941
eligibility.fromPrice.id
941942
);
942943
assert(fromSubscription, new GetCartSubscriptionMissingError(cartId));
944+
isUpgradeFromTrial = fromSubscription.status === 'trialing';
943945
const fromSubscriptionItem = retrieveSubscriptionItem(fromSubscription);
944-
upcomingInvoicePreview =
945-
await this.invoiceManager.previewUpcomingForUpgrade({
946+
const hasPaymentMethod =
947+
!!customer.invoice_settings.default_payment_method;
948+
if (isUpgradeFromTrial && !hasPaymentMethod) {
949+
upcomingInvoicePreview = await this.invoiceManager.previewUpcoming({
946950
priceId: price.id,
951+
currency: cart.currency,
947952
customer,
948-
fromSubscriptionItem,
953+
taxAddress: cart.taxAddress,
954+
couponCode: cart.couponCode || undefined,
949955
});
956+
} else {
957+
upcomingInvoicePreview =
958+
await this.invoiceManager.previewUpcomingForUpgrade({
959+
priceId: price.id,
960+
customer,
961+
fromSubscriptionItem,
962+
...(isUpgradeFromTrial && {
963+
trialEnd: Math.floor(Date.now() / 1000),
964+
}),
965+
});
966+
}
950967
} else {
951968
upcomingInvoicePreview = await this.invoiceManager.previewUpcoming({
952969
priceId: price.id,
@@ -1032,6 +1049,7 @@ export class CartService {
10321049
freeTrialEligibility,
10331050
trialStartDate,
10341051
trialEndDate,
1052+
isUpgradeFromTrial,
10351053
};
10361054
}
10371055

@@ -1086,6 +1104,7 @@ export class CartService {
10861104
freeTrialEligibility,
10871105
trialStartDate,
10881106
trialEndDate,
1107+
isUpgradeFromTrial,
10891108
};
10901109
}
10911110

libs/payments/cart/src/lib/cart.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export type BaseCartDTO = Omit<ResultCart, 'state'> & {
8383
freeTrialEligibility?: FreeTrial | null;
8484
trialStartDate?: number;
8585
trialEndDate?: number;
86+
isUpgradeFromTrial?: boolean;
8687
};
8788

8889
export type StartCartDTO = BaseCartDTO & {

libs/payments/cart/src/lib/checkout.service.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2315,6 +2315,60 @@ describe('CheckoutService', () => {
23152315
)
23162316
).rejects.toThrow(/UpgradeSubscriptionNullCurrencyError/);
23172317
});
2318+
2319+
it('passes trial_end now and trial_settings create_invoice when subscription is trialing', async () => {
2320+
const trialingSubscription = StripeSubscriptionFactory({
2321+
status: 'trialing',
2322+
});
2323+
jest
2324+
.spyOn(subscriptionManager, 'retrieveForCustomerAndPrice')
2325+
.mockReset()
2326+
.mockResolvedValueOnce(trialingSubscription);
2327+
jest
2328+
.spyOn(subscriptionManager, 'update')
2329+
.mockReset()
2330+
.mockResolvedValueOnce(
2331+
StripeResponseFactory(trialingSubscription)
2332+
);
2333+
2334+
await checkoutService.upgradeSubscription(
2335+
customerId,
2336+
toPriceId,
2337+
fromPriceId,
2338+
cart,
2339+
[],
2340+
mockAttributionData
2341+
);
2342+
expect(subscriptionManager.update).toHaveBeenCalledWith(
2343+
trialingSubscription.id,
2344+
expect.objectContaining({
2345+
trial_end: 'now',
2346+
trial_settings: {
2347+
end_behavior: {
2348+
missing_payment_method: 'create_invoice',
2349+
},
2350+
},
2351+
})
2352+
);
2353+
});
2354+
2355+
it('does not pass trial_end or trial_settings when subscription is active', async () => {
2356+
await checkoutService.upgradeSubscription(
2357+
customerId,
2358+
toPriceId,
2359+
fromPriceId,
2360+
cart,
2361+
[],
2362+
mockAttributionData
2363+
);
2364+
expect(subscriptionManager.update).toHaveBeenCalledWith(
2365+
subscription.id,
2366+
expect.not.objectContaining({
2367+
trial_end: expect.anything(),
2368+
trial_settings: expect.anything(),
2369+
})
2370+
);
2371+
});
23182372
});
23192373

23202374
describe('determineCheckoutAmount', () => {
@@ -2367,6 +2421,58 @@ describe('CheckoutService', () => {
23672421
expect(result).toEqual(mockInvoicePreviewForUpgrade.subtotal);
23682422
});
23692423

2424+
it('uses previewUpcoming for trialing upgrade with no payment method', async () => {
2425+
const trialingSubscription = StripeSubscriptionFactory({
2426+
status: 'trialing',
2427+
});
2428+
const customerNoPaymentMethod = StripeCustomerFactory({
2429+
invoice_settings: {
2430+
custom_fields: null,
2431+
default_payment_method: null,
2432+
footer: null,
2433+
rendering_options: null,
2434+
},
2435+
});
2436+
jest
2437+
.spyOn(subscriptionManager, 'retrieveForCustomerAndPrice')
2438+
.mockResolvedValue(trialingSubscription);
2439+
2440+
const result = await checkoutService.determineCheckoutAmount({
2441+
eligibility: mockEligibilityUpgrade,
2442+
customer: customerNoPaymentMethod,
2443+
priceId: mockPrice.id,
2444+
currency: mockCurrency,
2445+
taxAddress: mockTaxAddress,
2446+
});
2447+
expect(invoiceManager.previewUpcoming).toHaveBeenCalled();
2448+
expect(invoiceManager.previewUpcomingForUpgrade).not.toHaveBeenCalled();
2449+
expect(result).toEqual(mockInvoicePreview.subtotal);
2450+
});
2451+
2452+
it('uses previewUpcomingForUpgrade with trialEnd for trialing upgrade with payment method', async () => {
2453+
const trialingSubscription = StripeSubscriptionFactory({
2454+
status: 'trialing',
2455+
});
2456+
jest
2457+
.spyOn(subscriptionManager, 'retrieveForCustomerAndPrice')
2458+
.mockResolvedValue(trialingSubscription);
2459+
2460+
const result = await checkoutService.determineCheckoutAmount({
2461+
eligibility: mockEligibilityUpgrade,
2462+
customer: mockCustomer,
2463+
priceId: mockPrice.id,
2464+
currency: mockCurrency,
2465+
taxAddress: mockTaxAddress,
2466+
});
2467+
expect(invoiceManager.previewUpcomingForUpgrade).toHaveBeenCalledWith(
2468+
expect.objectContaining({
2469+
trialEnd: expect.any(Number),
2470+
})
2471+
);
2472+
expect(invoiceManager.previewUpcoming).not.toHaveBeenCalled();
2473+
expect(result).toEqual(mockInvoicePreviewForUpgrade.subtotal);
2474+
});
2475+
23702476
it('rejects with customer assertion failure', async () => {
23712477
// An issue with node assert.ok() and jest prevents testing that the
23722478
// correct error is thrown.

0 commit comments

Comments
 (0)