Skip to content

Commit

Permalink
setup paypal processnonzeroinvoice
Browse files Browse the repository at this point in the history
  • Loading branch information
david1alvarez committed Nov 1, 2024
1 parent 86c49e4 commit 24f451f
Show file tree
Hide file tree
Showing 21 changed files with 859 additions and 49 deletions.
104 changes: 104 additions & 0 deletions libs/payments/cart/src/lib/cart.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
StripePaymentIntentFactory,
StripeSubscriptionFactory,
StripePaymentMethodFactory,
StripeInvoiceFactory,
} from '@fxa/payments/stripe';
import {
MockProfileClientConfigProvider,
Expand Down Expand Up @@ -107,6 +108,7 @@ describe('CartService', () => {
let productConfigurationManager: ProductConfigurationManager;
let subscriptionManager: SubscriptionManager;
let paymentMethodManager: PaymentMethodManager;
let stripeClient: StripeClient;

const mockLogger = {
error: jest.fn(),
Expand Down Expand Up @@ -174,6 +176,7 @@ describe('CartService', () => {
productConfigurationManager = moduleRef.get(ProductConfigurationManager);
subscriptionManager = moduleRef.get(SubscriptionManager);
paymentMethodManager = moduleRef.get(PaymentMethodManager);
stripeClient = moduleRef.get(StripeClient);
});

describe('setupCart', () => {
Expand Down Expand Up @@ -482,6 +485,107 @@ describe('CartService', () => {
});
});

describe('pollCart', () => {
it('returns cartState if cart is in failed state', async () => {
const mockart = ResultCartFactory({ state: CartState.FAIL });

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockart);

const result = await cartService.pollCart(mockart.id);

expect(result).toEqual({ cartState: mockart.state });
});

it('returns cartState if cart is in success state', async () => {
const mockart = ResultCartFactory({ state: CartState.SUCCESS });

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockart);

const result = await cartService.pollCart(mockart.id);

expect(result).toEqual({ cartState: mockart.state });
});

it('calls invoiceManager.processPayPalNonZeroInvoice for send_invoice subscriptions', async () => {
const mockSubscriptionId = faker.string.uuid();
const mockInvoiceId = faker.string.uuid();
const mockCart = ResultCartFactory({
state: CartState.PROCESSING,
stripeSubscriptionId: mockSubscriptionId,
});
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockInvoice = StripeResponseFactory(
StripeInvoiceFactory({
id: mockInvoiceId,
currency: 'usd',
amount_due: 1000,
tax: 100,
})
);
const mockSubscription = StripeResponseFactory(
StripeSubscriptionFactory({
id: mockSubscriptionId,
latest_invoice: mockInvoiceId,
collection_method: 'send_invoice',
})
);

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest
.spyOn(subscriptionManager, 'retrieve')
.mockResolvedValue(mockSubscription);
jest
.spyOn(stripeClient, 'customersRetrieve')
.mockResolvedValue(mockCustomer);
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
jest
.spyOn(invoiceManager, 'processPayPalNonZeroInvoice')
.mockResolvedValue(mockInvoice);

const result = await cartService.pollCart(mockCart.id);

expect(invoiceManager.processPayPalNonZeroInvoice).toHaveBeenCalledWith(
mockCustomer,
mockInvoice
);

expect(result).toEqual({ cartState: CartState.PROCESSING });
});

it('calls subscriptionManager.processStripeSubscription for stripe subscriptions', async () => {
const mockSubscriptionId = faker.string.uuid();
const mockCart = ResultCartFactory({
state: CartState.PROCESSING,
stripeSubscriptionId: mockSubscriptionId,
});
const mockSubscription = StripeResponseFactory(
StripeSubscriptionFactory({
id: mockSubscriptionId,
collection_method: 'charge_automatically',
})
);
const mockPaymentIntent = StripeResponseFactory(
StripePaymentIntentFactory({ status: 'processing' })
);

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest
.spyOn(subscriptionManager, 'retrieve')
.mockResolvedValue(mockSubscription);
jest
.spyOn(subscriptionManager, 'processStripeSubscription')
.mockResolvedValue(mockPaymentIntent);

const result = await cartService.pollCart(mockCart.id);

expect(
subscriptionManager.processStripeSubscription
).toHaveBeenCalledWith(mockSubscription);

expect(result).toEqual({ cartState: CartState.PROCESSING });
});
});

describe('finalizeCartWithError', () => {
it('calls cartManager.finishErrorCart', async () => {
const mockCart = ResultCartFactory();
Expand Down
16 changes: 12 additions & 4 deletions libs/payments/cart/src/lib/cart.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,6 @@ export class CartService {
) {
let updatedCart: ResultCart | null = null;
try {
//Ensure that the cart version matches the value passed in from FE
const cart = await this.cartManager.fetchAndValidateCartVersion(
cartId,
version
Expand All @@ -263,12 +262,9 @@ export class CartService {
throw new CartStateProcessingError(cartId, e);
}

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

// PayPal payment method collection
if (subscription.collection_method === 'send_invoice') {
if (!cart.stripeCustomerId) {
throw new CartError('Invalid stripe customer id on cart', {
cartId,
});
}
if (subscription.latest_invoice) {
const invoice = await this.invoiceManager.retrieve(
subscription.latest_invoice
);
await this.invoiceManager.processPayPalInvoice(invoice);
}

return { cartState: cart.state };
}

Expand Down
31 changes: 31 additions & 0 deletions libs/payments/cart/src/lib/checkout.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ describe('CheckoutService', () => {
},
],
payment_behavior: 'default_incomplete',
currency: mockCart.currency,
metadata: {
amount: mockCart.amount,
currency: mockCart.currency,
Expand Down Expand Up @@ -642,6 +643,7 @@ describe('CheckoutService', () => {
customer: mockCustomer,
promotionCode: mockPromotionCode,
price: mockPrice,
version: mockCart.version + 1,
})
);
jest
Expand All @@ -666,6 +668,7 @@ describe('CheckoutService', () => {
jest.spyOn(subscriptionManager, 'cancel');
jest.spyOn(paypalBillingAgreementManager, 'cancel').mockResolvedValue();
jest.spyOn(checkoutService, 'postPaySteps').mockResolvedValue();
jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
});

describe('success', () => {
Expand Down Expand Up @@ -710,6 +713,7 @@ describe('CheckoutService', () => {
price: mockPrice.id,
},
],
currency: mockCart.currency,
metadata: {
amount: mockCart.amount,
currency: mockCart.currency,
Expand Down Expand Up @@ -755,6 +759,33 @@ describe('CheckoutService', () => {
it('does not cancel the billing agreement', () => {
expect(paypalBillingAgreementManager.cancel).not.toHaveBeenCalled();
});

it('updates the customers paypal agreement id', () => {
expect(customerManager.update).toHaveBeenCalledWith(mockCustomer.id, {
metadata: {
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: mockBillingAgreementId,
},
});
});

it('calls updateFreshCart', () => {
expect(cartManager.updateFreshCart).toHaveBeenCalledWith(
mockCart.id,
mockCart.version + 1,
{
stripeSubscriptionId: mockSubscription.id,
}
);
});

it('calls postPaySteps with the correct arguments', () => {
expect(checkoutService.postPaySteps).toHaveBeenCalledWith(
mockCart,
mockCart.version + 2,
mockSubscription,
mockCart.uid
);
});
});
});
});
44 changes: 23 additions & 21 deletions libs/payments/cart/src/lib/checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ export class CheckoutService {
// Cart only needs to be updated if we created a customer
if (!cart.uid || !cart.stripeCustomerId) {
await this.cartManager.updateFreshCart(cart.id, cart.version, {
uid: uid,
stripeCustomerId: stripeCustomerId,
uid,
stripeCustomerId,
});
version += 1;
}
Expand Down Expand Up @@ -260,6 +260,7 @@ export class CheckoutService {
},
],
payment_behavior: 'default_incomplete',
currency: cart.currency ?? undefined,
metadata: {
// Note: These fields are due to missing Fivetran support on Stripe multi-currency plans
[STRIPE_SUBSCRIPTION_METADATA.Amount]: cart.amount,
Expand Down Expand Up @@ -314,14 +315,8 @@ export class CheckoutService {
customerData: CheckoutCustomerData,
token?: string
) {
const {
uid,
customer,
enableAutomaticTax,
promotionCode,
price,
version: updatedVersion,
} = await this.prePaySteps(cart, customerData);
const { uid, customer, enableAutomaticTax, promotionCode, price, version } =
await this.prePaySteps(cart, customerData);

const paypalSubscriptions =
await this.subscriptionManager.getCustomerPayPalSubscriptions(
Expand Down Expand Up @@ -353,6 +348,7 @@ export class CheckoutService {
price: price.id,
},
],
currency: cart.currency ?? undefined,
metadata: {
// Note: These fields are due to missing Fivetran support on Stripe multi-currency plans
[STRIPE_SUBSCRIPTION_METADATA.Amount]: cart.amount,
Expand All @@ -365,18 +361,24 @@ export class CheckoutService {
);

await this.paypalCustomerManager.deletePaypalCustomersByUid(uid);
await this.paypalCustomerManager.createPaypalCustomer({
uid,
billingAgreementId,
status: 'active',
endedAt: null,
});
await Promise.all([
this.paypalCustomerManager.createPaypalCustomer({
uid,
billingAgreementId,
status: 'active',
endedAt: null,
}),
this.customerManager.update(customer.id, {
metadata: {
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: billingAgreementId,
},
}),
this.cartManager.updateFreshCart(cart.id, version, {
stripeSubscriptionId: subscription.id,
}),
]);

await this.customerManager.update(customer.id, {
metadata: {
[STRIPE_CUSTOMER_METADATA.PaypalAgreement]: billingAgreementId,
},
});
const updatedVersion = version + 1;

if (!subscription.latest_invoice) {
throw new CheckoutError('latest_invoice does not exist on subscription');
Expand Down
18 changes: 18 additions & 0 deletions libs/payments/customer/src/lib/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,21 @@ export class InvalidPaymentIntentError extends PaymentsCustomerError {
super('Invalid payment intent');
}
}

export class InvalidInvoiceError extends PaymentsCustomerError {
constructor() {
super('Invalid invoice');
}
}

export class StripePayPalAgreementNotFoundError extends PaymentsCustomerError {
constructor(customerId: string) {
super(`PayPal agreement not found for Stripe customer ${customerId}`);
}
}

export class PayPalPaymentFailedError extends PaymentsCustomerError {
constructor(status?: string) {
super(`PayPal payment failed with status ${status ?? 'undefined'}`);
}
}
Loading

0 comments on commit 24f451f

Please sign in to comment.