summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-webextension/src/cta/Withdraw/state.ts')
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw/state.ts488
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,
+ };
+ }, []);
+}