diff --git a/branded-checkout.html b/branded-checkout.html index cf436fd12..29f334990 100644 --- a/branded-checkout.html +++ b/branded-checkout.html @@ -11,8 +11,8 @@ - - + + diff --git a/src/app/branded/branded-checkout.component.js b/src/app/branded/branded-checkout.component.js index 21664c110..595ac8dfa 100644 --- a/src/app/branded/branded-checkout.component.js +++ b/src/app/branded/branded-checkout.component.js @@ -79,8 +79,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' @@ -185,6 +190,7 @@ export default angular onOrderCompleted: '&', onOrderFailed: '&', language: '@', - showCoverFees: '@' + showCoverFees: '@', + useV3: '@' } }) diff --git a/src/app/branded/branded-checkout.tpl.html b/src/app/branded/branded-checkout.tpl.html index e4eb31501..30ea74353 100644 --- a/src/app/branded/branded-checkout.tpl.html +++ b/src/app/branded/branded-checkout.tpl.html @@ -16,7 +16,8 @@ show-cover-fees="$ctrl.showCoverFees" next="$ctrl.next()" on-payment-failed="$ctrl.onPaymentFailed($event.donorDetails)" - radio-station-api-url="$ctrl.radioStationApiUrl"> + radio-station-api-url="$ctrl.radioStationApiUrl" + use-v3="$ctrl.useV3"> { + // Setting cart data and analytics + this.cartData = data + this.brandedAnalyticsFactory.saveCoverFees(this.orderService.retrieveCoverFeeDecision()) + if (this.cartData && this.cartData.items) { + this.brandedAnalyticsFactory.saveItem(this.cartData.items[0]) + } + this.brandedAnalyticsFactory.addPaymentInfo() + }, + error => { + // Handle errors by setting flag and logging the error + this.errorLoadingCart = true + this.$log.error('Error loading cart data for branded checkout (single step)', error) + } + ) + return cart + } + + loadCurrentPayment () { + this.loadingCurrentPayment = true + + const getCurrentPayment = this.orderService.getCurrentPayment() + getCurrentPayment.finally(() => { + this.loadingCurrentPayment = false + }).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') + } + }, + error => { + this.$log.error('Error loading current payment info', 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) + }) + .finally(() => { + this.loadingAndSubmitting = false + }) + .subscribe(() => { + this.next() + }) + } + + canSubmitOrder () { + return !this.submittingOrder + } } export default angular @@ -167,8 +261,10 @@ export default angular productConfigForm.name, contactInfo.name, checkoutStep2.name, + checkoutErrorMessages.name, cartService.name, orderService.name, + analyticsFactory.name, brandedAnalyticsFactory.name ]) .component(componentName, { @@ -187,6 +283,10 @@ export default angular showCoverFees: '<', next: '&', onPaymentFailed: '&', - radioStationApiUrl: '<' + radioStationApiUrl: '<', + 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 34d9d487c..f5f8b16bf 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,17 +1,24 @@ + +
Loading gift details... - +
+ + {{'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 ffe6ab1e0..49d707f17 100644 --- a/src/app/checkout/cart-summary/cart-summary.component.js +++ b/src/app/checkout/cart-summary/cart-summary.component.js @@ -12,8 +12,9 @@ export const submitOrderEvent = 'submitOrderEvent' class CartSummaryController { /* @ngInject */ - constructor (cartService, $scope) { + constructor (cartService, $scope, $rootScope) { this.$scope = $scope + this.$rootScope = $rootScope this.cartService = cartService } @@ -21,8 +22,8 @@ class CartSummaryController { return this.cartService.buildCartUrl() } - 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 e5b1106b4..78112454b 100644 --- a/src/app/checkout/cart-summary/cart-summary.spec.js +++ b/src/app/checkout/cart-summary/cart-summary.spec.js @@ -1,6 +1,6 @@ import angular from 'angular' import 'angular-mocks' -import module, { submitOrderEvent, recaptchaFailedEvent } from './cart-summary.component' +import module, { submitOrderEvent } from './cart-summary.component' describe('checkout', function () { describe('cart summary', function () { @@ -31,9 +31,9 @@ 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) }) }) }) diff --git a/src/app/checkout/checkout-error-messages/checkout-error-messages.component.js b/src/app/checkout/checkout-error-messages/checkout-error-messages.component.js new file mode 100644 index 000000000..d6a5a7f60 --- /dev/null +++ b/src/app/checkout/checkout-error-messages/checkout-error-messages.component.js @@ -0,0 +1,19 @@ +import angular from 'angular' + +import template from './checkout-error-messages.tpl.html' + +const componentName = 'checkoutErrorMessages' + +class CheckoutErrorMessagesController {} + +export default angular + .module(componentName, []) + .component(componentName, { + controller: CheckoutErrorMessagesController, + templateUrl: template, + bindings: { + needinfoErrors: '<', + submissionError: '<', + submissionErrorStatus: '<' + } + }) diff --git a/src/app/checkout/checkout-error-messages/checkout-error-messages.tpl.html b/src/app/checkout/checkout-error-messages/checkout-error-messages.tpl.html new file mode 100644 index 000000000..187a80e06 --- /dev/null +++ b/src/app/checkout/checkout-error-messages/checkout-error-messages.tpl.html @@ -0,0 +1,44 @@ + diff --git a/src/app/checkout/step-3/step-3.component.js b/src/app/checkout/step-3/step-3.component.js index 21fb26041..82b6c33c8 100644 --- a/src/app/checkout/step-3/step-3.component.js +++ b/src/app/checkout/step-3/step-3.component.js @@ -1,9 +1,8 @@ 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' +import checkoutErrorMessages from 'app/checkout/checkout-error-messages/checkout-error-messages.component' import displayRateTotals from 'common/components/displayRateTotals/displayRateTotals.component' import commonService from 'common/services/api/common.service' @@ -12,11 +11,9 @@ 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' -import { datadogRum } from '@datadog/browser-rum' import template from './step-3.tpl.html' @@ -39,7 +36,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, () => { @@ -132,52 +128,12 @@ 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.orderService.retrieveCoverFeeDecision()) } - componentInstance.$log.error('Error submitting purchase:', error) - datadogRum.addError(new Error(`Error submitting purchase: ${JSON.stringify(error)}`), { context: 'Checkout Submission', errorCode: error.status }) // here in order to show up in Error Tracking in DD - 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' }) }) } } @@ -193,7 +149,8 @@ export default angular analyticsFactory.name, cartService.name, commonService.name, - recaptchaComponent.name + recaptchaComponent.name, + checkoutErrorMessages.name ]) .component(componentName, { controller: Step3Controller, 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 51ea39e11..a2137300b 100644 --- a/src/app/checkout/step-3/step-3.component.spec.js +++ b/src/app/checkout/step-3/step-3.component.spec.js @@ -4,11 +4,10 @@ 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 { SignInEvent } from 'common/services/session/session.service' import module from './step-3.component' -import { recaptchaFailedEvent, submitOrderEvent } from '../cart-summary/cart-summary.component' +import { submitOrderEvent } from '../cart-summary/cart-summary.component' describe('checkout', () => { describe('step 3', () => { @@ -37,6 +36,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,136 +323,36 @@ describe('checkout', () => { }) }) + describe('event handling', () => { + it('should call submit order if the submitOrderEvent is received', () => { + jest.spyOn(self.controller, 'submitOrder').mockImplementation(() => {}) + self.controller.$rootScope.$emit(submitOrderEvent) + expect(self.controller.submitOrder).toHaveBeenCalled() + }) + }) + describe('submitOrder', () => { beforeEach(() => { - jest.spyOn(self.controller.orderService, 'submit') - jest.spyOn(self.controller.profileService, 'getPurchase') jest.spyOn(self.controller.analyticsFactory, 'purchase') + jest.spyOn(self.controller.orderService, 'retrieveCoverFeeDecision').mockReturnValue(true) }) - 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() + it('should call analyticsFactory when it is not branded checkout', () => { + self.controller.isBranded = false - 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) - }) + self.controller.submitOrder() - 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) - }) + expect(self.controller.analyticsFactory.purchase).toHaveBeenCalledWith(self.controller.donorDetails, self.controller.cartData, self.controller.orderService.retrieveCoverFeeDecision()) + expect(self.controller.changeStep).toHaveBeenCalledWith({ newStep: 'thankYou' }) + }) - it('should clear out cover fee data', () => { - self.controller.creditCardPaymentDetails = {} - self.controller.submitOrder() + it('should not call analyticsFactory when it is branded checkout', () => { + self.controller.isBranded = true - expect(self.controller.orderService.clearCoverFees).toHaveBeenCalled() - }) - }) - }) + self.controller.submitOrder() - describe('event handling', () => { - it('should call submit order if the submitOrderEvent is received', () => { - jest.spyOn(self.controller, 'submitOrder').mockImplementation(() => {}) - self.controller.$rootScope.$emit(submitOrderEvent) - expect(self.controller.submitOrder).toHaveBeenCalled() + 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 917fcb24d..f0775f1bf 100644 --- a/src/app/checkout/step-3/step-3.tpl.html +++ b/src/app/checkout/step-3/step-3.tpl.html @@ -1,48 +1,9 @@
- + +
@@ -204,8 +165,8 @@
-
+
+
- + - + - + - +

{{'GIFT_AMOUNT'}}

+
-
- +
+
+
+
+ + +
+
+
+ $ +
+ +
+
+
{{'VALID_DOLLAR_AMOUNT_ERROR'}}
+
{{'AMOUNT_EMPTY_ERROR'}}
+
{{'AMOUNT_MIN_ERROR'}}
+
{{'AMOUNT_MAX_ERROR'}}
+
+
+
-
-
+
+
-
-
{{'VALID_DOLLAR_AMOUNT_ERROR'}}
-
{{'AMOUNT_EMPTY_ERROR'}}
-
{{'AMOUNT_MIN_ERROR'}}
-
- {{'AMOUNT_MAX_ERROR'}} +
+
+
+ +
+
{{'VALID_DOLLAR_AMOUNT_ERROR'}}
+
{{'AMOUNT_EMPTY_ERROR'}}
+
{{'AMOUNT_MIN_ERROR'}}
+
+ {{'AMOUNT_MAX_ERROR'}} +
-
- - - -
-
{{'VALID_DOLLAR_AMOUNT_ERROR'}}
-
{{'AMOUNT_EMPTY_ERROR'}}
-
-
+
+ + + +
+
{{'VALID_DOLLAR_AMOUNT_ERROR'}}
+
{{'AMOUNT_EMPTY_ERROR'}}
+
+
+
-
-
-

- {{'GIFT_FREQUENCY'}} -

-
-
- - -
- - {{'CHANGING_FREQUENCY'}} - -
-
-
+

+ {{'GIFT_FREQUENCY'}} +

+
+
+ + +
+ + {{'CHANGING_FREQUENCY'}} + +
+
+

{{'RECURRING_START'}}

@@ -214,7 +269,7 @@

-
+

{{'OPTIONAL'}}

diff --git a/src/app/profile/yourGiving/giveOneTimeGift/giveOneTimeGift.modal.component.js b/src/app/profile/yourGiving/giveOneTimeGift/giveOneTimeGift.modal.component.js index 8d6e75948..22204aac0 100644 --- a/src/app/profile/yourGiving/giveOneTimeGift/giveOneTimeGift.modal.component.js +++ b/src/app/profile/yourGiving/giveOneTimeGift/giveOneTimeGift.modal.component.js @@ -5,7 +5,7 @@ import concat from 'lodash/concat' import step1SelectRecentRecipients from './step1/selectRecentRecipients.component' import step1SearchRecipients from './step1/searchRecipients.component' import step2EnterAmounts from './step2/enterAmounts.component' -import { giftAddedEvent } from 'common/components/nav/navCart/navCart.component' +import { giftAddedEvent } from 'common/lib/cartEvents' import RecurringGiftModel from 'common/models/recurringGift.model' diff --git a/src/app/profile/yourGiving/giveOneTimeGift/giveOneTimeGift.modal.component.spec.js b/src/app/profile/yourGiving/giveOneTimeGift/giveOneTimeGift.modal.component.spec.js index 70251998b..b30c57ea7 100644 --- a/src/app/profile/yourGiving/giveOneTimeGift/giveOneTimeGift.modal.component.spec.js +++ b/src/app/profile/yourGiving/giveOneTimeGift/giveOneTimeGift.modal.component.spec.js @@ -6,7 +6,7 @@ import 'rxjs/add/observable/from' import 'rxjs/add/observable/throw' import RecurringGiftModel from 'common/models/recurringGift.model' -import { giftAddedEvent } from 'common/components/nav/navCart/navCart.component' +import { giftAddedEvent } from 'common/lib/cartEvents' import module from './giveOneTimeGift.modal.component' diff --git a/src/assets/scss/_gift-config.scss b/src/assets/scss/_gift-config.scss index 8888cf81c..8e84a8d20 100644 --- a/src/assets/scss/_gift-config.scss +++ b/src/assets/scss/_gift-config.scss @@ -25,8 +25,6 @@ label.custom-amount { width: auto; } - - .radio, .checkbox { label { @@ -217,6 +215,20 @@ label.btn.btn-default-form.active { } } +.give-selection-reverse-order { + display: flex; + flex-direction: column; + & > .panel.panel-default:nth-child(1) { + order: 3; + } + & > .panel.panel-default:nth-child(2) { + order: 1; + } + & > .panel.panel-default:nth-child(3) { + order: 2; + } +} + @media (max-width: 549px) { .give-modal-recipient { margin-top: 5px; @@ -340,4 +352,3 @@ label.btn.btn-default-form.active { } } } - diff --git a/src/assets/scss/_give.scss b/src/assets/scss/_give.scss index 7b03e67c4..c62de2281 100644 --- a/src/assets/scss/_give.scss +++ b/src/assets/scss/_give.scss @@ -498,6 +498,137 @@ hr.horizontal-divider { font-weight: 700; } +.button-group { + display: flex; + flex-direction: column; + gap: 10px; + + .help-block { + padding: 10px; + color: #715927; + background-color: #f4d89d; + border: 2px solid #f9b625; + border-radius: 2px; + } + + input[type="radio"] { + position: absolute; + opacity: 0; + cursor: pointer; + } + + label { + display: flex; + flex: 1; + cursor: pointer; + margin: 0; + + .amount-box { + margin: 0; + min-width: 50px; + text-align: center; + align-content: center; + aspect-ratio: 1 / 1; + background-color: #ddd; + color: inherit; + border-radius: 2px 0 0 2px; + transition: all 0.3s ease; + } + + .amount-description { + width: 100%; + margin: 0; + padding: 5px 10px; + align-content: center; + background-color: #f0f0f0; + font-weight: normal; + } + } + + input[type="radio"]:hover + label { + .amount-description { + background-color: #e4e4e4; + } + } + input[type="radio"]:focus + label { + .amount-box { + min-width: 55px; + color:#ddd; + background-color:#3a3a3a; + } + } + input[type="radio"]:checked + label { + .amount-box { + min-width: 55px; + color: #ddd; + background-color: #3a3a3a; + } + } +} + +.custom-amount-button { + + display: flex; + flex-direction: row; + height: 50px; + border: 1px solid #ddd; + + .input-prepend { + margin: 0; + min-width: 50px; + text-align: center; + align-content: center; + min-width: 50px; + aspect-ratio: 1 / 1; + background-color: #ddd; + cursor: default; + } + + label { + all: unset; + width: 100%; + height: 100%; + + input[type="radio"] { + position: absolute; + opacity: 0; + } + + input[type="text"] { + width: 100%; + height: 100%; + padding: 0 7px; + border: none; + border-radius: 2px; + transition: all 0.3s ease; + + &::placeholder { + color: $colorCru-gray; + } + + &:focus { + border: 1px solid #3eb1c8; + outline: none; + } + } + } +} + +/* Check for custom amount input */ +.custom-amount-button:has(input:checked) { + border: 1px solid #3a3a3a; + font-weight: 700; + color: #3a3a3a; +} + +.custom-amount-button:has(input:checked) .input-prepend { + min-width: 55px; + aspect-ratio: 1 / 1; + background-color: #3a3a3a; + color: #fff; + transition: all 0.3s ease; +} + .table-payment-history { thead { th { diff --git a/src/common/components/Recaptcha/Recaptcha.test.tsx b/src/common/components/Recaptcha/Recaptcha.test.tsx index 66fbaad83..f637f0cb4 100644 --- a/src/common/components/Recaptcha/Recaptcha.test.tsx +++ b/src/common/components/Recaptcha/Recaptcha.test.tsx @@ -123,7 +123,6 @@ describe('Recaptcha component', () => { return void - componentInstance: any + onSuccess: () => void buttonId: string buttonType?: ButtonType buttonClasses: string @@ -34,7 +32,6 @@ interface RecaptchaProps { export const Recaptcha = ({ action, onSuccess, - componentInstance, buttonId, buttonType, buttonClasses, @@ -74,10 +71,10 @@ export const Recaptcha = ({ const token = await grecaptcha.enterprise.execute(recaptchaKey, { action: action }) window.sessionStorage.setItem('recaptchaToken', token) window.sessionStorage.setItem('recaptchaAction', action) - onSuccess(componentInstance) + onSuccess() } catch (error) { $log.error(`Failed to verify recaptcha, continuing on: ${error}`) - onSuccess(componentInstance) + onSuccess() } }) }, [grecaptcha, buttonId, ready]) @@ -100,7 +97,6 @@ export default angular [ 'action', 'onSuccess', - 'componentInstance', 'buttonId', 'buttonType', 'buttonClasses', diff --git a/src/common/components/Recaptcha/RecaptchaWrapper.test.tsx b/src/common/components/Recaptcha/RecaptchaWrapper.test.tsx index 9b323650b..bf689b3d1 100644 --- a/src/common/components/Recaptcha/RecaptchaWrapper.test.tsx +++ b/src/common/components/Recaptcha/RecaptchaWrapper.test.tsx @@ -16,6 +16,10 @@ describe('RecaptchaWrapper component', () => { warn: jest.fn() } + const $rootScope = { + $apply: jest.fn() + } + const mockExecuteRecaptcha = jest.fn() const mockRecaptchaReady = jest.fn() const mockRecaptcha = { @@ -43,6 +47,7 @@ describe('RecaptchaWrapper component', () => { envService={envService} $translate={$translate} $log={$log} + $rootScope={$rootScope} /> ) expect(getAllByRole('button')).toHaveLength(1) diff --git a/src/common/components/Recaptcha/RecaptchaWrapper.tsx b/src/common/components/Recaptcha/RecaptchaWrapper.tsx index aecc6fd59..9d52cfa39 100644 --- a/src/common/components/Recaptcha/RecaptchaWrapper.tsx +++ b/src/common/components/Recaptcha/RecaptchaWrapper.tsx @@ -13,7 +13,7 @@ declare global { interface RecaptchaWrapperProps { action: string - onSuccess: (componentInstance: any) => void + onSuccess: () => void componentInstance: any buttonId: string buttonType?: ButtonType @@ -23,6 +23,7 @@ interface RecaptchaWrapperProps { envService: any $translate: any $log: any + $rootScope: any } export const RecaptchaWrapper = ({ @@ -36,14 +37,21 @@ export const RecaptchaWrapper = ({ buttonLabel, envService, $translate, - $log + $log, + $rootScope }: RecaptchaWrapperProps): JSX.Element => { const recaptchaKey = envService.read('recaptchaKey') + // Because the onSuccess callback is 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) + }) + }) + return ( { - return !number || phoneNumberRegex.test(number) + if (this.useV3 !== 'true') { + this.detailsForm.phoneNumber.$validators.phone = number => { + return !number || phoneNumberRegex.test(number) + } } } @@ -178,6 +181,7 @@ export default angular .module(componentName, [ 'ngMessages', addressForm.name, + emailField.name, orderService.name, radioStationsService.name, sessionService.name, @@ -190,6 +194,7 @@ export default angular submitted: '<', donorDetails: '=?', onSubmit: '&', - radioStationApiUrl: '<' + radioStationApiUrl: '<', + useV3: '<' } }) diff --git a/src/common/components/contactInfo/contactInfo.tpl.html b/src/common/components/contactInfo/contactInfo.tpl.html index 82a37abf1..22e2e4ba7 100644 --- a/src/common/components/contactInfo/contactInfo.tpl.html +++ b/src/common/components/contactInfo/contactInfo.tpl.html @@ -45,19 +45,21 @@
-

{{'YOUR_INFORMATION'}}

+

{{'YOUR_INFORMATION'}}

+

{{'CONTACT_INFO'}}

-
+
{{'FIRST_NAME_ERROR'}}
@@ -65,7 +67,7 @@

{{'YOUR_INFORMAT

-
+
-
+
-
+
-
-
+
+ +
+
+ + +
+
+
@@ -218,26 +229,18 @@

{{'MAILING_ADDRE address="$ctrl.donorDetails.mailingAddress" parent-form="$ctrl.detailsForm" on-address-changed="$ctrl.loadRadioStations()" - address-disabled="$ctrl.donorDetails.staff" - > + address-disabled="$ctrl.donorDetails.staff">

-
+

{{'CONTACT_INFO'}}

-
- -
-
{{'EMAIL_MISSING_ERROR'}}
-
{{'EMAIL_INVALID_ERROR'}}
-
{{'EMAIL_LENGTH_ERROR'}}
-
-
+ +
diff --git a/src/common/components/contactInfo/emailField/emailField.component.js b/src/common/components/contactInfo/emailField/emailField.component.js new file mode 100644 index 000000000..29dffb7d2 --- /dev/null +++ b/src/common/components/contactInfo/emailField/emailField.component.js @@ -0,0 +1,17 @@ +import angular from 'angular' +import template from './emailField.tpl.html' + +const componentName = 'emailField' + +class EmailFieldController {} + +export default angular + .module(componentName, []) + .component(componentName, { + controller: EmailFieldController, + templateUrl: template, + bindings: { + donorDetails: '<', + detailsForm: '<' + } + }) diff --git a/src/common/components/contactInfo/emailField/emailField.tpl.html b/src/common/components/contactInfo/emailField/emailField.tpl.html new file mode 100644 index 000000000..f1af68f71 --- /dev/null +++ b/src/common/components/contactInfo/emailField/emailField.tpl.html @@ -0,0 +1,11 @@ +
+ +
+
{{'EMAIL_MISSING_ERROR'}}
+
{{'EMAIL_INVALID_ERROR'}}
+
{{'EMAIL_LENGTH_ERROR'}}
+
+
diff --git a/src/common/components/loading/loading.scss b/src/common/components/loading/loading.scss index 6e87dfc1e..40aa7f250 100644 --- a/src/common/components/loading/loading.scss +++ b/src/common/components/loading/loading.scss @@ -66,6 +66,13 @@ loading{ z-index: 2; } + .loading-fixed:first-child { + position: fixed; + top: 50%; + left: 50%; + z-index: 2; + } + .loadingWrap{ display: inline-block } diff --git a/src/common/components/loading/loading.tpl.html b/src/common/components/loading/loading.tpl.html index 3b6f33ff8..af5d5faef 100644 --- a/src/common/components/loading/loading.tpl.html +++ b/src/common/components/loading/loading.tpl.html @@ -1,6 +1,7 @@
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..c2397ca4c 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 ce83ccad7..3296503c3 100644 --- a/src/common/services/api/order.service.js +++ b/src/common/services/api/order.service.js @@ -6,16 +6,21 @@ import 'rxjs/add/operator/combineLatest' import 'rxjs/add/operator/pluck' import 'rxjs/add/observable/of' import 'rxjs/add/observable/throw' +import 'rxjs/add/operator/do' +import 'rxjs/add/operator/finally' 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' import tsysService from './tsys.service' import hateoasHelperService from 'common/services/hateoasHelper.service' import sessionService, { Roles } from 'common/services/session/session.service' +import { datadogRum } from '@datadog/browser-rum' import formatAddressForCortex from '../addressHelpers/formatAddressForCortex' import formatAddressForTemplate from '../addressHelpers/formatAddressForTemplate' @@ -35,6 +40,7 @@ class Order { this.analyticsFactory = analyticsFactory this.sessionStorage = $window.sessionStorage this.localStorage = $window.localStorage + this.$window = $window this.$log = $log this.$filter = $filter } @@ -403,6 +409,54 @@ 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(() => { + this.clearCardSecurityCodes() + this.clearCoverFees() + controller.onSubmitted() + controller.$scope.$emit(cartUpdatedEvent) + }, + (error) => { + // Handle the error side effects when the observable errors + this.analyticsFactory.checkoutFieldError('submitOrder', 'failed') + + 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) + datadogRum.addError(new Error(`Error submitting purchase: ${JSON.stringify(error)}`), { context: 'Checkout Submission', errorCode: error.status }) // here in order to show up in Error Tracking in DD + 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) + }) + .finally(() => { + controller.submittingOrder = false + controller.onSubmittingOrder({ value: false }) + }) + } } export default angular diff --git a/src/common/services/api/order.service.spec.js b/src/common/services/api/order.service.spec.js index 4b9fcbbf2..e76b7b8b5 100644 --- a/src/common/services/api/order.service.spec.js +++ b/src/common/services/api/order.service.spec.js @@ -4,6 +4,7 @@ import omit from 'lodash/omit' import cloneDeep from 'lodash/cloneDeep' import { Observable } from 'rxjs/Observable' import 'rxjs/add/observable/of' +import 'rxjs/add/observable/empty' import formatAddressForTemplate from '../addressHelpers/formatAddressForTemplate' import { Roles } from 'common/services/session/session.service' @@ -17,6 +18,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)) @@ -1247,4 +1249,157 @@ 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( + () => done("Observable unexpectedly succeeded"), + () => { + // 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() + }, 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( + () => done("Observable unexpectedly succeeded"), + () => { // 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( + () => done("Observable unexpectedly succeeded"), + () => { // 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( + () => done("Observable unexpectedly succeeded"), + () => { // 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() + }, done) + expect(self.orderService.clearCoverFees).toHaveBeenCalled() + }) + }) + }) })