From 87a19f87c829446dc74155e13a1145b58632d2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Silke=20Gr=C3=BCber?= <57660644+SGrueber@users.noreply.github.com> Date: Thu, 21 Jul 2022 10:39:35 +0200 Subject: [PATCH] feat: select payment methods as default in the payment settings (#1159) Co-authored-by: max.kless@googlemail.com --- .../integration/pages/account/payment.page.ts | 27 ++++ .../checkout-payments.b2c.e2e-spec.ts | 31 ++++- src/app/core/facades/account.facade.ts | 48 ++++++- .../payment-method.interface.ts | 1 + .../payment-method/payment-method.mapper.ts | 8 +- .../payment-method/payment-method.model.ts | 1 + .../core/services/payment/payment.service.ts | 12 +- src/app/core/services/user/user.service.ts | 4 +- .../store/core/messages/messages.effects.ts | 5 +- .../core/store/customer/user/user.actions.ts | 7 +- .../store/customer/user/user.effects.spec.ts | 59 +++++++- .../core/store/customer/user/user.effects.ts | 38 +++++- .../account-payment-page.component.html | 13 +- .../account-payment-page.component.spec.ts | 17 ++- .../account-payment-page.component.ts | 8 -- .../account-payment.component.html | 129 ++++++++++++------ .../account-payment.component.spec.ts | 53 ++----- .../account-payment.component.ts | 107 ++++++++++----- .../checkout-payment.component.html | 2 +- src/assets/i18n/de_DE.json | 14 +- src/assets/i18n/en_US.json | 14 +- src/assets/i18n/fr_FR.json | 14 +- src/styles/pages/payment/payment.scss | 2 +- 23 files changed, 433 insertions(+), 181 deletions(-) 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.heading' | translate }}

+ + + +
diff --git a/src/app/pages/account-payment/account-payment-page.component.spec.ts b/src/app/pages/account-payment/account-payment-page.component.spec.ts index e956786571..95cab04e13 100644 --- a/src/app/pages/account-payment/account-payment-page.component.spec.ts +++ b/src/app/pages/account-payment/account-payment-page.component.spec.ts @@ -1,8 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { MockComponent } from 'ng-mocks'; -import { instance, mock } from 'ts-mockito'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; import { AccountFacade } from 'ish-core/facades/account.facade'; +import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; import { AccountPaymentPageComponent } from './account-payment-page.component'; import { AccountPaymentComponent } from './account-payment/account-payment.component'; @@ -11,11 +14,17 @@ describe('Account Payment Page Component', () => { let component: AccountPaymentPageComponent; let fixture: ComponentFixture; let element: HTMLElement; - const accountFacade = mock(AccountFacade); + let accountFacade: AccountFacade; beforeEach(async () => { + accountFacade = mock(AccountFacade); await TestBed.configureTestingModule({ - declarations: [AccountPaymentPageComponent, MockComponent(AccountPaymentComponent)], + imports: [TranslateModule.forRoot()], + declarations: [ + AccountPaymentPageComponent, + MockComponent(AccountPaymentComponent), + MockComponent(ErrorMessageComponent), + ], providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], }).compileComponents(); }); @@ -24,6 +33,8 @@ describe('Account Payment Page Component', () => { fixture = TestBed.createComponent(AccountPaymentPageComponent); component = fixture.componentInstance; element = fixture.nativeElement; + + when(accountFacade.userError$).thenReturn(of(undefined)); }); it('should be created', () => { diff --git a/src/app/pages/account-payment/account-payment-page.component.ts b/src/app/pages/account-payment/account-payment-page.component.ts index a599c95917..e915bb9789 100644 --- a/src/app/pages/account-payment/account-payment-page.component.ts +++ b/src/app/pages/account-payment/account-payment-page.component.ts @@ -28,12 +28,4 @@ export class AccountPaymentPageComponent implements OnInit { this.loading$ = this.accountFacade.userLoading$; this.user$ = this.accountFacade.user$; } - - deletePaymentInstrument(instrumentId: string) { - this.accountFacade.deletePaymentInstrument(instrumentId); - } - - updateDefaultPaymentInstrument(user: User) { - this.accountFacade.updateUser(user, { message: 'account.payment.payment_created.message' }); - } } diff --git a/src/app/pages/account-payment/account-payment/account-payment.component.html b/src/app/pages/account-payment/account-payment/account-payment.component.html index 6f34621d39..f1086f30f8 100644 --- a/src/app/pages/account-payment/account-payment/account-payment.component.html +++ b/src/app/pages/account-payment/account-payment/account-payment.component.html @@ -1,53 +1,94 @@ -

{{ 'account.payment.heading' | translate }}

- - - -

{{ 'account.payment.message' | translate }}

- -
-

{{ 'account.payment.preferred_method' | translate }}

-

- {{ preferredPaymentMethod.displayName }} -

-
- +
+

{{ 'account.payment.message' | translate }}

+ + +
+

{{ 'account.payment.preferred_method' | translate }}

+
+ +
+ {{ getPreferredPaymentMethod()?.displayName }} +
+
+ +
+
+ {{ 'account.payment.no_preferred_method' | translate }} +
-
- - -

{{ 'account.payment.further_payments.heading' | translate }}

-
-

- {{ method.displayName }} -

-
- + + +
+

{{ 'account.payment.further_payments.heading' | translate }}

+
+
+ {{ method.displayName }} +
+ +
+ +
+
+ +
+

+ {{ 'account.payment.standard_methods' | translate }} +

+
+ + +
+
+ +
+ +
- + + +
+ {{ + 'checkout.payment.method.delete.link' | translate + }} + + + + +
+
+

{{ 'account.payment.no_entries' | translate }}

- - -
-
- - - -
-

{{ pi.accountIdentifier }}

- {{ 'account.payment.preferred.link' | translate }}
- -
-
diff --git a/src/app/pages/account-payment/account-payment/account-payment.component.spec.ts b/src/app/pages/account-payment/account-payment/account-payment.component.spec.ts index 921c4088e2..a3eb83e016 100644 --- a/src/app/pages/account-payment/account-payment/account-payment.component.spec.ts +++ b/src/app/pages/account-payment/account-payment/account-payment.component.spec.ts @@ -1,13 +1,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { ReactiveFormsModule } from '@angular/forms'; import { TranslateModule } from '@ngx-translate/core'; import { MockComponent } from 'ng-mocks'; -import { anything, capture, spy, verify } from 'ts-mockito'; +import { anything, instance, mock, verify } from 'ts-mockito'; +import { AccountFacade } from 'ish-core/facades/account.facade'; import { User } from 'ish-core/models/user/user.model'; -import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; -import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; import { AccountPaymentConcardisDirectdebitComponent } from '../account-payment-concardis-directdebit/account-payment-concardis-directdebit.component'; @@ -17,16 +16,14 @@ describe('Account Payment Component', () => { let component: AccountPaymentComponent; let fixture: ComponentFixture; let element: HTMLElement; + let accountFacade: AccountFacade; beforeEach(async () => { + accountFacade = mock(AccountFacade); await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [ - AccountPaymentComponent, - MockComponent(AccountPaymentConcardisDirectdebitComponent), - MockComponent(ErrorMessageComponent), - MockComponent(FaIconComponent), - ], + imports: [ReactiveFormsModule, TranslateModule.forRoot()], + declarations: [AccountPaymentComponent, MockComponent(AccountPaymentConcardisDirectdebitComponent)], + providers: [{ provide: AccountFacade, useFactory: () => instance(accountFacade) }], }).compileComponents(); }); @@ -54,7 +51,7 @@ describe('Account Payment Component', () => { component.user = { firstName: 'Paticia', lastName: 'Miller', - preferredPaymentInstrumentId: '123', + preferredPaymentInstrumentId: '456', } as User; }); @@ -84,6 +81,8 @@ describe('Account Payment Component', () => { it('should render a preferred payment instrument if there is one', () => { component.ngOnChanges(); fixture.detectChanges(); + + expect(component.preferredPaymentInstrument.id).toEqual(component.user.preferredPaymentInstrumentId); expect(element.querySelector('[data-testing-id="preferred-payment-instrument"]')).toBeTruthy(); }); @@ -95,46 +94,24 @@ describe('Account Payment Component', () => { }); }); - describe('error display', () => { - it('should not render an error if no error occurs', () => { - fixture.detectChanges(); - expect(element.querySelector('[role="alert"]')).toBeFalsy(); - }); - - it('should render an error if an error occurs', () => { - component.error = makeHttpError({ status: 404 }); - fixture.detectChanges(); - expect(element.querySelector('ish-error-message')).toBeTruthy(); - }); - }); - describe('delete payment instrument', () => { - it('should throw deletePaymentInstrument event when the user deletes a payment instrument', () => { + it('should call deletePaymentInstrument event the user deletes a payment instrument', () => { fixture.detectChanges(); - const emitter = spy(component.deletePaymentInstrument); - component.deleteUserPayment('paymentInstrumentId'); - verify(emitter.emit(anything())).once(); - const [arg] = capture(emitter.emit).last(); - expect(arg).toMatchInlineSnapshot(`"paymentInstrumentId"`); + verify(accountFacade.deletePaymentInstrument('paymentInstrumentId', anything())).once(); }); }); describe('update default payment instrument', () => { - it('should throw updateDefaultPaymentInstrument event when the user changes his preferred payment instrument', () => { + it('should call updateDefaultPaymentInstrument when the user changes his preferred payment instrument', () => { component.user = { firstName: 'Patricia', lastName: 'Miller' } as User; fixture.detectChanges(); - const emitter = spy(component.updateDefaultPaymentInstrument); - component.setAsDefaultPayment('paymentInstrumentId'); - - verify(emitter.emit(anything())).once(); - const [user] = capture(emitter.emit).last(); - expect(user?.preferredPaymentInstrumentId).toMatchInlineSnapshot(`"paymentInstrumentId"`); + verify(accountFacade.updateUserPreferredPaymentInstrument(anything(), anything(), anything())).once(); }); }); }); diff --git a/src/app/pages/account-payment/account-payment/account-payment.component.ts b/src/app/pages/account-payment/account-payment/account-payment.component.ts index 3b7da92885..46c3ee02d9 100644 --- a/src/app/pages/account-payment/account-payment/account-payment.component.ts +++ b/src/app/pages/account-payment/account-payment/account-payment.component.ts @@ -1,13 +1,15 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { Subject, filter, takeUntil } from 'rxjs'; -import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { AccountFacade } from 'ish-core/facades/account.facade'; import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-instrument.model'; import { PaymentMethod } from 'ish-core/models/payment-method/payment-method.model'; import { User } from 'ish-core/models/user/user.model'; /** - * The Account Payment Component displays the preferred payment instrument of the user - * and any further payment instruments. The user can delete payment instruments and change the preferred payment instrument. + * The Account Payment Component displays the preferred payment method/instrument of the user + * and any further payment options. The user can delete payment instruments and change the preferred payment method/instrument. * Adding a payment instrument is only possible during the checkout. * see also: {@link AccountPaymentPageComponent} */ @@ -16,28 +18,61 @@ import { User } from 'ish-core/models/user/user.model'; templateUrl: './account-payment.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AccountPaymentComponent implements OnChanges { +export class AccountPaymentComponent implements OnInit, OnChanges, OnDestroy { @Input() paymentMethods: PaymentMethod[]; @Input() user: User; - @Input() error: HttpError; - - @Output() deletePaymentInstrument = new EventEmitter(); - @Output() updateDefaultPaymentInstrument = new EventEmitter(); preferredPaymentInstrument: PaymentInstrument; - preferredPaymentMethod: PaymentMethod; savedPaymentMethods: PaymentMethod[]; + standardPaymentMethods: PaymentMethod[]; + + paymentForm: FormGroup; + + private destroy$ = new Subject(); + + constructor(private accountFacade: AccountFacade) {} + + ngOnInit() { + this.paymentForm = new FormGroup({ + id: new FormControl(this.user?.preferredPaymentInstrumentId), + }); + + // trigger update preferred payment method if payment selection changes + this.paymentForm + .get('id') + .valueChanges.pipe( + filter(paymentInstrumentId => paymentInstrumentId !== this.user.preferredPaymentInstrumentId), + takeUntil(this.destroy$) + ) + .subscribe(id => { + this.setAsDefaultPayment(id); + }); + } /** - * refresh the display of the preferred payment instrument and the shown further addresses. + * refreshes the display of the preferred payment instrument and the further payment options. */ ngOnChanges() { this.determinePreferredPaymentInstrument(); this.determineFurtherPaymentMethods(); } - determineFurtherPaymentMethods() { - this.savedPaymentMethods = this.paymentMethods; + private determinePreferredPaymentInstrument() { + this.preferredPaymentInstrument = undefined; + if (this.paymentMethods && this.user) { + this.paymentMethods.forEach(pm => { + this.preferredPaymentInstrument = + pm.paymentInstruments?.find(pi => pi.id === this.user.preferredPaymentInstrumentId) || + this.preferredPaymentInstrument; + }); + this.paymentForm?.get('id').setValue(this.preferredPaymentInstrument?.id, { emitEvent: false }); // update form value + } + } + + private determineFurtherPaymentMethods() { + this.savedPaymentMethods = this.paymentMethods?.length + ? this.paymentMethods.filter(pm => pm.paymentInstruments?.length) + : []; if (this.preferredPaymentInstrument) { this.savedPaymentMethods = this.savedPaymentMethods .map(pm => ({ @@ -46,41 +81,45 @@ export class AccountPaymentComponent implements OnChanges { })) .filter(pm => pm.paymentInstruments?.length); } + this.standardPaymentMethods = this.paymentMethods?.length + ? this.paymentMethods.filter(pm => !pm.paymentInstruments?.length) + : []; } - determinePreferredPaymentInstrument() { - this.preferredPaymentInstrument = undefined; - this.preferredPaymentMethod = undefined; - if (this.paymentMethods && this.user) { - this.paymentMethods.forEach(pm => { - this.preferredPaymentInstrument = - pm.paymentInstruments?.find(pi => pi.id === this.user.preferredPaymentInstrumentId) || - this.preferredPaymentInstrument; - }); - this.preferredPaymentMethod = - this.preferredPaymentInstrument && - this.paymentMethods.find(pm => pm.id === this.preferredPaymentInstrument.paymentMethod); - } + /** + * determines the preferred payment method + */ + getPreferredPaymentMethod() { + return ( + this.preferredPaymentInstrument && + this.paymentMethods.find(pm => pm.id === this.preferredPaymentInstrument.paymentMethod) + ); } /** - * deletes a user payment instrument + * deletes a user payment instrument and triggers a toast in case of success */ deleteUserPayment(paymentInstrumentId: string) { if (paymentInstrumentId) { - this.deletePaymentInstrument.emit(paymentInstrumentId); + this.accountFacade.deletePaymentInstrument(paymentInstrumentId, { + message: 'account.payment.payment_deleted.message', + }); } } /** * change the user's preferred payment instrument */ - setAsDefaultPayment(paymentInstrumentId: string) { - if (paymentInstrumentId) { - this.updateDefaultPaymentInstrument.emit({ - ...this.user, - preferredPaymentInstrumentId: paymentInstrumentId, - }); + setAsDefaultPayment(id: string) { + if (id && this.standardPaymentMethods?.some(pm => pm.id === id)) { + this.accountFacade.updateUserPreferredPaymentMethod(this.user, id, this.preferredPaymentInstrument); + } else { + this.accountFacade.updateUserPreferredPaymentInstrument(this.user, id, this.preferredPaymentInstrument); } } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/src/app/pages/checkout-payment/checkout-payment/checkout-payment.component.html b/src/app/pages/checkout-payment/checkout-payment/checkout-payment.component.html index 8cd3f1c2d8..cac05fecfc 100644 --- a/src/app/pages/checkout-payment/checkout-payment/checkout-payment.component.html +++ b/src/app/pages/checkout-payment/checkout-payment/checkout-payment.component.html @@ -18,7 +18,7 @@

{{ 'checkout.payment.method.select.heading' | translate }}

-
    +
    • diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index c5c3f08a18..89a13ff5a2 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -272,17 +272,19 @@ "account.overview.wishlist.view_all": "Alle Wunschlisten anzeigen", "account.password.error.regexp": "Ihr Kennwort muss aus mindestens 7 Zeichen bestehen.", "account.password.label": "Aktuelles Kennwort", - "account.payment.further_payments.heading": "Weitere gespeicherte Zahlungsmittel", + "account.payment.further_payments.heading": "Weitere Zahlungsmethoden", "account.payment.heading": "Gespeicherte Zahlungsdaten", "account.payment.link": "Zahlung", - "account.payment.message": "Wählen Sie ein bevorzugtes Zahlungsmittel für eine schnellere Zahlung bei zukünftigen Einkäufen.", - "account.payment.no_entries": "Derzeit haben Sie keine gespeicherten Zahlungsmittel.", - "account.payment.payment_created.message": "Ihr bevorzugtes Zahlungsmittel wurde in Ihrem Konto gespeichert.", + "account.payment.message": "Wählen Sie eine bevorzugte Zahlungsmethode für eine schnellere Zahlung bei zukünftigen Einkäufen.", + "account.payment.no_entries": "Derzeit haben Sie keine Zahlungsmethoden.", + "account.payment.no_preferred.message": "Sie haben keine bevorzugte Zahlungsmethode mehr ausgewählt.", + "account.payment.no_preferred_method": "Keine bevorzugte Zahlungmethode", + "account.payment.payment_created.message": "Ihre bevorzugte Zahlungsmethode wurde in Ihrem Konto gespeichert.", "account.payment.payment_deleted.message": "Ihr Zahlungsmittel wurde gelöscht.", - "account.payment.preferred.link": "Als bevorzugtes Zahlungsmittel speichern", - "account.payment.preferred_method": "Bevorzugtes Zahlungsmittel", + "account.payment.preferred_method": "Ihre Bevorzugte Zahlungsmethode", "account.payment.sepa_accepted_on": "Akzeptiert am", "account.payment.sepa_mandate_Reference": "Mandatsreferenz", + "account.payment.standard_methods": "Standardzahlungsmethoden", "account.payment.view_sepa_mandate.link": "SEPA-Mandat anzeigen", "account.payment.view_sepa_mandate_text.link": "SEPA-Lastschriftmandat", "account.profile.birthday.label": "Geburtsdatum", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index e7b9d3fc2e..2406f93406 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -272,17 +272,19 @@ "account.overview.wishlist.view_all": "View All Wish Lists", "account.password.error.regexp": "Your password must be at least 7 characters in length.", "account.password.label": "Current Password", - "account.payment.further_payments.heading": "Further Saved Payment Instruments", + "account.payment.further_payments.heading": "Further Payment Methods", "account.payment.heading": "Saved Payment Information", "account.payment.link": "Payment", - "account.payment.message": "Select a preferred payment instrument for quicker checkout on future purchases.", - "account.payment.no_entries": "Currently, you do not have any saved payment instruments.", - "account.payment.payment_created.message": "Your preferred payment instrument has been saved to your account.", + "account.payment.message": "Select a preferred payment method for quicker checkout on future purchases.", + "account.payment.no_entries": "Currently there are no payment methods.", + "account.payment.no_preferred.message": "You no longer have a preferred payment method selected.", + "account.payment.no_preferred_method": "No preferred payment method", + "account.payment.payment_created.message": "Your preferred payment method has been saved in your account.", "account.payment.payment_deleted.message": "Your payment instrument has been deleted.", - "account.payment.preferred.link": "Save as Preferred Payment Instrument", - "account.payment.preferred_method": "Preferred Payment Instrument", + "account.payment.preferred_method": "Your Preferred Payment Method", "account.payment.sepa_accepted_on": "Accepted on", "account.payment.sepa_mandate_Reference": "Mandate Reference", + "account.payment.standard_methods": "Standard Payment Methods", "account.payment.view_sepa_mandate.link": "View SEPA mandate", "account.payment.view_sepa_mandate_text.link": "SEPA Direct Debit Mandate", "account.profile.birthday.label": "Date of Birth", diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index d44a825fcb..64f962e822 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -272,17 +272,19 @@ "account.overview.wishlist.view_all": "Afficher toutes les listes de souhaits", "account.password.error.regexp": "Votre mot de passe doit comporter au moins 7 caractères.", "account.password.label": "Mot de passe actuel", - "account.payment.further_payments.heading": "Autres moyens de paiement sauvegardés", + "account.payment.further_payments.heading": "Autres modes de paiement", "account.payment.heading": "Informations de paiement enregistrées", "account.payment.link": "Paiement", - "account.payment.message": "Choisissez un moyen de paiement par défaut pour un paiement plus rapide de vos achats futurs.", - "account.payment.no_entries": "Actuellement, vous n’avez aucun moyen de paiement sauvegardé.", - "account.payment.payment_created.message": "Votre moyen de paiement par défaut a été enregistré sur votre compte.", + "account.payment.message": "Choisissez un mode de paiement par défaut pour un paiement plus rapide de vos achats futurs.", + "account.payment.no_entries": "Actuellement, vous n’avez aucun mode de paiement.", + "account.payment.no_preferred.message": "Vous n'avez plus sélectionné de mode de paiement préféré.", + "account.payment.no_preferred_method": "Pas de mode de paiement par défaut", + "account.payment.payment_created.message": "Votre mode de paiement par défaut a été enregistré sur votre compte.", "account.payment.payment_deleted.message": "Votre moyen de paiement a été supprimé.", - "account.payment.preferred.link": "Enregistrer comme moyen de paiement par défaut", - "account.payment.preferred_method": "Moyen de paiement par défaut", + "account.payment.preferred_method": "Votre mode de paiement par défaut", "account.payment.sepa_accepted_on": "Accepté le", "account.payment.sepa_mandate_Reference": "Référence du mandat", + "account.payment.standard_methods": "Modes de paiement standard", "account.payment.view_sepa_mandate.link": "Voir le mandat SEPA", "account.payment.view_sepa_mandate_text.link": "Mandat de prélèvement SEPA", "account.profile.birthday.label": "Date de naissance", diff --git a/src/styles/pages/payment/payment.scss b/src/styles/pages/payment/payment.scss index f9e1990183..c3119a1119 100644 --- a/src/styles/pages/payment/payment.scss +++ b/src/styles/pages/payment/payment.scss @@ -2,7 +2,7 @@ // Payment // -------------------------------------------------- -#payment-accordion { +.payment-methods { margin-bottom: $space-default * 2; & > .panel {