diff --git a/e2e/cypress/integration/pages/account/payment.page.ts b/e2e/cypress/integration/pages/account/payment.page.ts index ec119000e0..0f67551023 100644 --- a/e2e/cypress/integration/pages/account/payment.page.ts +++ b/e2e/cypress/integration/pages/account/payment.page.ts @@ -1,3 +1,4 @@ +import { waitLoadingEnd } from '../../framework'; import { HeaderModule } from '../header.module'; export class PaymentPage { @@ -9,7 +10,33 @@ export class PaymentPage { return cy.get(this.tag); } + get preferredPaymentMethod() { + return cy.get(this.tag).find('[data-testing-id="preferred-payment-method"]'); + } + + get noPreferredPaymentOption() { + return cy.get(this.tag).find('#paymentOption_empty'); + } + static navigateTo() { cy.visit('/account/payment'); } + + selectPayment(payment: 'INVOICE' | 'CASH_ON_DELIVERY' | 'CASH_IN_ADVANCE') { + cy.get(this.tag).find(`#paymentOption_ISH_${payment}`).check(); + cy.wait(1500); + waitLoadingEnd(); + } + + selectCreditCard() { + cy.get(this.tag).find('div[data-testing-id="paymentMethodList"] input').first().check(); + cy.wait(1500); + waitLoadingEnd(); + } + + selectNoPreferredPayment() { + this.noPreferredPaymentOption.check(); + cy.wait(1500); + waitLoadingEnd(); + } } diff --git a/e2e/cypress/integration/specs/checkout/checkout-payments.b2c.e2e-spec.ts b/e2e/cypress/integration/specs/checkout/checkout-payments.b2c.e2e-spec.ts index 66144b0c53..ba49323504 100644 --- a/e2e/cypress/integration/specs/checkout/checkout-payments.b2c.e2e-spec.ts +++ b/e2e/cypress/integration/specs/checkout/checkout-payments.b2c.e2e-spec.ts @@ -37,13 +37,10 @@ describe('Checkout Payment', () => { }); }); - it('should set first addresses automatically', () => { + it('should continue checkout up to the payment page', () => { at(CheckoutAddressesPage, page => { page.continueCheckout(); }); - }); - - it('should accept default shipping option', () => { at(CheckoutShippingPage, page => page.continueCheckout()); }); @@ -150,6 +147,32 @@ describe('Checkout Payment', () => { page.content.should('contain', '********1111'); page.content.should('not.contain', '********4444'); page.content.should('not.contain', '****************0000'); + page.preferredPaymentMethod.should('not.exist'); + page.noPreferredPaymentOption.should('not.exist'); + }); + }); + + it('should set a unparametrized payment method as preferred payment method', () => { + at(PaymentPage, page => { + page.selectPayment('INVOICE'); + page.preferredPaymentMethod.should('contain', 'Invoice'); + page.noPreferredPaymentOption.should('be.visible'); + }); + }); + + it('should set a parametrized payment method as preferred payment method', () => { + at(PaymentPage, page => { + page.selectCreditCard(); + page.preferredPaymentMethod.should('contain', 'ISH Demo Credit Card'); + page.noPreferredPaymentOption.should('be.visible'); + }); + }); + + it('should reset preferred payment method', () => { + at(PaymentPage, page => { + page.selectNoPreferredPayment(); + page.preferredPaymentMethod.should('not.exist'); + page.noPreferredPaymentOption.should('not.exist'); }); }); }); diff --git a/src/app/core/facades/account.facade.ts b/src/app/core/facades/account.facade.ts index 265f5d3a92..c6678e07ed 100644 --- a/src/app/core/facades/account.facade.ts +++ b/src/app/core/facades/account.facade.ts @@ -9,6 +9,7 @@ import { Customer, CustomerRegistrationType, SsoRegistrationType } from 'ish-cor import { HttpError } from 'ish-core/models/http-error/http-error.model'; import { PasswordReminderUpdate } from 'ish-core/models/password-reminder-update/password-reminder-update.model'; import { PasswordReminder } from 'ish-core/models/password-reminder/password-reminder.model'; +import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-instrument.model'; import { User } from 'ish-core/models/user/user.model'; import { MessagesPayloadType } from 'ish-core/store/core/messages'; import { @@ -51,6 +52,7 @@ import { updateUser, updateUserPassword, updateUserPasswordByPasswordReminder, + updateUserPreferredPayment, } from 'ish-core/store/customer/user'; import { whenTruthy } from 'ish-core/utils/operators'; @@ -164,8 +166,50 @@ export class AccountFacade { return this.eligiblePaymentMethods$; } - deletePaymentInstrument(paymentInstrumentId: string) { - this.store.dispatch(deleteUserPaymentInstrument({ id: paymentInstrumentId })); + deletePaymentInstrument(paymentInstrumentId: string, successMessage?: MessagesPayloadType) { + this.store.dispatch(deleteUserPaymentInstrument({ id: paymentInstrumentId, successMessage })); + } + + async updateUserPreferredPaymentMethod( + user: User, + paymentMethodId: string, + currentPreferredPaymentInstrument: PaymentInstrument + ) { + if (currentPreferredPaymentInstrument && !currentPreferredPaymentInstrument.parameters?.length) { + this.deletePaymentInstrument(currentPreferredPaymentInstrument.id); + await new Promise(resolve => setTimeout(resolve, 600)); // prevent server conflicts + } + + this.store.dispatch( + updateUserPreferredPayment({ + user, + paymentMethodId, + successMessage: { + message: 'account.payment.payment_created.message', + }, + }) + ); + } + + async updateUserPreferredPaymentInstrument( + user: User, + paymentInstrumentId: string, + currentPreferredPaymentInstrument: PaymentInstrument + ) { + if (currentPreferredPaymentInstrument && !currentPreferredPaymentInstrument.parameters?.length) { + this.deletePaymentInstrument(currentPreferredPaymentInstrument.id); + await new Promise(resolve => setTimeout(resolve, 600)); // prevent server conflicts + } + this.store.dispatch( + updateUser({ + user: { ...user, preferredPaymentInstrumentId: paymentInstrumentId }, + successMessage: { + message: paymentInstrumentId + ? 'account.payment.payment_created.message' + : 'account.payment.no_preferred.message', + }, + }) + ); } // ADDRESSES diff --git a/src/app/core/models/payment-method/payment-method.interface.ts b/src/app/core/models/payment-method/payment-method.interface.ts index 3e4e26b70e..1221368eca 100644 --- a/src/app/core/models/payment-method/payment-method.interface.ts +++ b/src/app/core/models/payment-method/payment-method.interface.ts @@ -30,6 +30,7 @@ export interface PaymentMethodBaseData { paymentInstruments?: string[]; parameterDefinitions?: PaymentMethodParameterType[]; hostedPaymentPageParameters?: { name: string; value: string }[]; + paymentParameters?: { name: string; key: string }[]; // Needed for old payment method user REST api } export interface PaymentMethodData { diff --git a/src/app/core/models/payment-method/payment-method.mapper.ts b/src/app/core/models/payment-method/payment-method.mapper.ts index b573651efb..5608c649b5 100644 --- a/src/app/core/models/payment-method/payment-method.mapper.ts +++ b/src/app/core/models/payment-method/payment-method.mapper.ts @@ -62,13 +62,16 @@ export class PaymentMethodMapper { return []; } - // return only payment methods that have also a payment instrument + const pmBlacklist = ['ISH_FASTPAY', 'ISH_INVOICE_TOTAL_ZERO']; + + // return only payment methods that have either payment instruments or no parameters return options.methods[0].payments .map(pm => ({ id: pm.id, serviceId: pm.id, // is missing displayName: pm.displayName, restrictionCauses: pm.restrictions, + hasParameters: !!pm.paymentParameters?.length, paymentInstruments: options.instruments .filter(i => i.name === pm.id) .map(i => ({ @@ -78,7 +81,8 @@ export class PaymentMethodMapper { paymentMethod: pm.id, })), })) - .filter(x => x.paymentInstruments?.length); + .filter(pm => !pmBlacklist.includes(pm.serviceId)) + .filter(pm => !pm.hasParameters || pm.paymentInstruments?.length); } /** diff --git a/src/app/core/models/payment-method/payment-method.model.ts b/src/app/core/models/payment-method/payment-method.model.ts index bb612e9080..81d22adb98 100644 --- a/src/app/core/models/payment-method/payment-method.model.ts +++ b/src/app/core/models/payment-method/payment-method.model.ts @@ -18,4 +18,5 @@ export interface PaymentMethod { paymentInstruments?: PaymentInstrument[]; parameters?: FormlyFieldConfig[]; hostedPaymentPageParameters?: { name: string; value: string }[]; + hasParameters?: boolean; // Needed for old payment method user REST api } diff --git a/src/app/core/services/payment/payment.service.ts b/src/app/core/services/payment/payment.service.ts index 8001434554..dd5d89dd7b 100644 --- a/src/app/core/services/payment/payment.service.ts +++ b/src/app/core/services/payment/payment.service.ts @@ -273,12 +273,8 @@ export class PaymentService { if (!customerNo) { return throwError(() => new Error('createUserPayment called without required customer number')); } - if (!paymentInstrument) { - return throwError(() => new Error('createUserPayment called without required payment instrument')); - } - - if (!paymentInstrument.parameters || !paymentInstrument.parameters.length) { - return throwError(() => new Error('createUserPayment called without required payment parameters')); + if (!paymentInstrument?.paymentMethod) { + return throwError(() => new Error('createUserPayment called without required valid payment instrument')); } const body: { @@ -289,7 +285,9 @@ export class PaymentService { }[]; } = { name: paymentInstrument.paymentMethod, - parameters: paymentInstrument.parameters.map(attr => ({ key: attr.name, property: attr.value })), + parameters: paymentInstrument.parameters?.length + ? paymentInstrument.parameters.map(attr => ({ key: attr.name, property: attr.value })) + : undefined, }; return this.appFacade.customerRestResource$.pipe( diff --git a/src/app/core/services/user/user.service.ts b/src/app/core/services/user/user.service.ts index 6640005d30..09b43ca6b7 100644 --- a/src/app/core/services/user/user.service.ts +++ b/src/app/core/services/user/user.service.ts @@ -192,7 +192,9 @@ export class UserService { ...body.user, preferredInvoiceToAddress: { urn: body.user.preferredInvoiceToAddressUrn }, preferredShipToAddress: { urn: body.user.preferredShipToAddressUrn }, - preferredPaymentInstrument: { id: body.user.preferredPaymentInstrumentId }, + preferredPaymentInstrument: body.user.preferredPaymentInstrumentId + ? { id: body.user.preferredPaymentInstrumentId } + : {}, preferredInvoiceToAddressUrn: undefined, preferredShipToAddressUrn: undefined, preferredPaymentInstrumentId: undefined, diff --git a/src/app/core/store/core/messages/messages.effects.ts b/src/app/core/store/core/messages/messages.effects.ts index ca10e5f3ae..38de8b2c56 100644 --- a/src/app/core/store/core/messages/messages.effects.ts +++ b/src/app/core/store/core/messages/messages.effects.ts @@ -6,7 +6,7 @@ import { Store, select } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { ActiveToast, IndividualConfig, ToastrService } from 'ngx-toastr'; import { OperatorFunction, Subject, combineLatest } from 'rxjs'; -import { map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; +import { filter, map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; import { getDeviceType } from 'ish-core/store/core/configuration'; import { isStickyHeader } from 'ish-core/store/core/viewconf'; @@ -82,6 +82,7 @@ export class MessagesEffects { this.actions$.pipe( ofType(displaySuccessMessage), mapToPayload(), + filter(payload => !!payload?.message), this.composeToastServiceArguments(), map(args => this.toastr.success(...args)), tap(() => { @@ -128,9 +129,9 @@ export class MessagesEffects { withLatestFrom(this.store.pipe(select(getDeviceType))), map(([payload, deviceType]) => { const timeOut = payload.duration ?? duration ?? 5000; - return [ // message translation + this.translate.instant(payload.message, payload.messageParams), // title translation payload.title ? this.translate.instant(payload.title, payload.titleParams) : payload.title, diff --git a/src/app/core/store/customer/user/user.actions.ts b/src/app/core/store/customer/user/user.actions.ts index 041d2a999c..114b0dfed4 100644 --- a/src/app/core/store/customer/user/user.actions.ts +++ b/src/app/core/store/customer/user/user.actions.ts @@ -41,6 +41,11 @@ export const updateUserSuccess = createAction( export const updateUserFail = createAction('[User API] Update User Failed', httpError()); +export const updateUserPreferredPayment = createAction( + '[User] Update User Preferred Payment', + payload<{ user: User; paymentMethodId: string; successMessage?: MessagesPayloadType }>() +); + export const updateUserPassword = createAction( '[User] Update User Password', payload<{ password: string; currentPassword: string; successMessage?: MessagesPayloadType }>() @@ -91,7 +96,7 @@ export const loadUserPaymentMethodsSuccess = createAction( export const deleteUserPaymentInstrument = createAction( '[User] Delete User Instrument Payment ', - payload<{ id: string }>() + payload<{ id: string; successMessage?: MessagesPayloadType }>() ); export const deleteUserPaymentInstrumentFail = createAction( diff --git a/src/app/core/store/customer/user/user.effects.spec.ts b/src/app/core/store/customer/user/user.effects.spec.ts index b0c0ec2c0f..43823b0639 100644 --- a/src/app/core/store/customer/user/user.effects.spec.ts +++ b/src/app/core/store/customer/user/user.effects.spec.ts @@ -52,6 +52,7 @@ import { updateUserPassword, updateUserPasswordFail, updateUserPasswordSuccess, + updateUserPreferredPayment, updateUserSuccess, } from './user.actions'; import { UserEffects } from './user.effects'; @@ -94,6 +95,7 @@ describe('User Effects', () => { when(userServiceMock.requestPasswordReminder(anything())).thenReturn(of({})); when(userServiceMock.getEligibleCostCenters()).thenReturn(of([])); when(paymentServiceMock.getUserPaymentMethods(anything())).thenReturn(of([])); + when(paymentServiceMock.createUserPayment(anything(), anything())).thenReturn(of({ id: 'paymentInstrumentId' })); when(paymentServiceMock.deleteUserPaymentInstrument(anyString(), anyString())).thenReturn(of(undefined)); when(apiTokenServiceMock.hasUserApiTokenCookie()).thenReturn(false); @@ -646,7 +648,10 @@ describe('User Effects', () => { }); it('should dispatch a DeleteUserPaymentSuccess action on successful', () => { - const action = deleteUserPaymentInstrument({ id: 'paymentInstrumentId' }); + const action = deleteUserPaymentInstrument({ + id: 'paymentInstrumentId', + successMessage: { message: 'account.payment.payment_deleted.message' }, + }); const completion1 = deleteUserPaymentInstrumentSuccess(); const completion2 = loadUserPaymentMethods(); const completion3 = displaySuccessMessage({ @@ -675,6 +680,58 @@ describe('User Effects', () => { expect(effects.deleteUserPayment$).toBeObservable(expected$); }); }); + + describe('updatePreferredUserPayment$', () => { + beforeEach(() => { + store$.dispatch( + loginUserSuccess({ + customer, + user: {} as User, + }) + ); + }); + + it('should call the payment service when UpdateUserPreferredPayment event is called', done => { + const action = updateUserPreferredPayment({ user: {} as User, paymentMethodId: 'paymentInstrumentId' }); + actions$ = of(action); + effects.updatePreferredUserPayment$.subscribe(() => { + verify(paymentServiceMock.createUserPayment(customer.customerNo, anything())).once(); + done(); + }); + }); + + it('should dispatch a UpdateUser action on successful payment instrument creation', () => { + const action = updateUserPreferredPayment({ + user: {} as User, + paymentMethodId: 'paymentInstrumentId', + }); + const completion1 = updateUser({ user: { preferredPaymentInstrumentId: 'paymentInstrumentId' } as User }); + const completion2 = loadUserPaymentMethods(); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-(cd)', { c: completion1, d: completion2 }); + + expect(effects.updatePreferredUserPayment$).toBeObservable(expected$); + }); + + it('should dispatch a UpdateUserFail action on failed', () => { + const error = makeHttpError({ status: 401, code: 'error' }); + when(paymentServiceMock.createUserPayment(anything(), anything())).thenReturn(throwError(() => error)); + + const action = updateUserPreferredPayment({ + user: {} as User, + paymentMethodId: 'paymentInstrumentId', + }); + const completion = updateUserFail({ + error, + }); + + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + expect(effects.updatePreferredUserPayment$).toBeObservable(expected$); + }); + }); + describe('requestPasswordReminder$', () => { const data: PasswordReminder = { email: 'patricia@test.intershop.de', diff --git a/src/app/core/store/customer/user/user.effects.ts b/src/app/core/store/customer/user/user.effects.ts index b1f9c4b13e..7188d6c433 100644 --- a/src/app/core/store/customer/user/user.effects.ts +++ b/src/app/core/store/customer/user/user.effects.ts @@ -49,6 +49,7 @@ import { updateUserPasswordByPasswordReminderSuccess, updateUserPasswordFail, updateUserPasswordSuccess, + updateUserPreferredPayment, updateUserSuccess, userErrorReset, } from './user.actions'; @@ -259,17 +260,15 @@ export class UserEffects { deleteUserPayment$ = createEffect(() => this.actions$.pipe( ofType(deleteUserPaymentInstrument), - mapToPayloadProperty('id'), + mapToPayload(), withLatestFrom(this.store$.pipe(select(getLoggedInCustomer))), filter(([, customer]) => !!customer), - concatMap(([id, customer]) => - this.paymentService.deleteUserPaymentInstrument(customer.customerNo, id).pipe( + concatMap(([payload, customer]) => + this.paymentService.deleteUserPaymentInstrument(customer.customerNo, payload.id).pipe( mergeMap(() => [ deleteUserPaymentInstrumentSuccess(), loadUserPaymentMethods(), - displaySuccessMessage({ - message: 'account.payment.payment_deleted.message', - }), + displaySuccessMessage(payload.successMessage), ]), mapErrorToAction(deleteUserPaymentInstrumentFail) ) @@ -277,6 +276,33 @@ export class UserEffects { ) ); + /** + * Creates a payment instrument for an unparametrized payment method (like invoice) and assigns it as preferred instrument at the user. + * This is necessary due to limitations of the payment user REST interface. + */ + updatePreferredUserPayment$ = createEffect(() => + this.actions$.pipe( + ofType(updateUserPreferredPayment), + mapToPayload(), + withLatestFrom(this.store$.pipe(select(getLoggedInCustomer))), + filter(([, customer]) => !!customer), + concatMap(([payload, customer]) => + this.paymentService + .createUserPayment(customer.customerNo, { id: undefined, paymentMethod: payload.paymentMethodId }) + .pipe( + mergeMap(pi => [ + updateUser({ + user: { ...payload.user, preferredPaymentInstrumentId: pi.id }, + successMessage: payload.successMessage, + }), + loadUserPaymentMethods(), + ]), + mapErrorToAction(updateUserFail) + ) + ) + ) + ); + requestPasswordReminder$ = createEffect(() => this.actions$.pipe( ofType(requestPasswordReminder), diff --git a/src/app/pages/account-payment/account-payment-page.component.html b/src/app/pages/account-payment/account-payment-page.component.html index 9e84e94b83..c183c30631 100644 --- a/src/app/pages/account-payment/account-payment-page.component.html +++ b/src/app/pages/account-payment/account-payment-page.component.html @@ -1,13 +1,10 @@
{{ 'account.payment.message' | translate }}
- -+ {{ 'account.payment.standard_methods' | translate }} +
+{{ 'account.payment.no_entries' | translate }}