Skip to content

Commit

Permalink
feat(next): paypal createOrder and onApprove hooks
Browse files Browse the repository at this point in the history
Because:

- Submit PayPal checkout to payments-next

This commit:

- Add logic to PayPal button createOrder and onApprove hooks.

Closes #FXA-7586
  • Loading branch information
StaberindeZA committed Oct 17, 2024
1 parent b575985 commit 10588e1
Show file tree
Hide file tree
Showing 12 changed files with 204 additions and 49 deletions.
6 changes: 6 additions & 0 deletions libs/payments/cart/src/lib/cart.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export class CartNotUpdatedError extends CartError {
}
}

export class CartStateProcessingError extends CartError {
constructor(cartId: string, cause: Error) {
super('Cart state not changed to processing', { cartId }, cause);
}
}

export class CartStateFinishedError extends CartError {
constructor() {
super('Cart state is already finished', {});
Expand Down
25 changes: 25 additions & 0 deletions libs/payments/cart/src/lib/cart.manager.in.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,31 @@ describe('CartManager', () => {
});
});

describe('fetchAndValidateCartVersion', () => {
it('succeeds', async () => {
const cart = await cartManager.fetchAndValidateCartVersion(CART_ID, 0);
expect(cart.id).toEqual(CART_ID);
});

it('errors - NotFound', async () => {
try {
await cartManager.fetchAndValidateCartVersion(RANDOM_ID, 1);
fail('Error in fetchCartById');
} catch (error) {
expect(error).toBeInstanceOf(CartNotFoundError);
}
});

it('errors - with cart version mismatch', async () => {
try {
await cartManager.fetchAndValidateCartVersion(CART_ID, 99);
fail('Error in fetchCartById');
} catch (error) {
expect(error).toBeInstanceOf(CartVersionMismatchError);
}
});
});

describe('updateFreshCart', () => {
it('succeeds', async () => {
const updateItems = UpdateCartFactory({
Expand Down
2 changes: 1 addition & 1 deletion libs/payments/cart/src/lib/cart.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class CartManager {
* Fetch a cart from the database by id and validate that the version
* matches the version passed in.
*/
private async fetchAndValidateCartVersion(cartId: string, version: number) {
async fetchAndValidateCartVersion(cartId: string, version: number) {
const cart = await this.fetchCartById(cartId);

if (cart.version !== version) {
Expand Down
47 changes: 36 additions & 11 deletions libs/payments/cart/src/lib/cart.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import { CheckoutService } from './checkout.service';
import {
CartInvalidCurrencyError,
CartInvalidPromoCodeError,
CartStateProcessingError,
} from './cart.error';
import { CurrencyManager } from '@fxa/payments/currency';
import { MockCurrencyConfigProvider } from 'libs/payments/currency/src/lib/currency.config';
Expand Down Expand Up @@ -399,9 +400,11 @@ describe('CartService', () => {
const mockCart = ResultCartFactory();
const mockToken = faker.string.uuid();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest
.spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockResolvedValue(mockCart);
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
jest.spyOn(checkoutService, 'payWithPaypal').mockResolvedValue();
jest.spyOn(cartManager, 'finishCart').mockResolvedValue();
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();

await cartService.checkoutCartWithPaypal(
Expand All @@ -412,25 +415,24 @@ describe('CartService', () => {
);

expect(checkoutService.payWithPaypal).toHaveBeenCalledWith(
mockCart,
{ ...mockCart, version: mockCart.version + 1 },
mockCustomerData,
mockToken
);
expect(cartManager.finishCart).toHaveBeenCalledWith(
mockCart.id,
mockCart.version,
{}
);
expect(cartManager.finishErrorCart).not.toHaveBeenCalled();
});

it('calls cartManager.finishErrorCart when error occurs during checkout', async () => {
const mockCart = ResultCartFactory();
const mockToken = faker.string.uuid();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest.spyOn(checkoutService, 'payWithPaypal').mockResolvedValue();
jest.spyOn(cartManager, 'finishCart').mockRejectedValue(undefined);
jest
.spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockResolvedValue(mockCart);
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
jest
.spyOn(checkoutService, 'payWithPaypal')
.mockRejectedValue(new Error('test'));
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();

await cartService.checkoutCartWithPaypal(
Expand All @@ -444,6 +446,29 @@ describe('CartService', () => {
errorReasonId: CartErrorReasonId.Unknown,
});
});

it('reject with CartStateProcessingError if cart could not be set to processing', async () => {
const mockCart = ResultCartFactory();
const mockToken = faker.string.uuid();

jest
.spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockRejectedValue(new Error('test'));
jest
.spyOn(checkoutService, 'payWithPaypal')
.mockRejectedValue(new Error('test'));

await expect(
cartService.checkoutCartWithPaypal(
mockCart.id,
mockCart.version,
mockCustomerData,
mockToken
)
).rejects.toBeInstanceOf(CartStateProcessingError);

expect(checkoutService.payWithPaypal).not.toHaveBeenCalled();
});
});

describe('finalizeCartWithError', () => {
Expand Down
42 changes: 30 additions & 12 deletions libs/payments/cart/src/lib/cart.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
CartError,
CartInvalidCurrencyError,
CartInvalidPromoCodeError,
CartStateProcessingError,
CartSubscriptionNotFoundError,
} from './cart.error';
import { AccountManager } from '@fxa/shared/account/account';
Expand Down Expand Up @@ -231,18 +232,33 @@ export class CartService {
customerData: CheckoutCustomerData,
token?: string
) {
let updatedCart: ResultCart | null = null;
try {
const cart = await this.cartManager.fetchCartById(cartId);

await this.checkoutService.payWithPaypal(cart, customerData, token);
//Ensure that the cart version matches the value passed in from FE
const cart = await this.cartManager.fetchAndValidateCartVersion(
cartId,
version
);

await this.cartManager.finishCart(cartId, version, {});
await this.cartManager.setProcessingCart(cartId);
updatedCart = {
...cart,
version: cart.version + 1,
};
} catch (e) {
// TODO: Handle errors and provide an associated reason for failure
await this.cartManager.finishErrorCart(cartId, {
errorReasonId: CartErrorReasonId.Unknown,
});
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 @@ -303,10 +319,12 @@ export class CartService {
if (!subscription) {
throw new CartSubscriptionNotFoundError(cartId);
}
await Promise.all([
this.checkoutService.postPaySteps(cart, subscription, cart.uid),
await this.cartManager.finishCart(cart.id, cart.version, {}),
]);
await this.checkoutService.postPaySteps(
cart,
cart.version,
subscription,
cart.uid
);
}

/**
Expand Down
23 changes: 23 additions & 0 deletions libs/payments/cart/src/lib/checkout.factories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
StripeCustomerFactory,
StripePriceFactory,
} from '@fxa/payments/stripe';
import { PrePayStepsResult } from './checkout.types';
import { ResultCartFactory } from './cart.factories';
import { faker } from '@faker-js/faker';

export const PrePayStepsResultFactory = (
override?: Partial<PrePayStepsResult>
): PrePayStepsResult => {
const cart = ResultCartFactory();

return {
version: cart.version,
uid: faker.string.uuid(),
email: cart.email,
customer: StripeCustomerFactory(),
enableAutomaticTax: true,
price: StripePriceFactory(),
...override,
};
};
49 changes: 31 additions & 18 deletions libs/payments/cart/src/lib/checkout.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
CartInvalidCurrencyError,
} from './cart.error';
import { CheckoutService } from './checkout.service';
import { PrePayStepsResultFactory } from './checkout.factories';

describe('CheckoutService', () => {
let accountCustomerManager: AccountCustomerManager;
Expand Down Expand Up @@ -451,19 +452,26 @@ describe('CheckoutService', () => {
beforeEach(async () => {
jest.spyOn(customerManager, 'setTaxId').mockResolvedValue();
jest.spyOn(profileClient, 'deleteCache').mockResolvedValue('test');
jest.spyOn(cartManager, 'finishCart').mockResolvedValue();
});

it('success', async () => {
const mockCart = ResultCartFactory();

await checkoutService.postPaySteps(mockCart, mockSubscription, mockUid);
await checkoutService.postPaySteps(
mockCart,
mockCart.version,
mockSubscription,
mockUid
);

expect(customerManager.setTaxId).toHaveBeenCalledWith(
mockSubscription.customer,
mockSubscription.currency
);

expect(privateMethod).toHaveBeenCalled();
expect(cartManager.finishCart).toHaveBeenCalled();
});

it('success - adds coupon code to subscription metadata if it exists', async () => {
Expand All @@ -483,7 +491,12 @@ describe('CheckoutService', () => {
.spyOn(subscriptionManager, 'update')
.mockResolvedValue(mockUpdatedSubscription);

await checkoutService.postPaySteps(mockCart, mockSubscription, mockUid);
await checkoutService.postPaySteps(
mockCart,
mockCart.version,
mockSubscription,
mockUid
);

expect(customerManager.setTaxId).toHaveBeenCalledWith(
mockSubscription.customer,
Expand Down Expand Up @@ -526,14 +539,14 @@ describe('CheckoutService', () => {
const mockPrice = StripePriceFactory();

beforeEach(async () => {
jest.spyOn(checkoutService, 'prePaySteps').mockResolvedValue({
uid: mockCart.uid as string,
customer: mockCustomer,
email: faker.internet.email(),
enableAutomaticTax: true,
promotionCode: mockPromotionCode,
price: mockPrice,
});
jest.spyOn(checkoutService, 'prePaySteps').mockResolvedValue(
PrePayStepsResultFactory({
uid: mockCart.uid,
customer: mockCustomer,
promotionCode: mockPromotionCode,
price: mockPrice,
})
);
jest
.spyOn(paymentMethodManager, 'attach')
.mockResolvedValue(mockPaymentMethod);
Expand Down Expand Up @@ -646,14 +659,14 @@ describe('CheckoutService', () => {
const mockPrice = StripePriceFactory();

beforeEach(async () => {
jest.spyOn(checkoutService, 'prePaySteps').mockResolvedValue({
uid: mockCart.uid as string,
customer: mockCustomer,
email: faker.internet.email(),
enableAutomaticTax: true,
promotionCode: mockPromotionCode,
price: mockPrice,
});
jest.spyOn(checkoutService, 'prePaySteps').mockResolvedValue(
PrePayStepsResultFactory({
uid: mockCart.uid,
customer: mockCustomer,
promotionCode: mockPromotionCode,
price: mockPrice,
})
);
jest
.spyOn(subscriptionManager, 'getCustomerPayPalSubscriptions')
.mockResolvedValue([]);
Expand Down
23 changes: 18 additions & 5 deletions libs/payments/cart/src/lib/checkout.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { CartManager } from './cart.manager';
import { CheckoutCustomerData, ResultCart } from './cart.types';
import { handleEligibilityStatusMap } from './cart.utils';
import { CheckoutError, CheckoutPaymentError } from './checkout.error';
import { PrePayStepsResult } from './checkout.types';

@Injectable()
export class CheckoutService {
Expand Down Expand Up @@ -80,7 +81,10 @@ export class CheckoutService {
});
}

async prePaySteps(cart: ResultCart, customerData: CheckoutCustomerData) {
async prePaySteps(
cart: ResultCart,
customerData: CheckoutCustomerData
): Promise<PrePayStepsResult> {
const taxAddress = cart.taxAddress as any as TaxAddress;
let version = cart.version;

Expand Down Expand Up @@ -214,6 +218,7 @@ export class CheckoutService {

async postPaySteps(
cart: ResultCart,
version: number,
subscription: StripeSubscription,
uid: string
) {
Expand All @@ -233,6 +238,8 @@ export class CheckoutService {
});
}

await this.cartManager.finishCart(cart.id, version, {});

// TODO: call sendFinishSetupEmailForStubAccount
console.log(cart.id, subscription.id);
}
Expand Down Expand Up @@ -320,7 +327,7 @@ export class CheckoutService {
}

if (paymentIntent.status === 'succeeded') {
await this.postPaySteps(cart, subscription, uid);
await this.postPaySteps(cart, updatedVersion, subscription, uid);
}
return paymentIntent;
}
Expand All @@ -330,8 +337,14 @@ export class CheckoutService {
customerData: CheckoutCustomerData,
token?: string
) {
const { uid, customer, enableAutomaticTax, promotionCode, price } =
await this.prePaySteps(cart, customerData);
const {
uid,
customer,
enableAutomaticTax,
promotionCode,
price,
version: updatedVersion,
} = await this.prePaySteps(cart, customerData);

const paypalSubscriptions =
await this.subscriptionManager.getCustomerPayPalSubscriptions(
Expand Down Expand Up @@ -401,6 +414,6 @@ export class CheckoutService {
await this.paypalBillingAgreementManager.cancel(billingAgreementId);
}

await this.postPaySteps(cart, subscription, uid);
await this.postPaySteps(cart, updatedVersion, subscription, uid);
}
}
Loading

0 comments on commit 10588e1

Please sign in to comment.