Created
July 17, 2022 14:08
-
-
Save KutsenkoA/83026ce69a97859e0065a9b1bb3f3170 to your computer and use it in GitHub Desktop.
This component is processing user purchases from the cart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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