Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OWA-107: Fix broken SCA flow payment finalization #677

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/common/src/controllers/CheckoutController.ts
Original file line number Diff line number Diff line change
@@ -224,6 +224,8 @@ export default class CheckoutController {
return response.responseData;
};

finalizeStripePpvPayment = (paymentIntent: string) => this.checkoutService?.finalizeStripePpvPayment?.({ paymentIntent });

paypalPayment = async ({
successUrl,
waitingUrl,
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ import type {
GetDirectPostCardPayment,
GetEntitlements,
GetFinalizeAdyenPayment,
GetFinalizeStripePpvPayment,
GetInitialAdyenPayment,
GetOffer,
GetOffers,
@@ -54,6 +55,8 @@ export default abstract class CheckoutService {

abstract finalizeAdyenPayment?: GetFinalizeAdyenPayment;

abstract finalizeStripePpvPayment?: GetFinalizeStripePpvPayment;

abstract updatePaymentMethodWithPayPal?: UpdatePaymentWithPayPal;

abstract deletePaymentMethod?: DeletePaymentMethod;
Original file line number Diff line number Diff line change
@@ -162,6 +162,8 @@ export default class CleengCheckoutService extends CheckoutService {
finalizeAdyenPayment: GetFinalizeAdyenPayment = async (payload) =>
this.cleengService.post('/connectors/adyen/initial-payment/finalize', JSON.stringify(payload), { authenticate: true });

finalizeStripePpvPayment: undefined;

updatePaymentMethodWithPayPal: UpdatePaymentWithPayPal = async (payload) => {
return this.cleengService.post('/connectors/paypal/v1/payment_details/tokens', JSON.stringify(payload), { authenticate: true });
};
Original file line number Diff line number Diff line change
@@ -306,6 +306,14 @@ export default class JWPCheckoutService extends CheckoutService {
}
};

finalizeStripePpvPayment = async ({ paymentIntent }: { paymentIntent: string }) => {
try {
await this.apiService.post<CommonResponse>('/payments', { pi_id: paymentIntent }, { withAuthentication: true });
} catch {
throw new Error('Failed to confirm payment');
}
};

getSubscriptionSwitches = undefined;

getOrder = undefined;
5 changes: 4 additions & 1 deletion packages/common/types/checkout.ts
Original file line number Diff line number Diff line change
@@ -94,10 +94,12 @@ export type Order = {
requiredPaymentDetails: boolean;
};

export type PaymentProvider = 'stripe' | 'adyen';

export type PaymentMethod = {
id: number;
methodName: 'card' | 'paypal';
provider?: 'stripe' | 'adyen';
provider?: PaymentProvider;
paymentGateway?: 'adyen' | 'paypal'; // @todo: merge with provider
logoUrl: string;
};
@@ -383,6 +385,7 @@ export type GetEntitlements = EnvironmentServiceRequest<GetEntitlementsPayload,
export type GetAdyenPaymentSession = EnvironmentServiceRequest<AdyenPaymentMethodPayload, AdyenPaymentSession>;
export type GetInitialAdyenPayment = EnvironmentServiceRequest<InitialAdyenPaymentPayload, InitialAdyenPayment>;
export type GetFinalizeAdyenPayment = EnvironmentServiceRequest<FinalizeAdyenPaymentPayload, FinalizeAdyenPayment>;
export type GetFinalizeStripePpvPayment = PromiseRequest<{ paymentIntent: string }, void>;
export type UpdatePaymentWithPayPal = EnvironmentServiceRequest<UpdatePaymentWithPayPalPayload, PaymentWithPayPalResponse>;
export type DeletePaymentMethod = EnvironmentServiceRequest<DeletePaymentMethodPayload, DeletePaymentMethodResponse>;
export type AddAdyenPaymentDetails = EnvironmentServiceRequest<AddAdyenPaymentDetailsPayload, AddAdyenPaymentDetailsResponse>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router';
import { useSearchParams } from 'react-router-dom';
import { getModule } from '@jwp/ott-common/src/modules/container';
import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore';
import AccountController from '@jwp/ott-common/src/controllers/AccountController';
import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController';
import { ACCESS_MODEL } from '@jwp/ott-common/src/constants';
import useEventCallback from '@jwp/ott-hooks-react/src/useEventCallback';

import Button from '../Button/Button';
import { modalURLFromLocation } from '../../utils/location';
import { useAriaAnnouncer } from '../../containers/AnnouncementProvider/AnnoucementProvider';

import styles from './FinalizePayment.module.scss';

type FinalizeAdyenPaymentProps = {
onError: () => void;
};

const FinalizeAdyenPayment = ({ onError }: FinalizeAdyenPaymentProps) => {
const accountController = getModule(AccountController);
const checkoutController = getModule(CheckoutController);

const { t } = useTranslation('account');
const announce = useAriaAnnouncer();
const navigate = useNavigate();
const location = useLocation();

const { accessModel } = useConfigStore(({ accessModel }) => ({ accessModel }));
const [searchParams] = useSearchParams();
const redirectResult = searchParams.get('redirectResult');
const orderIdQueryParam = searchParams.get('orderId');

const [errorMessage, setErrorMessage] = useState<string>();

const paymentSuccessUrl = useMemo(() => {
return modalURLFromLocation(location, accessModel === ACCESS_MODEL.SVOD ? 'welcome' : null);
}, [accessModel, location]);

const checkPaymentResult = useEventCallback(async (redirectResult: string) => {
const orderId = orderIdQueryParam ? parseInt(orderIdQueryParam, 10) : undefined;

try {
await checkoutController.finalizeAdyenPayment({ redirectResult: decodeURI(redirectResult) }, orderId);
await accountController.reloadSubscriptions({ retry: 10 });

announce(t('checkout.payment_success'), 'success');
navigate(paymentSuccessUrl);
} catch (error: unknown) {
if (error instanceof Error) {
setErrorMessage(error.message);
onError();
}
}
});

useEffect(() => {
if (!redirectResult) return;

checkPaymentResult(redirectResult);
}, [checkPaymentResult, redirectResult]);

return (
<>
{errorMessage && (
<>
<h2 className={styles.title}>{errorMessage}</h2>
<Button
label={t('checkout.go_back_to_checkout')}
variant="contained"
color="primary"
size="large"
onClick={() => navigate(modalURLFromLocation(location, 'checkout'))}
fullWidth
/>
</>
)}
</>
);
};

export default FinalizeAdyenPayment;
Original file line number Diff line number Diff line change
@@ -10,4 +10,3 @@
font-weight: var(--body-font-weight-bold);
font-size: 24px;
}

Original file line number Diff line number Diff line change
@@ -1,78 +1,30 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router';
import { useSearchParams } from 'react-router-dom';
import { getModule } from '@jwp/ott-common/src/modules/container';
import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore';
import AccountController from '@jwp/ott-common/src/controllers/AccountController';
import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController';
import { ACCESS_MODEL } from '@jwp/ott-common/src/constants';
import useEventCallback from '@jwp/ott-hooks-react/src/useEventCallback';
import React, { useMemo, useState } from 'react';
import type { PaymentProvider } from '@jwp/ott-common/types/checkout';

import Button from '../Button/Button';
import Spinner from '../Spinner/Spinner';
import { modalURLFromLocation } from '../../utils/location';
import { useAriaAnnouncer } from '../../containers/AnnouncementProvider/AnnoucementProvider';

import FinalizeAdyenPayment from './FinalizeAdyenPayment';
import FinalizeStripePpvPayment from './FinalizeStripePpvPayment';
import styles from './FinalizePayment.module.scss';

const FinalizePayment = () => {
const accountController = getModule(AccountController);
const checkoutController = getModule(CheckoutController);
type FinalizePaymentProps = { type: PaymentProvider };

const { t } = useTranslation('account');
const announce = useAriaAnnouncer();
const navigate = useNavigate();
const location = useLocation();

const { accessModel } = useConfigStore(({ accessModel }) => ({ accessModel }));
const [searchParams] = useSearchParams();
const redirectResult = searchParams.get('redirectResult');
const orderIdQueryParam = searchParams.get('orderId');

const [errorMessage, setErrorMessage] = useState<string>();

const paymentSuccessUrl = useMemo(() => {
return modalURLFromLocation(location, accessModel === ACCESS_MODEL.SVOD ? 'welcome' : null);
}, [accessModel, location]);

const checkPaymentResult = useEventCallback(async (redirectResult: string) => {
const orderId = orderIdQueryParam ? parseInt(orderIdQueryParam, 10) : undefined;

try {
await checkoutController.finalizeAdyenPayment({ redirectResult: decodeURI(redirectResult) }, orderId);
await accountController.reloadSubscriptions({ retry: 10 });

announce(t('checkout.payment_success'), 'success');
navigate(paymentSuccessUrl);
} catch (error: unknown) {
if (error instanceof Error) {
setErrorMessage(error.message);
}
}
});

useEffect(() => {
if (!redirectResult) return;

checkPaymentResult(redirectResult);
}, [checkPaymentResult, redirectResult]);
const FinalizePayment = ({ type }: FinalizePaymentProps) => {
const [isInProgress, setIsInProgress] = useState(true);

return (
<div className={styles.container}>
{errorMessage ? (
<>
<h2 className={styles.title}>{errorMessage}</h2>
<Button
label={t('checkout.go_back_to_checkout')}
variant="contained"
color="primary"
size="large"
onClick={() => navigate(modalURLFromLocation(location, 'checkout'))}
fullWidth
/>
</>
) : (
{useMemo(() => {
switch (type) {
case 'adyen':
return <FinalizeAdyenPayment onError={() => setIsInProgress(false)} />;
case 'stripe':
return <FinalizeStripePpvPayment />;
default:
return null;
}
}, [type])}
{isInProgress && (
<div>
<Spinner />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { getModule } from '@jwp/ott-common/src/modules/container';
import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController';

const FinalizeStripePpvPayment = () => {
const checkoutController = getModule(CheckoutController);

const [searchParams, setSearchParams] = useSearchParams();

const finalize = useCallback(async (paymentIntent: string) => {
try {
await checkoutController.finalizeStripePpvPayment(paymentIntent);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you try to reuse everything in FanalizeAdyenPayment except using a different method instead of a checkoutController.finalizeAdyenPayment one?


setSearchParams({ u: 'waiting-for-payment' });
} finally {
// we don't need to handle any outcome here, it is handled by notifications
// NotificationsTypes.CARD_SUCCESS and NotificationsTypes.CARD_FAILED
}

// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useEffect(() => {
const paymentIntent = searchParams.get('payment_intent');

if (paymentIntent) {
finalize(paymentIntent);
}

// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return null;
};

export default FinalizeStripePpvPayment;
Original file line number Diff line number Diff line change
@@ -37,7 +37,8 @@ const WaitingForPayment = () => {
}
},
});
//eslint-disable-next-line

// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={styles.center}>
Original file line number Diff line number Diff line change
@@ -60,7 +60,8 @@ export type AccountModals = {
'payment-method': 'payment-method';
'payment-method-success': 'payment-method-success';
'waiting-for-payment': 'waiting-for-payment';
'finalize-payment': 'finalize-payment';
'finalize-payment-adyen': 'finalize-payment-adyen';
'finalize-payment-stripe-ppv': 'finalize-payment-stripe-ppv';
};

const AccountModal = () => {
@@ -157,8 +158,10 @@ const AccountModal = () => {
return <UpdatePaymentMethod onCloseButtonClick={closeHandler} />;
case 'waiting-for-payment':
return <WaitingForPayment />;
case 'finalize-payment':
return <FinalizePayment />;
case 'finalize-payment-adyen':
return <FinalizePayment type="adyen" />;
case 'finalize-payment-stripe-ppv':
return <FinalizePayment type="stripe" />;
}
};

Original file line number Diff line number Diff line change
@@ -153,7 +153,11 @@ const Checkout = () => {
{isStripePayment && (
<PaymentForm
onPaymentFormSubmit={async (cardPaymentPayload: PaymentFormData) =>
await submitPaymentStripe.mutateAsync({ cardPaymentPayload, referrer, returnUrl: waitingUrl })
await submitPaymentStripe.mutateAsync({
cardPaymentPayload,
referrer,
returnUrl: modalURLFromWindowLocation('finalize-payment-stripe-ppv'),
})
}
/>
)}
Original file line number Diff line number Diff line change
@@ -69,7 +69,7 @@ export default function AdyenInitialPayment({ setUpdatingOrder, type, paymentSuc
const captchaValue = await getCaptchaValue();

const returnUrl = createURL(window.location.href, {
u: 'finalize-payment',
u: 'finalize-payment-adyen',
orderId: orderId,
});
const result = await checkoutController.initialAdyenPayment(state.data.paymentMethod, returnUrl, captchaValue);
2 changes: 1 addition & 1 deletion platforms/web/src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -49,7 +49,7 @@ export default function useNotifications() {
navigateToModal('payment-error', notification.resource?.message);
break;
case NotificationsTypes.CARD_SUCCESS:
await queryClient.invalidateQueries(['entitlements']);
await Promise.allSettled([queryClient.invalidateQueries(['entitlements']), accountController.reloadSubscriptions()]);
navigateToModal(null); // close modal
break;
case NotificationsTypes.SUBSCRIBE_SUCCESS: