Skip to content

Instantly share code, notes, and snippets.

@KutsenkoA
Created July 17, 2022 14:08
Show Gist options
  • Save KutsenkoA/83026ce69a97859e0065a9b1bb3f3170 to your computer and use it in GitHub Desktop.
Save KutsenkoA/83026ce69a97859e0065a9b1bb3f3170 to your computer and use it in GitHub Desktop.
This component is processing user purchases from the cart
import { Inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ShopFacade, UserCartService, UserFacade } from '@rr/player/da';
import { InformationPopupComponent, InvalidLocationPopupComponent, ThanksForOrderPopUpComponent } from '@rr/player/ui';
import { SidenavService } from '@rr/player/util';
import { AuthFacade } from '@rr/shared/da-auth';
import { CheckoutPayee, PaymentGatewayEnum, StoreCheckout } from '@rr/shared/graphql/data-access';
import { checkGraphQlErrorByCategory } from '@rr/shared/graphql/util';
import { LOGGER_SERVICE, LoggerService, DeviceDetection } from '@rr/shared/util-angular';
import { BehaviorSubject, combineLatest, Observable, throwError } from 'rxjs';
import { catchError, filter, finalize, first, map, switchMap } from 'rxjs/operators';
import { NavigationController } from './navigation.controller';
import { checkoutSummaryToUiMapper } from './shared/checkout-summary-to-ui.mapper';
import { logger } from '@rr/shared/util-logging';
/**
* Errors that are expected to receive in checkout API
* they're handled specifically and not logged
*/
const CHECKOUT_ERRORS: Record<string, string> = {
TOTAL_HAS_CHANGED: 'currentTotalIsNotMatch',
TOO_MANY_LINE_ITEMS: 'tooManyLineItems',
ITEM_IS_NOT_VALID: 'itemIsNotValid',
INVALID_INTEGRATION_RECIPIENT_ADDRESS: 'invalidIntegrationRecipientAddress',
RESOURCE_NOT_FOUND: 'resourceNotFound',
HAS_PROCESSING_CHECKOUT: 'hasProcessingCheckout',
CART_ITEM_NOT_AVAILABLE: 'cartItemNotAvailable',
};
const MAX_POPUP_WIDTH = '350px';
/** @decorator
* Adds function to the checkout error handlers list
* This method is called when graphql error does have provided category and errorCode
*/
const ErrorHandler =
(category: string, errorCode: string) =>
(controller: PaymentController, name: string, descriptor: PropertyDescriptor) => {
errorHandlers.push({ category, errorCode, handler: descriptor.value as () => void });
};
const errorHandlers: { category: string; errorCode: string; handler: () => void }[] = [];
@Injectable({
providedIn: 'root',
})
export class PaymentController {
public isPaymentInProgress$ = new BehaviorSubject(false);
private currentTotal$ = combineLatest([
this.shopFacade.cartSummaryAmountInfo$,
this.shopFacade.isCartSummaryLoading$,
]).pipe(
filter(([, isCartSummaryLoading]) => !isCartSummaryLoading),
map(([cartSummaryAmountInfo]) => cartSummaryAmountInfo.totalAmount),
);
constructor(
private userCartService: UserCartService,
private shopFacade: ShopFacade,
private userFacade: UserFacade,
private authFacade: AuthFacade,
private sidenavService: SidenavService,
private navigationController: NavigationController,
private dialog: MatDialog,
private snackBar: MatSnackBar,
private deviceDetection: DeviceDetection,
@Inject(LOGGER_SERVICE) private logger: LoggerService,
) {}
/**
* Create user order on the server and proceed to payment service popup
* @param gateway PAYPAL or STRIPE_CHECKOUT
* @param customer fan's nickname
* @param payees list of items to purchase
*
* @return StoreCheckout in case cart total amount is zero, otherwise returns PaymentPayload
* { id: number; gatewayPaymentToken: string; paymentUrl?: string }
* where
* id is rr internal payment id; gatewayPaymentToken for the Paypal; paymentUrl for Stripe only
*/
public checkout(
gateway: PaymentGatewayEnum,
customer: string,
payees: CheckoutPayee[],
): Observable<
| { __typename?: 'PaymentPayload'; id: number; gatewayPaymentToken: string; paymentUrl?: string }
| { __typename?: 'StoreCheckout'; id: number; uuid: string }
> {
return this.currentTotal$.pipe(
first(),
switchMap((currentTotal) =>
this.userCartService.checkoutV2({
gateway,
customer,
clientType: 'WEB',
deviceType: this.deviceDetection.isMobile ? 'MOBILE' : 'PC',
payees,
currentTotal,
}),
),
catchError((error) => {
if (error.graphQLErrors) {
for (let i = 0; i < errorHandlers.length; i++) {
const { category, errorCode, handler } = errorHandlers[i];
if (checkGraphQlErrorByCategory(error.graphQLErrors, category)?.errorCode === errorCode) {
handler.bind(this)();
return throwError(() => new Error(errorCode));
}
}
}
/* an unexpected error */
this.snackBar.open('Something went wrong. Try again later, please', '', {
duration: 5000,
verticalPosition: 'bottom',
});
return throwError(error);
}),
);
}
/**
* Sync payment status with backend
* Called when user approves payment on the payment service side
* @param id Payment id
*/
public syncPaymentStatus(id: number) {
this.isPaymentInProgress$.next(true);
return this.userCartService
.syncPaymentStatus({ id })
.pipe(
finalize(() => {
this.shopFacade.loadCart();
this.isPaymentInProgress$.next(false);
}),
)
.subscribe((checkoutSummary) => this.completePayment(checkoutSummary));
}
/**
* Checkout cart with zero total amount (items with 100% discount)
* @param customer
* @param payees
*/
public freeCheckout(customer: string, payees: CheckoutPayee[]) {
this.isPaymentInProgress$.next(true);
this.checkout(
// TODO Pass PAYPAL since there is no EMPTY value in enum, should we add it?
'PAYPAL',
customer,
payees,
)
.pipe(
switchMap(({ uuid }: StoreCheckout) => this.userCartService.checkoutSummary(uuid)),
finalize(() => {
this.shopFacade.loadCart();
this.isPaymentInProgress$.next(false);
}),
)
.subscribe((checkoutSummary) => this.completePayment(checkoutSummary));
}
/**
* Log all checkout errors except expected ones
* @param error
*/
public logError(error: Error) {
for (const expectedError in CHECKOUT_ERRORS) {
if (error.message === CHECKOUT_ERRORS[expectedError]) {
return;
}
}
this.logger.error({ type: '[payment]', message: error.message });
}
/**
* User has cancelled payment
* @param id Payment id
*/
public cancelPayment(id: number) {
this.userCartService.cancelPayment(id).subscribe();
}
private completePayment(summary: StoreCheckout) {
this.sidenavService.close();
this.showThanksForOrderPopup(summary);
this.userFacade.markAsHavingPurchases();
this.authFacade.refreshAuth(true);
}
@logger('CART_ACTION', 'Show Thanks For Order Popup')
private showThanksForOrderPopup(checkoutSummary: StoreCheckout) {
const dialogRef = this.dialog.open(ThanksForOrderPopUpComponent, {
data: checkoutSummaryToUiMapper(checkoutSummary),
width: MAX_POPUP_WIDTH,
});
return dialogRef.afterClosed().subscribe((dialogResult?: { type?: 'OPEN_ENTITY_PAGE' | 'OPEN_PURCHASE' }) => {
switch (dialogResult?.type) {
case 'OPEN_ENTITY_PAGE': {
const firstOrder = checkoutSummary.orders[0];
const firstItem = firstOrder.rootItems[0];
this.shopFacade.openPurchasedEntity({
itemType: firstItem.itemType,
relatedAssetId: firstItem.relatedAssetId,
itemId: firstItem.id,
websiteUrl: firstOrder.website.url,
});
break;
}
case 'OPEN_PURCHASE':
this.navigationController.openPurchases(checkoutSummary.id);
break;
}
});
}
@logger('CART_ACTION', 'Show Info Popup')
private showInformationPopup(title: string, message: string) {
this.dialog.open(InformationPopupComponent, {
data: {
title,
message,
},
width: MAX_POPUP_WIDTH,
});
}
@logger('CART_ACTION', 'Show Invalid Location Popup')
private showInvalidLocationPopup() {
const dialogRef = this.dialog.open(InvalidLocationPopupComponent, {
maxWidth: MAX_POPUP_WIDTH,
});
dialogRef
.afterClosed()
.pipe(filter(Boolean))
.subscribe(() => this.navigationController.openShippingDetails(false));
}
/* current total amount has changed while cart is being opened
* In such case real cart total is different from current number displayed in the summary block */
@ErrorHandler('FanStore', CHECKOUT_ERRORS.TOTAL_HAS_CHANGED)
/* some items in the cart become unavailable while cart is being opened */
@ErrorHandler('FanStore', CHECKOUT_ERRORS.CART_ITEM_NOT_AVAILABLE)
private totalHasChangedHandler() {
this.showInformationPopup('The Total amount has changed', 'Review products in the cart<br />to see recent changes');
this.shopFacade.refreshCart();
}
/* for the stripe gateway the number of line items must be less than 100 */
@ErrorHandler('FanStore', CHECKOUT_ERRORS.TOO_MANY_LINE_ITEMS)
private tooManyLineItemsHandler() {
this.showInformationPopup('Attention!', 'You cannot purchase more than<br />100 products at a time');
}
/* one of the selected item is not valid */
@ErrorHandler('FanStore', CHECKOUT_ERRORS.ITEM_IS_NOT_VALID)
private itemIsNotValid() {
this.snackBar.open('Some items are not available for purchase', '', {
duration: 3000,
verticalPosition: 'bottom',
});
this.shopFacade.refreshCart();
}
/* Fan's address hasn't passed printful validation */
@ErrorHandler('FanStore', CHECKOUT_ERRORS.INVALID_INTEGRATION_RECIPIENT_ADDRESS)
private invalidIntegrationRecipientAddress() {
this.showInvalidLocationPopup();
}
/* Address hasn't passed taxes service validation */
@ErrorHandler('TaxesApiError', CHECKOUT_ERRORS.RESOURCE_NOT_FOUND)
private resourceNotFound() {
this.shopFacade.refreshCart();
}
/* there is another order in the processing status, checkout is blocked */
@ErrorHandler('FanStore', CHECKOUT_ERRORS.HAS_PROCESSING_CHECKOUT)
public hasProcessingCheckout() {
this.showInformationPopup('Your order is being processed', 'Please wait until it is ready');
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment