diff --git a/src/app/branded/branded-checkout.component.js b/src/app/branded/branded-checkout.component.js index 3c7b6e26a..8b79056f5 100644 --- a/src/app/branded/branded-checkout.component.js +++ b/src/app/branded/branded-checkout.component.js @@ -75,8 +75,13 @@ class BrandedCheckoutController { next () { switch (this.checkoutStep) { case 'giftContactPayment': - this.checkoutStep = 'review' - this.fireAnalyticsEvents('review') + // If it is a single step form, the next step should be 'thankYou' + if (this.useV3 === 'true') { + this.checkoutStep = 'thankYou' + } else { + this.checkoutStep = 'review' + this.fireAnalyticsEvents('review') + } break case 'review': this.checkoutStep = 'thankYou' @@ -182,6 +187,6 @@ export default angular onOrderFailed: '&', language: '@', showCoverFees: '@', - useV3: '@', + useV3: '@' } }) diff --git a/src/app/branded/step-1/branded-checkout-step-1.component.js b/src/app/branded/step-1/branded-checkout-step-1.component.js index ae84fbe0a..9f985d491 100644 --- a/src/app/branded/step-1/branded-checkout-step-1.component.js +++ b/src/app/branded/step-1/branded-checkout-step-1.component.js @@ -7,22 +7,27 @@ import checkoutStep2 from 'app/checkout/step-2/step-2.component' import cartService from 'common/services/api/cart.service' import orderService from 'common/services/api/order.service' +import analyticsFactory from '../../analytics/analytics.factory' import brandedAnalyticsFactory from '../../branded/analytics/branded-analytics.factory' - import { FEE_DERIVATIVE } from 'common/components/paymentMethods/coverFees/coverFees.component' import template from './branded-checkout-step-1.tpl.html' +import { Observable } from 'rxjs' +import { tap, catchError } from 'rxjs/operators' const componentName = 'brandedCheckoutStep1' class BrandedCheckoutStep1Controller { /* @ngInject */ - constructor ($log, $filter, brandedAnalyticsFactory, cartService, orderService) { + constructor ($scope, $log, $filter, $window, analyticsFactory, brandedAnalyticsFactory, cartService, orderService) { + this.$scope = $scope this.$log = $log this.$filter = $filter + this.analyticsFactory = analyticsFactory this.brandedAnalyticsFactory = brandedAnalyticsFactory this.cartService = cartService this.orderService = orderService + this.$window = $window } $onInit () { @@ -154,12 +159,111 @@ class BrandedCheckoutStep1Controller { checkSuccessfulSubmission () { if (every(this.submission, 'completed')) { if (every(this.submission, { error: false })) { - this.next() + if (this.useV3 === 'true') { + this.submitOrderInternal() + } else { + this.next() + } } else { this.submitted = false } } } + + loadCart () { + this.errorLoadingCart = false + + const cart = this.cartService.get() + cart.subscribe( + data => { + // Setting cart data and analytics + this.cartData = data + this.brandedAnalyticsFactory.saveCoverFees(this.orderService.retrieveCoverFeeDecision()) + this.brandedAnalyticsFactory.saveItem(this.cartData.items[0]) + this.brandedAnalyticsFactory.addPaymentInfo() + this.brandedAnalyticsFactory.reviewOrder() + }, + error => { + // Handle errors by setting flag and logging the error + this.errorLoadingCart = true + this.$log.error('Error loading cart data for branded checkout step 2', error) + return Observable.throw(error) // Rethrow the error so the observable chain can handle it + } + ) + return cart + } + + loadCurrentPayment () { + this.loadingCurrentPayment = true + + const getCurrentPayment = this.orderService.getCurrentPayment() + getCurrentPayment.subscribe( + data => { + if (!data) { + this.$log.error('Error loading current payment info: current payment doesn\'t seem to exist') + } else if (data['account-type']) { + this.bankAccountPaymentDetails = data + } else if (data['card-type']) { + this.creditCardPaymentDetails = data + } else { + this.$log.error('Error loading current payment info: current payment type is unknown') + } + this.loadingCurrentPayment = false + }, + error => { + this.loadingCurrentPayment = false + this.$log.error('Error loading current payment info', error) + return Observable.throw(error) // Propagate error + } + ) + return getCurrentPayment + } + + checkErrors () { + // Then check for errors on the API + return this.orderService.checkErrors().do( + (data) => { + this.needinfoErrors = data + }) + .catch(error => { + this.$log.error('Error loading checkErrors', error) + }) + } + + submitOrderInternal () { + this.loadingAndSubmitting = true + this.loadCart() + .mergeMap(() => { + return this.loadCurrentPayment() + }) + .mergeMap(() => { + return this.checkErrors() + }) + .mergeMap(() => { + return this.orderService.submitOrder(this) + }) + .subscribe(() => { + this.next() + this.loadingAndSubmitting = false + }) + } + + handleRecaptchaFailure () { + this.analyticsFactory.checkoutFieldError('submitOrder', 'failed') + this.submittingOrder = false + this.loadingAndSubmitting = false + this.onSubmittingOrder({ value: false }) + + this.loadCart() + + this.onSubmitted() + this.submissionError = 'generic error' + this.$window.scrollTo(0, 0) + } + + canSubmitOrder () { + return !this.submittingOrder + } } export default angular @@ -169,6 +273,7 @@ export default angular checkoutStep2.name, cartService.name, orderService.name, + analyticsFactory.name, brandedAnalyticsFactory.name ]) .component(componentName, { @@ -189,6 +294,9 @@ export default angular onPaymentFailed: '&', radioStationApiUrl: '<', radioStationRadius: '<', + onSubmittingOrder: '&', + onSubmitted: '&', useV3: '<', + loadingAndSubmitting: '<' } }) diff --git a/src/app/branded/step-1/branded-checkout-step-1.tpl.html b/src/app/branded/step-1/branded-checkout-step-1.tpl.html index e2f85b2e2..2e90ccb49 100644 --- a/src/app/branded/step-1/branded-checkout-step-1.tpl.html +++ b/src/app/branded/step-1/branded-checkout-step-1.tpl.html @@ -1,3 +1,47 @@ +
@@ -46,10 +90,26 @@

{{'PAYMENT'}}

-
+ + -
+
+ + {{'SUBMITTING_GIFT'}} + diff --git a/src/app/cart/cart.component.js b/src/app/cart/cart.component.js index 9eef901ba..9ccbb83e7 100644 --- a/src/app/cart/cart.component.js +++ b/src/app/cart/cart.component.js @@ -10,7 +10,7 @@ import productModalService from 'common/services/productModal.service' import desigSrcDirective from 'common/directives/desigSrc.directive' import displayRateTotals from 'common/components/displayRateTotals/displayRateTotals.component' -import { cartUpdatedEvent } from 'common/components/nav/navCart/navCart.component' +import { cartUpdatedEvent } from 'common/lib/cartEvents' import analyticsFactory from 'app/analytics/analytics.factory' diff --git a/src/app/cart/cart.component.spec.js b/src/app/cart/cart.component.spec.js index ca14206fe..0f1a0fa29 100644 --- a/src/app/cart/cart.component.spec.js +++ b/src/app/cart/cart.component.spec.js @@ -6,7 +6,7 @@ import { Observable } from 'rxjs/Observable' import 'rxjs/add/observable/of' import 'rxjs/add/observable/throw' -import { cartUpdatedEvent } from 'common/components/nav/navCart/navCart.component' +import { cartUpdatedEvent } from 'common/lib/cartEvents' describe('cart', () => { beforeEach(angular.mock.module(module.name)) diff --git a/src/app/checkout/cart-summary/cart-summary.component.js b/src/app/checkout/cart-summary/cart-summary.component.js index 087f0ba97..eec5c7068 100644 --- a/src/app/checkout/cart-summary/cart-summary.component.js +++ b/src/app/checkout/cart-summary/cart-summary.component.js @@ -13,8 +13,9 @@ export const submitOrderEvent = 'submitOrderEvent' class CartSummaryController { /* @ngInject */ - constructor (cartService, $scope) { + constructor (cartService, $scope, $rootScope) { this.$scope = $scope + this.$rootScope = $rootScope this.cartService = cartService } @@ -22,12 +23,12 @@ class CartSummaryController { return this.cartService.buildCartUrl() } - handleRecaptchaFailure (componentInstance) { - componentInstance.$rootScope.$emit(recaptchaFailedEvent) + handleRecaptchaFailure () { + this.$rootScope.$emit(recaptchaFailedEvent) } - onSubmit (componentInstance) { - componentInstance.$rootScope.$emit(submitOrderEvent) + onSubmit () { + this.$rootScope.$emit(submitOrderEvent) } } diff --git a/src/app/checkout/cart-summary/cart-summary.spec.js b/src/app/checkout/cart-summary/cart-summary.spec.js index 5adf7ad7b..9d64e518c 100644 --- a/src/app/checkout/cart-summary/cart-summary.spec.js +++ b/src/app/checkout/cart-summary/cart-summary.spec.js @@ -31,17 +31,17 @@ describe('checkout', function () { describe('onSubmit', () => { it('should emit an event', () => { - jest.spyOn(componentInstance.$rootScope, '$emit').mockImplementation(() => {}) - self.controller.onSubmit(componentInstance) - expect(componentInstance.$rootScope.$emit).toHaveBeenCalledWith(submitOrderEvent) + jest.spyOn(self.controller.$rootScope, '$emit').mockImplementation(() => {}) + self.controller.onSubmit() + expect(self.controller.$rootScope.$emit).toHaveBeenCalledWith(submitOrderEvent) }) }) describe('handleRecaptchaFailure', () => { it('should emit an event', () => { - jest.spyOn(componentInstance.$rootScope, '$emit').mockImplementation(() => {}) - self.controller.handleRecaptchaFailure(componentInstance) - expect(componentInstance.$rootScope.$emit).toHaveBeenCalledWith(recaptchaFailedEvent) + jest.spyOn(self.controller.$rootScope, '$emit').mockImplementation(() => {}) + self.controller.handleRecaptchaFailure() + expect(self.controller.$rootScope.$emit).toHaveBeenCalledWith(recaptchaFailedEvent) }) }) }) diff --git a/src/app/checkout/step-3/step-3.component.js b/src/app/checkout/step-3/step-3.component.js index 76d955ed8..538da8e19 100644 --- a/src/app/checkout/step-3/step-3.component.js +++ b/src/app/checkout/step-3/step-3.component.js @@ -1,6 +1,4 @@ import angular from 'angular' -import isString from 'lodash/isString' -import { Observable } from 'rxjs/Observable' import 'rxjs/add/observable/throw' import displayAddressComponent from 'common/components/display-address/display-address.component' @@ -12,7 +10,6 @@ import orderService from 'common/services/api/order.service' import profileService from 'common/services/api/profile.service' import capitalizeFilter from 'common/filters/capitalize.filter' import desigSrcDirective from 'common/directives/desigSrc.directive' -import { cartUpdatedEvent } from 'common/components/nav/navCart/navCart.component' import { SignInEvent } from 'common/services/session/session.service' import { startDate } from 'common/services/giftHelpers/giftDates.service' import recaptchaComponent from 'common/components/Recaptcha/RecaptchaWrapper' @@ -38,7 +35,6 @@ class Step3Controller { this.commonService = commonService this.startDate = startDate this.sessionStorage = $window.sessionStorage - this.selfReference = this this.isBranded = envService.read('isBrandedCheckout') this.$scope.$on(SignInEvent, () => { @@ -46,7 +42,7 @@ class Step3Controller { }) this.$rootScope.$on(recaptchaFailedEvent, () => { - this.handleRecaptchaFailure(this) + this.handleRecaptchaFailure() }) this.$rootScope.$on(submitOrderEvent, () => { this.submitOrder() @@ -134,64 +130,25 @@ class Step3Controller { } submitOrder () { - this.submitOrderInternal(this) - } - - submitOrderInternal (componentInstance) { - delete componentInstance.submissionError - delete componentInstance.submissionErrorStatus - // Prevent multiple submissions - if (componentInstance.submittingOrder) return - componentInstance.submittingOrder = true - componentInstance.onSubmittingOrder({ value: true }) - - let submitRequest - if (componentInstance.bankAccountPaymentDetails) { - submitRequest = componentInstance.orderService.submit() - } else if (componentInstance.creditCardPaymentDetails) { - const cvv = componentInstance.orderService.retrieveCardSecurityCode() - submitRequest = componentInstance.orderService.submit(cvv) - } else { - submitRequest = Observable.throw({ data: 'Current payment type is unknown' }) - } - submitRequest.subscribe(() => { - componentInstance.analyticsFactory.purchase(componentInstance.donorDetails, componentInstance.cartData, componentInstance.orderService.retrieveCoverFeeDecision()) - componentInstance.submittingOrder = false - componentInstance.onSubmittingOrder({ value: false }) - componentInstance.orderService.clearCardSecurityCodes() - componentInstance.orderService.clearCoverFees() - componentInstance.onSubmitted() - componentInstance.$scope.$emit(cartUpdatedEvent) - componentInstance.changeStep({ newStep: 'thankYou' }) - }, - error => { - componentInstance.analyticsFactory.checkoutFieldError('submitOrder', 'failed') - componentInstance.submittingOrder = false - componentInstance.onSubmittingOrder({ value: false }) - - componentInstance.loadCart() - - if (error.config && error.config.data && error.config.data['security-code']) { - error.config.data['security-code'] = error.config.data['security-code'].replace(/./g, 'X') // Mask security-code + this.orderService.submitOrder(this).subscribe(() => { + if (!this.isBranded) { + // Branded checkout submits its purchase analytics event on the thank you page + this.analyticsFactory.purchase(this.donorDetails, this.cartData, this.retrieveCoverFeeDecision()) } - componentInstance.$log.error('Error submitting purchase:', error) - componentInstance.onSubmitted() - componentInstance.submissionErrorStatus = error.status - componentInstance.submissionError = isString(error && error.data) ? (error && error.data).replace(/[:].*$/, '') : 'generic error' // Keep prefix before first colon for easier ng-switch matching - componentInstance.$window.scrollTo(0, 0) + this.changeStep({ newStep: 'thankYou' }) }) } - handleRecaptchaFailure (componentInstance) { - componentInstance.analyticsFactory.checkoutFieldError('submitOrder', 'failed') - componentInstance.submittingOrder = false - componentInstance.onSubmittingOrder({ value: false }) + handleRecaptchaFailure () { + this.analyticsFactory.checkoutFieldError('submitOrder', 'failed') + this.submittingOrder = false + this.onSubmittingOrder({ value: false }) - componentInstance.loadCart() + this.loadCart() - componentInstance.onSubmitted() - componentInstance.submissionError = 'generic error' - componentInstance.$window.scrollTo(0, 0) + this.onSubmitted() + this.submissionError = 'generic error' + this.$window.scrollTo(0, 0) } } diff --git a/src/app/checkout/step-3/step-3.component.spec.js b/src/app/checkout/step-3/step-3.component.spec.js index e40f0d1ca..a64bd63dc 100644 --- a/src/app/checkout/step-3/step-3.component.spec.js +++ b/src/app/checkout/step-3/step-3.component.spec.js @@ -4,7 +4,7 @@ import { Observable } from 'rxjs/Observable' import 'rxjs/add/observable/of' import 'rxjs/add/observable/throw' -import { cartUpdatedEvent } from 'common/components/nav/navCart/navCart.component' +import { cartUpdatedEvent } from 'common/lib/cartEvents' import { SignInEvent } from 'common/services/session/session.service' import module from './step-3.component' @@ -37,6 +37,7 @@ describe('checkout', () => { getCurrentPayment: () => Observable.of(self.loadedPayment), checkErrors: () => Observable.of(['email-info']), submit: () => Observable.of('called submit'), + submitOrder: () => Observable.of('called submitOrder'), retrieveCardSecurityCode: () => self.storedCvv, retrieveLastPurchaseLink: () => Observable.of('purchaseLink'), retrieveCoverFeeDecision: () => self.coverFeeDecision, @@ -323,144 +324,18 @@ describe('checkout', () => { }) }) - describe('submitOrder', () => { - beforeEach(() => { - jest.spyOn(self.controller.orderService, 'submit') - jest.spyOn(self.controller.profileService, 'getPurchase') - jest.spyOn(self.controller.analyticsFactory, 'purchase') - }) - - describe('another order submission in progress', () => { - it('should not submit the order twice', () => { - self.controller.submittingOrder = true - self.controller.submitOrder() - - expect(self.controller.onSubmittingOrder).not.toHaveBeenCalled() - expect(self.controller.onSubmitted).not.toHaveBeenCalled() - }) - }) - - describe('submit single order', () => { - beforeEach(() => { - jest.spyOn(self.controller.$scope, '$emit').mockImplementation(() => {}) - }) - - afterEach(() => { - expect(self.controller.onSubmittingOrder).toHaveBeenCalledWith({ value: true }) - expect(self.controller.onSubmittingOrder).toHaveBeenCalledWith({ value: false }) - expect(self.controller.onSubmitted).toHaveBeenCalled() - }) - - it('should submit the order normally if paying with a bank account', () => { - self.controller.bankAccountPaymentDetails = {} - self.controller.submitOrder() - - expect(self.controller.orderService.submit).toHaveBeenCalled() - expect(self.controller.analyticsFactory.purchase).toHaveBeenCalledWith(self.controller.donorDetails, self.controller.cartData, self.coverFeeDecision) - expect(self.controller.orderService.clearCardSecurityCodes).toHaveBeenCalled() - expect(self.controller.changeStep).toHaveBeenCalledWith({ newStep: 'thankYou' }) - expect(self.controller.$scope.$emit).toHaveBeenCalledWith(cartUpdatedEvent) - }) - - it('should handle an error submitting an order with a bank account', () => { - self.controller.orderService.submit.mockImplementation(() => Observable.throw({ data: 'error saving bank account' })) - self.controller.bankAccountPaymentDetails = {} - self.controller.submitOrder() - - expect(self.controller.orderService.submit).toHaveBeenCalled() - expect(self.controller.loadCart).toHaveBeenCalled() - expect(self.controller.analyticsFactory.purchase).not.toHaveBeenCalled() - expect(self.controller.orderService.clearCardSecurityCodes).not.toHaveBeenCalled() - expect(self.controller.$log.error.logs[0]).toEqual(['Error submitting purchase:', { data: 'error saving bank account' }]) - expect(self.controller.changeStep).not.toHaveBeenCalled() - expect(self.controller.submissionError).toEqual('error saving bank account') - expect(self.controller.$window.scrollTo).toHaveBeenCalledWith(0, 0) - }) - - it('should submit the order with a CVV if paying with a credit card', () => { - self.controller.creditCardPaymentDetails = {} - self.storedCvv = '1234' - self.coverFeeDecision = true - self.controller.submitOrder() - - expect(self.controller.orderService.submit).toHaveBeenCalledWith('1234') - expect(self.controller.analyticsFactory.purchase).toHaveBeenCalledWith(self.controller.donorDetails, self.controller.cartData, self.coverFeeDecision) - expect(self.controller.orderService.clearCardSecurityCodes).toHaveBeenCalled() - expect(self.controller.changeStep).toHaveBeenCalledWith({ newStep: 'thankYou' }) - expect(self.controller.$scope.$emit).toHaveBeenCalledWith(cartUpdatedEvent) - }) - - it('should submit the order without a CVV if paying with an existing credit card or the cvv in session storage is missing', () => { - self.controller.creditCardPaymentDetails = {} - self.storedCvv = undefined - self.coverFeeDecision = true - self.controller.submitOrder() - - expect(self.controller.orderService.submit).toHaveBeenCalledWith(undefined) - expect(self.controller.analyticsFactory.purchase).toHaveBeenCalledWith(self.controller.donorDetails, self.controller.cartData, self.coverFeeDecision) - expect(self.controller.orderService.clearCardSecurityCodes).toHaveBeenCalled() - expect(self.controller.changeStep).toHaveBeenCalledWith({ newStep: 'thankYou' }) - expect(self.controller.$scope.$emit).toHaveBeenCalledWith(cartUpdatedEvent) - }) - - it('should handle an error submitting an order with a credit card', () => { - self.controller.orderService.submit.mockImplementation(() => Observable.throw({ data: 'CardErrorException: Invalid Card Number: some details' })) - self.controller.creditCardPaymentDetails = {} - self.storedCvv = '1234' - self.controller.submitOrder() - - expect(self.controller.orderService.submit).toHaveBeenCalledWith('1234') - expect(self.controller.analyticsFactory.purchase).not.toHaveBeenCalled() - expect(self.controller.orderService.clearCardSecurityCodes).not.toHaveBeenCalled() - expect(self.controller.$log.error.logs[0]).toEqual(['Error submitting purchase:', { data: 'CardErrorException: Invalid Card Number: some details' }]) - expect(self.controller.changeStep).not.toHaveBeenCalled() - expect(self.controller.submissionError).toEqual('CardErrorException') - expect(self.controller.$window.scrollTo).toHaveBeenCalledWith(0, 0) - }) - - it('should mask the security code on a credit card error', () => { - self.controller.orderService.submit.mockReturnValue(Observable.throw({ data: 'some error', config: { data: { 'security-code': '1234' } } })) - self.controller.creditCardPaymentDetails = {} - self.storedCvv = '1234' - self.controller.submitOrder() - - expect(self.controller.orderService.submit).toHaveBeenCalledWith('1234') - expect(self.controller.$log.error.logs[0]).toEqual(['Error submitting purchase:', { data: 'some error', config: { data: { 'security-code': 'XXXX' } } }]) - }) - - it('should throw an error if neither bank account or credit card details are loaded', () => { - self.controller.submitOrder() - - expect(self.controller.orderService.submit).not.toHaveBeenCalled() - expect(self.controller.orderService.clearCardSecurityCodes).not.toHaveBeenCalled() - expect(self.controller.$log.error.logs[0]).toEqual(['Error submitting purchase:', { data: 'Current payment type is unknown' }]) - expect(self.controller.changeStep).not.toHaveBeenCalled() - expect(self.controller.submissionError).toEqual('Current payment type is unknown') - expect(self.controller.$window.scrollTo).toHaveBeenCalledWith(0, 0) - }) - - it('should clear out cover fee data', () => { - self.controller.creditCardPaymentDetails = {} - self.controller.submitOrder() - - expect(self.controller.orderService.clearCoverFees).toHaveBeenCalled() - }) - }) - }) - describe('handleRecaptchaFailure', () => { it('should show an error if recaptcha fails', () => { - const componentInstance = self.controller - jest.spyOn(componentInstance.analyticsFactory, 'checkoutFieldError').mockImplementation(() => {}) - self.controller.handleRecaptchaFailure(componentInstance) - - expect(componentInstance.analyticsFactory.checkoutFieldError).toHaveBeenCalledWith('submitOrder', 'failed') - expect(componentInstance.submittingOrder).toEqual(false) - expect(componentInstance.onSubmittingOrder).toHaveBeenCalledWith({ value: false }) - expect(componentInstance.loadCart).toHaveBeenCalled() - expect(componentInstance.onSubmitted).toHaveBeenCalled() - expect(componentInstance.submissionError).toEqual('generic error') - expect(componentInstance.$window.scrollTo).toHaveBeenCalledWith(0, 0) + jest.spyOn(self.controller.analyticsFactory, 'checkoutFieldError').mockImplementation(() => {}) + self.controller.handleRecaptchaFailure() + + expect(self.controller.analyticsFactory.checkoutFieldError).toHaveBeenCalledWith('submitOrder', 'failed') + expect(self.controller.submittingOrder).toEqual(false) + expect(self.controller.onSubmittingOrder).toHaveBeenCalledWith({ value: false }) + expect(self.controller.loadCart).toHaveBeenCalled() + expect(self.controller.onSubmitted).toHaveBeenCalled() + expect(self.controller.submissionError).toEqual('generic error') + expect(self.controller.$window.scrollTo).toHaveBeenCalledWith(0, 0) }) }) @@ -474,7 +349,34 @@ describe('checkout', () => { it('should call handleRecaptchaFailure if the recaptchaFailedEvent is received', () => { jest.spyOn(self.controller, 'handleRecaptchaFailure').mockImplementation(() => {}) self.controller.$rootScope.$emit(recaptchaFailedEvent) - expect(self.controller.handleRecaptchaFailure).toHaveBeenCalledWith(self.controller) + expect(self.controller.handleRecaptchaFailure).toHaveBeenCalled(); + }) + }) + + describe('submitOrder', () => { + beforeEach(() => { + jest.spyOn(self.controller.analyticsFactory, 'purchase') + self.controller.retrieveCoverFeeDecision = () => true; + }) + + it('should call analyticsFactory when it is not branded checkout', () => { + jest.spyOn(self.controller.orderService, 'retrieveCoverFeeDecision') + self.controller.isBranded = false + + self.controller.submitOrder() + + expect(self.controller.analyticsFactory.purchase).toHaveBeenCalledWith(self.controller.donorDetails, self.controller.cartData, self.controller.retrieveCoverFeeDecision()) + expect(self.controller.changeStep).toHaveBeenCalledWith({ newStep: 'thankYou' }) + }) + + it('should not call analyticsFactory when it is branded checkout', () => { + jest.spyOn(self.controller.orderService, 'retrieveCoverFeeDecision') + self.controller.isBranded = true + + self.controller.submitOrder() + + expect(self.controller.analyticsFactory.purchase).not.toHaveBeenCalled() + expect(self.controller.changeStep).toHaveBeenCalledWith({ newStep: 'thankYou' }) }) }) }) diff --git a/src/app/checkout/step-3/step-3.tpl.html b/src/app/checkout/step-3/step-3.tpl.html index 6e62128de..fd65b98fe 100644 --- a/src/app/checkout/step-3/step-3.tpl.html +++ b/src/app/checkout/step-3/step-3.tpl.html @@ -204,9 +204,9 @@
{ interface RecaptchaProps { action: string - onSuccess: (componentInstance: any) => void - onFailure: (componentInstance: any) => void - componentInstance: any + onSuccess: () => void + onFailure: () => void buttonId: string buttonType?: ButtonType buttonClasses: string @@ -40,7 +39,6 @@ export const Recaptcha = ({ action, onSuccess, onFailure, - componentInstance, buttonId, buttonType, buttonClasses, @@ -89,29 +87,29 @@ export const Recaptcha = ({ if (data?.success === true && isValidAction(data?.action)) { if (data.score < 0.5) { $log.warn(`Captcha score was below the threshold: ${data.score}`) - onFailure(componentInstance) + onFailure() return } - onSuccess(componentInstance) + onSuccess() return } if (data?.success === false && isValidAction(data?.action)) { $log.warn('Recaptcha call was unsuccessful, continuing anyway') - onSuccess(componentInstance) + onSuccess() return } if (!data) { $log.warn('Data was missing!') - onSuccess(componentInstance) + onSuccess() return } if (!isValidAction(data?.action)) { $log.warn(`Invalid action: ${data?.action}`) - onFailure(componentInstance) + onFailure() } } catch (error) { $log.error(`Failed to verify recaptcha, continuing on: ${error}`) - onSuccess(componentInstance) + onSuccess() } }) }, [grecaptcha, buttonId, ready]) @@ -135,7 +133,6 @@ export default angular 'action', 'onSuccess', 'onFailure', - 'componentInstance', 'buttonId', 'buttonType', 'buttonClasses', diff --git a/src/common/components/Recaptcha/RecaptchaWrapper.tsx b/src/common/components/Recaptcha/RecaptchaWrapper.tsx index 05a863846..e105c153f 100644 --- a/src/common/components/Recaptcha/RecaptchaWrapper.tsx +++ b/src/common/components/Recaptcha/RecaptchaWrapper.tsx @@ -13,8 +13,8 @@ declare global { interface RecaptchaWrapperProps { action: string - onSuccess: (componentInstance: any) => void - onFailure: (componentInstance: any) => void + onSuccess: () => void + onFailure: () => void componentInstance: any buttonId: string buttonType?: ButtonType @@ -24,6 +24,7 @@ interface RecaptchaWrapperProps { envService: any $translate: any $log: any + $rootScope: any } export const RecaptchaWrapper = ({ @@ -38,7 +39,8 @@ export const RecaptchaWrapper = ({ buttonLabel, envService, $translate, - $log + $log, + $rootScope }: RecaptchaWrapperProps): JSX.Element => { const recaptchaKey = envService.read('recaptchaKey') const apiUrl = envService.read('apiUrl') @@ -50,11 +52,23 @@ export const RecaptchaWrapper = ({ document.body.appendChild(script) }, []) + // Because The onSuccess and onFailure callbacks are called by a React component, AngularJS doesn't know that an event happened and doesn't know it needs to rerender. We have to use $apply to ensure that AngularJS rerenders after the event handlers return. + const onSuccessWrapped = (()=>{ + $rootScope.$apply(()=> { + onSuccess.call(componentInstance) + }) + }) + + const onFailureWrapped = (()=>{ + $rootScope.$apply(()=> { + onFailure.call(componentInstance) + }) + }) + return (
diff --git a/src/common/components/nav/navCart/navCart.component.js b/src/common/components/nav/navCart/navCart.component.js index 42f30aedf..c5a5f2f57 100644 --- a/src/common/components/nav/navCart/navCart.component.js +++ b/src/common/components/nav/navCart/navCart.component.js @@ -7,9 +7,7 @@ import orderService from 'common/services/api/order.service' import analyticsFactory from 'app/analytics/analytics.factory' import template from './navCart.tpl.html' - -export const giftAddedEvent = 'giftAddedToCart' -export const cartUpdatedEvent = 'cartUpdatedEvent' +import { giftAddedEvent, cartUpdatedEvent } from 'common/lib/cartEvents' const componentName = 'navCart' diff --git a/src/common/components/nav/navCart/navCart.component.spec.js b/src/common/components/nav/navCart/navCart.component.spec.js index 3ea72ba33..8423fcf85 100644 --- a/src/common/components/nav/navCart/navCart.component.spec.js +++ b/src/common/components/nav/navCart/navCart.component.spec.js @@ -4,7 +4,8 @@ import { Observable } from 'rxjs/Observable' import { Subject } from 'rxjs/Subject' import 'rxjs/add/observable/of' import 'rxjs/add/observable/throw' -import module, { giftAddedEvent, cartUpdatedEvent } from './navCart.component' +import module from './navCart.component' +import { giftAddedEvent, cartUpdatedEvent } from 'common/lib/cartEvents' describe('navCart', () => { beforeEach(angular.mock.module(module.name)) diff --git a/src/common/components/nav/navCartIcon.component.js b/src/common/components/nav/navCartIcon.component.js index 12bdd894c..32ca30663 100644 --- a/src/common/components/nav/navCartIcon.component.js +++ b/src/common/components/nav/navCartIcon.component.js @@ -1,6 +1,7 @@ import angular from 'angular' -import navCart, { giftAddedEvent, cartUpdatedEvent } from 'common/components/nav/navCart/navCart.component' +import navCart from 'common/components/nav/navCart/navCart.component' +import { giftAddedEvent, cartUpdatedEvent } from 'common/lib/cartEvents' import uibDropdown from 'angular-ui-bootstrap/src/dropdown' import analyticsFactory from 'app/analytics/analytics.factory' diff --git a/src/common/components/nav/navCartIcon.component.spec.js b/src/common/components/nav/navCartIcon.component.spec.js index 517c6c5be..7814dc0f0 100644 --- a/src/common/components/nav/navCartIcon.component.spec.js +++ b/src/common/components/nav/navCartIcon.component.spec.js @@ -2,7 +2,7 @@ import angular from 'angular' import 'angular-mocks' import module from './navCartIcon.component' -import { giftAddedEvent, cartUpdatedEvent } from 'common/components/nav/navCart/navCart.component' +import { giftAddedEvent,cartUpdatedEvent } from 'common/lib/cartEvents' describe('nav cart icon', function () { beforeEach(angular.mock.module(module.name)) diff --git a/src/common/lib/cartEvents.js b/src/common/lib/cartEvents.js new file mode 100644 index 000000000..d4b9d0c42 --- /dev/null +++ b/src/common/lib/cartEvents.js @@ -0,0 +1,2 @@ +export const giftAddedEvent = 'giftAddedToCart' +export const cartUpdatedEvent = 'cartUpdatedEvent' diff --git a/src/common/services/api/order.service.js b/src/common/services/api/order.service.js index fcbb2ad41..2ea0b4830 100644 --- a/src/common/services/api/order.service.js +++ b/src/common/services/api/order.service.js @@ -8,8 +8,10 @@ import 'rxjs/add/observable/of' import 'rxjs/add/observable/throw' import map from 'lodash/map' import omit from 'lodash/omit' +import isString from 'lodash/isString' import sortPaymentMethods from 'common/services/paymentHelpers/paymentMethodSort' import extractPaymentAttributes from 'common/services/paymentHelpers/extractPaymentAttributes' +import { cartUpdatedEvent } from 'common/lib/cartEvents' import cortexApiService from '../cortexApi.service' import cartService from './cart.service' @@ -35,6 +37,7 @@ class Order { this.analyticsFactory = analyticsFactory this.sessionStorage = $window.sessionStorage this.localStorage = $window.localStorage + this.$window = $window this.$log = $log this.$filter = $filter } @@ -399,6 +402,53 @@ class Order { return startedOrderWithoutSpouse } } + + submitOrder (controller) { + delete controller.submissionError + delete controller.submissionErrorStatus + // Prevent multiple submissions + if (controller.submittingOrder) return Observable.empty() + controller.submittingOrder = true + controller.onSubmittingOrder({ value: true }) + + let submitRequest + if (controller.bankAccountPaymentDetails) { + submitRequest = this.submit() + } else if (controller.creditCardPaymentDetails) { + const cvv = this.retrieveCardSecurityCode() + submitRequest = this.submit(cvv) + } else { + submitRequest = Observable.throw({ data: 'Current payment type is unknown' }) + } + return submitRequest + .do(() => { + controller.submittingOrder = false + controller.onSubmittingOrder({ value: false }) + this.clearCardSecurityCodes() + this.clearCoverFees() + controller.onSubmitted() + controller.$scope.$emit(cartUpdatedEvent) + }) + .catch((error) => { + // Handle the error side effects when the observable errors + this.analyticsFactory.checkoutFieldError('submitOrder', 'failed') + controller.submittingOrder = false + controller.onSubmittingOrder({ value: false }) + + controller.loadCart() + + if (error.config && error.config.data && error.config.data['security-code']) { + error.config.data['security-code'] = error.config.data['security-code'].replace(/./g, 'X') // Mask security-code + } + this.$log.error('Error submitting purchase:', error) + controller.onSubmitted() + controller.submissionErrorStatus = error.status + controller.submissionError = isString(error && error.data) ? (error && error.data).replace(/[:].*$/, '') : 'generic error' // Keep prefix before first colon for easier ng-switch matching + this.$window.scrollTo(0, 0) + + return Observable.throw(error) // Return the error as an observable + }) + } } export default angular diff --git a/src/common/services/api/order.service.spec.js b/src/common/services/api/order.service.spec.js index 8be9e6019..ca068775f 100644 --- a/src/common/services/api/order.service.spec.js +++ b/src/common/services/api/order.service.spec.js @@ -2,7 +2,7 @@ import angular from 'angular' import 'angular-mocks' import omit from 'lodash/omit' import cloneDeep from 'lodash/cloneDeep' -import { Observable } from 'rxjs/Observable' +import { Observable } from 'rxjs' import 'rxjs/add/observable/of' import formatAddressForTemplate from '../addressHelpers/formatAddressForTemplate' import { Roles } from 'common/services/session/session.service' @@ -17,6 +17,7 @@ import purchaseFormResponse from 'common/services/api/fixtures/cortex-order-purc import donorDetailsResponse from 'common/services/api/fixtures/cortex-donordetails.fixture.js' import needInfoResponse from 'common/services/api/fixtures/cortex-order-needinfo.fixture.js' import purchaseResponse from 'common/services/api/fixtures/cortex-purchase.fixture.js' +import { cartUpdatedEvent } from 'common/lib/cartEvents' describe('order service', () => { beforeEach(angular.mock.module(module.name)) @@ -1206,4 +1207,161 @@ describe('order service', () => { expect(self.$window.localStorage.getItem('currentOrder')).toEqual('order id 2') }) }) + + describe('submitOrder', () => { + let mockController; + + beforeEach(() => { + mockController = { + submittingOrder: false, + onSubmittingOrder: jest.fn(), + onSubmitted: jest.fn(), + $scope: { + $emit: jest.fn(), + }, + }; + + // Mock the submit() method to return a resolved observable + self.orderService.submit = jest.fn().mockReturnValue(Observable.of({})); + }) + + describe('another order submission in progress', () => { + it('should not submit the order twice', () => { + mockController.submittingOrder = true; + // Call submitOrder + const result = self.orderService.submitOrder(mockController) + + // The submit method should not be called + expect(self.orderService.submit).not.toHaveBeenCalled(); + + // It should return an empty observable + expect(result).toEqual(Observable.empty()); + }) + }) + + describe('submit single order', () => { + beforeEach(() => { + self.orderService.clearCardSecurityCodes = jest.fn(); + self.orderService.retrieveCardSecurityCode = jest.fn(); + self.orderService.clearCoverFees = jest.fn(); + mockController.loadCart = jest.fn(); + self.$window.scrollTo = jest.fn(); + }) + + afterEach(() => { + expect(mockController.onSubmittingOrder).toHaveBeenCalledWith({ value: true }) + expect(mockController.onSubmittingOrder).toHaveBeenCalledWith({ value: false }) + expect(mockController.onSubmitted).toHaveBeenCalled() + }) + + it('should submit the order normally if paying with a bank account', (done) => { + mockController.bankAccountPaymentDetails = {} + self.orderService.submitOrder(mockController).subscribe( + () => { + expect(self.orderService.submit).toHaveBeenCalled() + expect(self.orderService.clearCardSecurityCodes).toHaveBeenCalled() + + expect(mockController.$scope.$emit).toHaveBeenCalledWith(cartUpdatedEvent) + done() + }) + }) + + it('should handle an error submitting an order with a bank account', (done) => { + self.orderService.submit.mockImplementation(() => Observable.throw({ data: 'error saving bank account' })) + + mockController.bankAccountPaymentDetails = {} + self.orderService.submitOrder(mockController).subscribe( + ()=>{ + fail() + }, + () => { + // Handle the error and continue with assertions + expect(self.orderService.submit).toHaveBeenCalled(); + expect(mockController.loadCart).toHaveBeenCalled(); + expect(self.orderService.clearCardSecurityCodes).not.toHaveBeenCalled(); + expect(self.$log.error.logs[0]).toEqual(['Error submitting purchase:', { data: 'error saving bank account' }]); + expect(mockController.submissionError).toEqual('error saving bank account'); + expect(self.$window.scrollTo).toHaveBeenCalledWith(0, 0); + + done(); + }) + }) + + it('should submit the order with a CVV if paying with a credit card', (done) => { + self.orderService.retrieveCardSecurityCode = jest.fn().mockReturnValue('1234'); + mockController.creditCardPaymentDetails = {} + mockController.coverFeeDecision = true + self.orderService.submitOrder(mockController).subscribe(() => { + expect(self.orderService.submit).toHaveBeenCalledWith('1234') + expect(self.orderService.clearCardSecurityCodes).toHaveBeenCalled() + expect(mockController.$scope.$emit).toHaveBeenCalledWith(cartUpdatedEvent) + done() + }) + }) + + it('should submit the order without a CVV if paying with an existing credit card or the cvv in session storage is missing', (done) => { + mockController.creditCardPaymentDetails = {} + self.orderService.retrieveCardSecurityCode = jest.fn().mockReturnValue(undefined); + mockController.coverFeeDecision = true + self.orderService.submitOrder(mockController).subscribe(() => { + expect(self.orderService.submit).toHaveBeenCalledWith(undefined) + expect(self.orderService.clearCardSecurityCodes).toHaveBeenCalled() + expect(mockController.$scope.$emit).toHaveBeenCalledWith(cartUpdatedEvent) + done() + }) + + + }) + + it('should handle an error submitting an order with a credit card', (done) => { + self.orderService.submit.mockImplementation(() => Observable.throw({ data: 'CardErrorException: Invalid Card Number: some details' })) + mockController.creditCardPaymentDetails = {} + self.orderService.retrieveCardSecurityCode = jest.fn().mockReturnValue('1234'); + self.orderService.submitOrder(mockController).subscribe( + () => {}, + () => { // error handler + expect(self.orderService.submit).toHaveBeenCalledWith('1234') + expect(self.orderService.clearCardSecurityCodes).not.toHaveBeenCalled() + expect(self.$log.error.logs[0]).toEqual(['Error submitting purchase:', { data: 'CardErrorException: Invalid Card Number: some details' }]) + expect(mockController.submissionError).toEqual('CardErrorException') + expect(self.$window.scrollTo).toHaveBeenCalledWith(0, 0); + done(); + }) + }) + + it('should mask the security code on a credit card error', (done) => { + self.orderService.submit.mockReturnValue(Observable.throw({ data: 'some error', config: { data: { 'security-code': '1234' } } })) + self.orderService.retrieveCardSecurityCode = jest.fn().mockReturnValue('1234'); + mockController.creditCardPaymentDetails = {} + self.orderService.submitOrder(mockController).subscribe( + () => {}, + () => { // error handler + expect(self.orderService.submit).toHaveBeenCalledWith('1234') + expect(self.$log.error.logs[0]).toEqual(['Error submitting purchase:', { data: 'some error', config: { data: { 'security-code': 'XXXX' } } }]) + done(); + }) + }) + + it('should throw an error if neither bank account or credit card details are loaded', (done) => { + self.orderService.submitOrder(mockController).subscribe( + () => {}, + () => { // error handler + expect(self.orderService.submit).not.toHaveBeenCalled() + expect(self.orderService.clearCardSecurityCodes).not.toHaveBeenCalled() + expect(self.$log.error.logs[0]).toEqual(['Error submitting purchase:', { data: 'Current payment type is unknown' }]) + expect(mockController.submissionError).toEqual('Current payment type is unknown') + expect(self.$window.scrollTo).toHaveBeenCalledWith(0, 0); + done(); + }) + }) + + it('should clear out cover fee data', (done) => { + mockController.creditCardPaymentDetails = {} + self.orderService.submitOrder(mockController).subscribe(() => { + done() + }) + expect(self.orderService.clearCoverFees).toHaveBeenCalled() + }) + }) + }) })