ts/screens/wallet/payment/ConfirmPaymentMethodScreen.tsx
import {
FooterWithButtons,
HSpacer,
IOColors,
IOToast,
Icon,
VSpacer
} from "@pagopa/io-app-design-system";
import { AmountInEuroCents, RptId } from "@pagopa/io-pagopa-commons/lib/pagopa";
import { Route, useRoute } from "@react-navigation/native";
import * as O from "fp-ts/lib/Option";
import { pipe } from "fp-ts/lib/function";
import * as React from "react";
import {
Alert,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
View
} from "react-native";
import { connect } from "react-redux";
import { ImportoEuroCents } from "../../../../definitions/backend/ImportoEuroCents";
import { PaymentRequestsGetResponse } from "../../../../definitions/backend/PaymentRequestsGetResponse";
import { PspData } from "../../../../definitions/pagopa/PspData";
import CardIcon from "../../../../img/wallet/card.svg";
import BancomatPayLogo from "../../../../img/wallet/payment-methods/bancomat_pay.svg";
import PaypalLogo from "../../../../img/wallet/payment-methods/paypal/paypal_logo.svg";
import TagIcon from "../../../../img/wallet/tag.svg";
import {
getValueOrElse,
isError,
isLoading,
isReady
} from "../../../common/model/RemoteValue";
import LoadingSpinnerOverlay from "../../../components/LoadingSpinnerOverlay";
import { H1 } from "../../../components/core/typography/H1";
import { H3 } from "../../../components/core/typography/H3";
import { H4 } from "../../../components/core/typography/H4";
import { LabelSmall } from "../../../components/core/typography/LabelSmall";
import { IOStyles } from "../../../components/core/variables/IOStyles";
import BaseScreenComponent, {
ContextualHelpPropsMarkdown
} from "../../../components/screens/BaseScreenComponent";
import {
LightModalContext,
LightModalContextInterface
} from "../../../components/ui/LightModal";
import { PayWebViewModal } from "../../../components/wallet/PayWebViewModal";
import { SelectionBox } from "../../../components/wallet/SelectionBox";
import { getCardIconFromBrandLogo } from "../../../components/wallet/card/Logo";
import { pagoPaApiUrlPrefix, pagoPaApiUrlPrefixTest } from "../../../config";
import { BrandImage } from "../../../features/wallet/component/card/BrandImage";
import I18n from "../../../i18n";
import { useIONavigation } from "../../../navigation/params/AppParamsList";
import ROUTES from "../../../navigation/routes";
import {
navigateToPaymentOutcomeCode,
navigateToPaymentPickPaymentMethodScreen,
navigateToPaymentPickPspScreen
} from "../../../store/actions/navigation";
import { Dispatch } from "../../../store/actions/types";
import { paymentOutcomeCode } from "../../../store/actions/wallet/outcomeCode";
import {
PaymentMethodType,
PaymentWebViewEndReason,
abortRunningPayment,
paymentCompletedFailure,
paymentCompletedSuccess,
paymentExecuteStart,
paymentWebViewEnd
} from "../../../store/actions/wallet/payment";
import { fetchTransactionsRequestWithExpBackoff } from "../../../store/actions/wallet/transactions";
import {
bancomatPayConfigSelector,
isPaypalEnabledSelector
} from "../../../store/reducers/backendStatus";
import { isPagoPATestEnabledSelector } from "../../../store/reducers/persistedPreferences";
import { GlobalState } from "../../../store/reducers/types";
import { outcomeCodesSelector } from "../../../store/reducers/wallet/outcomeCode";
import {
PaymentStartWebViewPayload,
paymentStartPayloadSelector,
pmSessionTokenSelector,
pspSelectedV2ListSelector,
pspV2ListSelector
} from "../../../store/reducers/wallet/payment";
import { paymentMethodByIdSelector } from "../../../store/reducers/wallet/wallets";
import { OutcomeCodesKey } from "../../../types/outcomeCode";
import {
PaymentMethod,
RawPaymentMethod,
Wallet,
isCreditCard,
isRawPayPal
} from "../../../types/pagopa";
import { PayloadForAction } from "../../../types/utils";
import { getTranslatedShortNumericMonthYear } from "../../../utils/dates";
import { getLocalePrimaryWithFallback } from "../../../utils/locale";
import { isPaymentOutcomeCodeSuccessfully } from "../../../utils/payment";
import { getPaypalAccountEmail } from "../../../utils/paypal";
import { getLookUpIdPO } from "../../../utils/pmLookUpId";
import { formatNumberCentsToAmount } from "../../../utils/stringBuilder";
import { openWebUrl } from "../../../utils/url";
// temporary feature flag since this feature is still WIP
// (missing task to complete https://pagopa.atlassian.net/browse/IA-684?filter=10121)
export const editPaypalPspEnabled = false;
export type ConfirmPaymentMethodScreenNavigationParams = Readonly<{
rptId: RptId;
initialAmount: AmountInEuroCents;
verifica: PaymentRequestsGetResponse;
idPayment: string;
wallet: Wallet;
psps: ReadonlyArray<PspData>;
}>;
type Props = ReturnType<typeof mapStateToProps> &
ReturnType<typeof mapDispatchToProps>;
type ConfirmPaymentMethodScreenProps = LightModalContextInterface & Props;
const styles = StyleSheet.create({
totalContainer: {
flexDirection: "row",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: IOColors.greyLight
},
iconRow: {
flexDirection: "row",
alignItems: "center"
},
flex: { flex: 1 }
});
const contextualHelpMarkdown: ContextualHelpPropsMarkdown = {
title: "wallet.whyAFee.title",
body: "wallet.whyAFee.text"
};
const payUrlSuffix = "/v3/webview/transactions/pay";
const webViewExitPathName = "/v3/webview/logout/bye";
const webViewOutcomeParamName = "outcome";
type ComputedPaymentMethodInfo = {
logo: JSX.Element;
subject: string;
caption: string;
accessibilityLabel: string;
};
const getPaymentMethodInfo = (
paymentMethod: PaymentMethod | undefined,
options: { isPaypalEnabled: boolean; isBPayPaymentEnabled: boolean }
): O.Option<ComputedPaymentMethodInfo> => {
switch (paymentMethod?.kind) {
case "CreditCard":
const holder = paymentMethod.info.holder ?? "";
const expiration =
getTranslatedShortNumericMonthYear(
paymentMethod.info.expireYear,
paymentMethod.info.expireMonth
) ?? "";
return O.some({
logo: (
<BrandImage
image={getCardIconFromBrandLogo(paymentMethod.info)}
scale={0.7}
/>
),
subject: `${holder}${expiration ? " ยท " + expiration : ""}`,
expiration,
caption: paymentMethod.caption ?? "",
accessibilityLabel: `${I18n.t(
"wallet.accessibility.folded.creditCard",
{
brand: paymentMethod.info.brand,
blurredNumber: paymentMethod.info.blurredNumber
}
)}, ${holder}, ${expiration}`
});
case "PayPal":
const paypalEmail = getPaypalAccountEmail(paymentMethod.info);
return pipe(
O.some({
logo: <PaypalLogo width={24} height={24} />,
subject: paypalEmail,
caption: I18n.t("wallet.onboarding.paypal.name"),
accessibilityLabel: `${I18n.t(
"wallet.onboarding.paypal.name"
)}, ${paypalEmail}`
}),
O.filter(() => options.isPaypalEnabled)
);
case "BPay":
return pipe(
O.some({
logo: <BancomatPayLogo width={24} height={24} />,
subject: paymentMethod?.caption,
caption: paymentMethod.info.numberObfuscated ?? "",
accessibilityLabel: `${I18n.t("wallet.methods.bancomatPay.name")}`
}),
O.filter(() => options.isBPayPaymentEnabled)
);
default:
return O.none;
}
};
/**
* return the type of the paying method
* atm only three methods can pay: credit card, paypal and bancomat pay
* @param paymentMethod
*/
const getPaymentMethodType = (
paymentMethod: RawPaymentMethod | undefined
): PaymentMethodType => {
switch (paymentMethod?.kind) {
case "BPay":
case "CreditCard":
case "PayPal":
return paymentMethod.kind;
default:
return "Unknown";
}
};
const ConfirmPaymentMethodScreen: React.FC<ConfirmPaymentMethodScreenProps> = (
props: ConfirmPaymentMethodScreenProps
) => {
const navigation = useIONavigation();
const { rptId, initialAmount, verifica, idPayment, wallet, psps } =
useRoute<
Route<
"PAYMENT_CONFIRM_PAYMENT_METHOD",
ConfirmPaymentMethodScreenNavigationParams
>
>().params;
React.useEffect(() => {
// show a toast if we got an error while retrieving pm session token
if (O.isSome(props.retrievingSessionTokenError)) {
IOToast.error(I18n.t("global.actions.retry"));
}
}, [props.retrievingSessionTokenError]);
const pickPaymentMethod = () =>
navigateToPaymentPickPaymentMethodScreen({
rptId,
initialAmount,
verifica,
idPayment
});
const pickPsp = () =>
navigateToPaymentPickPspScreen({
rptId,
initialAmount,
verifica,
idPayment,
psps,
wallet,
chooseToChange: true
});
const urlPrefix = props.isPagoPATestEnabled
? pagoPaApiUrlPrefixTest
: pagoPaApiUrlPrefix;
const paymentReason = verifica.causaleVersamento;
const maybePsp = O.fromNullable(wallet.psp);
const isPayingWithPaypal = isRawPayPal(wallet.paymentMethod);
// each payment method has its own psp fee
const paymentMethodType = getPaymentMethodType(wallet.paymentMethod);
const fee: number | undefined = isPayingWithPaypal
? props.paypalSelectedPsp?.fee
: pipe(
maybePsp,
O.fold(
() => undefined,
psp => psp.fixedCost.amount
)
);
const totalAmount =
(verifica.importoSingoloVersamento as number) + (fee ?? 0);
// emit an event to inform the pay web view finished
// dispatch the outcome code and navigate to payment outcome code screen
const handlePaymentOutcome = (maybeOutcomeCode: O.Option<string>) => {
// the outcome is a payment done successfully
if (
O.isSome(maybeOutcomeCode) &&
isPaymentOutcomeCodeSuccessfully(
maybeOutcomeCode.value,
props.outcomeCodes
)
) {
// store the rptid of a payment done
props.dispatchPaymentCompleteSuccessfully(rptId);
// refresh transactions list
props.loadTransactions();
} else {
props.dispatchPaymentFailure(
pipe(maybeOutcomeCode, O.filter(OutcomeCodesKey.is), O.toUndefined),
idPayment
);
}
props.dispatchEndPaymentWebview("EXIT_PATH", paymentMethodType);
props.dispatchPaymentOutCome(maybeOutcomeCode, paymentMethodType);
props.navigateToOutComePaymentScreen((fee ?? 0) as ImportoEuroCents);
};
// the user press back during the pay web view challenge
const handlePayWebviewGoBack = () => {
Alert.alert(I18n.t("payment.abortWebView.title"), "", [
{
text: I18n.t("payment.abortWebView.confirm"),
onPress: () => {
props.dispatchCancelPayment();
props.dispatchEndPaymentWebview("USER_ABORT", paymentMethodType);
},
style: "cancel"
},
{
text: I18n.t("payment.abortWebView.cancel")
}
]);
};
// navigate to the screen where the user can pick the desired psp
const handleOnEditPaypalPsp = () => {
navigation.navigate(ROUTES.WALLET_NAVIGATOR, {
screen: ROUTES.WALLET_PAYPAL_UPDATE_PAYMENT_PSP,
params: { idWallet: wallet.idWallet, idPayment }
});
};
// Handle the PSP change, this will trigger
// a different callback for a payment with PayPal.
const handleChangePsp = isPayingWithPaypal ? handleOnEditPaypalPsp : pickPsp;
const formData = pipe(
props.payStartWebviewPayload,
O.map(payload => ({
...payload,
...getLookUpIdPO()
})),
O.getOrElse(() => ({}))
);
const paymentMethod = props.getPaymentMethodById(wallet.idWallet);
const isPaymentMethodCreditCard =
paymentMethod !== undefined && isCreditCard(paymentMethod);
const formattedSingleAmount = formatNumberCentsToAmount(
verifica.importoSingoloVersamento,
true
);
const formattedTotal = formatNumberCentsToAmount(totalAmount, true);
const formattedFees = formatNumberCentsToAmount(fee ?? 0, true);
// Retrieve all the informations needed by the
// user interface based on the payment method
// selected by the user.
const paymentMethodInfo = pipe(
getPaymentMethodInfo(paymentMethod, {
isPaypalEnabled: props.isPaypalEnabled,
isBPayPaymentEnabled: props.isBPayPaymentEnabled
}),
O.getOrElse(() => ({
subject: "",
caption: "",
logo: <View />,
accessibilityLabel: ""
}))
);
// It should be possible to change PSP only when the user
// is not paying using PayPal or the relative flag is
// enabled.
const canChangePsp = !isPayingWithPaypal || editPaypalPspEnabled;
// The privacy url needed when paying
// using PayPal.
const privacyUrl = props.paypalSelectedPsp?.privacyUrl;
// Retrieve the PSP name checking if the user is
// paying using PayPal or another method. The PSP
// could always be `undefined`.
const pspName = pipe(
isPayingWithPaypal
? props.paypalSelectedPsp?.ragioneSociale
: wallet.psp?.businessName,
O.fromNullable,
O.map(name => `${I18n.t("wallet.ConfirmPayment.providedBy")} ${name}`),
O.getOrElse(() => I18n.t("payment.noPsp"))
);
return (
<LoadingSpinnerOverlay isLoading={props.isLoading}>
<BaseScreenComponent
goBack={props.onCancel}
headerTitle={I18n.t("wallet.ConfirmPayment.header")}
contextualHelpMarkdown={contextualHelpMarkdown}
faqCategories={["payment"]}
backButtonTestID="cancelPaymentButton"
>
<SafeAreaView style={styles.flex}>
<ScrollView style={styles.flex}>
<View style={IOStyles.horizontalContentPadding}>
<VSpacer size={16} />
<View
style={styles.totalContainer}
accessibilityRole="header"
accessibilityLabel={`${I18n.t(
"wallet.ConfirmPayment.total"
)} ${formattedTotal}`}
accessible
>
<H1>{I18n.t("wallet.ConfirmPayment.total")}</H1>
<H1>{formattedTotal}</H1>
</View>
<VSpacer size={24} />
<View style={styles.iconRow}>
<Icon name="info" size={20} color="bluegrey" />
<HSpacer size={8} />
<H3 color="bluegrey" accessibilityRole="header">
{I18n.t("wallet.ConfirmPayment.paymentInformations")}
</H3>
</View>
<VSpacer size={16} />
<View
accessibilityLabel={`${paymentReason}, ${formattedSingleAmount}`}
accessible
>
<H4 weight="Semibold" color="bluegreyDark" numberOfLines={1}>
{paymentReason}
</H4>
<LabelSmall color="bluegrey" weight="Regular">
{formattedSingleAmount}
</LabelSmall>
</View>
<VSpacer size={24} />
<View style={styles.iconRow}>
<CardIcon width={20} height={20} />
<HSpacer size={8} />
<H3 color="bluegrey" accessibilityRole="header">
{I18n.t("wallet.ConfirmPayment.payWith")}
</H3>
</View>
<VSpacer size={16} />
<SelectionBox
logo={paymentMethodInfo.logo}
mainText={paymentMethodInfo.caption}
subText={paymentMethodInfo.subject}
ctaText={I18n.t("wallet.ConfirmPayment.edit")}
onPress={pickPaymentMethod}
accessibilityLabel={`${
paymentMethodInfo.accessibilityLabel
}, ${I18n.t("wallet.ConfirmPayment.accessibility.edit")}`}
/>
<VSpacer size={24} />
<View style={styles.iconRow}>
<TagIcon width={20} height={20} />
<HSpacer size={8} />
<H3 color="bluegrey" accessibilityRole="header">
{I18n.t("wallet.ConfirmPayment.transactionCosts")}
</H3>
</View>
<VSpacer size={16} />
<SelectionBox
mainText={formattedFees}
subText={pspName}
ctaText={
canChangePsp
? I18n.t("wallet.ConfirmPayment.edit")
: undefined
}
onPress={canChangePsp ? handleChangePsp : undefined}
accessibilityLabel={`${I18n.t(
"wallet.ConfirmPayment.accessibility.transactionCosts",
{
cost: formattedFees,
psp: pspName
}
)}${
canChangePsp
? ", " + I18n.t("wallet.ConfirmPayment.accessibility.edit")
: ""
}`}
/>
{isPayingWithPaypal && privacyUrl && (
<>
<VSpacer size={16} />
<Text
onPress={() => openWebUrl(privacyUrl)}
accessibilityRole="link"
>
<LabelSmall color="bluegrey" weight="Regular">
{`${I18n.t(
"wallet.onboarding.paypal.paymentCheckout.privacyDisclaimer"
)} `}
</LabelSmall>
<LabelSmall weight="Semibold">
{I18n.t(
"wallet.onboarding.paypal.paymentCheckout.privacyTerms"
)}
</LabelSmall>
</Text>
</>
)}
<VSpacer size={40} />
</View>
</ScrollView>
{O.isSome(props.payStartWebviewPayload) && (
<PayWebViewModal
postUri={urlPrefix + payUrlSuffix}
formData={formData}
showInfoHeader={isPaymentMethodCreditCard}
finishPathName={webViewExitPathName}
onFinish={handlePaymentOutcome}
outcomeQueryparamName={webViewOutcomeParamName}
onGoBack={handlePayWebviewGoBack}
modalHeaderTitle={I18n.t("wallet.challenge3ds.header")}
/>
)}
</SafeAreaView>
<FooterWithButtons
type="SingleButton"
primary={{
type: "Solid",
buttonProps: {
label: `${I18n.t("wallet.ConfirmPayment.pay")} ${formattedTotal}`,
disabled: O.isSome(props.payStartWebviewPayload),
onPress: () =>
props.dispatchPaymentStart({
idWallet: wallet.idWallet,
idPayment,
language: getLocalePrimaryWithFallback()
})
}
}}
/>
</BaseScreenComponent>
</LoadingSpinnerOverlay>
);
};
const mapStateToProps = (state: GlobalState) => {
const pmSessionToken = pmSessionTokenSelector(state);
const paymentStartPayload = paymentStartPayloadSelector(state);
// if there is no psp selected pick the default one from the list (if any)
const paypalSelectedPsp: PspData | undefined =
pspSelectedV2ListSelector(state) ||
getValueOrElse(pspV2ListSelector(state), []).find(psp => psp.defaultPsp);
const payStartWebviewPayload: O.Option<PaymentStartWebViewPayload> =
isReady(pmSessionToken) && paymentStartPayload
? O.some({ ...paymentStartPayload, sessionToken: pmSessionToken.value })
: O.none;
return {
paypalSelectedPsp,
getPaymentMethodById: (idWallet: number) =>
paymentMethodByIdSelector(state, idWallet),
isPagoPATestEnabled: isPagoPATestEnabledSelector(state),
outcomeCodes: outcomeCodesSelector(state),
isPaypalEnabled: isPaypalEnabledSelector(state),
isBPayPaymentEnabled: bancomatPayConfigSelector(state).payment,
payStartWebviewPayload,
isLoading: isLoading(pmSessionToken),
retrievingSessionTokenError: isError(pmSessionToken)
? O.some(pmSessionToken.error.message)
: O.none
};
};
const mapDispatchToProps = (dispatch: Dispatch) => {
const dispatchCancelPayment = () => {
dispatch(abortRunningPayment());
IOToast.success(I18n.t("wallet.ConfirmPayment.cancelPaymentSuccess"));
};
return {
onCancel: () => {
Alert.alert(
I18n.t("wallet.ConfirmPayment.confirmCancelTitle"),
undefined,
[
{
text: I18n.t("wallet.ConfirmPayment.confirmCancelPayment"),
style: "destructive",
onPress: () => {
dispatchCancelPayment();
}
},
{
text: I18n.t("wallet.ConfirmPayment.confirmContinuePayment"),
style: "cancel"
}
]
);
},
dispatchPaymentStart: (
payload: PayloadForAction<(typeof paymentExecuteStart)["request"]>
) => dispatch(paymentExecuteStart.request(payload)),
dispatchEndPaymentWebview: (
reason: PaymentWebViewEndReason,
paymentMethodType: PaymentMethodType
) => {
dispatch(paymentWebViewEnd({ reason, paymentMethodType }));
},
dispatchCancelPayment,
dispatchPaymentOutCome: (
outComeCode: O.Option<string>,
paymentMethodType: PaymentMethodType
) =>
dispatch(paymentOutcomeCode({ outcome: outComeCode, paymentMethodType })),
navigateToOutComePaymentScreen: (fee: ImportoEuroCents) =>
navigateToPaymentOutcomeCode({ fee }),
loadTransactions: () =>
dispatch(fetchTransactionsRequestWithExpBackoff({ start: 0 })),
dispatchPaymentCompleteSuccessfully: (rptId: RptId) =>
dispatch(
paymentCompletedSuccess({
kind: "COMPLETED",
rptId,
transaction: undefined
})
),
dispatchPaymentFailure: (
outcomeCode: OutcomeCodesKey | undefined,
paymentId: string
) => dispatch(paymentCompletedFailure({ outcomeCode, paymentId }))
};
};
const ConfirmPaymentMethodScreenWithContext = (props: Props) => {
const { ...modalContext } = React.useContext(LightModalContext);
return <ConfirmPaymentMethodScreen {...props} {...modalContext} />;
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(ConfirmPaymentMethodScreenWithContext);