/* 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 */ import { AmountJson, AmountString, Amounts, ExchangeFullDetails, ExchangeListItem, NotificationType, TransactionMajorState, 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 [updatedExchangeByUser, setUpdatedExchangeByUser] = useState(); const uriInfoHook = useAsyncAsHook(async () => { const exchanges = await api.wallet.call( WalletApiOperation.ListExchanges, {}, ); const uri = maybeTalerUri ? parseWithdrawExchangeUri(maybeTalerUri) : undefined; const exchangeByTalerUri = updatedExchangeByUser ?? uri?.exchangeBaseUrl; let ex: ExchangeFullDetails | undefined; if (exchangeByTalerUri) { await api.wallet.call(WalletApiOperation.AddExchange, { exchangeBaseUrl: exchangeByTalerUri, }); 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, 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, amount: AmountString, ): Promise<{ transactionId: string; confirmTransferUrl: string | undefined; }> { const res = await api.wallet.call( WalletApiOperation.AcceptManualWithdrawal, { exchangeBaseUrl: exchange, amount, restrictAge: ageRestricted, }, ); return { confirmTransferUrl: undefined, transactionId: res.transactionId, }; } return () => exchangeSelectionState( doManualWithdraw, cancel, onSuccess, undefined, chosenAmount, chosenAmount.currency, exchangeList, exchangeByTalerUri, setUpdatedExchangeByUser, ); } export function useComponentStateFromURI({ talerWithdrawUri: maybeTalerUri, cancel, onSuccess, }: PropsFromURI): RecursiveState { const api = useBackendContext(); const { i18n } = useTranslationContext(); const [updatedExchangeByUser, setUpdatedExchangeByUser] = useState(); /** * 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.PrepareBankIntegratedWithdrawal, { talerWithdrawUri, selectedExchange: updatedExchangeByUser, }, ); const { amount, defaultExchangeBaseUrl, possibleExchanges, confirmTransferUrl, status, } = uriInfo.info; const txInfo = uriInfo.transactionId === undefined ? undefined : await api.wallet.call(WalletApiOperation.GetTransactionById, { transactionId: uriInfo.transactionId, }); return { talerWithdrawUri, status, transactionId: uriInfo.transactionId, currency: uriInfo.info.currency, txInfo: txInfo, confirmTransferUrl, amount: !amount ? undefined : Amounts.parseOrThrow(amount), thisExchange: defaultExchangeBaseUrl, exchanges: possibleExchanges, }; }); const readyToListen = uriInfoHook && !uriInfoHook.hasError; useEffect(() => { if (!uriInfoHook || uriInfoHook.hasError) { return; } const txId = uriInfoHook.response.transactionId; return api.listener.onUpdateNotification( [NotificationType.TransactionStateTransition], (notif) => { if ( notif.type === NotificationType.TransactionStateTransition && notif.transactionId === txId ) { uriInfoHook.retry(); } }, ); }, [readyToListen]); if (!uriInfoHook) return { status: "loading", error: undefined }; if (uriInfoHook.hasError) { return { status: "error", error: alertFromError( i18n, i18n.str`Could not load info from URI`, uriInfoHook, ), }; } const uri = uriInfoHook.response.talerWithdrawUri; const txId = uriInfoHook.response.transactionId; const infoAmount = uriInfoHook.response.amount; const defaultExchange = uriInfoHook.response.thisExchange; const exchangeList = uriInfoHook.response.exchanges; async function doManagedWithdraw( exchange: string, ageRestricted: number | undefined, amount: AmountString, ): Promise<{ transactionId: string; confirmTransferUrl: string | undefined; }> { if (!txId) { throw Error("can't confirm transaction"); } const res = await api.wallet.call(WalletApiOperation.ConfirmWithdrawal, { exchangeBaseUrl: exchange, amount, restrictAge: ageRestricted, transactionId: txId, }); return { confirmTransferUrl: res.confirmTransferUrl, transactionId: res.transactionId, }; } if ( uriInfoHook.response.txInfo && uriInfoHook.response.status !== "pending" ) { const info = uriInfoHook.response.txInfo; return { status: "already-completed", operationState: uriInfoHook.response.status, confirmTransferUrl: uriInfoHook.response.confirmTransferUrl, thisWallet: info.txState.major === TransactionMajorState.Pending, redirectToTx: () => onSuccess(info.transactionId), error: undefined, }; } return useCallback(() => { return exchangeSelectionState( doManagedWithdraw, cancel, onSuccess, uri, infoAmount, uriInfoHook.response.currency, exchangeList, defaultExchange, setUpdatedExchangeByUser, ); }, []); } type ManualOrManagedWithdrawFunction = ( exchange: string, ageRestricted: number | undefined, amount: AmountString, ) => Promise<{ transactionId: string; confirmTransferUrl: string | undefined }>; function exchangeSelectionState( doWithdraw: ManualOrManagedWithdrawFunction, cancel: () => Promise, onSuccess: (txid: string) => Promise, talerWithdrawUri: string | undefined, infoAmount: AmountJson | undefined, currency: string, exchangeList: ExchangeListItem[], exchangeSuggestedByTheBank: string | undefined, onExchangeUpdated: (ex: string) => void, ): RecursiveState { const api = useBackendContext(); const selectedExchange = useSelectedExchange({ currency: currency, defaultExchange: exchangeSuggestedByTheBank, list: exchangeList, }); const current = selectedExchange.status !== "ready" ? undefined : selectedExchange.selected.exchangeBaseUrl; useEffect(() => { if (current) { onExchangeUpdated(current); } }, [current]); const safeAmount = !infoAmount ? Amounts.zeroOfCurrency(currency) : infoAmount const [choosenAmount, setChoosenAmount] = useState(safeAmount) 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(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(choosenAmount), restrictAge: ageRestricted, }, ); const withdrawAmount = { raw: Amounts.parseOrThrow(info.amountRaw), effective: Amounts.parseOrThrow(info.amountEffective), }; return { amount: withdrawAmount, ageRestrictionOptions: info.ageRestrictionOptions, accounts: info.withdrawalAccountsList, }; }, []); const [doingWithdraw, setDoingWithdraw] = useState(false); async function doWithdrawAndCheckError(): Promise { try { setDoingWithdraw(true); const res = await doWithdraw( currentExchange.exchangeBaseUrl, !ageRestricted ? undefined : ageRestricted, Amounts.stringify(choosenAmount), ); if (res.confirmTransferUrl) { document.location.href = res.confirmTransferUrl; } else { onSuccess(res.transactionId); } } catch (e) { console.error(e); // if (e instanceof TalerError) { // } } setDoingWithdraw(false); } if (!amountHook) { return { status: "loading", error: undefined }; } if (amountHook.hasError) { return { status: "error", error: alertFromError( i18n, 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]: i18n.str`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, amount: { value: choosenAmount, onInput: pushAlertOnError(async (v) => { setChoosenAmount(v) }) }, talerWithdrawUri, ageRestriction, doWithdrawal: { onClick: doingWithdraw ? undefined : pushAlertOnError(doWithdrawAndCheckError), }, cancel, }; }, []); }