diff options
author | Sebastian <sebasjm@gmail.com> | 2022-09-23 15:18:18 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2022-09-23 15:18:50 -0300 |
commit | 9811e19252ef859099fa5c16d703808f6c778a94 (patch) | |
tree | bc22a113f3a5c69c6b6883d1e6445697c5eb63af /packages/taler-wallet-webextension/src/wallet/DepositPage | |
parent | fbf050267244b72afb193e6ab80ea485e0eaf309 (diff) | |
download | wallet-core-9811e19252ef859099fa5c16d703808f6c778a94.tar.xz |
new deposit page
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/DepositPage')
5 files changed, 1233 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts new file mode 100644 index 000000000..eb97ccf7f --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts @@ -0,0 +1,112 @@ +/* + 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 { Loading } from "../../components/Loading.js"; +import { HookError } from "../../hooks/useAsyncAsHook.js"; +import { compose, StateViewMap } from "../../utils/index.js"; +import { AmountOrCurrencyErrorView, LoadingErrorView, NoAccountToDepositView, NoEnoughBalanceView, ReadyView } from "./views.js"; +import * as wxApi from "../../wxApi.js"; +import { useComponentState } from "./state.js"; +import { AmountJson, PaytoUri } from "@gnu-taler/taler-util"; +import { ButtonHandler, SelectFieldHandler, TextFieldHandler, ToggleHandler } from "../../mui/handlers.js"; +import { AddAccountPage } from "../AddAccount/index.js"; + +export interface Props { + amount?: string; + currency?: string; + onCancel: (currency: string) => void; + onSuccess: (currency: string) => void; +} + +export type State = State.Loading + | State.LoadingUriError + | State.AmountOrCurrencyError + | State.NoEnoughBalance + | State.Ready + | State.NoAccounts + | State.AddingAccount; + +export namespace State { + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingUriError { + status: "loading-error"; + error: HookError; + } + + export interface AddingAccount { + status: "adding-account"; + error: undefined; + currency: string; + onAccountAdded: (p: string) => void; + onCancel: () => void; + } + + export interface AmountOrCurrencyError { + status: "amount-or-currency-error"; + error: undefined; + } + + export interface BaseInfo { + error: undefined; + } + + export interface NoEnoughBalance extends BaseInfo { + status: "no-enough-balance"; + currency: string; + } + + export interface NoAccounts extends BaseInfo { + status: "no-accounts"; + currency: string; + onAddAccount: ButtonHandler; + } + + export interface Ready extends BaseInfo { + status: "ready"; + error: undefined; + currency: string; + + selectedAccount: PaytoUri | undefined; + totalFee: AmountJson; + totalToDeposit: AmountJson; + + amount: TextFieldHandler; + account: SelectFieldHandler; + cancelHandler: ButtonHandler; + depositHandler: ButtonHandler; + onAddAccount: ButtonHandler; + } +} + +const viewMapping: StateViewMap<State> = { + loading: Loading, + "loading-error": LoadingErrorView, + "amount-or-currency-error": AmountOrCurrencyErrorView, + "no-enough-balance": NoEnoughBalanceView, + "no-accounts": NoAccountToDepositView, + "adding-account": AddAccountPage, + ready: ReadyView, +}; + +export const DepositPage = compose( + "DepositPage", + (p: Props) => useComponentState(p, wxApi), + viewMapping, +); diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts new file mode 100644 index 000000000..87705507c --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts @@ -0,0 +1,240 @@ +/* + 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, DepositGroupFees, KnownBankAccountsInfo, parsePaytoUri, PaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import * as wxApi from "../../wxApi.js"; +import { Props, State } from "./index.js"; + +export function useComponentState({ amount: amountStr, currency: currencyStr, onCancel, onSuccess }: Props, api: typeof wxApi): State { + const parsed = amountStr === undefined ? undefined : Amounts.parse(amountStr); + const currency = parsed !== undefined ? parsed.currency : currencyStr; + + const hook = useAsyncAsHook(async () => { + const { balances } = await api.getBalance(); + const { accounts } = await api.listKnownBankAccounts(currency); + + return { accounts, balances }; + }); + + const initialValue = + parsed !== undefined ? Amounts.stringifyValue(parsed) : "0"; + // const [accountIdx, setAccountIdx] = useState<number>(0); + const [amount, setAmount] = useState(initialValue); + + const [selectedAccount, setSelectedAccount] = useState< + PaytoUri | undefined + >(); + + const [fee, setFee] = useState<DepositGroupFees | undefined>(undefined); + const [addingAccount, setAddingAccount] = useState(false); + + if (!currency) { + return { + status: "amount-or-currency-error", + error: undefined + } + } + + if (!hook) { + return { + status: "loading", + error: undefined, + }; + } + if (hook.hasError) { + return { + status: "loading-error", + error: hook, + } + } + const { accounts, balances } = hook.response; + + const parsedAmount = Amounts.parse(`${currency}:${amount}`); + + if (addingAccount) { + return { + status: "adding-account", + error: undefined, + currency, + onAccountAdded: (p: string) => { + updateAccountFromList(p); + setAddingAccount(false); + hook.retry() + }, + onCancel: () => { + setAddingAccount(false); + } + , + } + } + + const bs = balances.filter((b) => b.available.startsWith(currency)); + const balance = + bs.length > 0 + ? Amounts.parseOrThrow(bs[0].available) + : Amounts.getZero(currency); + + if (Amounts.isZero(balance)) { + return { + status: "no-enough-balance", + error: undefined, + currency, + }; + } + + if (accounts.length === 0) { + return { + status: "no-accounts", + error: undefined, + currency, + onAddAccount: { + onClick: async () => { setAddingAccount(true) } + }, + } + } + + const accountMap = createLabelsForBankAccount(accounts); + accountMap[""] = "Select one account..." + + async function updateAccountFromList(accountStr: string): Promise<void> { + // const newSelected = !accountMap[accountStr] ? undefined : accountMap[accountStr]; + // if (!newSelected) return; + const uri = !accountStr ? undefined : parsePaytoUri(accountStr); + setSelectedAccount(uri); + if (uri && parsedAmount) { + try { + const result = await getFeeForAmount(uri, parsedAmount, api); + setFee(result); + } catch (e) { + setFee(undefined); + } + } + } + + async function updateAmount(numStr: string): Promise<void> { + setAmount(numStr); + const parsed = Amounts.parse(`${currency}:${numStr}`); + if (parsed && selectedAccount) { + try { + const result = await getFeeForAmount(selectedAccount, parsed, api); + setFee(result); + } catch (e) { + setFee(undefined); + } + } + } + + const totalFee = + fee !== undefined + ? Amounts.sum([fee.wire, fee.coin, fee.refresh]).amount + : Amounts.getZero(currency); + + const totalToDeposit = parsedAmount && fee !== undefined + ? Amounts.sub(parsedAmount, totalFee).amount + : Amounts.getZero(currency); + + const isDirty = amount !== initialValue; + const amountError = !isDirty + ? undefined + : !parsedAmount + ? "Invalid amount" + : Amounts.cmp(balance, parsedAmount) === -1 + ? `Too much, your current balance is ${Amounts.stringifyValue(balance)}` + : undefined; + + const unableToDeposit = + !parsedAmount || + selectedAccount === undefined || + Amounts.isZero(totalToDeposit) || + fee === undefined || + amountError !== undefined; + + async function doSend(): Promise<void> { + if (!selectedAccount || !parsedAmount || !currency) return; + + const account = `payto://${selectedAccount.targetType}/${selectedAccount.targetPath}`; + const amount = Amounts.stringify(parsedAmount); + await api.createDepositGroup(account, amount); + onSuccess(currency); + } + + return { + status: "ready", + error: undefined, + currency, + amount: { + value: String(amount), + onInput: updateAmount, + error: amountError, + + }, + onAddAccount: { + onClick: async () => { setAddingAccount(true) } + }, + account: { + list: accountMap, + value: !selectedAccount ? "" : stringifyPaytoUri(selectedAccount), + onChange: updateAccountFromList, + }, + selectedAccount, + cancelHandler: { + onClick: async () => { + onCancel(currency); + }, + }, + depositHandler: { + onClick: unableToDeposit ? undefined : doSend, + }, + totalFee, + totalToDeposit, + // currentAccount, + // parsedAmount, + }; +} + +async function getFeeForAmount( + p: PaytoUri, + a: AmountJson, + api: typeof wxApi, +): Promise<DepositGroupFees> { + const account = `payto://${p.targetType}/${p.targetPath}`; + const amount = Amounts.stringify(a); + return await api.getFeeForDeposit(account, amount); +} + +export function labelForAccountType(id: string) { + switch (id) { + case "": return "Choose one"; + case "x-taler-bank": return "Taler Bank"; + case "bitcoin": return "Bitcoin"; + case "iban": return "IBAN"; + default: return id; + } +} + +export function createLabelsForBankAccount( + knownBankAccounts: Array<KnownBankAccountsInfo>, +): { [value: string]: string } { + const initialList: Record<string, string> = { + } + if (!knownBankAccounts.length) return initialList; + return knownBankAccounts.reduce((prev, cur, i) => { + prev[stringifyPaytoUri(cur.uri)] = cur.alias + return prev; + }, initialList); +} diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx new file mode 100644 index 000000000..ed5945c06 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx @@ -0,0 +1,131 @@ +/* + 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { Amounts, DepositGroupFees } from "@gnu-taler/taler-util"; +import { createExample } from "../../test-utils.js"; +import { labelForAccountType } from "./state.js"; +import { ReadyView } from "./views.js"; + +export default { + title: "wallet/deposit", +}; + +const accountsType = { + "": labelForAccountType(""), + iban: labelForAccountType("iban"), + bitcoin: labelForAccountType("bitcoin"), + "x-taler-bank": labelForAccountType("x-taler-bank"), +}; +async function alwaysReturnFeeToOne(): Promise<DepositGroupFees> { + const fee = { + currency: "EUR", + value: 1, + fraction: 0, + }; + return { coin: fee, refresh: fee, wire: fee }; +} + +// const ac = parsePaytoUri("payto://iban/ES8877998399652238")!; +// const accountMap = createLabelsForBankAccount([ac]); + +export const WithNoAccountForIBAN = createExample(ReadyView, { + status: "ready", + account: { + list: {}, + value: "", + onChange: async () => { + null; + }, + }, + currency: "USD", + amount: { + onInput: async () => { + null; + }, + value: "10:USD", + }, + onAddAccount: {}, + cancelHandler: {}, + depositHandler: { + onClick: async () => { + return; + }, + }, + totalFee: Amounts.getZero("USD"), + totalToDeposit: Amounts.parseOrThrow("USD:10"), + // onCalculateFee: alwaysReturnFeeToOne, +}); + +export const WithIBANAccountTypeSelected = createExample(ReadyView, { + status: "ready", + account: { + list: { asdlkajsdlk: "asdlkajsdlk", qwerqwer: "qwerqwer" }, + value: "asdlkajsdlk", + onChange: async () => { + null; + }, + }, + currency: "USD", + amount: { + onInput: async () => { + null; + }, + value: "10:USD", + }, + onAddAccount: {}, + cancelHandler: {}, + depositHandler: { + onClick: async () => { + return; + }, + }, + totalFee: Amounts.getZero("USD"), + totalToDeposit: Amounts.parseOrThrow("USD:10"), + // onCalculateFee: alwaysReturnFeeToOne, +}); + +export const NewBitcoinAccountTypeSelected = createExample(ReadyView, { + status: "ready", + account: { + list: {}, + value: "asdlkajsdlk", + onChange: async () => { + null; + }, + }, + onAddAccount: {}, + currency: "USD", + amount: { + onInput: async () => { + null; + }, + value: "10:USD", + }, + cancelHandler: {}, + depositHandler: { + onClick: async () => { + return; + }, + }, + totalFee: Amounts.getZero("USD"), + totalToDeposit: Amounts.parseOrThrow("USD:10"), + // onCalculateFee: alwaysReturnFeeToOne, +}); diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts new file mode 100644 index 000000000..a1d4ca85a --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts @@ -0,0 +1,508 @@ +/* + 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { Amounts, Balance, BalancesResponse, DepositGroupFees, parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; +import { expect } from "chai"; +import { mountHook } from "../../test-utils.js"; + +import * as wxApi from "../../wxApi.js"; +import { useComponentState } from "./state.js"; + +const currency = "EUR"; +const withoutFee = async (): Promise<DepositGroupFees> => ({ + coin: Amounts.parseOrThrow(`${currency}:0`), + wire: Amounts.parseOrThrow(`${currency}:0`), + refresh: Amounts.parseOrThrow(`${currency}:0`), +}); + +const withSomeFee = async (): Promise<DepositGroupFees> => ({ + coin: Amounts.parseOrThrow(`${currency}:1`), + wire: Amounts.parseOrThrow(`${currency}:1`), + refresh: Amounts.parseOrThrow(`${currency}:1`), +}); + +const freeJustForIBAN = async (account: string): Promise<DepositGroupFees> => + /IBAN/i.test(account) ? withSomeFee() : withoutFee(); + +const someBalance = [ + { + available: "EUR:10", + } as Balance, +]; + +const nullFunction: any = () => null; +type VoidFunction = () => void; + +describe("DepositPage states", () => { + + it("should have status 'no-enough-balance' when balance is empty", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, { + getBalance: async () => + ({ + balances: [{ available: `${currency}:0` }], + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: {} }), + } as Partial<typeof wxApi> as any), + ); + + { + const { status } = getLastResultOrThrow(); + expect(status).equal("loading"); + } + + await waitNextUpdate(); + + { + const { status } = getLastResultOrThrow(); + expect(status).equal("no-enough-balance"); + } + + await assertNoPendingUpdate(); + }); + + // it("should have status 'no-accounts' when balance is not empty and accounts is empty", async () => { + // const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + // mountHook(() => + // useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, { + // getBalance: async () => + // ({ + // balances: [{ available: `${currency}:1` }], + // } as Partial<BalancesResponse>), + // listKnownBankAccounts: async () => ({ accounts: {} }), + // } as Partial<typeof wxApi> as any), + // ); + + // { + // const { status } = getLastResultOrThrow(); + // expect(status).equal("loading"); + // } + + // await waitNextUpdate(); + // { + // const r = getLastResultOrThrow(); + // if (r.status !== "no-accounts") expect.fail(); + // expect(r.cancelHandler.onClick).not.undefined; + // } + + // await assertNoPendingUpdate(); + // }); + + const ibanPayto = { + uri: parsePaytoUri("payto://iban/ES8877998399652238")!, + kyc_completed: false, + currency: "EUR", + alias: "my iban account" + }; + const talerBankPayto = { + uri: parsePaytoUri("payto://x-taler-bank/ES8877998399652238")!, + kyc_completed: false, + currency: "EUR", + alias: "my taler account" + }; + + it("should have status 'ready' but unable to deposit ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, { + getBalance: async () => + ({ + balances: [{ available: `${currency}:1` }], + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }), + } as Partial<typeof wxApi> as any), + ); + + { + const { status } = getLastResultOrThrow(); + expect(status).equal("loading"); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("0"); + expect(r.depositHandler.onClick).undefined; + } + + await assertNoPendingUpdate(); + }); + + it.skip("should not be able to deposit more than the balance ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, { + getBalance: async () => + ({ + balances: [{ available: `${currency}:1` }], + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }), + getFeeForDeposit: withoutFee, + } as Partial<typeof wxApi> as any), + ); + + { + const { status } = getLastResultOrThrow(); + expect(status).equal("loading"); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("0"); + expect(r.depositHandler.onClick).undefined; + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + + r.amount.onInput("10"); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + expect(r.depositHandler.onClick).undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + expect(r.depositHandler.onClick).undefined; + } + + await assertNoPendingUpdate(); + }); + + it.skip("should calculate the fee upon entering amount ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, { + getBalance: async () => + ({ + balances: [{ available: `${currency}:1` }], + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }), + getFeeForDeposit: withSomeFee, + } as Partial<typeof wxApi> as any), + ); + + { + const { status } = getLastResultOrThrow(); + expect(status).equal("loading"); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("0"); + expect(r.depositHandler.onClick).undefined; + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + + r.amount.onInput("10"); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:10`)); + expect(r.depositHandler.onClick).undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`)); + expect(r.depositHandler.onClick).undefined; + } + + await assertNoPendingUpdate(); + }); + + it("should calculate the fee upon selecting account ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, { + getBalance: async () => + ({ + balances: [{ available: `${currency}:1` }], + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ + accounts: [ibanPayto, talerBankPayto], + }), + getFeeForDeposit: freeJustForIBAN, + } as Partial<typeof wxApi> as any), + ); + + { + const { status } = getLastResultOrThrow(); + expect(status).equal("loading"); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("0"); + expect(r.depositHandler.onClick).undefined; + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + + if (r.account.onChange === undefined) expect.fail(); + r.account.onChange(stringifyPaytoUri(ibanPayto.uri)); + } + + await waitNextUpdate(""); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(stringifyPaytoUri(ibanPayto.uri)); + expect(r.amount.value).eq("0"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + expect(r.depositHandler.onClick).undefined; + + } + + await waitNextUpdate(""); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(stringifyPaytoUri(ibanPayto.uri)); + expect(r.amount.value).eq("0"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + expect(r.depositHandler.onClick).undefined; + + r.amount.onInput("10"); + } + + await waitNextUpdate(""); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(stringifyPaytoUri(ibanPayto.uri)); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`)); + expect(r.depositHandler.onClick).undefined; + + } + + await waitNextUpdate(""); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(stringifyPaytoUri(ibanPayto.uri)); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`)); + expect(r.depositHandler.onClick).undefined; + + + if (r.account.onChange === undefined) expect.fail(); + r.account.onChange(stringifyPaytoUri(talerBankPayto.uri)); + } + + await waitNextUpdate(""); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(stringifyPaytoUri(talerBankPayto.uri)); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`)); + expect(r.depositHandler.onClick).undefined; + + } + + await waitNextUpdate(""); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(stringifyPaytoUri(talerBankPayto.uri)); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:10`)); + expect(r.depositHandler.onClick).undefined; + } + + await assertNoPendingUpdate(); + }); + + it.skip("should be able to deposit if has the enough balance ", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = + mountHook(() => + useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, { + getBalance: async () => + ({ + balances: [{ available: `${currency}:15` }], + } as Partial<BalancesResponse>), + listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }), + getFeeForDeposit: withSomeFee, + } as Partial<typeof wxApi> as any), + ); + + { + const { status } = getLastResultOrThrow(); + expect(status).equal("loading"); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("0"); + expect(r.depositHandler.onClick).undefined; + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + + r.amount.onInput("10"); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:10`)); + expect(r.depositHandler.onClick).undefined; + + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("10"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`)); + expect(r.depositHandler.onClick).not.undefined; + + r.amount.onInput("13"); + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("13"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:10`)); + expect(r.depositHandler.onClick).not.undefined; + } + + await waitNextUpdate(); + + { + const r = getLastResultOrThrow(); + if (r.status !== "ready") expect.fail(); + expect(r.cancelHandler.onClick).not.undefined; + expect(r.currency).eq(currency); + expect(r.account.value).eq(""); + expect(r.amount.value).eq("13"); + expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); + expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:10`)); + expect(r.depositHandler.onClick).not.undefined; + } + + await assertNoPendingUpdate(); + }); +}); diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx new file mode 100644 index 000000000..ddb23c9bb --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx @@ -0,0 +1,242 @@ +/* + 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 { Amounts, PaytoUri } from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; +import { LoadingError } from "../../components/LoadingError.js"; +import { SelectList } from "../../components/SelectList.js"; +import { + ErrorText, + Input, + InputWithLabel, + SubTitle, + WarningBox, +} from "../../components/styled/index.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { Button } from "../../mui/Button.js"; +import { State } from "./index.js"; + +export function LoadingErrorView({ error }: State.LoadingUriError): VNode { + const { i18n } = useTranslationContext(); + + return ( + <LoadingError + title={<i18n.Translate>Could not load deposit balance</i18n.Translate>} + error={error} + /> + ); +} + +export function AmountOrCurrencyErrorView( + p: State.AmountOrCurrencyError, +): VNode { + const { i18n } = useTranslationContext(); + + return ( + <ErrorMessage + title={ + <i18n.Translate> + A currency or an amount should be indicated + </i18n.Translate> + } + /> + ); +} + +export function NoEnoughBalanceView({ + currency, +}: State.NoEnoughBalance): VNode { + const { i18n } = useTranslationContext(); + + return ( + <ErrorMessage + title={ + <i18n.Translate> + There is no enough balance to make a deposit for currency {currency} + </i18n.Translate> + } + /> + ); +} + +function AccountDetails({ account }: { account: PaytoUri }): VNode { + if (account.isKnown) { + if (account.targetType === "bitcoin") { + return ( + <dl> + <dt>Bitcoin</dt> + <dd>{account.targetPath}</dd> + </dl> + ); + } + if (account.targetType === "x-taler-bank") { + return ( + <dl> + <dt>Bank host</dt> + <dd>{account.targetPath.split("/")[0]}</dd> + <dt>Account name</dt> + <dd>{account.targetPath.split("/")[1]}</dd> + </dl> + ); + } + if (account.targetType === "iban") { + return ( + <dl> + <dt>IBAN</dt> + <dd>{account.targetPath}</dd> + </dl> + ); + } + } + return <Fragment />; +} + +export function NoAccountToDepositView({ + currency, + onAddAccount, +}: State.NoAccounts): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <SubTitle> + <i18n.Translate>Send {currency} to your account</i18n.Translate> + </SubTitle> + + <WarningBox> + <i18n.Translate> + There is no account to make a deposit for currency {currency} + </i18n.Translate> + </WarningBox> + + <Button onClick={onAddAccount.onClick} variant="contained"> + <i18n.Translate>Add account</i18n.Translate> + </Button> + </Fragment> + ); +} + +export function ReadyView(state: State.Ready): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <SubTitle> + <i18n.Translate>Send {state.currency} to your account</i18n.Translate> + </SubTitle> + <section> + <div + style={{ + display: "flex", + justifyContent: "space-between", + marginBottom: 16, + }} + > + <Input> + <SelectList + label={<i18n.Translate>Select account</i18n.Translate>} + list={state.account.list} + name="account" + value={state.account.value} + onChange={state.account.onChange} + /> + </Input> + <Button + onClick={state.onAddAccount.onClick} + variant="text" + style={{ marginLeft: "auto" }} + > + <i18n.Translate>Add another account</i18n.Translate> + </Button> + </div> + + {state.selectedAccount && ( + <Fragment> + <p> + <AccountDetails account={state.selectedAccount} /> + </p> + <InputWithLabel invalid={!!state.amount.error}> + <label> + <i18n.Translate>Amount</i18n.Translate> + </label> + <div> + <span>{state.currency}</span> + <input + type="number" + value={state.amount.value} + onInput={(e) => state.amount.onInput(e.currentTarget.value)} + /> + </div> + {state.amount.error && ( + <ErrorText>{state.amount.error}</ErrorText> + )} + </InputWithLabel> + + <InputWithLabel> + <label> + <i18n.Translate>Deposit fee</i18n.Translate> + </label> + <div> + <span>{state.currency}</span> + <input + type="number" + disabled + value={Amounts.stringifyValue(state.totalFee)} + /> + </div> + </InputWithLabel> + + <InputWithLabel> + <label> + <i18n.Translate>Total deposit</i18n.Translate> + </label> + <div> + <span>{state.currency}</span> + <input + type="number" + disabled + value={Amounts.stringifyValue(state.totalToDeposit)} + /> + </div> + </InputWithLabel> + </Fragment> + )} + </section> + <footer> + <Button + variant="contained" + color="secondary" + onClick={state.cancelHandler.onClick} + > + <i18n.Translate>Cancel</i18n.Translate> + </Button> + {!state.depositHandler.onClick ? ( + <Button variant="contained" disabled> + <i18n.Translate>Deposit</i18n.Translate> + </Button> + ) : ( + <Button variant="contained" onClick={state.depositHandler.onClick}> + <i18n.Translate> + Deposit {Amounts.stringifyValue(state.totalToDeposit)}{" "} + {state.currency} + </i18n.Translate> + </Button> + )} + </footer> + </Fragment> + ); +} |