diff --git a/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.component.js b/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.component.js index ce59dea7a..04b7d188d 100644 --- a/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.component.js +++ b/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.component.js @@ -6,19 +6,20 @@ import paymentMethodDisplay from 'common/components/paymentMethods/paymentMethod import paymentMethodFormModal from 'common/components/paymentMethods/paymentMethodForm/paymentMethodForm.modal.component' import coverFees from 'common/components/paymentMethods/coverFees/coverFees.component' +import * as cruPayments from '@cruglobal/cru-payments/dist/cru-payments' import orderService from 'common/services/api/order.service' import cartService from 'common/services/api/cart.service' import { validPaymentMethod } from 'common/services/paymentHelpers/validPaymentMethods' import giveModalWindowTemplate from 'common/templates/giveModalWindow.tpl.html' import { SignInEvent } from 'common/services/session/session.service' - +import creditCardCvv from '../../../../common/directives/creditCardCvv.directive' import template from './existingPaymentMethods.tpl.html' const componentName = 'checkoutExistingPaymentMethods' class ExistingPaymentMethodsController { /* @ngInject */ - constructor ($log, $scope, orderService, cartService, $uibModal) { + constructor ($log, $scope, orderService, cartService, $uibModal, $window) { this.$log = $log this.$scope = $scope this.orderService = orderService @@ -26,6 +27,7 @@ class ExistingPaymentMethodsController { this.$uibModal = $uibModal this.paymentFormResolve = {} this.validPaymentMethod = validPaymentMethod + this.sessionStorage = $window.sessionStorage this.$scope.$on(SignInEvent, () => { this.$onInit() @@ -33,7 +35,9 @@ class ExistingPaymentMethodsController { } $onInit () { + this.enableContinue({ $event: false }) this.loadPaymentMethods() + this.waitForFormInitialization() } $onChanges (changes) { @@ -52,6 +56,27 @@ class ExistingPaymentMethodsController { } } + waitForFormInitialization () { + const unregister = this.$scope.$watch('$ctrl.creditCardPaymentForm.securityCode', () => { + if (this.creditCardPaymentForm && this.creditCardPaymentForm.securityCode) { + unregister() + this.addCvvValidators() + this.switchPayment() + } + }) + } + + addCvvValidators () { + this.$scope.$watch('$ctrl.creditCardPaymentForm.securityCode.$viewValue', (number) => { + if (this.selectedPaymentMethod?.['card-type'] && this.creditCardPaymentForm.securityCode) { + this.creditCardPaymentForm.securityCode.$validators.minLength = cruPayments.creditCard.cvv.validate.minLength + this.creditCardPaymentForm.securityCode.$validators.maxLength = cruPayments.creditCard.cvv.validate.maxLength + this.enableContinue({ $event: cruPayments.creditCard.cvv.validate.minLength(number) && cruPayments.creditCard.cvv.validate.maxLength(number) }) + this.selectedPaymentMethod.cvv = number + } + }) + } + loadPaymentMethods () { this.orderService.getExistingPaymentMethods() .subscribe((data) => { @@ -80,6 +105,7 @@ class ExistingPaymentMethodsController { // Select the first payment method this.selectedPaymentMethod = paymentMethods[0] } + this.shouldRecoverCvv = true this.switchPayment() } @@ -130,6 +156,13 @@ class ExistingPaymentMethodsController { switchPayment () { this.onPaymentChange({ selectedPaymentMethod: this.selectedPaymentMethod }) + if (this.selectedPaymentMethod?.['card-type'] && this.creditCardPaymentForm?.securityCode) { + // Set cvv from session storage + const storage = this.shouldRecoverCvv ? JSON.parse(this.sessionStorage.getItem('cvv')) : '' + this.creditCardPaymentForm.securityCode.$setViewValue(storage) + this.creditCardPaymentForm.securityCode.$render() + this.shouldRecoverCvv = false + } if (this.selectedPaymentMethod?.['bank-name']) { // This is an EFT payment method so we need to remove any fee coverage this.orderService.storeCoverFeeDecision(false) @@ -144,7 +177,8 @@ export default angular paymentMethodFormModal.name, coverFees.name, orderService.name, - cartService.name + cartService.name, + creditCardCvv.name ]) .component(componentName, { controller: ExistingPaymentMethodsController, @@ -159,6 +193,7 @@ export default angular brandedCheckoutItem: '<', onPaymentFormStateChange: '&', onPaymentChange: '&', - onLoad: '&' + onLoad: '&', + enableContinue: '&' } }) diff --git a/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.component.spec.js b/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.component.spec.js index d2194b8ca..372ea7726 100644 --- a/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.component.spec.js +++ b/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.component.spec.js @@ -4,6 +4,7 @@ import { Observable } from 'rxjs/Observable' import 'rxjs/add/observable/of' import 'rxjs/add/observable/throw' import 'rxjs/add/operator/toPromise' +import * as cruPayments from '@cruglobal/cru-payments/dist/cru-payments' import { SignInEvent } from 'common/services/session/session.service' @@ -15,24 +16,43 @@ describe('checkout', () => { beforeEach(angular.mock.module(module.name)) const self = {} - beforeEach(inject(($componentController, $timeout) => { + beforeEach(inject(($componentController, $timeout, $window) => { self.$timeout = $timeout self.controller = $componentController(module.name, {}, { onLoad: jest.fn(), onPaymentChange: jest.fn(), + enableContinue: jest.fn(), onPaymentFormStateChange: jest.fn(), - cartData: { items: [] } + cartData: { items: [] }, + creditCardPaymentForm: { + securityCode: { + $valid: true, + $validators: { + minLength: (value) => cruPayments.creditCard.cvv.validate.minLength(value), + maxLength: cruPayments.creditCard.cvv.validate.maxLength + }, + $setViewValue: jest.fn(), + $render: jest.fn(), + } + }, + selectedPaymentMethod: { + cvv: '', + 'card-type': 'Visa' + } }) + self.$window = $window + self.$window.sessionStorage.clear() })) - describe('$onInit', () => { it('should call loadPaymentMethods', () => { jest.spyOn(self.controller, 'loadPaymentMethods').mockImplementation(() => {}) + jest.spyOn(self.controller, 'waitForFormInitialization').mockImplementation(() => {}) self.controller.$onInit() expect(self.controller.loadPaymentMethods).toHaveBeenCalled() + expect(self.controller.waitForFormInitialization).toHaveBeenCalled() }) it('should be called on sign in', () => { @@ -329,6 +349,80 @@ describe('checkout', () => { expect(self.controller.onPaymentChange).toHaveBeenCalledWith({ selectedPaymentMethod: undefined }) expect(self.controller.orderService.storeCoverFeeDecision).not.toHaveBeenCalled() }) + + it('should reset securityCode viewValue', () => { + self.controller.switchPayment() + + expect(self.controller.creditCardPaymentForm.securityCode.$setViewValue).toHaveBeenCalledWith('') + expect(self.controller.creditCardPaymentForm.securityCode.$render).toHaveBeenCalled() + }) + + it('should add securityCode viewValue from sessionStorage', () => { + self.$window.sessionStorage.setItem( + 'cvv', + '456' + ) + self.controller.shouldRecoverCvv = true + self.controller.switchPayment() + + expect(self.controller.creditCardPaymentForm.securityCode.$setViewValue).toHaveBeenCalledWith(456) + expect(self.controller.creditCardPaymentForm.securityCode.$render).toHaveBeenCalled() + }) + }) + + describe('addCvvValidators', () => { + it('should add a watch on the security code value', () => { + self.controller.creditCardPaymentForm = { + $valid: true, + $dirty: false, + securityCode: { + $viewValue: '123', + $validators: {} + } + } + self.controller.addCvvValidators() + expect(self.controller.$scope.$$watchers.length).toEqual(1) + expect(self.controller.$scope.$$watchers[0].exp).toEqual('$ctrl.creditCardPaymentForm.securityCode.$viewValue') + }) + + it('should add validator functions to creditCardPaymentForm.securityCode', () => { + jest.spyOn(self.controller, 'addCvvValidators') + self.controller.selectedPaymentMethod.self = { + type: 'cru.creditcards.named-credit-card', + uri: 'selected uri' + } + self.controller.waitForFormInitialization() + self.controller.$scope.$digest() + + expect(self.controller.addCvvValidators).toHaveBeenCalled() + expect(Object.keys(self.controller.creditCardPaymentForm.securityCode.$validators).length).toEqual(2) + expect(typeof self.controller.creditCardPaymentForm.securityCode.$validators.minLength).toBe('function') + expect(typeof self.controller.creditCardPaymentForm.securityCode.$validators.maxLength).toBe('function') + }) + + it('should call enableContinue when cvv is valid', () => { + self.controller.creditCardPaymentForm.securityCode.$viewValue = '123' + self.controller.addCvvValidators() + self.controller.$scope.$apply() + + expect(self.controller.enableContinue).toHaveBeenCalledWith({ $event: true }) + }) + + it('should call enableContinue when cvv is too long', () => { + self.controller.creditCardPaymentForm.securityCode.$viewValue = '12345' + self.controller.addCvvValidators() + self.controller.$scope.$apply() + + expect(self.controller.enableContinue).toHaveBeenCalledWith({ $event: false }) + }) + + it('should call enableContinue when cvv is too short', () => { + self.controller.creditCardPaymentForm.securityCode.$viewValue = '1' + self.controller.addCvvValidators() + self.controller.$scope.$apply() + + expect(self.controller.enableContinue).toHaveBeenCalledWith({ $event: false }) + }) }) }) }) diff --git a/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.tpl.html b/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.tpl.html index 54080a339..287cc758b 100644 --- a/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.tpl.html +++ b/src/app/checkout/step-2/existingPaymentMethods/existingPaymentMethods.tpl.html @@ -1,17 +1,24 @@ -
+
Your Payment Methods
-
- -
- +
+
+
+ +
+
+ +
+
+
+
{{'OPTIONAL'}}
diff --git a/src/app/checkout/step-2/step-2.component.js b/src/app/checkout/step-2/step-2.component.js index d1f384c3a..2b6c54b4e 100644 --- a/src/app/checkout/step-2/step-2.component.js +++ b/src/app/checkout/step-2/step-2.component.js @@ -78,6 +78,7 @@ class Step2Controller { onPaymentFormStateChange ($event) { this.paymentFormState = $event.state + if ($event.state === 'loading' && $event.payload) { const paymentType = $event.payload.creditCard ? $event.payload.creditCard['card-type'] : $event.payload.bankAccount ? $event.payload.bankAccount['account-type'] : 'Unknown' const request = $event.update @@ -109,6 +110,8 @@ class Step2Controller { this.changeStep({ newStep: 'review' }) this.onStateChange({ state: 'submitted' }) this.paymentFormState = 'success' + } else if ($event.state === 'submitted') { + this.orderService.storeCardSecurityCode(this.selectedPaymentMethod.cvv, this.selectedPaymentMethod.self.uri) } else if ($event.state === 'unsubmitted') { this.onStateChange({ state: 'unsubmitted' }) } else if ($event.state === 'error') { @@ -116,7 +119,10 @@ class Step2Controller { } } - getContinueDisabled () { + isContinueDisabled () { + if (this.selectedPaymentMethod?.['card-type'] && !this.isCvvValid) { + return true + } if (this.loadingPaymentMethods) { return true } @@ -129,6 +135,10 @@ class Step2Controller { } return false } + + enableContinue (isCvvValid) { + this.isCvvValid = isCvvValid + } } export default angular diff --git a/src/app/checkout/step-2/step-2.component.spec.js b/src/app/checkout/step-2/step-2.component.spec.js index f3e61ae79..32dfa424c 100644 --- a/src/app/checkout/step-2/step-2.component.spec.js +++ b/src/app/checkout/step-2/step-2.component.spec.js @@ -164,6 +164,7 @@ describe('checkout', () => { it('should update paymentFormState if transitioning to a different state', () => { self.controller.paymentFormState = 'unsubmitted' + self.controller.selectedPaymentMethod = { cvv: '123', self: { uri: 'uri'} } self.controller.onPaymentFormStateChange({ state: 'submitted' }) expect(self.controller.paymentFormState).toEqual('submitted') @@ -249,14 +250,14 @@ describe('checkout', () => { }) }) - describe('getContinueDisabled', () => { + describe('isContinueDisabled', () => { it('should return true when there are existing payment methods but none are valid', () => { self.controller.handleExistingPaymentLoading(true, true) self.controller.handlePaymentChange(undefined) expect(self.controller.existingPaymentMethods).toBe(true) expect(self.controller.selectedPaymentMethod).toBeUndefined() - expect(self.controller.getContinueDisabled()).toBe(true) + expect(self.controller.isContinueDisabled()).toBe(true) }) it('should return false when there are existing payment methods and at least one is valid', () => { @@ -265,7 +266,7 @@ describe('checkout', () => { expect(self.controller.existingPaymentMethods).toBe(true) expect(self.controller.selectedPaymentMethod).not.toBeUndefined() - expect(self.controller.getContinueDisabled()).toBe(false) + expect(self.controller.isContinueDisabled()).toBe(false) }) it('should return false when there are not existing payment methods', () => { @@ -273,19 +274,19 @@ describe('checkout', () => { expect(self.controller.existingPaymentMethods).toBe(false) expect(self.controller.selectedPaymentMethod).toBeUndefined() - expect(self.controller.getContinueDisabled()).toBe(false) + expect(self.controller.isContinueDisabled()).toBe(false) }) it('should return true while the payment methods are loading', () => { self.controller.$onInit() expect(self.controller.loadingPaymentMethods).toBe(true) - expect(self.controller.getContinueDisabled()).toBe(true) + expect(self.controller.isContinueDisabled()).toBe(true) self.controller.handleExistingPaymentLoading(true, false) expect(self.controller.loadingPaymentMethods).toBe(false) - expect(self.controller.getContinueDisabled()).toBe(false) + expect(self.controller.isContinueDisabled()).toBe(false) }) it('should return true while the payment form is encrypting or loading', () => { @@ -295,18 +296,90 @@ describe('checkout', () => { self.controller.onPaymentFormStateChange({ state: 'encrypting' }) expect(self.controller.paymentFormState).toBe('encrypting') - expect(self.controller.getContinueDisabled()).toBe(true) + expect(self.controller.isContinueDisabled()).toBe(true) self.controller.onPaymentFormStateChange({ state: 'loading', payload: {}, update: false }) expect(self.controller.paymentFormState).toBe('loading') - expect(self.controller.getContinueDisabled()).toBe(true) + expect(self.controller.isContinueDisabled()).toBe(true) deferred.resolve() self.$flushPendingTasks() expect(self.controller.paymentFormState).toBe('success') - expect(self.controller.getContinueDisabled()).toBe(false) + expect(self.controller.isContinueDisabled()).toBe(false) + }) + + describe('existing credit card used', () => { + it('should disable continue when cvv is invalid', () => { + self.controller.handleExistingPaymentLoading(true, true) + self.controller.isCvvValid = false + self.controller.handlePaymentChange({'card-type': 'visa'}) + + expect(self.controller.isContinueDisabled()).toBe(true) + }) + + it('should disable continue when cvv is valid', () => { + self.controller.handleExistingPaymentLoading(true, true) + self.controller.isCvvValid = true + self.controller.handlePaymentChange({'card-type': 'visa'}) + + expect(self.controller.isContinueDisabled()).toBe(false) + }) + + it('should not disable continue when cvv is invalid', () => { + self.controller.handleExistingPaymentLoading(true, true) + self.controller.isCvvValid = false + self.controller.handlePaymentChange({'account-type': 'checking'}) + + expect(self.controller.isContinueDisabled()).toBe(false) + }) + }) + + describe('existing EFT used', () => { + it('should not disable continue when cvv validity is undefined', () => { + self.controller.handleExistingPaymentLoading(true, true) + self.controller.isCvvValid = undefined + self.controller.handlePaymentChange({'account-type': 'checking'}) + + expect(self.controller.isContinueDisabled()).toBe(false) + }) + + it('should not disable continue when cvv is valid', () => { + self.controller.handleExistingPaymentLoading(true, true) + self.controller.isCvvValid = true + self.controller.handlePaymentChange({'account-type': 'checking'}) + + expect(self.controller.isContinueDisabled()).toBe(false) + }) + }) + + describe('new credit card used', () => { + it('should disable continue when cvv is invalid and new credit card payment is added', () => { + self.controller.handlePaymentChange({'card-type': 'visa'}) + self.controller.isCvvValid = false + expect(self.controller.isContinueDisabled()).toBe(true) + }) + }) + + describe('new EFT used', () => { + it('should not disable continue when cvv is invalid and new EFT is added', () => { + self.controller.handlePaymentChange({'account-type': 'checking'}) + self.controller.isCvvValid = false + expect(self.controller.isContinueDisabled()).toBe(false) + }) + }) + }) + + describe('enableContinue', () => { + it('should set isCvvValid to false', () => { + self.controller.enableContinue(false) + expect(self.controller.isCvvValid).toBe(false) + }) + + it('should set isCvvValid to true', () => { + self.controller.enableContinue(true) + expect(self.controller.isCvvValid).toBe(true) }) }) }) diff --git a/src/app/checkout/step-2/step-2.tpl.html b/src/app/checkout/step-2/step-2.tpl.html index 7a3edb704..d2203b9cd 100644 --- a/src/app/checkout/step-2/step-2.tpl.html +++ b/src/app/checkout/step-2/step-2.tpl.html @@ -31,7 +31,8 @@ default-payment-type="$ctrl.defaultPaymentType" hide-payment-type-options="$ctrl.hidePaymentTypeOptions" cart-data="$ctrl.cartData" - branded-checkout-item="$ctrl.brandedCheckoutItem"> + branded-checkout-item="$ctrl.brandedCheckoutItem" + enable-continue="$ctrl.enableContinue($event)">
@@ -42,7 +43,7 @@
- diff --git a/src/common/components/paymentMethods/creditCardForm/creditCardForm.component.js b/src/common/components/paymentMethods/creditCardForm/creditCardForm.component.js index 6e152e545..88ca925b4 100644 --- a/src/common/components/paymentMethods/creditCardForm/creditCardForm.component.js +++ b/src/common/components/paymentMethods/creditCardForm/creditCardForm.component.js @@ -21,6 +21,7 @@ import tsys from 'common/services/api/tsys.service' import template from './creditCardForm.tpl.html' import creditCardNumberDirective from '../../../directives/creditCardNumber.directive' +import creditCardCvv from '../../../../common/directives/creditCardCvv.directive' const componentName = 'creditCardForm' @@ -195,7 +196,8 @@ export default angular showErrors.name, analyticsFactory.name, tsys.name, - creditCardNumberDirective.name + creditCardNumberDirective.name, + creditCardCvv.name ]) .component(componentName, { controller: CreditCardController, diff --git a/src/common/components/paymentMethods/creditCardForm/creditCardForm.tpl.html b/src/common/components/paymentMethods/creditCardForm/creditCardForm.tpl.html index b22a1d791..1cd86debb 100644 --- a/src/common/components/paymentMethods/creditCardForm/creditCardForm.tpl.html +++ b/src/common/components/paymentMethods/creditCardForm/creditCardForm.tpl.html @@ -102,27 +102,7 @@

{{'CREDIT_CARD_PAYMENT'}}

-
- -
-
{{'CARD_SEC_CODE_ERROR'}}
-
{{'MIN_LENGTH_CARD_SEC_CODE'}}
-
{{'MAX_LENGTH_CARD_SEC_CODE'}}
-
- {{'LOCATION_OF_CODE_OTHER'}} - {{'LOCATION_OF_CODE_AMEX'}} -
-
-
+
diff --git a/src/common/directives/creditCardCvv.directive.js b/src/common/directives/creditCardCvv.directive.js new file mode 100644 index 000000000..c77e49378 --- /dev/null +++ b/src/common/directives/creditCardCvv.directive.js @@ -0,0 +1,35 @@ +import angular from 'angular' +const directiveName = 'creditCardCvv' + +const template = +`
+ + +
+
{{'CARD_SEC_CODE_ERROR'}}
+
{{'MIN_LENGTH_CARD_SEC_CODE'}}
+
{{'MAX_LENGTH_CARD_SEC_CODE'}}
+
+ {{'LOCATION_OF_CODE_OTHER'}} + {{'LOCATION_OF_CODE_AMEX'}} +
+
+
` + +const creditCardCvv = /* @ngInject */ () => { + const directiveDefinitionObject = { + restrict: 'E', + template + } + return directiveDefinitionObject +} + +export default angular + .module(directiveName, []) + .directive(directiveName, creditCardCvv)