Skip to content

Commit 0a665bd

Browse files
committed
feat(next): support existing payment methods
Because: - Adds support for customers with existing payment methods. This commit: - Create Customer Sessions and add it to the Payment Element to support reuse of existing payment method. This only supports non-external payment methods, i.e. PayPal. - Support existing PayPal payment method. - If existing Payment Element payment method exists, do not allow PayPal as a payment method. - If existing PayPal payment method exists, do not allow adding of new Payment Element payment method. Closes #FXA-7591
1 parent fde38f6 commit 0a665bd

19 files changed

Lines changed: 390 additions & 120 deletions

File tree

apps/payments/next/tailwind.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export default <Partial<Config>>{
2020
boxShadow: {
2121
inputError:
2222
'0 1px 2px rgba(0, 0, 0, 0.3), 0 3px 6px rgba(0, 0, 0, 0.02), 0 0 0 1px #df1b41',
23+
stripeBox:
24+
'rgba(0, 0, 0, 0.03) 0px 1px 1px 0px, rgba(0, 0, 0, 0.02) 0px 3px 6px 0px',
2325
},
2426
colors: {
2527
'alert-red': '#D70022',

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

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import {
2020
CouponErrorExpired,
2121
CustomerManager,
22+
CustomerSessionManager,
2223
InvoiceManager,
2324
InvoicePreviewFactory,
2425
PaymentIntentManager,
@@ -41,6 +42,8 @@ import {
4142
StripeSubscriptionFactory,
4243
StripePaymentMethodFactory,
4344
StripePaymentIntentFactory,
45+
StripeCustomerSessionFactory,
46+
StripeApiListFactory,
4447
} from '@fxa/payments/stripe';
4548
import {
4649
MockProfileClientConfigProvider,
@@ -116,6 +119,7 @@ describe('CartService', () => {
116119
let cartManager: CartManager;
117120
let checkoutService: CheckoutService;
118121
let customerManager: CustomerManager;
122+
let customerSessionManager: CustomerSessionManager;
119123
let currencyManager: CurrencyManager;
120124
let paymentIntentManager: PaymentIntentManager;
121125
let promotionCodeManager: PromotionCodeManager;
@@ -140,6 +144,7 @@ describe('CartService', () => {
140144
CartService,
141145
CheckoutService,
142146
CustomerManager,
147+
CustomerSessionManager,
143148
EligibilityManager,
144149
EligibilityService,
145150
GeoDBManager,
@@ -184,6 +189,7 @@ describe('CartService', () => {
184189
cartService = moduleRef.get(CartService);
185190
checkoutService = moduleRef.get(CheckoutService);
186191
customerManager = moduleRef.get(CustomerManager);
192+
customerSessionManager = moduleRef.get(CustomerSessionManager);
187193
currencyManager = moduleRef.get(CurrencyManager);
188194
paymentIntentManager = moduleRef.get(PaymentIntentManager);
189195
promotionCodeManager = moduleRef.get(PromotionCodeManager);
@@ -694,6 +700,27 @@ describe('CartService', () => {
694700
});
695701

696702
describe('getCart', () => {
703+
const mockCustomerSession = StripeResponseFactory(
704+
StripeCustomerSessionFactory()
705+
);
706+
const mockSubscription = StripeSubscriptionFactory();
707+
const mockListSubscriptions = StripeApiListFactory([mockSubscription]);
708+
const mockPaymentMethod = StripeResponseFactory(
709+
StripePaymentMethodFactory({})
710+
);
711+
712+
beforeEach(() => {
713+
jest
714+
.spyOn(customerSessionManager, 'create')
715+
.mockResolvedValue(mockCustomerSession);
716+
jest
717+
.spyOn(subscriptionManager, 'listForCustomer')
718+
.mockResolvedValue(mockListSubscriptions.data);
719+
jest
720+
.spyOn(paymentMethodManager, 'retrieve')
721+
.mockResolvedValue(mockPaymentMethod);
722+
});
723+
697724
it('returns cart and upcomingInvoicePreview', async () => {
698725
const mockCart = ResultCartFactory({ stripeSubscriptionId: null });
699726
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
@@ -713,6 +740,12 @@ describe('CartService', () => {
713740
expect(result).toEqual({
714741
...mockCart,
715742
upcomingInvoicePreview: mockInvoicePreview,
743+
paymentInfo: {
744+
type: mockPaymentMethod.type,
745+
last4: mockPaymentMethod.card?.last4,
746+
brand: mockPaymentMethod.card?.brand,
747+
customerSessionClientSecret: mockCustomerSession.client_secret,
748+
},
716749
metricsOptedOut: false,
717750
});
718751

@@ -732,9 +765,6 @@ describe('CartService', () => {
732765
});
733766

734767
it('returns cart and upcomingInvoicePreview and latestInvoicePreview', async () => {
735-
const mockSubscription = StripeResponseFactory(
736-
StripeSubscriptionFactory()
737-
);
738768
const mockCart = ResultCartFactory({
739769
stripeSubscriptionId: mockSubscription.id,
740770
});
@@ -754,9 +784,6 @@ describe('CartService', () => {
754784
jest
755785
.spyOn(invoiceManager, 'previewUpcoming')
756786
.mockResolvedValue(mockUpcomingInvoicePreview);
757-
jest
758-
.spyOn(subscriptionManager, 'retrieve')
759-
.mockResolvedValue(mockSubscription);
760787
jest
761788
.spyOn(invoiceManager, 'preview')
762789
.mockResolvedValue(mockLatestInvoicePreview);
@@ -774,6 +801,7 @@ describe('CartService', () => {
774801
type: mockPaymentMethod.type,
775802
last4: mockPaymentMethod.card?.last4,
776803
brand: mockPaymentMethod.card?.brand,
804+
customerSessionClientSecret: mockCustomerSession.client_secret,
777805
},
778806
});
779807
expect(result.latestInvoicePreview).not.toEqual(

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

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ import {
1616
CouponErrorExpired,
1717
CouponErrorGeneric,
1818
CouponErrorLimitReached,
19+
CustomerSessionManager,
1920
} from '@fxa/payments/customer';
2021
import { EligibilityService } from '@fxa/payments/eligibility';
2122
import {
2223
AccountCustomerManager,
2324
AccountCustomerNotFoundError,
2425
StripeCustomer,
26+
StripeSubscription,
2527
} from '@fxa/payments/stripe';
2628
import { ProductConfigurationManager } from '@fxa/shared/cms';
2729
import { CurrencyManager } from '@fxa/payments/currency';
@@ -70,6 +72,7 @@ export class CartService {
7072
private checkoutService: CheckoutService,
7173
private currencyManager: CurrencyManager,
7274
private customerManager: CustomerManager,
75+
private customerSessionManager: CustomerSessionManager,
7376
private promotionCodeManager: PromotionCodeManager,
7477
private eligibilityService: EligibilityService,
7578
private geodbManager: GeoDBManager,
@@ -437,8 +440,12 @@ export class CartService {
437440
]);
438441

439442
let customer: StripeCustomer | undefined;
443+
let subscriptions: StripeSubscription[] = [];
440444
if (cart.stripeCustomerId) {
441-
customer = await this.customerManager.retrieve(cart.stripeCustomerId);
445+
[customer, subscriptions] = await Promise.all([
446+
this.customerManager.retrieve(cart.stripeCustomerId),
447+
this.subscriptionManager.listForCustomer(cart.stripeCustomerId),
448+
]);
442449
}
443450

444451
const upcomingInvoicePreview = await this.invoiceManager.previewUpcoming({
@@ -449,41 +456,48 @@ export class CartService {
449456
couponCode: cart.couponCode || undefined,
450457
});
451458

452-
// Cart latest invoice data
453-
let latestInvoicePreview: InvoicePreview | undefined;
454459
let paymentInfo: PaymentInfo | undefined;
455-
if (customer && cart.stripeSubscriptionId) {
456-
// fetch latest payment info from subscription
457-
const subscription = await this.subscriptionManager.retrieve(
458-
cart.stripeSubscriptionId
460+
if (customer?.invoice_settings.default_payment_method) {
461+
const paymentMethodPromise = this.paymentMethodManager.retrieve(
462+
customer.invoice_settings.default_payment_method
459463
);
460-
assert(subscription.latest_invoice, 'Subscription not found');
461-
latestInvoicePreview = await this.invoiceManager.preview(
462-
subscription.latest_invoice
463-
);
464-
464+
const customerSessionPromise = cart.stripeCustomerId
465+
? this.customerSessionManager.create(cart.stripeCustomerId)
466+
: undefined;
467+
const [paymentMethod, customerSession] = await Promise.all([
468+
paymentMethodPromise,
469+
customerSessionPromise,
470+
]);
471+
paymentInfo = {
472+
type: paymentMethod.type,
473+
last4: paymentMethod.card?.last4,
474+
brand: paymentMethod.card?.brand,
475+
customerSessionClientSecret: customerSession?.client_secret,
476+
};
477+
} else if (subscriptions.length) {
478+
const firstListedSubscription = subscriptions[0];
465479
// fetch payment method info
466-
if (subscription.collection_method === 'send_invoice') {
480+
if (firstListedSubscription.collection_method === 'send_invoice') {
467481
// PayPal payment method collection
468-
// TODO: render paypal payment info in the UI (FXA-10608)
469482
paymentInfo = {
470483
type: 'external_paypal',
471484
};
472-
} else {
473-
// Stripe payment method collection
474-
if (customer.invoice_settings.default_payment_method) {
475-
const paymentMethod = await this.paymentMethodManager.retrieve(
476-
customer.invoice_settings.default_payment_method
477-
);
478-
paymentInfo = {
479-
type: paymentMethod.type,
480-
last4: paymentMethod.card?.last4,
481-
brand: paymentMethod.card?.brand,
482-
};
483-
}
484485
}
485486
}
486487

488+
// Cart latest invoice data
489+
let latestInvoicePreview: InvoicePreview | undefined;
490+
if (customer && cart.stripeSubscriptionId) {
491+
const subscription = subscriptions.find(
492+
(subscription) => subscription.id === cart.stripeSubscriptionId
493+
);
494+
// fetch latest payment info from subscription
495+
assert(subscription?.latest_invoice, 'Subscription not found');
496+
latestInvoicePreview = await this.invoiceManager.preview(
497+
subscription.latest_invoice
498+
);
499+
}
500+
487501
return {
488502
...cart,
489503
upcomingInvoicePreview,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export interface PaymentInfo {
5151
type: PaymentProvidersType;
5252
last4?: string;
5353
brand?: string;
54+
customerSessionClientSecret?: string;
5455
}
5556

5657
export type ResultCart = Readonly<Omit<Cart, 'id' | 'uid'>> & {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '@fxa/payments/paypal';
2929
import {
3030
CustomerManager,
31+
CustomerSessionManager,
3132
InvoiceManager,
3233
InvoicePreviewFactory,
3334
PaymentIntentManager,
@@ -126,6 +127,7 @@ describe('CheckoutService', () => {
126127
CartService,
127128
CheckoutService,
128129
CustomerManager,
130+
CustomerSessionManager,
129131
CurrencyManager,
130132
EligibilityManager,
131133
EligibilityService,
@@ -579,7 +581,7 @@ describe('CheckoutService', () => {
579581
it('calls calls paymentIntentManager.confirm', async () => {
580582
expect(paymentIntentManager.confirm).toHaveBeenCalledWith(
581583
mockInvoice.payment_intent,
582-
{ confirmation_token: mockConfirmationToken.id }
584+
{ confirmation_token: mockConfirmationToken.id, off_session: false }
583585
);
584586
});
585587

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export class CheckoutService {
119119
customer = await this.customerManager.retrieve(stripeCustomerId);
120120
}
121121

122-
if (uid && stripeCustomerId) {
122+
if (uid && !cart.stripeCustomerId) {
123123
await this.accountCustomerManager.createAccountCustomer({
124124
uid,
125125
stripeCustomerId,
@@ -297,6 +297,7 @@ export class CheckoutService {
297297
invoice.payment_intent,
298298
{
299299
confirmation_token: confirmationTokenId,
300+
off_session: false,
300301
}
301302
);
302303

libs/payments/customer/src/index.ts

Lines changed: 1 addition & 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
export * from './lib/customer.manager';
6+
export * from './lib/customerSession.manager';
67
export * from './lib/invoice.manager';
78
export * from './lib/invoice.factories';
89
export * from './lib/paymentIntent.manager';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 { Test } from '@nestjs/testing';
6+
7+
import { CustomerSessionManager } from './customerSession.manager';
8+
import {
9+
StripeClient,
10+
MockStripeConfigProvider,
11+
StripeResponseFactory,
12+
StripeCustomerSessionFactory,
13+
} from '@fxa/payments/stripe';
14+
15+
describe('CustomerSessionManager', () => {
16+
let customerSessionManager: CustomerSessionManager;
17+
let stripeClient: StripeClient;
18+
19+
beforeEach(async () => {
20+
const moduleRef = await Test.createTestingModule({
21+
providers: [
22+
MockStripeConfigProvider,
23+
CustomerSessionManager,
24+
StripeClient,
25+
],
26+
}).compile();
27+
28+
customerSessionManager = moduleRef.get(CustomerSessionManager);
29+
stripeClient = moduleRef.get(StripeClient);
30+
});
31+
32+
describe('create', () => {
33+
it('should create a customer session', async () => {
34+
const customerId = 'customerId';
35+
const mockCustomerSession = StripeCustomerSessionFactory();
36+
const mockResponse = StripeResponseFactory(mockCustomerSession);
37+
38+
jest
39+
.spyOn(stripeClient, 'customersSessionsCreate')
40+
.mockResolvedValue(mockResponse);
41+
42+
const result = await customerSessionManager.create(customerId);
43+
44+
expect(stripeClient.customersSessionsCreate).toHaveBeenCalledWith(
45+
expect.objectContaining({ customer: customerId })
46+
);
47+
expect(result).toEqual(mockResponse);
48+
});
49+
});
50+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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 { StripeClient } from '@fxa/payments/stripe';
6+
import { Injectable } from '@nestjs/common';
7+
8+
@Injectable()
9+
export class CustomerSessionManager {
10+
constructor(private stripeClient: StripeClient) {}
11+
12+
async create(customerId: string) {
13+
return this.stripeClient.customersSessionsCreate({
14+
customer: customerId,
15+
components: {
16+
payment_element: {
17+
enabled: true,
18+
features: {
19+
payment_method_redisplay: 'enabled',
20+
payment_method_save: 'disabled',
21+
payment_method_remove: 'disabled',
22+
payment_method_allow_redisplay_filters: ['always', 'unspecified'],
23+
},
24+
},
25+
},
26+
});
27+
}
28+
}

libs/payments/stripe/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { StripeCardFactory } from './lib/factories/card.factory';
1313
export { StripeConfirmationTokenFactory } from './lib/factories/confirmation-token.factory';
1414
export { StripeCouponFactory } from './lib/factories/coupon.factory';
1515
export { StripeCustomerFactory } from './lib/factories/customer.factory';
16+
export { StripeCustomerSessionFactory } from './lib/factories/customer-session.factory';
1617
export { StripeDiscountFactory } from './lib/factories/discount.factory';
1718
export { StripeInvoiceLineItemFactory } from './lib/factories/invoice-line-item.factory';
1819
export { StripeInvoiceFactory } from './lib/factories/invoice.factory';

0 commit comments

Comments
 (0)