Skip to content

Commit

Permalink
feat: fast checkout payment (e.g. PAYPAL Express) (#1682)
Browse files Browse the repository at this point in the history
* introducing of new shared component for payment costs
* prevent console errors when rendering order detail page before payment information is available

---------

Co-authored-by: Silke <s.grueber@intershop.de>
  • Loading branch information
skoch-intershop and SGrueber authored Aug 19, 2024
1 parent f7176c9 commit fb5a0d6
Show file tree
Hide file tree
Showing 33 changed files with 622 additions and 67 deletions.
9 changes: 9 additions & 0 deletions src/app/core/facades/checkout.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
getBasketShippingAddress,
getBasketValidationResults,
getCurrentBasket,
getEligibleFastCheckoutPaymentMethods,
getSubmittedBasket,
isBasketInvoiceAndShippingAddressEqual,
loadBasketEligibleAddresses,
Expand All @@ -47,6 +48,7 @@ import {
setBasketDesiredDeliveryDate,
setBasketPayment,
startCheckout,
startFastCheckout,
submitOrder,
updateBasket,
updateBasketAddress,
Expand Down Expand Up @@ -265,12 +267,19 @@ export class CheckoutFacade {
switchMap(() => this.store.pipe(select(getBasketEligiblePaymentMethods)))
);
}

eligibleFastCheckoutPaymentMethods$ = this.store.pipe(select(getEligibleFastCheckoutPaymentMethods));

priceType$ = this.store.pipe(select(getServerConfigParameter<'gross' | 'net'>('pricing.priceType')));

setBasketPayment(paymentName: string) {
this.store.dispatch(setBasketPayment({ id: paymentName }));
}

startFastCheckout(paymentName: string) {
this.store.dispatch(startFastCheckout({ paymentId: paymentName }));
}

createBasketPayment(paymentInstrument: PaymentInstrument, saveForLater = false) {
this.store.dispatch(createBasketPayment({ paymentInstrument, saveForLater }));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export class PaymentMethodMapper {
* valid: payment methods without capabilities or which have no capabilities given in the list below
*/
private static isPaymentMethodValid(paymentData: PaymentMethodBaseData): boolean {
const invalidCapabilities = ['LimitedTender', 'FastCheckout'];
const invalidCapabilities = ['LimitedTender'];

// without capabilities
if (!paymentData.capabilities?.length) {
Expand Down
19 changes: 18 additions & 1 deletion src/app/core/services/payment/payment.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,25 @@ describe('Payment Service', () => {
});
});

it("should set a payment to the basket when 'setBasketFastCheckoutPayment' is called", done => {
when(apiServiceMock.put(anyString(), anything(), anything())).thenReturn(
of({
data: {
redirect: {
redirectUrl: '/checkout/review',
},
},
})
);

paymentService.setBasketFastCheckoutPayment('testPayment').subscribe(() => {
verify(apiServiceMock.put(`payments/open-tender`, anything(), anything())).once();
done();
});
});

it("should set a payment to the basket when 'setBasketPayment' is called", done => {
when(apiServiceMock.put(anyString(), anything(), anything())).thenReturn(of([]));
when(apiServiceMock.put(anyString(), anything(), anything())).thenReturn(of({}));

paymentService.setBasketPayment('testPayment').subscribe(() => {
verify(apiServiceMock.put(`payments/open-tender`, anything(), anything())).once();
Expand Down
44 changes: 43 additions & 1 deletion src/app/core/services/payment/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable, of, throwError } from 'rxjs';
import { concatMap, first, map, withLatestFrom } from 'rxjs/operators';
import { concatMap, first, map, switchMap, take, withLatestFrom } from 'rxjs/operators';

import { AppFacade } from 'ish-core/facades/app.facade';
import { Basket } from 'ish-core/models/basket/basket.model';
Expand All @@ -20,6 +20,7 @@ import { PaymentMethod } from 'ish-core/models/payment-method/payment-method.mod
import { Payment } from 'ish-core/models/payment/payment.model';
import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service';
import { getCurrentLocale } from 'ish-core/store/core/configuration';
import { whenTruthy } from 'ish-core/utils/operators';

/**
* The Payment Service handles the interaction with the 'baskets' and 'users' REST API concerning payment functionality.
Expand Down Expand Up @@ -55,6 +56,47 @@ export class PaymentService {
.pipe(map(PaymentMethodMapper.fromData));
}

/**
* Adds a fast checkout payment at the selected basket and generate redirect url in one step/request.
*
* @param paymentInstrument The payment instrument id.
* @returns The necessary url for redirecting to payment provider.
*/
setBasketFastCheckoutPayment(paymentInstrument: string): Observable<string> {
if (!paymentInstrument) {
return throwError(() => new Error('setBasketFastCheckoutPayment() called without paymentInstrument'));
}
const loc = `${location.origin}${this.baseHref}`;

return this.store.pipe(select(getCurrentLocale)).pipe(
whenTruthy(),
take(1),
switchMap(currentLocale => {
const body = {
paymentInstrument,
redirect: {
successUrl: `${loc}/checkout/review;lang=${currentLocale}?redirect=success`,
cancelUrl: `${loc}/basket;lang=${currentLocale}?redirect=cancel`,
failureUrl: `${loc}/basket;lang=${currentLocale}?redirect=cancel`,
},
};

return this.apiService
.currentBasketEndpoint()
.put<{
data: {
redirect: {
redirectUrl: string;
};
};
}>('payments/open-tender', body, {
headers: this.basketHeaders,
})
.pipe(map(payload => payload.data.redirect.redirectUrl));
})
);
}

/**
* Adds a payment at the selected basket. If redirect is required the redirect urls are saved at basket in dependence of the payment instrument capabilities (redirectBeforeCheckout/RedirectAfterCheckout).
*
Expand Down
74 changes: 71 additions & 3 deletions src/app/core/store/customer/basket/basket-payment.effects.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { TestBed } from '@angular/core/testing';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Action, Store } from '@ngrx/store';
import { cold, hot } from 'jasmine-marbles';
import { Observable, of, throwError } from 'rxjs';
import { Observable, noop, of, throwError } from 'rxjs';
import { anyString, anything, instance, mock, verify, when } from 'ts-mockito';

import { BasketValidation } from 'ish-core/models/basket-validation/basket-validation.model';
import { Basket } from 'ish-core/models/basket/basket.model';
import { Customer } from 'ish-core/models/customer/customer.model';
import { PaymentInstrument } from 'ish-core/models/payment-instrument/payment-instrument.model';
import { PaymentMethod } from 'ish-core/models/payment-method/payment-method.model';
import { Payment } from 'ish-core/models/payment/payment.model';
import { PaymentService } from 'ish-core/services/payment/payment.service';
import { CoreStoreModule } from 'ish-core/store/core/core-store.module';
import { loadServerConfigSuccess } from 'ish-core/store/core/server-config';
import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module';
import { loginUserSuccess } from 'ish-core/store/customer/user';
import { makeHttpError } from 'ish-core/utils/dev/api-service-utils';
Expand All @@ -20,6 +22,7 @@ import { routerTestNavigatedAction } from 'ish-core/utils/dev/routing';

import { BasketPaymentEffects } from './basket-payment.effects';
import {
continueWithFastCheckout,
createBasketPayment,
createBasketPaymentFail,
createBasketPaymentSuccess,
Expand Down Expand Up @@ -49,7 +52,10 @@ describe('Basket Payment Effects', () => {
paymentServiceMock = mock(PaymentService);

TestBed.configureTestingModule({
imports: [CoreStoreModule.forTesting(['router']), CustomerStoreModule.forTesting('user', 'basket')],
imports: [
CoreStoreModule.forTesting(['configuration', 'serverConfig', 'router']),
CustomerStoreModule.forTesting('user', 'basket'),
],
providers: [
{ provide: PaymentService, useFactory: () => instance(paymentServiceMock) },
BasketPaymentEffects,
Expand Down Expand Up @@ -483,4 +489,66 @@ describe('Basket Payment Effects', () => {
expect(effects.loadBasketAfterBasketChangeSuccess$).toBeObservable(expected$);
});
});

describe('continueWithFastCheckout$ - continue with redirect', () => {
const basketValidation: BasketValidation = {
basket: BasketMockData.getBasket(),
results: {
valid: true,
adjusted: false,
},
};

beforeEach(() => {
when(paymentServiceMock.setBasketFastCheckoutPayment(anyString())).thenReturn(of(undefined));
store.dispatch(
loadServerConfigSuccess({
config: {
general: {
defaultLocale: 'de_DE',
defaultCurrency: 'EUR',
locales: ['en_US', 'de_DE', 'fr_BE', 'nl_BE'],
currencies: ['USD', 'EUR'],
},
},
})
);
});

it('should start redirect in case of successful payment instrument assignment', fakeAsync(() => {
// mock location.assign() with jest.fn()
Object.defineProperty(window, 'location', {
value: { assign: jest.fn() },
writable: true,
});

const action = continueWithFastCheckout({
targetRoute: undefined,
basketValidation,
paymentId: 'FastCheckout',
});
actions$ = of(action);

effects.continueWithFastCheckout$.subscribe({ next: noop, error: fail, complete: noop });

tick(500);

expect(window.location.assign).toHaveBeenCalled();
}));
it('should map to action of type setBasketPaymentFail in case of failure', () => {
when(paymentServiceMock.setBasketFastCheckoutPayment(anyString())).thenReturn(
throwError(() => makeHttpError({ message: 'invalid' }))
);
const action = continueWithFastCheckout({
targetRoute: undefined,
basketValidation,
paymentId: 'FastCheckout',
});
const completion = setBasketPaymentFail({ error: makeHttpError({ message: 'invalid' }) });
actions$ = hot('-a-a-a', { a: action });
const expected$ = cold('-c-c-c', { c: completion });

expect(effects.continueWithFastCheckout$).toBeObservable(expected$);
});
});
});
23 changes: 23 additions & 0 deletions src/app/core/store/customer/basket/basket-payment.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { Actions, concatLatestFrom, createEffect, ofType } from '@ngrx/effects';
import { routerNavigatedAction } from '@ngrx/router-store';
import { Store, select } from '@ngrx/store';
import { EMPTY } from 'rxjs';
import { concatMap, filter, map, switchMap, take } from 'rxjs/operators';

import { PaymentService } from 'ish-core/services/payment/payment.service';
Expand All @@ -10,6 +11,7 @@ import { getLoggedInCustomer } from 'ish-core/store/customer/user';
import { mapErrorToAction, mapToPayload, mapToPayloadProperty, whenTruthy } from 'ish-core/utils/operators';

import {
continueWithFastCheckout,
createBasketPayment,
createBasketPaymentFail,
createBasketPaymentSuccess,
Expand All @@ -36,6 +38,27 @@ import { getCurrentBasket, getCurrentBasketId } from './basket.selectors';
export class BasketPaymentEffects {
constructor(private actions$: Actions, private store: Store, private paymentService: PaymentService) {}

/**
* The redirect fast checkout payment method effect.
*/
continueWithFastCheckout$ = createEffect(
() =>
this.actions$.pipe(
ofType(continueWithFastCheckout),
mapToPayloadProperty('paymentId'),
switchMap(paymentInstrumentId =>
this.paymentService.setBasketFastCheckoutPayment(paymentInstrumentId).pipe(
concatMap(redirectUrl => {
location.assign(redirectUrl);
return EMPTY;
}),
mapErrorToAction(setBasketPaymentFail)
)
)
),
{ dispatch: false }
);

/**
* The load basket eligible payment methods effect.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ import {
continueCheckoutFail,
continueCheckoutSuccess,
continueCheckoutWithIssues,
continueWithFastCheckout,
loadBasketSuccess,
startCheckout,
startCheckoutFail,
startCheckoutSuccess,
startFastCheckout,
submitBasket,
validateBasket,
} from './basket.actions';
Expand Down Expand Up @@ -280,6 +282,59 @@ describe('Basket Validation Effects', () => {
});
});

describe('startFastCheckoutProcess$ - trigger basket validation before redirect', () => {
const basketValidation: BasketValidation = {
basket: BasketMockData.getBasket(),
results: {
valid: true,
adjusted: false,
},
};

beforeEach(() => {
when(basketServiceMock.validateBasket(anything())).thenReturn(of(basketValidation));
});

it('should map to action of type ContinueWithFastCheckout in case of success', () => {
const action = startFastCheckout({ paymentId: 'FastCheckout' });
const completion = continueWithFastCheckout({
targetRoute: undefined,
basketValidation,
paymentId: 'FastCheckout',
});
actions$ = hot('-a-a-a', { a: action });
const expected$ = cold('-c-c-c', { c: completion });

expect(effects.startFastCheckoutProcess$).toBeObservable(expected$);
});

it('should map invalid request to action of type StartCheckoutFail', () => {
when(basketServiceMock.validateBasket(anything())).thenReturn(
throwError(() => makeHttpError({ message: 'invalid' }))
);

const action = startFastCheckout({ paymentId: 'FastCheckout' });
const completion = startCheckoutFail({ error: makeHttpError({ message: 'invalid' }) });
actions$ = hot('-a', { a: action });
const expected$ = cold('-c', { c: completion });

expect(effects.startFastCheckoutProcess$).toBeObservable(expected$);
});

it('should map to action of type ContinueCheckoutWithIssues if basket is not valid', () => {
const action = startFastCheckout({ paymentId: 'FastCheckout' });
basketValidation.results.valid = false;
const completion = continueCheckoutWithIssues({
targetRoute: undefined,
basketValidation,
});
actions$ = hot('-a', { a: action });
const expected$ = cold('-c', { c: completion });

expect(effects.startFastCheckoutProcess$).toBeObservable(expected$);
});
});

describe('validateBasketAndContinueCheckout$', () => {
const basketValidation: BasketValidation = {
basket: BasketMockData.getBasket(),
Expand Down
Loading

0 comments on commit fb5a0d6

Please sign in to comment.