/* This file is part of GNU Taler (C) 2022 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ /* eslint-disable react-hooks/rules-of-hooks */ import { AmountJson, Amounts, ExchangeFullDetails, ExchangeListItem, NotificationType, TalerError, parseWithdrawExchangeUri, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useCallback, useEffect, useState } from "preact/hooks"; import { alertFromError, useAlertContext } from "../../context/alert.js"; import { useBackendContext } from "../../context/backend.js"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { useSelectedExchange } from "../../hooks/useSelectedExchange.js"; import { RecursiveState } from "../../utils/index.js"; import { PropsFromParams, PropsFromURI, State } from "./index.js"; export function useComponentStateFromParams({ talerExchangeWithdrawUri: maybeTalerUri, amount, cancel, onAmountChanged, onSuccess, }: PropsFromParams): RecursiveState { const api = useBackendContext(); const { i18n } = useTranslationContext(); const paramsAmount = amount ? Amounts.parse(amount) : undefined; const uriInfoHook = useAsyncAsHook(async () => { const exchanges = await api.wallet.call( WalletApiOperation.ListExchanges, {}, ); const uri = maybeTalerUri ? parseWithdrawExchangeUri(maybeTalerUri) : undefined; const exchangeByTalerUri = uri?.exchangeBaseUrl; let ex: ExchangeFullDetails | undefined; if (exchangeByTalerUri && uri.exchangePub) { await api.wallet.call(WalletApiOperation.AddExchange, { exchangeBaseUrl: exchangeByTalerUri, masterPub: uri.exchangePub, }); const info = await api.wallet.call( WalletApiOperation.GetExchangeDetailedInfo, { exchangeBaseUrl: exchangeByTalerUri, }, ); ex = info.exchange; } const chosenAmount = !uri || !uri.amount ? undefined : Amounts.parse(uri.amount); return { amount: chosenAmount, exchanges, exchange: ex }; }); if (!uriInfoHook) return { status: "loading", error: undefined }; if (uriInfoHook.hasError) { return { status: "error", error: alertFromError( i18n.str`Could not load the list of exchanges`, uriInfoHook, ), }; } useEffect(() => { uriInfoHook?.retry(); }, [amount]); const exchangeByTalerUri = uriInfoHook.response.exchange?.exchangeBaseUrl; const exchangeList = uriInfoHook.response.exchanges.exchanges; const maybeAmount = uriInfoHook.response.amount ?? paramsAmount; if (!maybeAmount) { const exchangeBaseUrl = uriInfoHook.response.exchange?.exchangeBaseUrl ?? (exchangeList.length > 0 ? exchangeList[0].exchangeBaseUrl : undefined); const currency = uriInfoHook.response.exchange?.currency ?? (exchangeList.length > 0 ? exchangeList[0].currency : undefined); if (!exchangeBaseUrl) { return { status: "error", error: { message: i18n.str`Can't withdraw from exchange`, description: i18n.str`Missing base URL`, cause: undefined, context: {}, type: "error", }, }; } if (!currency) { return { status: "error", error: { message: i18n.str`Can't withdraw from exchange`, description: i18n.str`Missing unknown currency`, cause: undefined, context: {}, type: "error", }, }; } return () => { const { pushAlertOnError } = useAlertContext(); const [amount, setAmount] = useState( Amounts.zeroOfCurrency(currency), ); const isValid = Amounts.isNonZero(amount); return { status: "select-amount", currency, exchangeBaseUrl, error: undefined, confirm: { onClick: isValid ? pushAlertOnError(async () => { onAmountChanged(Amounts.stringify(amount)); }) : undefined, }, amount: { value: amount, onInput: pushAlertOnError(async (e) => { setAmount(e); }), }, }; }; } const chosenAmount = maybeAmount; async function doManualWithdraw( exchange: string, ageRestricted: number | undefined, ): Promise<{ transactionId: string; confirmTransferUrl: string | undefined; }> { const res = await api.wallet.call( WalletApiOperation.AcceptManualWithdrawal, { exchangeBaseUrl: exchange, amount: Amounts.stringify(chosenAmount), restrictAge: ageRestricted, }, ); return { confirmTransferUrl: undefined, transactionId: res.transactionId, }; } return () => exchangeSelectionState( doManualWithdraw, cancel, onSuccess, undefined, chosenAmount, exchangeList, exchangeByTalerUri, ); } export function useComponentStateFromURI({ talerWithdrawUri: maybeTalerUri, cancel, onSuccess, }: PropsFromURI): RecursiveState { const api = useBackendContext(); const { i18n } = useTranslationContext(); /** * Ask the wallet about the withdraw URI */ const uriInfoHook = useAsyncAsHook(async () => { if (!maybeTalerUri) throw Error("ERROR_NO-URI-FOR-WITHDRAWAL"); const talerWithdrawUri = maybeTalerUri.startsWith("ext+") ? maybeTalerUri.substring(4) : maybeTalerUri; const uriInfo = await api.wallet.call( WalletApiOperation.GetWithdrawalDetailsForUri, { talerWithdrawUri, notifyChangeFromPendingTimeoutMs: 30 * 1000, }, ); const { amount, defaultExchangeBaseUrl, possibleExchanges, operationId, confirmTransferUrl, status, } = uriInfo; const transaction = await api.wallet.call( WalletApiOperation.GetWithdrawalTransactionByUri, { talerWithdrawUri }, ); return { talerWithdrawUri, operationId, status, transaction, confirmTransferUrl, amount: Amounts.parseOrThrow(amount), thisExchange: defaultExchangeBaseUrl, exchanges: possibleExchanges, }; }); const readyToListen = uriInfoHook && !uriInfoHook.hasError; useEffect(() => { if (!uriInfoHook) { return; } return api.listener.onUpdateNotification( [NotificationType.TransactionStateTransition], () => { uriInfoHook.retry(); }, ); }, [readyToListen]); if (!uriInfoHook) return { status: "loading", error: undefined }; if (uriInfoHook.hasError) { return { status: "error", error: alertFromError( i18n.str`Could not load info from URI`, uriInfoHook, ), }; } const uri = uriInfoHook.response.talerWithdrawUri; const chosenAmount = uriInfoHook.response.amount; const defaultExchange = uriInfoHook.response.thisExchange; const exchangeList = uriInfoHook.response.exchanges; async function doManagedWithdraw( exchange: string, ageRestricted: number | undefined, ): Promise<{ transactionId: string; confirmTransferUrl: string | undefined; }> { const res = await api.wallet.call( WalletApiOperation.AcceptBankIntegratedWithdrawal, { exchangeBaseUrl: exchange, talerWithdrawUri: uri, restrictAge: ageRestricted, }, ); return { confirmTransferUrl: res.confirmTransferUrl, transactionId: res.transactionId, }; } if (uriInfoHook.response.status !== "pending") { if (uriInfoHook.response.transaction) { onSuccess(uriInfoHook.response.transaction.transactionId); } return { status: "already-completed", operationState: uriInfoHook.response.status, confirmTransferUrl: uriInfoHook.response.confirmTransferUrl, error: undefined, }; } return useCallback(() => { return exchangeSelectionState( doManagedWithdraw, cancel, onSuccess, uri, chosenAmount, exchangeList, defaultExchange, ); }, []); } type ManualOrManagedWithdrawFunction = ( exchange: string, ageRestricted: number | undefined, ) => Promise<{ transactionId: string; confirmTransferUrl: string | undefined }>; function exchangeSelectionState( doWithdraw: ManualOrManagedWithdrawFunction, cancel: () => Promise, onSuccess: (txid: string) => Promise, talerWithdrawUri: string | undefined, chosenAmount: AmountJson, exchangeList: ExchangeListItem[], exchangeSuggestedByTheBank: string | undefined, ): RecursiveState { const api = useBackendContext(); const selectedExchange = useSelectedExchange({ currency: chosenAmount.currency, defaultExchange: exchangeSuggestedByTheBank, list: exchangeList, }); if (selectedExchange.status !== "ready") { return selectedExchange; } return useCallback((): | State.Success | State.LoadingUriError | State.Loading => { const { i18n } = useTranslationContext(); const { pushAlertOnError } = useAlertContext(); const [ageRestricted, setAgeRestricted] = useState(0); const currentExchange = selectedExchange.selected; const [selectedCurrency, setSelectedCurrency] = useState( chosenAmount.currency, ); /** * With the exchange and amount, ask the wallet the information * about the withdrawal */ const amountHook = useAsyncAsHook(async () => { const info = await api.wallet.call( WalletApiOperation.GetWithdrawalDetailsForAmount, { exchangeBaseUrl: currentExchange.exchangeBaseUrl, amount: Amounts.stringify(chosenAmount), restrictAge: ageRestricted, }, ); const withdrawAmount = { raw: Amounts.parseOrThrow(info.amountRaw), effective: Amounts.parseOrThrow(info.amountEffective), }; return { amount: withdrawAmount, ageRestrictionOptions: info.ageRestrictionOptions, accounts: info.withdrawalAccountsList, }; }, []); const [withdrawError, setWithdrawError] = useState( undefined, ); const [doingWithdraw, setDoingWithdraw] = useState(false); async function doWithdrawAndCheckError(): Promise { try { setDoingWithdraw(true); const res = await doWithdraw( currentExchange.exchangeBaseUrl, !ageRestricted ? undefined : ageRestricted, ); if (res.confirmTransferUrl) { document.location.href = res.confirmTransferUrl; } else { onSuccess(res.transactionId); } } catch (e) { if (e instanceof TalerError) { setWithdrawError(e); } } setDoingWithdraw(false); } if (!amountHook) { return { status: "loading", error: undefined }; } if (amountHook.hasError) { return { status: "error", error: alertFromError( i18n.str`Could not load the withdrawal details`, amountHook, ), }; } if (!amountHook.response) { return { status: "loading", error: undefined }; } const withdrawalFee = Amounts.sub( amountHook.response.amount.raw, amountHook.response.amount.effective, ).amount; const toBeReceived = amountHook.response.amount.effective; const ageRestrictionOptions = amountHook.response.ageRestrictionOptions?.reduce( (p, c) => ({ ...p, [c]: `under ${c}` }), {} as Record, ); const ageRestrictionEnabled = ageRestrictionOptions !== undefined; if (ageRestrictionEnabled) { ageRestrictionOptions["0"] = "Not restricted"; } //TODO: calculate based on exchange info const ageRestriction = ageRestrictionEnabled ? { list: ageRestrictionOptions, value: String(ageRestricted), onChange: pushAlertOnError(async (v: string) => setAgeRestricted(parseInt(v, 10)), ), } : undefined; const altCurrencies = amountHook.response.accounts .filter((a) => !!a.currencySpecification) .map((a) => a.currencySpecification!.name); const chooseCurrencies = altCurrencies.length === 0 ? [] : [toBeReceived.currency, ...altCurrencies]; const convAccount = amountHook.response.accounts.find((c) => { return ( c.currencySpecification && c.currencySpecification.name === selectedCurrency ); }); const conversionInfo = !convAccount ? undefined : { spec: convAccount.currencySpecification!, amount: Amounts.parseOrThrow(convAccount.transferAmount!), }; return { status: "success", error: undefined, doSelectExchange: selectedExchange.doSelect, currentExchange, toBeReceived, chooseCurrencies, selectedCurrency, changeCurrency: (s) => { setSelectedCurrency(s); }, conversionInfo, withdrawalFee, chosenAmount, talerWithdrawUri, ageRestriction, doWithdrawal: { onClick: doingWithdraw ? undefined : pushAlertOnError(doWithdrawAndCheckError), }, cancel, }; }, []); }