Skip to content

Commit 24f451f

Browse files
committed
setup paypal processnonzeroinvoice
1 parent 86c49e4 commit 24f451f

21 files changed

+859
-49
lines changed

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
StripePaymentIntentFactory,
4242
StripeSubscriptionFactory,
4343
StripePaymentMethodFactory,
44+
StripeInvoiceFactory,
4445
} from '@fxa/payments/stripe';
4546
import {
4647
MockProfileClientConfigProvider,
@@ -107,6 +108,7 @@ describe('CartService', () => {
107108
let productConfigurationManager: ProductConfigurationManager;
108109
let subscriptionManager: SubscriptionManager;
109110
let paymentMethodManager: PaymentMethodManager;
111+
let stripeClient: StripeClient;
110112

111113
const mockLogger = {
112114
error: jest.fn(),
@@ -174,6 +176,7 @@ describe('CartService', () => {
174176
productConfigurationManager = moduleRef.get(ProductConfigurationManager);
175177
subscriptionManager = moduleRef.get(SubscriptionManager);
176178
paymentMethodManager = moduleRef.get(PaymentMethodManager);
179+
stripeClient = moduleRef.get(StripeClient);
177180
});
178181

179182
describe('setupCart', () => {
@@ -482,6 +485,107 @@ describe('CartService', () => {
482485
});
483486
});
484487

488+
describe('pollCart', () => {
489+
it('returns cartState if cart is in failed state', async () => {
490+
const mockart = ResultCartFactory({ state: CartState.FAIL });
491+
492+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockart);
493+
494+
const result = await cartService.pollCart(mockart.id);
495+
496+
expect(result).toEqual({ cartState: mockart.state });
497+
});
498+
499+
it('returns cartState if cart is in success state', async () => {
500+
const mockart = ResultCartFactory({ state: CartState.SUCCESS });
501+
502+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockart);
503+
504+
const result = await cartService.pollCart(mockart.id);
505+
506+
expect(result).toEqual({ cartState: mockart.state });
507+
});
508+
509+
it('calls invoiceManager.processPayPalNonZeroInvoice for send_invoice subscriptions', async () => {
510+
const mockSubscriptionId = faker.string.uuid();
511+
const mockInvoiceId = faker.string.uuid();
512+
const mockCart = ResultCartFactory({
513+
state: CartState.PROCESSING,
514+
stripeSubscriptionId: mockSubscriptionId,
515+
});
516+
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
517+
const mockInvoice = StripeResponseFactory(
518+
StripeInvoiceFactory({
519+
id: mockInvoiceId,
520+
currency: 'usd',
521+
amount_due: 1000,
522+
tax: 100,
523+
})
524+
);
525+
const mockSubscription = StripeResponseFactory(
526+
StripeSubscriptionFactory({
527+
id: mockSubscriptionId,
528+
latest_invoice: mockInvoiceId,
529+
collection_method: 'send_invoice',
530+
})
531+
);
532+
533+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
534+
jest
535+
.spyOn(subscriptionManager, 'retrieve')
536+
.mockResolvedValue(mockSubscription);
537+
jest
538+
.spyOn(stripeClient, 'customersRetrieve')
539+
.mockResolvedValue(mockCustomer);
540+
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
541+
jest
542+
.spyOn(invoiceManager, 'processPayPalNonZeroInvoice')
543+
.mockResolvedValue(mockInvoice);
544+
545+
const result = await cartService.pollCart(mockCart.id);
546+
547+
expect(invoiceManager.processPayPalNonZeroInvoice).toHaveBeenCalledWith(
548+
mockCustomer,
549+
mockInvoice
550+
);
551+
552+
expect(result).toEqual({ cartState: CartState.PROCESSING });
553+
});
554+
555+
it('calls subscriptionManager.processStripeSubscription for stripe subscriptions', async () => {
556+
const mockSubscriptionId = faker.string.uuid();
557+
const mockCart = ResultCartFactory({
558+
state: CartState.PROCESSING,
559+
stripeSubscriptionId: mockSubscriptionId,
560+
});
561+
const mockSubscription = StripeResponseFactory(
562+
StripeSubscriptionFactory({
563+
id: mockSubscriptionId,
564+
collection_method: 'charge_automatically',
565+
})
566+
);
567+
const mockPaymentIntent = StripeResponseFactory(
568+
StripePaymentIntentFactory({ status: 'processing' })
569+
);
570+
571+
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
572+
jest
573+
.spyOn(subscriptionManager, 'retrieve')
574+
.mockResolvedValue(mockSubscription);
575+
jest
576+
.spyOn(subscriptionManager, 'processStripeSubscription')
577+
.mockResolvedValue(mockPaymentIntent);
578+
579+
const result = await cartService.pollCart(mockCart.id);
580+
581+
expect(
582+
subscriptionManager.processStripeSubscription
583+
).toHaveBeenCalledWith(mockSubscription);
584+
585+
expect(result).toEqual({ cartState: CartState.PROCESSING });
586+
});
587+
});
588+
485589
describe('finalizeCartWithError', () => {
486590
it('calls cartManager.finishErrorCart', async () => {
487591
const mockCart = ResultCartFactory();

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,6 @@ export class CartService {
248248
) {
249249
let updatedCart: ResultCart | null = null;
250250
try {
251-
//Ensure that the cart version matches the value passed in from FE
252251
const cart = await this.cartManager.fetchAndValidateCartVersion(
253252
cartId,
254253
version
@@ -263,12 +262,9 @@ export class CartService {
263262
throw new CartStateProcessingError(cartId, e);
264263
}
265264

266-
// Intentionally left out of try/catch block to so that the rest of the logic
267-
// is non-blocking and can be handled asynchronously.
268265
this.checkoutService
269266
.payWithPaypal(updatedCart, customerData, token)
270267
.catch(async () => {
271-
// TODO: Handle errors and provide an associated reason for failure
272268
await this.cartManager.finishErrorCart(cartId, {
273269
errorReasonId: CartErrorReasonId.Unknown,
274270
});
@@ -300,6 +296,18 @@ export class CartService {
300296

301297
// PayPal payment method collection
302298
if (subscription.collection_method === 'send_invoice') {
299+
if (!cart.stripeCustomerId) {
300+
throw new CartError('Invalid stripe customer id on cart', {
301+
cartId,
302+
});
303+
}
304+
if (subscription.latest_invoice) {
305+
const invoice = await this.invoiceManager.retrieve(
306+
subscription.latest_invoice
307+
);
308+
await this.invoiceManager.processPayPalInvoice(invoice);
309+
}
310+
303311
return { cartState: cart.state };
304312
}
305313

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,7 @@ describe('CheckoutService', () => {
536536
},
537537
],
538538
payment_behavior: 'default_incomplete',
539+
currency: mockCart.currency,
539540
metadata: {
540541
amount: mockCart.amount,
541542
currency: mockCart.currency,
@@ -642,6 +643,7 @@ describe('CheckoutService', () => {
642643
customer: mockCustomer,
643644
promotionCode: mockPromotionCode,
644645
price: mockPrice,
646+
version: mockCart.version + 1,
645647
})
646648
);
647649
jest
@@ -666,6 +668,7 @@ describe('CheckoutService', () => {
666668
jest.spyOn(subscriptionManager, 'cancel');
667669
jest.spyOn(paypalBillingAgreementManager, 'cancel').mockResolvedValue();
668670
jest.spyOn(checkoutService, 'postPaySteps').mockResolvedValue();
671+
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
669672
});
670673

671674
describe('success', () => {
@@ -710,6 +713,7 @@ describe('CheckoutService', () => {
710713
price: mockPrice.id,
711714
},
712715
],
716+
currency: mockCart.currency,
713717
metadata: {
714718
amount: mockCart.amount,
715719
currency: mockCart.currency,
@@ -755,6 +759,33 @@ describe('CheckoutService', () => {
755759
it('does not cancel the billing agreement', () => {
756760
expect(paypalBillingAgreementManager.cancel).not.toHaveBeenCalled();
757761
});
762+
763+
it('updates the customers paypal agreement id', () => {
764+
expect(customerManager.update).toHaveBeenCalledWith(mockCustomer.id, {
765+
metadata: {
766+
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: mockBillingAgreementId,
767+
},
768+
});
769+
});
770+
771+
it('calls updateFreshCart', () => {
772+
expect(cartManager.updateFreshCart).toHaveBeenCalledWith(
773+
mockCart.id,
774+
mockCart.version + 1,
775+
{
776+
stripeSubscriptionId: mockSubscription.id,
777+
}
778+
);
779+
});
780+
781+
it('calls postPaySteps with the correct arguments', () => {
782+
expect(checkoutService.postPaySteps).toHaveBeenCalledWith(
783+
mockCart,
784+
mockCart.version + 2,
785+
mockSubscription,
786+
mockCart.uid
787+
);
788+
});
758789
});
759790
});
760791
});

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

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ export class CheckoutService {
128128
// Cart only needs to be updated if we created a customer
129129
if (!cart.uid || !cart.stripeCustomerId) {
130130
await this.cartManager.updateFreshCart(cart.id, cart.version, {
131-
uid: uid,
132-
stripeCustomerId: stripeCustomerId,
131+
uid,
132+
stripeCustomerId,
133133
});
134134
version += 1;
135135
}
@@ -260,6 +260,7 @@ export class CheckoutService {
260260
},
261261
],
262262
payment_behavior: 'default_incomplete',
263+
currency: cart.currency ?? undefined,
263264
metadata: {
264265
// Note: These fields are due to missing Fivetran support on Stripe multi-currency plans
265266
[STRIPE_SUBSCRIPTION_METADATA.Amount]: cart.amount,
@@ -314,14 +315,8 @@ export class CheckoutService {
314315
customerData: CheckoutCustomerData,
315316
token?: string
316317
) {
317-
const {
318-
uid,
319-
customer,
320-
enableAutomaticTax,
321-
promotionCode,
322-
price,
323-
version: updatedVersion,
324-
} = await this.prePaySteps(cart, customerData);
318+
const { uid, customer, enableAutomaticTax, promotionCode, price, version } =
319+
await this.prePaySteps(cart, customerData);
325320

326321
const paypalSubscriptions =
327322
await this.subscriptionManager.getCustomerPayPalSubscriptions(
@@ -353,6 +348,7 @@ export class CheckoutService {
353348
price: price.id,
354349
},
355350
],
351+
currency: cart.currency ?? undefined,
356352
metadata: {
357353
// Note: These fields are due to missing Fivetran support on Stripe multi-currency plans
358354
[STRIPE_SUBSCRIPTION_METADATA.Amount]: cart.amount,
@@ -365,18 +361,24 @@ export class CheckoutService {
365361
);
366362

367363
await this.paypalCustomerManager.deletePaypalCustomersByUid(uid);
368-
await this.paypalCustomerManager.createPaypalCustomer({
369-
uid,
370-
billingAgreementId,
371-
status: 'active',
372-
endedAt: null,
373-
});
364+
await Promise.all([
365+
this.paypalCustomerManager.createPaypalCustomer({
366+
uid,
367+
billingAgreementId,
368+
status: 'active',
369+
endedAt: null,
370+
}),
371+
this.customerManager.update(customer.id, {
372+
metadata: {
373+
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: billingAgreementId,
374+
},
375+
}),
376+
this.cartManager.updateFreshCart(cart.id, version, {
377+
stripeSubscriptionId: subscription.id,
378+
}),
379+
]);
374380

375-
await this.customerManager.update(customer.id, {
376-
metadata: {
377-
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: billingAgreementId,
378-
},
379-
});
381+
const updatedVersion = version + 1;
380382

381383
if (!subscription.latest_invoice) {
382384
throw new CheckoutError('latest_invoice does not exist on subscription');

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,21 @@ export class InvalidPaymentIntentError extends PaymentsCustomerError {
9292
super('Invalid payment intent');
9393
}
9494
}
95+
96+
export class InvalidInvoiceError extends PaymentsCustomerError {
97+
constructor() {
98+
super('Invalid invoice');
99+
}
100+
}
101+
102+
export class StripePayPalAgreementNotFoundError extends PaymentsCustomerError {
103+
constructor(customerId: string) {
104+
super(`PayPal agreement not found for Stripe customer ${customerId}`);
105+
}
106+
}
107+
108+
export class PayPalPaymentFailedError extends PaymentsCustomerError {
109+
constructor(status?: string) {
110+
super(`PayPal payment failed with status ${status ?? 'undefined'}`);
111+
}
112+
}

0 commit comments

Comments
 (0)