Skip to content

Commit ba5d694

Browse files
committed
feat(next): upgrade shows incorrect interval
Because: - The purchase details component for upgrades shows the incorrect interval and currency amount during an upgrade. This commit: - Fetches the correct price amounts for the customers currency - Converts Stripe intervals to SubplatIntervals Closes #FXA-11605 #FXA-11457
1 parent 846181a commit ba5d694

19 files changed

Lines changed: 305 additions & 95 deletions

File tree

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
PaymentIntentManager,
2828
PaymentMethodManager,
2929
PriceManager,
30+
PricingForCurrencyFactory,
3031
ProductManager,
3132
PromotionCodeManager,
3233
SubplatInterval,
@@ -47,6 +48,7 @@ import {
4748
StripeCustomerSessionFactory,
4849
StripeApiListFactory,
4950
StripeInvoiceFactory,
51+
StripePriceRecurringFactory,
5052
} from '@fxa/payments/stripe';
5153
import {
5254
MockProfileClientConfigProvider,
@@ -144,6 +146,7 @@ describe('CartService', () => {
144146
let productConfigurationManager: ProductConfigurationManager;
145147
let subscriptionManager: SubscriptionManager;
146148
let paymentMethodManager: PaymentMethodManager;
149+
let priceManager: PriceManager;
147150

148151
const mockLogger = {
149152
error: jest.fn(),
@@ -226,6 +229,7 @@ describe('CartService', () => {
226229
productConfigurationManager = moduleRef.get(ProductConfigurationManager);
227230
subscriptionManager = moduleRef.get(SubscriptionManager);
228231
paymentMethodManager = moduleRef.get(PaymentMethodManager);
232+
priceManager = moduleRef.get(PriceManager);
229233
});
230234

231235
describe('wrapCartWithCatch', () => {
@@ -1452,10 +1456,13 @@ describe('CartService', () => {
14521456
eligibilityStatus: CartEligibilityStatus.UPGRADE,
14531457
});
14541458
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
1455-
const mockPrice = StripePriceFactory();
1459+
const mockPrice = StripePriceFactory({ currency: mockCart.currency });
14561460
const mockInvoicePreview = InvoicePreviewFactory();
14571461
const mockFromOfferingId = faker.string.uuid();
1458-
const mockFromPrice = StripePriceFactory();
1462+
const mockFromPrice = StripePriceFactory({
1463+
recurring: StripePriceRecurringFactory({ interval: 'month' })
1464+
});
1465+
const mockPricingForCurrency = PricingForCurrencyFactory({ price: mockFromPrice })
14591466
const mockSubscription = StripeSubscriptionFactory();
14601467

14611468
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
@@ -1474,6 +1481,7 @@ describe('CartService', () => {
14741481
jest
14751482
.spyOn(subscriptionManager, 'retrieveForCustomerAndPrice')
14761483
.mockResolvedValue(mockSubscription);
1484+
jest.spyOn(priceManager, 'retrievePricingForCurrency').mockResolvedValue(mockPricingForCurrency);
14771485

14781486
const result = await cartService.getCart(mockCart.id);
14791487
expect(result).toEqual({
@@ -1488,9 +1496,9 @@ describe('CartService', () => {
14881496
metricsOptedOut: false,
14891497
fromOfferingConfigId: mockFromOfferingId,
14901498
fromPrice: {
1491-
currency: mockFromPrice.currency,
1492-
interval: mockFromPrice.recurring?.interval,
1493-
listAmount: mockFromPrice.unit_amount,
1499+
currency: mockCart.currency,
1500+
interval: 'monthly',
1501+
unitAmount: mockFromPrice.unit_amount,
14941502
},
14951503
hasActiveSubscriptions: true,
14961504
});

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

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
retrieveSubscriptionItem,
2727
PromotionCodeCouldNotBeAttachedError,
2828
TaxAddress,
29+
PriceManager,
30+
getSubplatInterval,
2931
} from '@fxa/payments/customer';
3032
import {
3133
EligibilityService,
@@ -95,16 +97,17 @@ export class CartService {
9597
private currencyManager: CurrencyManager,
9698
private customerManager: CustomerManager,
9799
private customerSessionManager: CustomerSessionManager,
98-
private promotionCodeManager: PromotionCodeManager,
99100
private eligibilityService: EligibilityService,
100101
private invoiceManager: InvoiceManager,
101102
@Inject(LOGGER_PROVIDER) private log: LoggerService,
102-
private productConfigurationManager: ProductConfigurationManager,
103-
private subscriptionManager: SubscriptionManager,
104103
private paymentMethodManager: PaymentMethodManager,
105104
private paymentIntentManager: PaymentIntentManager,
106-
@Inject(StatsDService) private statsd: StatsD
107-
) {}
105+
private priceManager: PriceManager,
106+
private productConfigurationManager: ProductConfigurationManager,
107+
private promotionCodeManager: PromotionCodeManager,
108+
private subscriptionManager: SubscriptionManager,
109+
@Inject(StatsDService) private statsd: StatsD,
110+
) { }
108111

109112
/**
110113
* Should be used to wrap any method that mutates an existing cart.
@@ -383,12 +386,12 @@ export class CartService {
383386

384387
const accountCustomer = oldCart.uid
385388
? await this.accountCustomerManager
386-
.getAccountCustomerByUid(oldCart.uid)
387-
.catch((error) => {
388-
if (!(error instanceof AccountCustomerNotFoundError)) {
389-
throw error;
390-
}
391-
})
389+
.getAccountCustomerByUid(oldCart.uid)
390+
.catch((error) => {
391+
if (!(error instanceof AccountCustomerNotFoundError)) {
392+
throw error;
393+
}
394+
})
392395
: undefined;
393396

394397
if (!(oldCart.taxAddress && oldCart.currency)) {
@@ -762,12 +765,18 @@ export class CartService {
762765
let fromPrice: FromPrice | undefined;
763766
if (cartEligibilityStatus === CartEligibilityStatus.UPGRADE) {
764767
assert('fromPrice' in eligibility, 'fromPrice not present for upgrade');
765-
assertNotNull(eligibility.fromPrice.unit_amount);
766-
assertNotNull(eligibility.fromPrice.recurring);
768+
769+
const { price: priceForCurrency, unitAmountForCurrency } = await this.priceManager.retrievePricingForCurrency(eligibility.fromPrice.id, cart.currency);
770+
assertNotNull(unitAmountForCurrency);
771+
assertNotNull(priceForCurrency.recurring);
772+
773+
const interval = getSubplatInterval(priceForCurrency.recurring.interval, priceForCurrency.recurring.interval_count);
774+
assert(interval, 'Interval not found but is required');
775+
767776
fromPrice = {
768-
currency: eligibility.fromPrice.currency,
769-
interval: eligibility.fromPrice.recurring.interval,
770-
listAmount: eligibility.fromPrice.unit_amount,
777+
currency: cart.currency,
778+
interval,
779+
unitAmount: unitAmountForCurrency,
771780
};
772781
}
773782

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import { TaxAddress } from '@fxa/payments/customer';
5+
import { TaxAddress, type SubplatInterval } from '@fxa/payments/customer';
66
import {
77
Cart,
88
CartEligibilityStatus,
99
CartErrorReasonId,
1010
CartState,
1111
} from '@fxa/shared/db/mysql/account';
12-
import { StripePrice } from '@fxa/payments/stripe';
1312
import Stripe from 'stripe';
1413

1514
export type CheckoutCustomerData = {
@@ -64,8 +63,8 @@ export type ResultCart = Readonly<Omit<Cart, 'id' | 'uid'>> & {
6463

6564
export type FromPrice = {
6665
currency: string;
67-
interval: NonNullable<StripePrice['recurring']>['interval'];
68-
listAmount: number;
66+
interval: SubplatInterval;
67+
unitAmount: number;
6968
};
7069

7170
export type BaseCartDTO = Omit<ResultCart, 'state'> & {

libs/payments/customer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from './lib/product.manager';
1313
export * from './lib/promotionCode.manager';
1414
export * from './lib/subscription.manager';
1515
export * from './lib/types';
16+
export * from './lib/factories/pricing-for-currency.factory';
1617
export * from './lib/factories/tax-address.factory';
1718
export * from './lib/error';
1819
export * from './lib/util/stripeInvoiceToFirstInvoicePreviewDTO';

libs/payments/customer/src/lib/error.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export class PlanIntervalMultiplePlansError extends PaymentsCustomerError {
2828
}
2929
}
3030

31+
export class PriceForCurrencyNotFoundError extends PaymentsCustomerError {
32+
constructor(priceId: string, currency: string) {
33+
super('Price for currency not found', { info: { priceId, currency } });
34+
}
35+
}
36+
3137
export class PromotionCodeCouldNotBeAttachedError extends PaymentsCustomerError {
3238
customerId?: string;
3339
subscriptionId?: string;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { type PricingForCurrency } from '../types';
6+
import { StripePriceFactory } from '@fxa/payments/stripe';
7+
8+
export const PricingForCurrencyFactory = (
9+
override?: Partial<PricingForCurrency>
10+
): PricingForCurrency => {
11+
const price = override?.price || StripePriceFactory();
12+
return {
13+
price,
14+
unitAmountForCurrency: price.unit_amount,
15+
currencyOptionForCurrency: price.currency_options[0],
16+
...override,
17+
}
18+
};
19+

libs/payments/customer/src/lib/price.manager.spec.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import {
1111
StripePriceRecurringFactory,
1212
MockStripeConfigProvider,
1313
} from '@fxa/payments/stripe';
14-
import { PlanIntervalMultiplePlansError } from './error';
14+
import { PlanIntervalMultiplePlansError, PriceForCurrencyNotFoundError } from './error';
1515
import { PriceManager } from './price.manager';
1616
import { SubplatInterval } from './types';
1717
import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
18+
import { StripePriceCurrencyOptionFactory } from 'libs/payments/stripe/src/lib/factories/price.factory';
19+
import { faker } from '@faker-js/faker/.';
1820

1921
describe('PriceManager', () => {
2022
let priceManager: PriceManager;
@@ -93,4 +95,59 @@ describe('PriceManager', () => {
9395
).rejects.toBeInstanceOf(PlanIntervalMultiplePlansError);
9496
});
9597
});
98+
99+
describe('retrievePricingForCurrency', () => {
100+
it('returns price data if currency matches default currency of price', async () => {
101+
const mockPrice = StripeResponseFactory(
102+
StripePriceFactory()
103+
);
104+
105+
jest.spyOn(priceManager, 'retrieve').mockResolvedValue(mockPrice);
106+
107+
const result = await priceManager.retrievePricingForCurrency(mockPrice.id, mockPrice.currency)
108+
expect(result).toEqual({
109+
price: mockPrice,
110+
unitAmountForCurrency: mockPrice.unit_amount,
111+
currencyOptionForCurrency: mockPrice.currency_options[mockPrice.currency],
112+
})
113+
})
114+
115+
it('returns price currency options data if price does not match default currency of price', async () => {
116+
const defaultCurrency = 'usd';
117+
const currencyOption = 'eur';
118+
const mockPrice = StripeResponseFactory(
119+
StripePriceFactory({
120+
currency_options: {
121+
[defaultCurrency]: StripePriceCurrencyOptionFactory({
122+
unit_amount: faker.number.int({ max: 1000 }),
123+
unit_amount_decimal: faker.commerce.price({ min: 1000 }),
124+
}),
125+
[currencyOption]: StripePriceCurrencyOptionFactory({
126+
unit_amount: faker.number.int({ max: 1000 }),
127+
unit_amount_decimal: faker.commerce.price({ min: 1000 }),
128+
}),
129+
},
130+
})
131+
);
132+
133+
jest.spyOn(priceManager, 'retrieve').mockResolvedValue(mockPrice);
134+
135+
const result = await priceManager.retrievePricingForCurrency(mockPrice.id, currencyOption)
136+
expect(result).toEqual({
137+
price: mockPrice,
138+
unitAmountForCurrency: mockPrice.currency_options[currencyOption].unit_amount,
139+
currencyOptionForCurrency: mockPrice.currency_options[currencyOption],
140+
})
141+
})
142+
143+
it('throws an error if price does not have provided currency', async () => {
144+
const mockPrice = StripeResponseFactory(
145+
StripePriceFactory()
146+
);
147+
148+
jest.spyOn(priceManager, 'retrieve').mockResolvedValue(mockPrice);
149+
150+
await expect(priceManager.retrievePricingForCurrency(mockPrice.id, 'invalid')).rejects.toBeInstanceOf(PriceForCurrencyNotFoundError)
151+
})
152+
})
96153
});

libs/payments/customer/src/lib/price.manager.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,39 @@
55
import { Injectable } from '@nestjs/common';
66

77
import { StripeClient, StripePrice } from '@fxa/payments/stripe';
8-
import { PlanIntervalMultiplePlansError } from './error';
9-
import { SubplatInterval } from './types';
8+
import { PlanIntervalMultiplePlansError, PriceForCurrencyNotFoundError } from './error';
9+
import { SubplatInterval, type PricingForCurrency } from './types';
1010
import { doesPriceMatchSubplatInterval } from './util/doesPriceMatchSubplatInterval';
11+
import { determinePriceUnitAmount } from './util/determinePriceUnitAmount';
1112

1213
@Injectable()
1314
export class PriceManager {
14-
constructor(private stripeClient: StripeClient) {}
15+
constructor(private stripeClient: StripeClient) { }
1516

1617
async retrieve(priceId: string) {
1718
const price = await this.stripeClient.pricesRetrieve(priceId);
1819
return price;
1920
}
2021

22+
async retrievePricingForCurrency(priceId: string, currency: string): Promise<PricingForCurrency> {
23+
const stripeCurrency = currency.toLowerCase();
24+
const price = await this.retrieve(priceId);
25+
26+
const currencyOptionForCurrency = price.currency_options[stripeCurrency];
27+
28+
if (!currencyOptionForCurrency) {
29+
throw new PriceForCurrencyNotFoundError(priceId, currency);
30+
}
31+
32+
const unitAmountForCurrency = determinePriceUnitAmount(currencyOptionForCurrency)
33+
34+
return {
35+
price,
36+
unitAmountForCurrency,
37+
currencyOptionForCurrency,
38+
}
39+
}
40+
2141
async retrieveByInterval(priceIds: string[], interval: SubplatInterval) {
2242
const prices: StripePrice[] = [];
2343
for (const priceId of priceIds) {

libs/payments/customer/src/lib/subscription.manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { InvalidPaymentIntentError, PaymentIntentNotFoundError } from './error';
1616

1717
@Injectable()
1818
export class SubscriptionManager {
19-
constructor(private stripeClient: StripeClient) {}
19+
constructor(private stripeClient: StripeClient) { }
2020

2121
async cancel(
2222
subscriptionId: string,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import { StripePrice } from '@fxa/payments/stripe';
6+
import { Stripe } from 'stripe';
67

78
export type InvoicePreview = {
89
currency: string;
@@ -24,6 +25,12 @@ export interface Interval {
2425
intervalCount: number;
2526
}
2627

28+
export interface PricingForCurrency {
29+
price: StripePrice,
30+
unitAmountForCurrency: number | null;
31+
currencyOptionForCurrency: Stripe.Price.CurrencyOptions;
32+
}
33+
2734
export interface TaxAmount {
2835
title: string;
2936
inclusive: boolean;

0 commit comments

Comments
 (0)