diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/cta/Withdraw/state.ts')
-rw-r--r-- | packages/taler-wallet-webextension/src/cta/Withdraw/state.ts | 488 |
1 files changed, 488 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts new file mode 100644 index 000000000..f2fa04902 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts @@ -0,0 +1,488 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { + AmountJson, + Amounts, + ExchangeFullDetails, + ExchangeListItem, + NotificationType, + 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<State> { + 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) { + 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, + 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<AmountJson>( + 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<State> { + 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.WithdrawalOperationTransition], + 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 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<void>, + onSuccess: (txid: string) => Promise<void>, + talerWithdrawUri: string | undefined, + chosenAmount: AmountJson, + exchangeList: ExchangeListItem[], + exchangeSuggestedByTheBank: string | undefined, +): RecursiveState<State> { + 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<string>( + 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 [doingWithdraw, setDoingWithdraw] = useState<boolean>(false); + + async function doWithdrawAndCheckError(): Promise<void> { + 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) { + 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]: `under ${c}` }), + {} as Record<string, string>, + ); + + 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, + }; + }, []); +} |