From 2a4dbc67e8d1e6a256431e34f0b2e0e19d204f70 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 8 Mar 2024 09:20:02 -0300 Subject: remove dead code --- packages/demobank-ui/src/Routing.tsx | 22 +- packages/demobank-ui/src/app.tsx | 137 +++ .../demobank-ui/src/components/Cashouts/state.ts | 2 +- .../demobank-ui/src/components/Cashouts/test.ts | 17 +- .../demobank-ui/src/components/Cashouts/views.tsx | 2 +- .../src/components/Transactions/state.ts | 2 +- .../src/components/Transactions/test.ts | 129 ++- packages/demobank-ui/src/components/app.tsx | 142 --- packages/demobank-ui/src/context/backend.ts | 79 -- packages/demobank-ui/src/context/config.ts | 8 +- packages/demobank-ui/src/endpoints.ts | 48 - packages/demobank-ui/src/hooks/access.ts | 294 ------ packages/demobank-ui/src/hooks/account.ts | 294 ++++++ packages/demobank-ui/src/hooks/async.ts | 80 -- packages/demobank-ui/src/hooks/backend.ts | 131 --- packages/demobank-ui/src/hooks/bank-state.ts | 11 + packages/demobank-ui/src/hooks/circuit.ts | 484 ---------- packages/demobank-ui/src/hooks/form.ts | 100 ++ packages/demobank-ui/src/hooks/index.ts | 60 -- packages/demobank-ui/src/hooks/preferences.ts | 67 +- packages/demobank-ui/src/hooks/regional.ts | 484 ++++++++++ packages/demobank-ui/src/hooks/session.ts | 131 +++ packages/demobank-ui/src/index.tsx | 2 +- .../demobank-ui/src/pages/AccountPage/state.ts | 2 +- packages/demobank-ui/src/pages/BankFrame.tsx | 10 +- .../demobank-ui/src/pages/ConversionConfig.tsx | 1018 -------------------- packages/demobank-ui/src/pages/DownloadStats.tsx | 585 ----------- packages/demobank-ui/src/pages/LoginForm.tsx | 12 +- .../demobank-ui/src/pages/OperationState/state.ts | 6 +- packages/demobank-ui/src/pages/PaymentOptions.tsx | 6 +- .../src/pages/PaytoWireTransferForm.tsx | 4 +- .../demobank-ui/src/pages/ProfileNavigation.tsx | 4 +- .../demobank-ui/src/pages/PublicHistoriesPage.tsx | 2 +- packages/demobank-ui/src/pages/QrCodeSection.tsx | 4 +- .../demobank-ui/src/pages/SolveChallengePage.tsx | 8 +- .../demobank-ui/src/pages/WalletWithdrawForm.tsx | 4 +- packages/demobank-ui/src/pages/WireTransfer.tsx | 6 +- .../src/pages/WithdrawalConfirmationQuestion.tsx | 6 +- .../demobank-ui/src/pages/WithdrawalQRCode.tsx | 2 +- .../src/pages/account/CashoutListForAccount.tsx | 6 +- .../src/pages/account/ShowAccountDetails.tsx | 6 +- .../src/pages/account/UpdateAccountPassword.tsx | 4 +- .../demobank-ui/src/pages/admin/AccountForm.tsx | 4 +- .../demobank-ui/src/pages/admin/AccountList.tsx | 2 +- packages/demobank-ui/src/pages/admin/AdminHome.tsx | 2 +- .../src/pages/admin/CreateNewAccount.tsx | 4 +- .../demobank-ui/src/pages/admin/DownloadStats.tsx | 585 +++++++++++ .../demobank-ui/src/pages/admin/RemoveAccount.tsx | 6 +- .../src/pages/business/CreateCashout.tsx | 809 ---------------- .../src/pages/business/ShowCashoutDetails.tsx | 192 ---- .../src/pages/regional/ConversionConfig.tsx | 980 +++++++++++++++++++ .../src/pages/regional/CreateCashout.tsx | 809 ++++++++++++++++ .../src/pages/regional/ShowCashoutDetails.tsx | 192 ++++ packages/demobank-ui/src/stories.test.ts | 12 +- 54 files changed, 3908 insertions(+), 4110 deletions(-) create mode 100644 packages/demobank-ui/src/app.tsx delete mode 100644 packages/demobank-ui/src/components/app.tsx delete mode 100644 packages/demobank-ui/src/context/backend.ts delete mode 100644 packages/demobank-ui/src/endpoints.ts delete mode 100644 packages/demobank-ui/src/hooks/access.ts create mode 100644 packages/demobank-ui/src/hooks/account.ts delete mode 100644 packages/demobank-ui/src/hooks/async.ts delete mode 100644 packages/demobank-ui/src/hooks/backend.ts delete mode 100644 packages/demobank-ui/src/hooks/circuit.ts create mode 100644 packages/demobank-ui/src/hooks/form.ts delete mode 100644 packages/demobank-ui/src/hooks/index.ts create mode 100644 packages/demobank-ui/src/hooks/regional.ts create mode 100644 packages/demobank-ui/src/hooks/session.ts delete mode 100644 packages/demobank-ui/src/pages/ConversionConfig.tsx delete mode 100644 packages/demobank-ui/src/pages/DownloadStats.tsx create mode 100644 packages/demobank-ui/src/pages/admin/DownloadStats.tsx delete mode 100644 packages/demobank-ui/src/pages/business/CreateCashout.tsx delete mode 100644 packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx create mode 100644 packages/demobank-ui/src/pages/regional/ConversionConfig.tsx create mode 100644 packages/demobank-ui/src/pages/regional/CreateCashout.tsx create mode 100644 packages/demobank-ui/src/pages/regional/ShowCashoutDetails.tsx (limited to 'packages/demobank-ui/src') diff --git a/packages/demobank-ui/src/Routing.tsx b/packages/demobank-ui/src/Routing.tsx index a8ed58db1..c85d74e17 100644 --- a/packages/demobank-ui/src/Routing.tsx +++ b/packages/demobank-ui/src/Routing.tsx @@ -27,12 +27,13 @@ import { TranslatedString, assertUnreachable, } from "@gnu-taler/taler-util"; +import { useEffect } from "preact/hooks"; import { useBankCoreApiContext } from "./context/config.js"; +import { useNavigationContext } from "./context/navigation.js"; import { useSettingsContext } from "./context/settings.js"; -import { useBackendState } from "./hooks/backend.js"; +import { useSessionState } from "./hooks/session.js"; import { AccountPage } from "./pages/AccountPage/index.js"; import { BankFrame } from "./pages/BankFrame.js"; -import { DownloadStats } from "./pages/DownloadStats.js"; import { LoginForm } from "./pages/LoginForm.js"; import { PublicHistoriesPage } from "./pages/PublicHistoriesPage.js"; import { RegistrationPage } from "./pages/RegistrationPage.js"; @@ -44,19 +45,18 @@ import { ShowAccountDetails } from "./pages/account/ShowAccountDetails.js"; import { UpdateAccountPassword } from "./pages/account/UpdateAccountPassword.js"; import { AdminHome } from "./pages/admin/AdminHome.js"; import { CreateNewAccount } from "./pages/admin/CreateNewAccount.js"; +import { DownloadStats } from "./pages/admin/DownloadStats.js"; import { RemoveAccount } from "./pages/admin/RemoveAccount.js"; -import { CreateCashout } from "./pages/business/CreateCashout.js"; -import { ShowCashoutDetails } from "./pages/business/ShowCashoutDetails.js"; +import { ConversionConfig } from "./pages/regional/ConversionConfig.js"; +import { CreateCashout } from "./pages/regional/CreateCashout.js"; +import { ShowCashoutDetails } from "./pages/regional/ShowCashoutDetails.js"; import { urlPattern, useCurrentLocation } from "./route.js"; -import { useNavigationContext } from "./context/navigation.js"; -import { useEffect } from "preact/hooks"; -import { ConversionConfig } from "./pages/ConversionConfig.js"; export function Routing(): VNode { - const backend = useBackendState(); + const session = useSessionState(); - if (backend.state.status === "loggedIn") { - const { isUserAdministrator, username } = backend.state; + if (session.state.status === "loggedIn") { + const { isUserAdministrator, username } = session.state; return ( @@ -67,7 +67,7 @@ export function Routing(): VNode { { - backend.logIn({ username, token: token }); + session.logIn({ username, token: token }); }} /> diff --git a/packages/demobank-ui/src/app.tsx b/packages/demobank-ui/src/app.tsx new file mode 100644 index 000000000..3a7fafccf --- /dev/null +++ b/packages/demobank-ui/src/app.tsx @@ -0,0 +1,137 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + canonicalizeBaseUrl, + getGlobalLogLevel, + setGlobalLogLevelFromString, +} from "@gnu-taler/taler-util"; +import { Loading, TranslationProvider } from "@gnu-taler/web-util/browser"; +import { h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { SWRConfig } from "swr"; +import { Routing } from "./Routing.js"; +import { BankCoreApiProvider } from "./context/config.js"; +import { BrowserHashNavigationProvider } from "./context/navigation.js"; +import { SettingsProvider } from "./context/settings.js"; +import { TalerWalletIntegrationBrowserProvider } from "./context/wallet-integration.js"; +import { strings } from "./i18n/strings.js"; +import { BankFrame } from "./pages/BankFrame.js"; +import { BankUiSettings, fetchSettings } from "./settings.js"; +const WITH_LOCAL_STORAGE_CACHE = false; + +export function App() { + const [settings, setSettings] = useState(); + useEffect(() => { + fetchSettings(setSettings); + }, []); + if (!settings) return ; + + const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL); + return ( + + + + + + + + + + + + + + ); +}; + +// @ts-expect-error creating a new property for window object +window.setGlobalLogLevelFromString = setGlobalLogLevelFromString; +// @ts-expect-error creating a new property for window object +window.getGlobalLevel = getGlobalLogLevel; + +function localStorageProvider(): Map { + const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]")); + + window.addEventListener("beforeunload", () => { + const appCache = JSON.stringify(Array.from(map.entries())); + localStorage.setItem("app-cache", appCache); + }); + return map; +} + +function getInitialBackendBaseURL( + backendFromSettings: string | undefined, +): string { + const overrideUrl = + typeof localStorage !== "undefined" + ? localStorage.getItem("corebank-api-base-url") + : undefined; + let result: string; + + if (!overrideUrl) { + // normal path + if (!backendFromSettings) { + console.error( + "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'", + ); + result = window.origin; + } else { + result = backendFromSettings; + } + } else { + // testing/development path + result = overrideUrl; + } + try { + return canonicalizeBaseUrl(result); + } catch (e) { + // fall back + return canonicalizeBaseUrl(window.origin); + } +} diff --git a/packages/demobank-ui/src/components/Cashouts/state.ts b/packages/demobank-ui/src/components/Cashouts/state.ts index 344b93e14..8616faa1b 100644 --- a/packages/demobank-ui/src/components/Cashouts/state.ts +++ b/packages/demobank-ui/src/components/Cashouts/state.ts @@ -15,7 +15,7 @@ */ import { TalerError } from "@gnu-taler/taler-util"; -import { useCashouts } from "../../hooks/circuit.js"; +import { useCashouts } from "../../hooks/regional.js"; import { Props, State } from "./index.js"; export function useComponentState({ diff --git a/packages/demobank-ui/src/components/Cashouts/test.ts b/packages/demobank-ui/src/components/Cashouts/test.ts index 569cbc6f0..4bd6b5eac 100644 --- a/packages/demobank-ui/src/components/Cashouts/test.ts +++ b/packages/demobank-ui/src/components/Cashouts/test.ts @@ -22,7 +22,6 @@ import * as tests from "@gnu-taler/web-util/testing"; import { SwrMockEnvironment } from "@gnu-taler/web-util/testing"; import { expect } from "chai"; -import { CASHOUT_API_EXAMPLE } from "../../endpoints.js"; import { Props } from "./index.js"; import { useComponentState } from "./state.js"; import { buildNullRoutDefinition } from "../../route.js"; @@ -36,15 +35,15 @@ describe("Cashout states", () => { routeCashoutDetails: buildNullRoutDefinition(), }; - env.addRequestExpectation(CASHOUT_API_EXAMPLE.LIST_FIRST_PAGE, { - response: { - cashouts: [], - }, - }); + // env.addRequestExpectation(CASHOUT_API_EXAMPLE.LIST_FIRST_PAGE, { + // response: { + // cashouts: [], + // }, + // }); - env.addRequestExpectation(CASHOUT_API_EXAMPLE.MULTI_GET_EMPTY_FIRST_PAGE, { - response: [], - }); + // env.addRequestExpectation(CASHOUT_API_EXAMPLE.MULTI_GET_EMPTY_FIRST_PAGE, { + // response: [], + // }); const hookBehavior = await tests.hookBehaveLikeThis( useComponentState, diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx index 09e986dd4..7f16d5840 100644 --- a/packages/demobank-ui/src/components/Cashouts/views.tsx +++ b/packages/demobank-ui/src/components/Cashouts/views.tsx @@ -29,7 +29,7 @@ import { } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; -import { useConversionInfo } from "../../hooks/circuit.js"; +import { useConversionInfo } from "../../hooks/regional.js"; import { RenderAmount } from "../../pages/PaytoWireTransferForm.js"; import { ErrorLoadingWithDebug } from "../ErrorLoadingWithDebug.js"; import { State } from "./index.js"; diff --git a/packages/demobank-ui/src/components/Transactions/state.ts b/packages/demobank-ui/src/components/Transactions/state.ts index 40e1b0ced..3e9103b59 100644 --- a/packages/demobank-ui/src/components/Transactions/state.ts +++ b/packages/demobank-ui/src/components/Transactions/state.ts @@ -20,7 +20,7 @@ import { TalerError, parsePaytoUri, } from "@gnu-taler/taler-util"; -import { useTransactions } from "../../hooks/access.js"; +import { useTransactions } from "../../hooks/account.js"; import { Props, State, Transaction } from "./index.js"; export function useComponentState({ account, routeCreateWireTransfer }: Props): State { diff --git a/packages/demobank-ui/src/components/Transactions/test.ts b/packages/demobank-ui/src/components/Transactions/test.ts index 9ded218c1..d9442c742 100644 --- a/packages/demobank-ui/src/components/Transactions/test.ts +++ b/packages/demobank-ui/src/components/Transactions/test.ts @@ -23,7 +23,6 @@ import { TalerErrorCode } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { SwrMockEnvironment } from "@gnu-taler/web-util/testing"; import { expect } from "chai"; -import { TRANSACTION_API_EXAMPLE } from "../../endpoints.js"; import { Props } from "./index.js"; import { useComponentState } from "./state.js"; @@ -36,63 +35,63 @@ describe("Transaction states", () => { routeCreateWireTransfer: undefined, }; - env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, { - response: { - data: { - transactions: [ - { - creditorIban: "DE159593", - creditorBic: "SANDBOXX", - creditorName: "exchange company", - debtorIban: "DE118695", - debtorBic: "SANDBOXX", - debtorName: "Name unknown", - amount: "1", - currency: "KUDOS", - subject: - "Taler Withdrawal N588V8XE9TR49HKAXFQ20P0EQ0EYW2AC9NNANV8ZP5P59N6N0410", - date: "2022-12-12Z", - uid: "8PPFR9EM", - direction: "DBIT", - pmtInfId: null, - msgId: null, - }, - { - creditorIban: "DE159593", - creditorBic: "SANDBOXX", - creditorName: "exchange company", - debtorIban: "DE118695", - debtorBic: "SANDBOXX", - debtorName: "Name unknown", - amount: "5.00", - currency: "KUDOS", - subject: "HNEWWT679TQC5P1BVXJS48FX9NW18FWM6PTK2N80Z8GVT0ACGNK0", - date: "2022-12-07Z", - uid: "7FZJC3RJ", - direction: "DBIT", - pmtInfId: null, - msgId: null, - }, - { - creditorIban: "DE118695", - creditorBic: "SANDBOXX", - creditorName: "Name unknown", - debtorIban: "DE579516", - debtorBic: "SANDBOXX", - debtorName: "The Bank", - amount: "100", - currency: "KUDOS", - subject: "Sign-up bonus", - date: "2022-12-07Z", - uid: "I31A06J8", - direction: "CRDT", - pmtInfId: null, - msgId: null, - }, - ], - }, - }, - }); + // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, { + // response: { + // data: { + // transactions: [ + // { + // creditorIban: "DE159593", + // creditorBic: "SANDBOXX", + // creditorName: "exchange company", + // debtorIban: "DE118695", + // debtorBic: "SANDBOXX", + // debtorName: "Name unknown", + // amount: "1", + // currency: "KUDOS", + // subject: + // "Taler Withdrawal N588V8XE9TR49HKAXFQ20P0EQ0EYW2AC9NNANV8ZP5P59N6N0410", + // date: "2022-12-12Z", + // uid: "8PPFR9EM", + // direction: "DBIT", + // pmtInfId: null, + // msgId: null, + // }, + // { + // creditorIban: "DE159593", + // creditorBic: "SANDBOXX", + // creditorName: "exchange company", + // debtorIban: "DE118695", + // debtorBic: "SANDBOXX", + // debtorName: "Name unknown", + // amount: "5.00", + // currency: "KUDOS", + // subject: "HNEWWT679TQC5P1BVXJS48FX9NW18FWM6PTK2N80Z8GVT0ACGNK0", + // date: "2022-12-07Z", + // uid: "7FZJC3RJ", + // direction: "DBIT", + // pmtInfId: null, + // msgId: null, + // }, + // { + // creditorIban: "DE118695", + // creditorBic: "SANDBOXX", + // creditorName: "Name unknown", + // debtorIban: "DE579516", + // debtorBic: "SANDBOXX", + // debtorName: "The Bank", + // amount: "100", + // currency: "KUDOS", + // subject: "Sign-up bonus", + // date: "2022-12-07Z", + // uid: "I31A06J8", + // direction: "CRDT", + // pmtInfId: null, + // msgId: null, + // }, + // ], + // }, + // }, + // }); const hookBehavior = await tests.hookBehaveLikeThis( useComponentState, @@ -165,13 +164,13 @@ describe("Transaction states", () => { routeCreateWireTransfer: undefined, }; - env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, { - response: { - error: { - code: TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, - }, - }, - }); + // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, { + // response: { + // error: { + // code: TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + // }, + // }, + // }); const hookBehavior = await tests.hookBehaveLikeThis( useComponentState, diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx deleted file mode 100644 index 97778e6d7..000000000 --- a/packages/demobank-ui/src/components/app.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - canonicalizeBaseUrl, - getGlobalLogLevel, - setGlobalLogLevelFromString, -} from "@gnu-taler/taler-util"; -import { Loading, TranslationProvider } from "@gnu-taler/web-util/browser"; -import { FunctionalComponent, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { SWRConfig } from "swr"; -import { Routing } from "../Routing.js"; -import { BackendStateProvider } from "../context/backend.js"; -import { BankCoreApiProvider } from "../context/config.js"; -import { SettingsProvider } from "../context/settings.js"; -import { strings } from "../i18n/strings.js"; -import { BankFrame } from "../pages/BankFrame.js"; -import { BankUiSettings, fetchSettings } from "../settings.js"; -import { TalerWalletIntegrationBrowserProvider } from "../context/wallet-integration.js"; -import { BrowserHashNavigationProvider } from "../context/navigation.js"; -const WITH_LOCAL_STORAGE_CACHE = false; - -const App: FunctionalComponent = () => { - const [settings, setSettings] = useState(); - useEffect(() => { - fetchSettings(setSettings); - }, []); - if (!settings) return ; - - const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL); - return ( - - - - - - - - - - - - - - - - ); -}; - -// @ts-expect-error creating a new property for window object -window.setGlobalLogLevelFromString = setGlobalLogLevelFromString; -// @ts-expect-error creating a new property for window object -window.getGlobalLevel = getGlobalLogLevel; - -function localStorageProvider(): Map { - const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]")); - - window.addEventListener("beforeunload", () => { - const appCache = JSON.stringify(Array.from(map.entries())); - localStorage.setItem("app-cache", appCache); - }); - return map; -} - -export default App; - -function getInitialBackendBaseURL( - backendFromSettings: string | undefined, -): string { - const overrideUrl = - typeof localStorage !== "undefined" - ? localStorage.getItem("corebank-api-base-url") - : undefined; - let result: string; - - if (!overrideUrl) { - // normal path - if (!backendFromSettings) { - console.error( - "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'", - ); - result = window.origin; - } else { - result = backendFromSettings; - } - } else { - // testing/development path - result = overrideUrl; - } - try { - return canonicalizeBaseUrl(result); - } catch (e) { - // fall back - return canonicalizeBaseUrl(window.origin); - } -} diff --git a/packages/demobank-ui/src/context/backend.ts b/packages/demobank-ui/src/context/backend.ts deleted file mode 100644 index 18b4a1f03..000000000 --- a/packages/demobank-ui/src/context/backend.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { ComponentChildren, createContext, h, VNode } from "preact"; -import { useContext } from "preact/hooks"; -import { - BackendStateHandler, - defaultState, - useBackendState, -} from "../hooks/backend.js"; - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -export type Type = BackendStateHandler; - -const initial: Type = { - state: defaultState, - logOut() { - null; - }, - expired() { - null; - }, - logIn(_info) { - null; - }, -}; -const Context = createContext(initial); - -export const useBackendContext = (): Type => useContext(Context); - -export const BackendStateProvider = ({ - children, -}: { - children: ComponentChildren; -}): VNode => { - const value = useBackendState(); - - return h(Context.Provider, { - value, - children, - }); -}; - -export const BackendStateProviderTesting = ({ - children, - state, -}: { - children: ComponentChildren; - state: typeof defaultState; -}): VNode => { - const value: BackendStateHandler = { - state, - logIn: () => {}, - expired: () => {}, - logOut: () => {}, - }; - - return h(Context.Provider, { - value, - children, - }); -}; diff --git a/packages/demobank-ui/src/context/config.ts b/packages/demobank-ui/src/context/config.ts index e968b7ff4..72c176679 100644 --- a/packages/demobank-ui/src/context/config.ts +++ b/packages/demobank-ui/src/context/config.ts @@ -46,12 +46,12 @@ import { revalidateAccountDetails, revalidatePublicAccounts, revalidateTransactions, -} from "../hooks/access.js"; +} from "../hooks/account.js"; import { revalidateBusinessAccounts, revalidateCashouts, revalidateConversionInfo, -} from "../hooks/circuit.js"; +} from "../hooks/regional.js"; /** * @@ -156,6 +156,10 @@ export const BankCoreApiProvider = ({ children, }); }; + +/** + * + */ class CacheAwareTalerBankConversionHttpClient extends TalerBankConversionHttpClient { constructor(baseUrl: string, httpClient?: HttpRequestLibrary) { super(baseUrl, httpClient); diff --git a/packages/demobank-ui/src/endpoints.ts b/packages/demobank-ui/src/endpoints.ts deleted file mode 100644 index b68a36529..000000000 --- a/packages/demobank-ui/src/endpoints.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -export const TRANSACTION_API_EXAMPLE = { - LIST_FIRST_PAGE: { - method: "get" as const, - url: '["access-api/accounts/myAccount/transactions",null,20]', - }, - LIST_ERROR: { - method: "get" as const, - url: '["access-api/accounts/myAccount/transactions",null,20]', - code: 500, - }, - LIST_NOT_FOUND: { - method: "get" as const, - url: '["access-api/accounts/myAccount/transactions",null,20]', - code: 404, - }, -}; - -export const CASHOUT_API_EXAMPLE = { - LIST_FIRST_PAGE: { - method: "get" as const, - url: '["circuit-api/cashouts","123"]', - }, - MULTI_GET_EMPTY_FIRST_PAGE: { - method: "get" as const, - url: "[[]]", - }, -}; diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts deleted file mode 100644 index a101dc83e..000000000 --- a/packages/demobank-ui/src/hooks/access.ts +++ /dev/null @@ -1,294 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AccessToken, - TalerCoreBankResultByMethod, - TalerHttpError, - WithdrawalOperationStatus, -} from "@gnu-taler/taler-util"; -import { useEffect, useState } from "preact/hooks"; -import { PAGE_SIZE } from "../utils.js"; -import { useBackendState } from "./backend.js"; - -// FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import _useSWR, { SWRHook, mutate } from "swr"; -import { useBankCoreApiContext } from "../context/config.js"; -const useSWR = _useSWR as unknown as SWRHook; - -export interface InstanceTemplateFilter { - // FIXME: add filter to the template list - position?: string; -} - -export function revalidateAccountDetails() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getAccount", - undefined, - { revalidate: true }, - ); -} - -export function useAccountDetails(account: string) { - const { state: credentials } = useBackendState(); - const { api } = useBankCoreApiContext(); - - async function fetcher([username, token]: [string, AccessToken]) { - return await api.getAccount({ username, token }); - } - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getAccount">, - TalerHttpError - >([account, token, "getAccount"], fetcher, {}); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidateWithdrawalDetails() { - return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById", undefined, { revalidate: true }); -} - -export function useWithdrawalDetails(wid: string) { - const { api } = useBankCoreApiContext(); - const [latestStatus, setLatestStatus] = useState(); - - async function fetcher([wid, old_state]: [ - string, - WithdrawalOperationStatus | undefined, - ]) { - return await api.getWithdrawalById( - wid, - old_state === undefined ? undefined : { old_state, timeoutMs: 15000 }, - ); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getWithdrawalById">, - TalerHttpError - >([wid, latestStatus, "getWithdrawalById"], fetcher, { - refreshInterval: 3000, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - const currentStatus = - data !== undefined && data.type === "ok" ? data.body.status : undefined; - - useEffect(() => { - if (currentStatus !== undefined && currentStatus !== latestStatus) { - setLatestStatus(currentStatus); - } - }, [currentStatus]); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidateTransactionDetails() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getTransactionById", undefined, { revalidate: true } - ); -} -export function useTransactionDetails(account: string, tid: number) { - const { state: credentials } = useBackendState(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - const { api } = useBankCoreApiContext(); - - async function fetcher([username, token, txid]: [ - string, - AccessToken, - number, - ]) { - return await api.getTransactionById({ username, token }, txid); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getTransactionById">, - TalerHttpError - >([account, token, tid, "getTransactionById"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidatePublicAccounts() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts", undefined, { revalidate: true } - ); -} -export function usePublicAccounts( - filterAccount: string | undefined, - initial?: number, -) { - const [offset, setOffset] = useState(initial); - - const { api } = useBankCoreApiContext(); - - async function fetcher([account, txid]: [ - string | undefined, - number | undefined, - ]) { - return await api.getPublicAccounts( - { account }, - { - limit: PAGE_SIZE, - offset: txid ? String(txid) : undefined, - order: "asc", - }, - ); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getPublicAccounts">, - TalerHttpError - >([filterAccount, offset, "getPublicAccounts"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - const isLastPage = - data && data.type === "ok" && data.body.public_accounts.length <= PAGE_SIZE; - const isFirstPage = !offset; - - const result = data && data.type == "ok" ? structuredClone(data.body.public_accounts) : [] - if (result.length == PAGE_SIZE+1) { - result.pop() - } - const pagination = { - result, - isLastPage, - isFirstPage, - loadNext: () => { - if (!result.length) return; - setOffset(result[result.length - 1].row_id); - }, - loadFirst: () => { - setOffset(0); - }, - }; - - // const public_accountslist = data?.type !== "ok" ? [] : data.body.public_accounts; - if (data) { - return { ok: true, data: data.body, ...pagination }; - } - if (error) { - return error; - } - return undefined; -} - -export function revalidateTransactions() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getTransactions", - undefined, - { revalidate: true }, - ); -} -export function useTransactions(account: string, initial?: number) { - const { state: credentials } = useBackendState(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - - const [offset, setOffset] = useState(initial); - const { api } = useBankCoreApiContext(); - - async function fetcher([username, token, txid]: [ - string, - AccessToken, - number | undefined, - ]) { - return await api.getTransactions( - { username, token }, - { - limit: PAGE_SIZE +1 , - offset: txid ? String(txid) : undefined, - order: "dec", - }, - ); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getTransactions">, - TalerHttpError - >([account, token, offset, "getTransactions"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - refreshWhenOffline: false, - // revalidateOnMount: false, - revalidateIfStale: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - }); - - const isLastPage = - data && data.type === "ok" && data.body.transactions.length <= PAGE_SIZE; - const isFirstPage = !offset; - - const result = data && data.type == "ok" ? structuredClone(data.body.transactions) : [] - if (result.length == PAGE_SIZE+1) { - result.pop() - } - const pagination = { - result, - isLastPage, - isFirstPage, - loadNext: () => { - if (!result.length) return; - setOffset(result[result.length - 1].row_id); - }, - loadFirst: () => { - setOffset(0); - }, - }; - - if (data) { - return { ok: true, data, ...pagination }; - } - if (error) { - return error; - } - return undefined; -} diff --git a/packages/demobank-ui/src/hooks/account.ts b/packages/demobank-ui/src/hooks/account.ts new file mode 100644 index 000000000..61a11b1a5 --- /dev/null +++ b/packages/demobank-ui/src/hooks/account.ts @@ -0,0 +1,294 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + AccessToken, + TalerCoreBankResultByMethod, + TalerHttpError, + WithdrawalOperationStatus, +} from "@gnu-taler/taler-util"; +import { useEffect, useState } from "preact/hooks"; +import { PAGE_SIZE } from "../utils.js"; +import { useSessionState } from "./session.js"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, mutate } from "swr"; +import { useBankCoreApiContext } from "../context/config.js"; +const useSWR = _useSWR as unknown as SWRHook; + +export interface InstanceTemplateFilter { + // FIXME: add filter to the template list + position?: string; +} + +export function revalidateAccountDetails() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getAccount", + undefined, + { revalidate: true }, + ); +} + +export function useAccountDetails(account: string) { + const { state: credentials } = useSessionState(); + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token]: [string, AccessToken]) { + return await api.getAccount({ username, token }); + } + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getAccount">, + TalerHttpError + >([account, token, "getAccount"], fetcher, {}); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidateWithdrawalDetails() { + return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById", undefined, { revalidate: true }); +} + +export function useWithdrawalDetails(wid: string) { + const { api } = useBankCoreApiContext(); + const [latestStatus, setLatestStatus] = useState(); + + async function fetcher([wid, old_state]: [ + string, + WithdrawalOperationStatus | undefined, + ]) { + return await api.getWithdrawalById( + wid, + old_state === undefined ? undefined : { old_state, timeoutMs: 15000 }, + ); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getWithdrawalById">, + TalerHttpError + >([wid, latestStatus, "getWithdrawalById"], fetcher, { + refreshInterval: 3000, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + const currentStatus = + data !== undefined && data.type === "ok" ? data.body.status : undefined; + + useEffect(() => { + if (currentStatus !== undefined && currentStatus !== latestStatus) { + setLatestStatus(currentStatus); + } + }, [currentStatus]); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidateTransactionDetails() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getTransactionById", undefined, { revalidate: true } + ); +} +export function useTransactionDetails(account: string, tid: number) { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token, txid]: [ + string, + AccessToken, + number, + ]) { + return await api.getTransactionById({ username, token }, txid); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getTransactionById">, + TalerHttpError + >([account, token, tid, "getTransactionById"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidatePublicAccounts() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts", undefined, { revalidate: true } + ); +} +export function usePublicAccounts( + filterAccount: string | undefined, + initial?: number, +) { + const [offset, setOffset] = useState(initial); + + const { api } = useBankCoreApiContext(); + + async function fetcher([account, txid]: [ + string | undefined, + number | undefined, + ]) { + return await api.getPublicAccounts( + { account }, + { + limit: PAGE_SIZE, + offset: txid ? String(txid) : undefined, + order: "asc", + }, + ); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getPublicAccounts">, + TalerHttpError + >([filterAccount, offset, "getPublicAccounts"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + const isLastPage = + data && data.type === "ok" && data.body.public_accounts.length <= PAGE_SIZE; + const isFirstPage = !offset; + + const result = data && data.type == "ok" ? structuredClone(data.body.public_accounts) : [] + if (result.length == PAGE_SIZE+1) { + result.pop() + } + const pagination = { + result, + isLastPage, + isFirstPage, + loadNext: () => { + if (!result.length) return; + setOffset(result[result.length - 1].row_id); + }, + loadFirst: () => { + setOffset(0); + }, + }; + + // const public_accountslist = data?.type !== "ok" ? [] : data.body.public_accounts; + if (data) { + return { ok: true, data: data.body, ...pagination }; + } + if (error) { + return error; + } + return undefined; +} + +export function revalidateTransactions() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getTransactions", + undefined, + { revalidate: true }, + ); +} +export function useTransactions(account: string, initial?: number) { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + + const [offset, setOffset] = useState(initial); + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token, txid]: [ + string, + AccessToken, + number | undefined, + ]) { + return await api.getTransactions( + { username, token }, + { + limit: PAGE_SIZE +1 , + offset: txid ? String(txid) : undefined, + order: "dec", + }, + ); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getTransactions">, + TalerHttpError + >([account, token, offset, "getTransactions"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + refreshWhenOffline: false, + // revalidateOnMount: false, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }); + + const isLastPage = + data && data.type === "ok" && data.body.transactions.length <= PAGE_SIZE; + const isFirstPage = !offset; + + const result = data && data.type == "ok" ? structuredClone(data.body.transactions) : [] + if (result.length == PAGE_SIZE+1) { + result.pop() + } + const pagination = { + result, + isLastPage, + isFirstPage, + loadNext: () => { + if (!result.length) return; + setOffset(result[result.length - 1].row_id); + }, + loadFirst: () => { + setOffset(0); + }, + }; + + if (data) { + return { ok: true, data, ...pagination }; + } + if (error) { + return error; + } + return undefined; +} diff --git a/packages/demobank-ui/src/hooks/async.ts b/packages/demobank-ui/src/hooks/async.ts deleted file mode 100644 index 556673992..000000000 --- a/packages/demobank-ui/src/hooks/async.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ -import { useState } from "preact/hooks"; - -export interface Options { - slowTolerance: number; -} - -export interface AsyncOperationApi { - request: (...a: Array) => void; - cancel: () => void; - data: T | undefined; - isSlow: boolean; - isLoading: boolean; - error: string | undefined; -} - -export function useAsync( - fn?: (...args: Array) => Promise, - { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }, -): AsyncOperationApi { - const [data, setData] = useState(undefined); - const [isLoading, setLoading] = useState(false); - const [error, setError] = useState(undefined); - const [isSlow, setSlow] = useState(false); - - const request = async (...args: Array) => { - if (!fn) return; - setLoading(true); - const handler = setTimeout(() => { - setSlow(true); - }, tooLong); - - try { - const result = await fn(...args); - setData(result); - } catch (error) { - if (error instanceof Error) { - setError(error.message); - } else { - setError(`Unknown error: ${error}`); - } - } - setLoading(false); - setSlow(false); - clearTimeout(handler); - }; - - function cancel() { - setLoading(false); - setSlow(false); - } - - return { - request, - cancel, - data, - isSlow, - isLoading, - error, - }; -} diff --git a/packages/demobank-ui/src/hooks/backend.ts b/packages/demobank-ui/src/hooks/backend.ts deleted file mode 100644 index 8e9af533b..000000000 --- a/packages/demobank-ui/src/hooks/backend.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AccessToken, - Codec, - buildCodecForObject, - buildCodecForUnion, - codecForBoolean, - codecForConstString, - codecForString, -} from "@gnu-taler/taler-util"; -import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; -import { mutate } from "swr"; - -/** - * Has the information to reach and - * authenticate at the bank's backend. - */ -export type BackendState = LoggedIn | LoggedOut | Expired; - -interface LoggedIn { - status: "loggedIn"; - isUserAdministrator: boolean; - username: string; - token: AccessToken; -} -interface Expired { - status: "expired"; - isUserAdministrator: boolean; - username: string; -} -interface LoggedOut { - status: "loggedOut"; -} - -export const codecForBackendStateLoggedIn = (): Codec => - buildCodecForObject() - .property("status", codecForConstString("loggedIn")) - .property("username", codecForString()) - .property("token", codecForString() as Codec) - .property("isUserAdministrator", codecForBoolean()) - .build("BackendState.LoggedIn"); - -export const codecForBackendStateExpired = (): Codec => - buildCodecForObject() - .property("status", codecForConstString("expired")) - .property("username", codecForString()) - .property("isUserAdministrator", codecForBoolean()) - .build("BackendState.Expired"); - -export const codecForBackendStateLoggedOut = (): Codec => - buildCodecForObject() - .property("status", codecForConstString("loggedOut")) - .build("BackendState.LoggedOut"); - -export const codecForBackendState = (): Codec => - buildCodecForUnion() - .discriminateOn("status") - .alternative("loggedIn", codecForBackendStateLoggedIn()) - .alternative("loggedOut", codecForBackendStateLoggedOut()) - .alternative("expired", codecForBackendStateExpired()) - .build("BackendState"); - -export const defaultState: BackendState = { - status: "loggedOut", -}; - -export interface BackendStateHandler { - state: BackendState; - logOut(): void; - expired(): void; - logIn(info: { username: string; token: AccessToken }): void; -} - -const BACKEND_STATE_KEY = buildStorageKey("bank-state", codecForBackendState()); - -/** - * Return getters and setters for - * login credentials and backend's - * base URL. - */ -export function useBackendState(): BackendStateHandler { - const { value: state, update } = useLocalStorage( - BACKEND_STATE_KEY, - defaultState, - ); - - return { - state, - logOut() { - update(defaultState); - }, - expired() { - if (state.status === "loggedOut") return; - const nextState: BackendState = { - status: "expired", - username: state.username, - isUserAdministrator: state.username === "admin", - }; - update(nextState); - }, - logIn(info) { - // admin is defined by the username - const nextState: BackendState = { - status: "loggedIn", - ...info, - isUserAdministrator: info.username === "admin", - }; - update(nextState); - cleanAllCache(); - }, - }; -} - -function cleanAllCache(): void { - mutate(() => true, undefined, { revalidate: false }); -} diff --git a/packages/demobank-ui/src/hooks/bank-state.ts b/packages/demobank-ui/src/hooks/bank-state.ts index 15daf9180..83bb009cf 100644 --- a/packages/demobank-ui/src/hooks/bank-state.ts +++ b/packages/demobank-ui/src/hooks/bank-state.ts @@ -141,6 +141,8 @@ const codecForChallenge = (): Codec => .alternative("update-password", codecForChallengeUpdatePassword()) .build("ChallengeInProgess"); + + interface BankState { currentWithdrawalOperationId: string | undefined; currentChallenge: ChallengeInProgess | undefined; @@ -159,6 +161,14 @@ const defaultBankState: BankState = { const BANK_STATE_KEY = buildStorageKey("bank-app-state", codecForBankState()); +/** + * Client state saved in local storage. + * + * This information is saved in the client because + * the backend server session API is not enough. + * + * @returns tuple of [state, update(), reset()] + */ export function useBankState(): [ Readonly, (key: T, value: BankState[T]) => void, @@ -175,3 +185,4 @@ export function useBankState(): [ } return [value, updateField, reset]; } + diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts deleted file mode 100644 index f87cbd843..000000000 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ /dev/null @@ -1,484 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { PAGE_SIZE } from "../utils.js"; -import { useBackendState } from "./backend.js"; - -import { - AccessToken, - AmountJson, - Amounts, - HttpStatusCode, - OperationOk, - TalerBankConversionResultByMethod, - TalerCoreBankErrorsByMethod, - TalerCoreBankResultByMethod, - TalerCorebankApi, - TalerError, - TalerHttpError, - opFixedSuccess, -} from "@gnu-taler/taler-util"; -import _useSWR, { SWRHook, mutate } from "swr"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useState } from "preact/hooks"; - -// FIX default import https://github.com/microsoft/TypeScript/issues/49189 -const useSWR = _useSWR as unknown as SWRHook; - -export type TransferCalculation = { - debit: AmountJson; - credit: AmountJson; - beforeFee: AmountJson; -} | "amount-is-too-small"; -type EstimatorFunction = ( - amount: AmountJson, - fee: AmountJson, -) => Promise; - -type ConversionEstimators = { - estimateByCredit: EstimatorFunction; - estimateByDebit: EstimatorFunction; -}; - -export function revalidateConversionInfo() { - return mutate( - (key) => - Array.isArray(key) && key[key.length - 1] === "getConversionInfoAPI", - ); -} -export function useConversionInfo() { - const { api, config } = useBankCoreApiContext(); - - async function fetcher() { - return await api.getConversionInfoAPI().getConfig(); - } - const { data, error } = useSWR< - TalerBankConversionResultByMethod<"getConfig">, - TalerHttpError - >(!config.allow_conversion ? undefined : ["getConversionInfoAPI"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function useCashinEstimator(): ConversionEstimators { - const { api } = useBankCoreApiContext(); - return { - estimateByCredit: async (fiatAmount, fee) => { - const resp = await api.getConversionInfoAPI().getCashinRate({ - credit: fiatAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small" - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.sub(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - estimateByDebit: async (regionalAmount, fee) => { - const resp = await api.getConversionInfoAPI().getCashinRate({ - debit: regionalAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small" - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.add(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - }; -} - -export function useCashoutEstimator(): ConversionEstimators { - const { api } = useBankCoreApiContext(); - return { - estimateByCredit: async (fiatAmount, fee) => { - const resp = await api.getConversionInfoAPI().getCashoutRate({ - credit: fiatAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small" - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.sub(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - estimateByDebit: async (regionalAmount, fee) => { - const resp = await api.getConversionInfoAPI().getCashoutRate({ - debit: regionalAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small" - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.add(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - }; -} - -/** - * @deprecated use useCashoutEstimator - */ -export function useEstimator(): ConversionEstimators { - return useCashoutEstimator() -} - -export function revalidateBusinessAccounts() { - return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", undefined, { revalidate: true }); -} -export function useBusinessAccounts() { - const { state: credentials } = useBackendState(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - const { api } = useBankCoreApiContext(); - - const [offset, setOffset] = useState(); - - function fetcher([token, offset]: [AccessToken, number]) { - // FIXME: add account name filter - return api.getAccounts( - token, - {}, - { - limit: PAGE_SIZE + 1, - offset: String(offset), - order: "asc", - }, - ); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getAccounts">, - TalerHttpError - >([token, offset ?? 0, "getAccounts"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - const isLastPage = - data && data.type === "ok" && data.body.accounts.length <= PAGE_SIZE; - const isFirstPage = !offset; - - const result = data && data.type == "ok" ? structuredClone(data.body.accounts) : [] - if (result.length == PAGE_SIZE + 1) { - result.pop() - } - const pagination = { - result, - isLastPage, - isFirstPage, - loadNext: () => { - if (!result.length) return; - setOffset(result[result.length - 1].row_id); - }, - loadFirst: () => { - setOffset(0); - }, - }; - - if (data) return { ok: true, data, ...pagination }; - if (error) return error; - return undefined; -} - -type CashoutWithId = TalerCorebankApi.CashoutStatusResponse & { id: number }; -function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { - return c !== undefined; -} -export function revalidateOnePendingCashouts() { - return mutate( - (key) => - Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts", undefined, { revalidate: true } - ); -} -export function useOnePendingCashouts(account: string) { - const { state: credentials } = useBackendState(); - const { api, config } = useBankCoreApiContext(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - - async function fetcher([username, token]: [string, AccessToken]) { - const list = await api.getAccountCashouts({ username, token }); - if (list.type !== "ok") { - return list; - } - const pendingCashout = list.body.cashouts.length > 0 ? list.body.cashouts[0] : undefined; - if (!pendingCashout) return opFixedSuccess(list.httpResp, undefined); - const cashoutInfo = await api.getCashoutById( - { username, token }, - pendingCashout.cashout_id, - ); - if (cashoutInfo.type !== "ok") { - return cashoutInfo; - } - return opFixedSuccess(list.httpResp, { - ...cashoutInfo.body, - id: pendingCashout.cashout_id, - }); - } - - const { data, error } = useSWR< - | OperationOk - | TalerCoreBankErrorsByMethod<"getAccountCashouts"> - | TalerCoreBankErrorsByMethod<"getCashoutById">, - TalerHttpError - >( - !config.allow_conversion - ? undefined - : [account, token, "useOnePendingCashouts"], - fetcher, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidateCashouts() { - return mutate((key) => Array.isArray(key) && key[key.length - 1] === "useCashouts"); -} -export function useCashouts(account: string) { - const { state: credentials } = useBackendState(); - const { api, config } = useBankCoreApiContext(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - - async function fetcher([username, token]: [string, AccessToken]) { - const list = await api.getAccountCashouts({ username, token }); - if (list.type !== "ok") { - return list; - } - const all: Array = await Promise.all( - list.body.cashouts.map(async (c) => { - const r = await api.getCashoutById({ username, token }, c.cashout_id); - if (r.type === "fail") { - return undefined; - } - return { ...r.body, id: c.cashout_id }; - }), - ); - const cashouts = all.filter(notUndefined); - return { type: "ok" as const, body: { cashouts }, httpResp: list.httpResp }; - } - const { data, error } = useSWR< - | OperationOk<{ cashouts: CashoutWithId[] }> - | TalerCoreBankErrorsByMethod<"getAccountCashouts">, - TalerHttpError - >( - !config.allow_conversion ? undefined : [account, token, "useCashouts"], - fetcher, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); - - if (data) return data; - if (error) return error; - return undefined; -} - -export function revalidateCashoutDetails() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getCashoutById", undefined, { revalidate: true } - ); -} -export function useCashoutDetails(cashoutId: number | undefined) { - const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const { api } = useBankCoreApiContext(); - - async function fetcher([username, token, id]: [string, AccessToken, number]) { - return api.getCashoutById({ username, token }, id); - } - - const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getCashoutById">, - TalerHttpError - >( - cashoutId === undefined - ? undefined - : [creds?.username, creds?.token, cashoutId, "getCashoutById"], - fetcher, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); - - if (data) return data; - if (error) return error; - return undefined; -} -export type MonitorMetrics = { - lastHour: TalerCoreBankResultByMethod<"getMonitor">; - lastDay: TalerCoreBankResultByMethod<"getMonitor">; - lastMonth: TalerCoreBankResultByMethod<"getMonitor">; -}; - -export type LastMonitor = { - current: TalerCoreBankResultByMethod<"getMonitor">; - previous: TalerCoreBankResultByMethod<"getMonitor">; -}; -export function revalidateLastMonitorInfo() { - return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo", undefined, { revalidate: true } - ); -} -export function useLastMonitorInfo( - currentMoment: number, - previousMoment: number, - timeframe: TalerCorebankApi.MonitorTimeframeParam, -) { - const { api } = useBankCoreApiContext(); - const { state: credentials } = useBackendState(); - const token = - credentials.status !== "loggedIn" ? undefined : credentials.token; - - async function fetcher([token, timeframe]: [ - AccessToken, - TalerCorebankApi.MonitorTimeframeParam, - ]) { - const [current, previous] = await Promise.all([ - api.getMonitor(token, { timeframe, which: currentMoment }), - api.getMonitor(token, { timeframe, which: previousMoment }), - ]); - return { - current, - previous, - }; - } - - const { data, error } = useSWR( - !token ? undefined : [token, timeframe, "useLastMonitorInfo"], - fetcher, - { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }, - ); - - if (data) return data; - if (error) return error; - return undefined; -} diff --git a/packages/demobank-ui/src/hooks/form.ts b/packages/demobank-ui/src/hooks/form.ts new file mode 100644 index 000000000..26354b108 --- /dev/null +++ b/packages/demobank-ui/src/hooks/form.ts @@ -0,0 +1,100 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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, TalerBankConversionApi, TranslatedString } from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; + +export type UIField = { + value: string | undefined; + onUpdate: (s: string) => void; + error: TranslatedString | undefined; +} + +type FormHandler = { + [k in keyof T]?: + T[k] extends string ? UIField : + T[k] extends AmountJson ? UIField : + FormHandler; +} + +export type FormValues = { + [k in keyof T]: + T[k] extends string ? (string | undefined) : + T[k] extends AmountJson ? (string | undefined) : + FormValues; +} + +export type RecursivePartial = { + [k in keyof T]?: + T[k] extends string ? (string) : + T[k] extends AmountJson ? (AmountJson) : + RecursivePartial; +} + +export type FormErrors = { + [k in keyof T]?: + T[k] extends string ? (TranslatedString) : + T[k] extends AmountJson ? (TranslatedString) : + FormErrors; +} + +export type FormStatus = { + status: "ok", + result: T, + errors: undefined, +} | { + status: "fail", + result: RecursivePartial, + errors: FormErrors, +} + + +function constructFormHandler(form: FormValues, updateForm: (d: FormValues) => void, errors: FormErrors | undefined): FormHandler { + const keys = (Object.keys(form) as Array) + + const handler = keys.reduce((prev, fieldName) => { + const currentValue: any = form[fieldName]; + const currentError: any = errors ? errors[fieldName] : undefined; + function updater(newValue: any) { + updateForm({ ...form, [fieldName]: newValue }) + } + if (typeof currentValue === "object") { + const group = constructFormHandler(currentValue, updater, currentError) + // @ts-expect-error asdasd + prev[fieldName] = group + return prev; + } + const field: UIField = { + error: currentError, + value: currentValue, + onUpdate: updater + } + // @ts-expect-error asdasd + prev[fieldName] = field + return prev + }, {} as FormHandler) + + return handler; +} + +export function useFormState(defaultValue: FormValues, check: (f: FormValues) => FormStatus): [FormHandler, FormStatus] { + const [form, updateForm] = useState>(defaultValue) + + const status = check(form) + const handler = constructFormHandler(form, updateForm, status.errors) + + return [handler, status] +} \ No newline at end of file diff --git a/packages/demobank-ui/src/hooks/index.ts b/packages/demobank-ui/src/hooks/index.ts deleted file mode 100644 index 2620f4697..000000000 --- a/packages/demobank-ui/src/hooks/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { StateUpdater } from "preact/hooks"; -import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; -import { codecForBoolean } from "@gnu-taler/taler-util"; -export type ValueOrFunction = T | ((p: T) => T); - -const calculateRootPath = () => { - const rootPath = - typeof window !== "undefined" - ? window.location.origin + window.location.pathname - : "/"; - return rootPath; -}; - -const BACKEND_URL_KEY = buildStorageKey("backend-url"); -const TRIED_LOGIN_KEY = buildStorageKey("tried-login", codecForBoolean()); - -export function useBackendURL( - url?: string, -): [string, boolean, StateUpdater, () => void] { - const { value, update: setter } = useLocalStorage( - BACKEND_URL_KEY, - url || calculateRootPath(), - ); - - const { - value: triedToLog, - update: setTriedToLog, - reset: resetBackend, - } = useLocalStorage(TRIED_LOGIN_KEY); - - const checkedSetter = (v: ValueOrFunction) => { - setTriedToLog(true); - const computedValue = - v instanceof Function ? v(value) : v.replace(/\/$/, ""); - return setter(computedValue); - }; - - return [value, !!triedToLog, checkedSetter, resetBackend]; -} diff --git a/packages/demobank-ui/src/hooks/preferences.ts b/packages/demobank-ui/src/hooks/preferences.ts index 454d840b2..454dc8d80 100644 --- a/packages/demobank-ui/src/hooks/preferences.ts +++ b/packages/demobank-ui/src/hooks/preferences.ts @@ -36,36 +36,6 @@ interface Preferences { showDebugInfo: boolean; } -export function getAllBooleanPreferences(): Array { - return [ - "fastWithdrawal", - "showDebugInfo", - "showDemoDescription", - "showInstallWallet", - "showWithdrawalSuccess", - ]; -} - -export function getLabelForPreferences( - k: keyof Preferences, - i18n: ReturnType["i18n"], -): TranslatedString { - switch (k) { - case "maxWithdrawalAmount": - return i18n.str`Max withdrawal amount`; - case "showWithdrawalSuccess": - return i18n.str`Show withdrawal confirmation`; - case "showDemoDescription": - return i18n.str`Show demo description`; - case "showInstallWallet": - return i18n.str`Show install wallet first`; - case "fastWithdrawal": - return i18n.str`Use fast withdrawal form`; - case "showDebugInfo": - return i18n.str`Show debug info`; - } -} - export const codecForPreferences = (): Codec => buildCodecForObject() .property("showWithdrawalSuccess", codecForBoolean()) @@ -89,7 +59,11 @@ const BANK_PREFERENCES_KEY = buildStorageKey( "bank-preferences", codecForPreferences(), ); - +/** + * User preferences. + * + * @returns tuple of [state, update()] + */ export function usePreferences(): [ Readonly, (key: T, value: Preferences[T]) => void, @@ -105,3 +79,34 @@ export function usePreferences(): [ } return [value, updateField]; } + +export function getAllBooleanPreferences(): Array { + return [ + "fastWithdrawal", + "showDebugInfo", + "showDemoDescription", + "showInstallWallet", + "showWithdrawalSuccess", + ]; +} + +export function getLabelForPreferences( + k: keyof Preferences, + i18n: ReturnType["i18n"], +): TranslatedString { + switch (k) { + case "maxWithdrawalAmount": + return i18n.str`Max withdrawal amount`; + case "showWithdrawalSuccess": + return i18n.str`Show withdrawal confirmation`; + case "showDemoDescription": + return i18n.str`Show demo description`; + case "showInstallWallet": + return i18n.str`Show install wallet first`; + case "fastWithdrawal": + return i18n.str`Use fast withdrawal form`; + case "showDebugInfo": + return i18n.str`Show debug info`; + } +} + diff --git a/packages/demobank-ui/src/hooks/regional.ts b/packages/demobank-ui/src/hooks/regional.ts new file mode 100644 index 000000000..a9ebb30a2 --- /dev/null +++ b/packages/demobank-ui/src/hooks/regional.ts @@ -0,0 +1,484 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { PAGE_SIZE } from "../utils.js"; +import { useSessionState } from "./session.js"; + +import { + AccessToken, + AmountJson, + Amounts, + HttpStatusCode, + OperationOk, + TalerBankConversionResultByMethod, + TalerCoreBankErrorsByMethod, + TalerCoreBankResultByMethod, + TalerCorebankApi, + TalerError, + TalerHttpError, + opFixedSuccess, +} from "@gnu-taler/taler-util"; +import _useSWR, { SWRHook, mutate } from "swr"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useState } from "preact/hooks"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +const useSWR = _useSWR as unknown as SWRHook; + +export type TransferCalculation = { + debit: AmountJson; + credit: AmountJson; + beforeFee: AmountJson; +} | "amount-is-too-small"; +type EstimatorFunction = ( + amount: AmountJson, + fee: AmountJson, +) => Promise; + +type ConversionEstimators = { + estimateByCredit: EstimatorFunction; + estimateByDebit: EstimatorFunction; +}; + +export function revalidateConversionInfo() { + return mutate( + (key) => + Array.isArray(key) && key[key.length - 1] === "getConversionInfoAPI", + ); +} +export function useConversionInfo() { + const { api, config } = useBankCoreApiContext(); + + async function fetcher() { + return await api.getConversionInfoAPI().getConfig(); + } + const { data, error } = useSWR< + TalerBankConversionResultByMethod<"getConfig">, + TalerHttpError + >(!config.allow_conversion ? undefined : ["getConversionInfoAPI"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function useCashinEstimator(): ConversionEstimators { + const { api } = useBankCoreApiContext(); + return { + estimateByCredit: async (fiatAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashinRate({ + credit: fiatAmount, + }); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Conflict: { + return "amount-is-too-small" + } + // this below can't happen + case HttpStatusCode.NotImplemented: //it should not be able to call this function + case HttpStatusCode.BadRequest: //we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.sub(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + estimateByDebit: async (regionalAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashinRate({ + debit: regionalAmount, + }); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Conflict: { + return "amount-is-too-small" + } + // this below can't happen + case HttpStatusCode.NotImplemented: //it should not be able to call this function + case HttpStatusCode.BadRequest: //we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.add(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + }; +} + +export function useCashoutEstimator(): ConversionEstimators { + const { api } = useBankCoreApiContext(); + return { + estimateByCredit: async (fiatAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashoutRate({ + credit: fiatAmount, + }); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Conflict: { + return "amount-is-too-small" + } + // this below can't happen + case HttpStatusCode.NotImplemented: //it should not be able to call this function + case HttpStatusCode.BadRequest: //we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.sub(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + estimateByDebit: async (regionalAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashoutRate({ + debit: regionalAmount, + }); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Conflict: { + return "amount-is-too-small" + } + // this below can't happen + case HttpStatusCode.NotImplemented: //it should not be able to call this function + case HttpStatusCode.BadRequest: //we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.add(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + }; +} + +/** + * @deprecated use useCashoutEstimator + */ +export function useEstimator(): ConversionEstimators { + return useCashoutEstimator() +} + +export function revalidateBusinessAccounts() { + return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", undefined, { revalidate: true }); +} +export function useBusinessAccounts() { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { api } = useBankCoreApiContext(); + + const [offset, setOffset] = useState(); + + function fetcher([token, offset]: [AccessToken, number]) { + // FIXME: add account name filter + return api.getAccounts( + token, + {}, + { + limit: PAGE_SIZE + 1, + offset: String(offset), + order: "asc", + }, + ); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getAccounts">, + TalerHttpError + >([token, offset ?? 0, "getAccounts"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + const isLastPage = + data && data.type === "ok" && data.body.accounts.length <= PAGE_SIZE; + const isFirstPage = !offset; + + const result = data && data.type == "ok" ? structuredClone(data.body.accounts) : [] + if (result.length == PAGE_SIZE + 1) { + result.pop() + } + const pagination = { + result, + isLastPage, + isFirstPage, + loadNext: () => { + if (!result.length) return; + setOffset(result[result.length - 1].row_id); + }, + loadFirst: () => { + setOffset(0); + }, + }; + + if (data) return { ok: true, data, ...pagination }; + if (error) return error; + return undefined; +} + +type CashoutWithId = TalerCorebankApi.CashoutStatusResponse & { id: number }; +function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { + return c !== undefined; +} +export function revalidateOnePendingCashouts() { + return mutate( + (key) => + Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts", undefined, { revalidate: true } + ); +} +export function useOnePendingCashouts(account: string) { + const { state: credentials } = useSessionState(); + const { api, config } = useBankCoreApiContext(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + + async function fetcher([username, token]: [string, AccessToken]) { + const list = await api.getAccountCashouts({ username, token }); + if (list.type !== "ok") { + return list; + } + const pendingCashout = list.body.cashouts.length > 0 ? list.body.cashouts[0] : undefined; + if (!pendingCashout) return opFixedSuccess(list.httpResp, undefined); + const cashoutInfo = await api.getCashoutById( + { username, token }, + pendingCashout.cashout_id, + ); + if (cashoutInfo.type !== "ok") { + return cashoutInfo; + } + return opFixedSuccess(list.httpResp, { + ...cashoutInfo.body, + id: pendingCashout.cashout_id, + }); + } + + const { data, error } = useSWR< + | OperationOk + | TalerCoreBankErrorsByMethod<"getAccountCashouts"> + | TalerCoreBankErrorsByMethod<"getCashoutById">, + TalerHttpError + >( + !config.allow_conversion + ? undefined + : [account, token, "useOnePendingCashouts"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidateCashouts() { + return mutate((key) => Array.isArray(key) && key[key.length - 1] === "useCashouts"); +} +export function useCashouts(account: string) { + const { state: credentials } = useSessionState(); + const { api, config } = useBankCoreApiContext(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + + async function fetcher([username, token]: [string, AccessToken]) { + const list = await api.getAccountCashouts({ username, token }); + if (list.type !== "ok") { + return list; + } + const all: Array = await Promise.all( + list.body.cashouts.map(async (c) => { + const r = await api.getCashoutById({ username, token }, c.cashout_id); + if (r.type === "fail") { + return undefined; + } + return { ...r.body, id: c.cashout_id }; + }), + ); + const cashouts = all.filter(notUndefined); + return { type: "ok" as const, body: { cashouts }, httpResp: list.httpResp }; + } + const { data, error } = useSWR< + | OperationOk<{ cashouts: CashoutWithId[] }> + | TalerCoreBankErrorsByMethod<"getAccountCashouts">, + TalerHttpError + >( + !config.allow_conversion ? undefined : [account, token, "useCashouts"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidateCashoutDetails() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getCashoutById", undefined, { revalidate: true } + ); +} +export function useCashoutDetails(cashoutId: number | undefined) { + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { api } = useBankCoreApiContext(); + + async function fetcher([username, token, id]: [string, AccessToken, number]) { + return api.getCashoutById({ username, token }, id); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getCashoutById">, + TalerHttpError + >( + cashoutId === undefined + ? undefined + : [creds?.username, creds?.token, cashoutId, "getCashoutById"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (data) return data; + if (error) return error; + return undefined; +} +export type MonitorMetrics = { + lastHour: TalerCoreBankResultByMethod<"getMonitor">; + lastDay: TalerCoreBankResultByMethod<"getMonitor">; + lastMonth: TalerCoreBankResultByMethod<"getMonitor">; +}; + +export type LastMonitor = { + current: TalerCoreBankResultByMethod<"getMonitor">; + previous: TalerCoreBankResultByMethod<"getMonitor">; +}; +export function revalidateLastMonitorInfo() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo", undefined, { revalidate: true } + ); +} +export function useLastMonitorInfo( + currentMoment: number, + previousMoment: number, + timeframe: TalerCorebankApi.MonitorTimeframeParam, +) { + const { api } = useBankCoreApiContext(); + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + + async function fetcher([token, timeframe]: [ + AccessToken, + TalerCorebankApi.MonitorTimeframeParam, + ]) { + const [current, previous] = await Promise.all([ + api.getMonitor(token, { timeframe, which: currentMoment }), + api.getMonitor(token, { timeframe, which: previousMoment }), + ]); + return { + current, + previous, + }; + } + + const { data, error } = useSWR( + !token ? undefined : [token, timeframe, "useLastMonitorInfo"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (data) return data; + if (error) return error; + return undefined; +} diff --git a/packages/demobank-ui/src/hooks/session.ts b/packages/demobank-ui/src/hooks/session.ts new file mode 100644 index 000000000..35f87e1be --- /dev/null +++ b/packages/demobank-ui/src/hooks/session.ts @@ -0,0 +1,131 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + AccessToken, + Codec, + buildCodecForObject, + buildCodecForUnion, + codecForBoolean, + codecForConstString, + codecForString, +} from "@gnu-taler/taler-util"; +import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; +import { mutate } from "swr"; + +/** + * Has the information to reach and + * authenticate at the bank's backend. + */ +export type SessionState = LoggedIn | LoggedOut | Expired; + +interface LoggedIn { + status: "loggedIn"; + isUserAdministrator: boolean; + username: string; + token: AccessToken; +} +interface Expired { + status: "expired"; + isUserAdministrator: boolean; + username: string; +} +interface LoggedOut { + status: "loggedOut"; +} + +export const codecForSessionStateLoggedIn = (): Codec => + buildCodecForObject() + .property("status", codecForConstString("loggedIn")) + .property("username", codecForString()) + .property("token", codecForString() as Codec) + .property("isUserAdministrator", codecForBoolean()) + .build("SessionState.LoggedIn"); + +export const codecForSessionStateExpired = (): Codec => + buildCodecForObject() + .property("status", codecForConstString("expired")) + .property("username", codecForString()) + .property("isUserAdministrator", codecForBoolean()) + .build("SessionState.Expired"); + +export const codecForSessionStateLoggedOut = (): Codec => + buildCodecForObject() + .property("status", codecForConstString("loggedOut")) + .build("SessionState.LoggedOut"); + +export const codecForSessionState = (): Codec => + buildCodecForUnion() + .discriminateOn("status") + .alternative("loggedIn", codecForSessionStateLoggedIn()) + .alternative("loggedOut", codecForSessionStateLoggedOut()) + .alternative("expired", codecForSessionStateExpired()) + .build("SessionState"); + +export const defaultState: SessionState = { + status: "loggedOut", +}; + +export interface SessionStateHandler { + state: SessionState; + logOut(): void; + expired(): void; + logIn(info: { username: string; token: AccessToken }): void; +} + +const SESSION_STATE_KEY = buildStorageKey("bank-state", codecForSessionState()); + +/** + * Return getters and setters for + * login credentials and backend's + * base URL. + */ +export function useSessionState(): SessionStateHandler { + const { value: state, update } = useLocalStorage( + SESSION_STATE_KEY, + defaultState, + ); + + return { + state, + logOut() { + update(defaultState); + }, + expired() { + if (state.status === "loggedOut") return; + const nextState: SessionState = { + status: "expired", + username: state.username, + isUserAdministrator: state.username === "admin", + }; + update(nextState); + }, + logIn(info) { + // admin is defined by the username + const nextState: SessionState = { + status: "loggedIn", + ...info, + isUserAdministrator: info.username === "admin", + }; + update(nextState); + cleanAllCache(); + }, + }; +} + +function cleanAllCache(): void { + mutate(() => true, undefined, { revalidate: false }); +} diff --git a/packages/demobank-ui/src/index.tsx b/packages/demobank-ui/src/index.tsx index 5f00f1b68..f559288a3 100644 --- a/packages/demobank-ui/src/index.tsx +++ b/packages/demobank-ui/src/index.tsx @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see */ -import App from "./components/app.js"; +import { App } from "./app.js"; import { h, render } from "preact"; import "./scss/main.css"; diff --git a/packages/demobank-ui/src/pages/AccountPage/state.ts b/packages/demobank-ui/src/pages/AccountPage/state.ts index b531ac757..e84fef025 100644 --- a/packages/demobank-ui/src/pages/AccountPage/state.ts +++ b/packages/demobank-ui/src/pages/AccountPage/state.ts @@ -21,7 +21,7 @@ import { assertUnreachable, parsePaytoUri, } from "@gnu-taler/taler-util"; -import { useAccountDetails } from "../../hooks/access.js"; +import { useAccountDetails } from "../../hooks/account.js"; import { Props, State } from "./index.js"; export function useComponentState({ diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx index e1b8d6b83..427e9a156 100644 --- a/packages/demobank-ui/src/pages/BankFrame.tsx +++ b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -28,8 +28,8 @@ import { ComponentChildren, VNode, h } from "preact"; import { useEffect, useErrorBoundary } from "preact/hooks"; import { useBankCoreApiContext } from "../context/config.js"; import { useSettingsContext } from "../context/settings.js"; -import { useAccountDetails } from "../hooks/access.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useAccountDetails } from "../hooks/account.js"; +import { useSessionState } from "../hooks/session.js"; import { useBankState } from "../hooks/bank-state.js"; import { getAllBooleanPreferences, @@ -52,7 +52,7 @@ export function BankFrame({ children: ComponentChildren; }): VNode { const { i18n } = useTranslationContext(); - const backend = useBackendState(); + const session = useSessionState(); const settings = useSettingsContext(); const [preferences, updatePreferences] = usePreferences(); const [, , resetBankState] = useBankState(); @@ -86,10 +86,10 @@ export function BankFrame({ iconLinkURL={settings.iconLinkURL ?? "#"} profileURL={routeAccountDetails?.url({})} onLogout={ - backend.state.status !== "loggedIn" + session.state.status !== "loggedIn" ? undefined : () => { - backend.logOut(); + session.logOut(); resetBankState(); } } diff --git a/packages/demobank-ui/src/pages/ConversionConfig.tsx b/packages/demobank-ui/src/pages/ConversionConfig.tsx deleted file mode 100644 index 2d52cd99f..000000000 --- a/packages/demobank-ui/src/pages/ConversionConfig.tsx +++ /dev/null @@ -1,1018 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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, - Amounts, - HttpStatusCode, - TalerBankConversionApi, - TalerError, - TranslatedString, - assertUnreachable -} from "@gnu-taler/taler-util"; -import { - Attention, - InternationalizationAPI, - LocalNotificationBanner, - ShowInputErrorLabel, - useLocalNotification, - useTranslationContext, - utils -} from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; -import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo } from "../hooks/circuit.js"; -import { RouteDefinition } from "../route.js"; -import { undefinedIfEmpty } from "../utils.js"; -import { InputAmount, RenderAmount } from "./PaytoWireTransferForm.js"; -import { ProfileNavigation } from "./ProfileNavigation.js"; - -interface Props { - routeMyAccountDetails: RouteDefinition; - routeMyAccountDelete: RouteDefinition; - routeMyAccountPassword: RouteDefinition; - routeMyAccountCashout: RouteDefinition; - routeConversionConfig: RouteDefinition; - routeCancel: RouteDefinition; - onUpdateSuccess: () => void; -} - -type UIField = { - value: string | undefined; - onUpdate: (s: string) => void; - error: TranslatedString | undefined; -} - -type FormHandler = { - [k in keyof T]?: - T[k] extends string ? UIField : - T[k] extends AmountJson ? UIField : - FormHandler; -} - -type FormValues = { - [k in keyof T]: - T[k] extends string ? (string | undefined) : - T[k] extends AmountJson ? (string | undefined) : - FormValues; -} - -type RecursivePartial = { - [k in keyof T]?: - T[k] extends string ? (string) : - T[k] extends AmountJson ? (AmountJson) : - RecursivePartial; -} - -type FormErrors = { - [k in keyof T]?: - T[k] extends string ? (TranslatedString) : - T[k] extends AmountJson ? (TranslatedString) : - FormErrors; -} - -type FormStatus = { - status: "ok", - result: T, - errors: undefined, -} | { - status: "fail", - result: RecursivePartial, - errors: FormErrors, -} -type FormType = { amount: AmountJson, conv: TalerBankConversionApi.ConversionRate } - -function constructFormHandler(form: FormValues, updateForm: (d: FormValues) => void, errors: FormErrors | undefined): FormHandler { - const keys = (Object.keys(form) as Array) - - const handler = keys.reduce((prev, fieldName) => { - const currentValue: any = form[fieldName]; - const currentError: any = errors ? errors[fieldName] : undefined; - function updater(newValue: any) { - updateForm({ ...form, [fieldName]: newValue }) - } - if (typeof currentValue === "object") { - const group = constructFormHandler(currentValue, updater, currentError) - // @ts-expect-error asdasd - prev[fieldName] = group - return prev; - } - const field: UIField = { - error: currentError, - value: currentValue, - onUpdate: updater - } - // @ts-expect-error asdasd - prev[fieldName] = field - return prev - }, {} as FormHandler) - - return handler; -} - -function useFormState(defaultValue: FormValues, check: (f: FormValues) => FormStatus): [FormHandler, FormStatus] { - const [form, updateForm] = useState>(defaultValue) - - const status = check(form) - const handler = constructFormHandler(form, updateForm, status.errors) - - return [handler, status] -} - -function useComponentState({ - onUpdateSuccess, - routeCancel, - routeConversionConfig, - routeMyAccountCashout, - routeMyAccountDelete, - routeMyAccountDetails, - routeMyAccountPassword, -}: Props): utils.RecursiveState { - - const result = useConversionInfo() - const info = result && !(result instanceof TalerError) && result.type === "ok" ? - result.body : undefined; - - const { state: credentials } = useBackendState(); - const creds = - credentials.status !== "loggedIn" || !credentials.isUserAdministrator - ? undefined - : credentials; - - if (!info) { - return
waiting...
- } - - if (!creds) { - return
only admin can setup conversion
; - } - - return () => { - const { i18n } = useTranslationContext(); - - const { api, config } = useBankCoreApiContext(); - - const [notification, notify, handleError] = useLocalNotification(); - - const initalState: FormValues = { - amount: "100", - conv: { - cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1], - cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1], - cashin_fee: info.conversion_rate.cashin_fee.split(":")[1], - cashin_ratio: info.conversion_rate.cashin_ratio, - cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode, - cashout_min_amount: info.conversion_rate.cashout_min_amount.split(":")[1], - cashout_tiny_amount: info.conversion_rate.cashout_tiny_amount.split(":")[1], - cashout_fee: info.conversion_rate.cashout_fee.split(":")[1], - cashout_ratio: info.conversion_rate.cashout_ratio, - cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode, - } - } - - const [form, status] = useFormState( - initalState, - checkConversionForm(i18n, info.regional_currency, info.fiat_currency) - ) - - const { - estimateByDebit: calculateCashoutFromDebit, - } = useCashoutEstimator(); - - const { - estimateByDebit: calculateCashinFromDebit, - } = useCashinEstimator(); - - const [calculationResult, setCalc] = useState<{ cashin: TransferCalculation, cashout: TransferCalculation }>() - - useEffect(() => { - async function doAsync() { - await handleError(async () => { - if (!info) return; - if (!form.amount?.value || form.amount.error) return; - const in_amount = Amounts.parseOrThrow(`${info.fiat_currency}:${form.amount.value}`) - const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee) - const cashin = await calculateCashinFromDebit(in_amount, in_fee); - - if (cashin === "amount-is-too-small") { - setCalc(undefined) - return; - } - // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`) - const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee) - const cashout = await calculateCashoutFromDebit(cashin.credit, out_fee); - - setCalc({ cashin, cashout }); - }); - } - doAsync(); - }, [form.amount?.value, form.conv?.cashin_fee?.value, form.conv?.cashout_fee?.value]); - - const [section, setSection] = useState<"detail" | "cashout" | "cashin">("detail") - const cashinCalc = calculationResult?.cashin === "amount-is-too-small" ? undefined : calculationResult?.cashin - const cashoutCalc = calculationResult?.cashout === "amount-is-too-small" ? undefined : calculationResult?.cashout - async function doUpdate() { - if (!creds) return - await handleError(async () => { - if (status.status === "fail") return; - const resp = await api - .getConversionInfoAPI() - .updateConversionRate(creds.token, status.result.conv) - if (resp.type === "ok") { - setSection("detail") - } else { - switch (resp.case) { - case HttpStatusCode.Unauthorized: { - return notify({ - type: "error", - title: i18n.str`Wrong credentials`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - } - case HttpStatusCode.NotImplemented: { - return notify({ - type: "error", - title: i18n.str`Conversion is disabled`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - } - default: - assertUnreachable(resp); - } - } - }); - } - - const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio) - const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio) - - const both_high = in_ratio > 1 && out_ratio > 1; - const both_low = in_ratio < 1 && out_ratio < 1; - - - return ( -
- - - -
- -
-

- Conversion -

-
- - - - -
- -
- -
{ - e.preventDefault(); - }} - > - {section == "cashin" && - } - - - - {section == "cashout" && - - } - - - - - {section == "detail" && -
-
-
- Cashin ratio -
-
- {info.conversion_rate.cashin_ratio} -
-
-
- -
-
-
- Cashout ratio -
-
- {info.conversion_rate.cashout_ratio} -
-
-
- - {both_low || both_high ?
- - - One of the ratios should be higher or equal than 1 an the other should be lower or equal than 1. - - -
: undefined} - -
-
-
- - - -

- Use it to test how the conversion will affect the amount. -

-
-
-
- - {!cashoutCalc || !cashinCalc ? undefined : ( -
-
-
-
-
- Sending to this bank -
-
- -
-
- - {Amounts.isZero(cashinCalc.beforeFee) ? undefined : ( -
-
- - Converted - -
-
- -
-
- )} -
-
- Cashin after fee -
-
- -
-
-
-
- -
-
-
-
- Sending from this bank -
-
- -
-
- - {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : ( -
-
- - Converted - -
-
- -
-
- )} -
-
- Cashout after fee -
-
- -
-
-
-
- - {cashoutCalc && status.status === "ok" && Amounts.cmp(status.result.amount, cashoutCalc.credit) < 0 ?
- - - This configuration allows users to cash out more of what has been cashed in. - - -
: undefined} -
- )} -
} - - -
- - Cancel - - {section == "cashin" || section == "cashout" ? - - :
} -
- - - -
-
- ); - - } -} - -/** - * Show histories of public accounts. - */ -export const ConversionConfig = utils.recursive(useComponentState); - -function checkConversionForm(i18n: InternationalizationAPI, regional: string, fiat: string) { - return function check(state: FormValues): FormStatus { - - const cashin_min_amount = Amounts.parse(`${fiat}:${state.conv.cashin_min_amount}`) - const cashin_tiny_amount = Amounts.parse(`${regional}:${state.conv.cashin_tiny_amount}`) - const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`) - - const cashout_min_amount = Amounts.parse(`${regional}:${state.conv.cashout_min_amount}`) - const cashout_tiny_amount = Amounts.parse(`${fiat}:${state.conv.cashout_tiny_amount}`) - const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`) - - const am = Amounts.parse(`${fiat}:${state.amount}`) - - const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? "") - const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? "") - - const errors = undefinedIfEmpty>({ - conv: undefinedIfEmpty>({ - cashin_min_amount: !state.conv.cashin_min_amount ? i18n.str`required` : - !cashin_min_amount ? i18n.str`invalid` : - undefined, - cashin_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : - !cashin_tiny_amount ? i18n.str`invalid` : - undefined, - cashin_fee: !state.conv.cashin_fee ? i18n.str`required` : - !cashin_fee ? i18n.str`invalid` : - undefined, - - cashout_min_amount: !state.conv.cashout_min_amount ? i18n.str`required` : - !cashout_min_amount ? i18n.str`invalid` : - undefined, - cashout_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : - !cashout_tiny_amount ? i18n.str`invalid` : - undefined, - cashout_fee: !state.conv.cashin_fee ? i18n.str`required` : - !cashout_fee ? i18n.str`invalid` : - undefined, - - cashin_rounding_mode: !state.conv.cashin_rounding_mode ? i18n.str`required` : undefined, - cashout_rounding_mode: !state.conv.cashout_rounding_mode ? i18n.str`required` : undefined, - - cashin_ratio: !state.conv.cashin_ratio ? i18n.str`required` : Number.isNaN(cashin_ratio) ? i18n.str`invalid` : undefined, - cashout_ratio: !state.conv.cashout_ratio ? i18n.str`required` : Number.isNaN(cashout_ratio) ? i18n.str`invalid` : undefined, - }), - - amount: !state.amount ? i18n.str`required` : - !am ? i18n.str`invalid` : - undefined, - }) - - const result: RecursivePartial = { - amount: am, - conv: { - cashin_fee: !errors?.conv?.cashin_fee ? Amounts.stringify(cashin_fee!) : undefined, - cashin_min_amount: !errors?.conv?.cashin_min_amount ? Amounts.stringify(cashin_min_amount!) : undefined, - cashin_ratio: !errors?.conv?.cashin_ratio ? String(cashin_ratio!) : undefined, - cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode ? (state.conv.cashin_rounding_mode!) : undefined, - cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount ? Amounts.stringify(cashin_tiny_amount!) : undefined, - cashout_fee: !errors?.conv?.cashout_fee ? Amounts.stringify(cashout_fee!) : undefined, - cashout_min_amount: !errors?.conv?.cashout_min_amount ? Amounts.stringify(cashout_min_amount!) : undefined, - cashout_ratio: !errors?.conv?.cashout_ratio ? String(cashout_ratio!) : undefined, - cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode ? (state.conv.cashout_rounding_mode!) : undefined, - cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount ? Amounts.stringify(cashout_tiny_amount!) : undefined, - } - - } - return errors === undefined ? - { status: "ok", result: result as FormType, errors } : - { status: "fail", result, errors } - } -} - - -function ConversionForm({ id, inputCurrency, outputCurrency, fee, minimum, ratio, rounding, tiny }: { - inputCurrency: string, - outputCurrency: string, - minimum: UIField | undefined, - tiny: UIField | undefined, - fee: UIField | undefined, - rounding: UIField | undefined, - ratio: UIField | undefined, - id: string, -}): VNode { - const { i18n } = useTranslationContext(); - return -
-
-
- - - -

- Only cashout operation above this threshold will be allowed -

-
-
-
- -
- -
- { - ratio?.onUpdate(e.currentTarget.value); - }} - autocomplete="off" - /> - -
-

- - Conversion ratio between currencies - -

-
- -
- - 1 {inputCurrency} will be converted into {ratio?.value} {outputCurrency} - -
- -
-
-
- - - -

- Smallest difference between two amounts after the ratio is applied. -

-
-
-
- -
-
-
- -
-
- - - - -
-
-
-
-
- -
- -
-
- - Rounding an amount of 1.24 with rounding value 0.1 - - -

- Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. -

-

- With the "zero" mode the value will be rounded to 1.2 -

-

- With the "nearest" mode the value will be rounded to 1.2 -

-

- With the "up" mode the value will be rounded to 1.3 -

-
-
- - Rounding an amount of 1.26 with rounding value 0.1 - - -

- Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. -

-

- With the "zero" mode the value will be rounded to 1.2 -

-

- With the "nearest" mode the value will be rounded to 1.3 -

-

- With the "up" mode the value will be rounded to 1.3 -

-
-
- - Rounding an amount of 1.24 with rounding value 0.3 - - -

- Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. -

-

- With the "zero" mode the value will be rounded to 1.2 -

-

- With the "nearest" mode the value will be rounded to 1.2 -

-

- With the "up" mode the value will be rounded to 1.5 -

-
-
- - Rounding an amount of 1.26 with rounding value 0.3 - - -

- Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. -

-

- With the "zero" mode the value will be rounded to 1.2 -

-

- With the "nearest" mode the value will be rounded to 1.3 -

-

- With the "up" mode the value will be rounded to 1.3 -

-
-
-
-
- - - -
-
-
- - - -

- Amount to be deducted before amount is credited. -

-
-
-
- -
-} diff --git a/packages/demobank-ui/src/pages/DownloadStats.tsx b/packages/demobank-ui/src/pages/DownloadStats.tsx deleted file mode 100644 index 353238c13..000000000 --- a/packages/demobank-ui/src/pages/DownloadStats.tsx +++ /dev/null @@ -1,585 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AccessToken, - AmountString, - TalerCoreBankHttpClient, - TalerCorebankApi, - TalerError, -} from "@gnu-taler/taler-util"; -import { - Attention, - LocalNotificationBanner, - useLocalNotification, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; -import { EmptyObject, RouteDefinition } from "../route.js"; -import { getTimeframesForDate } from "./admin/AdminHome.js"; - -interface Props { - routeCancel: RouteDefinition; -} - -type Options = { - dayMetric: boolean; - hourMetric: boolean; - monthMetric: boolean; - yearMetric: boolean; - compareWithPrevious: boolean; - endOnFirstFail: boolean; - includeHeader: boolean; -}; - -/** - * Show histories of public accounts. - */ -export function DownloadStats({ routeCancel }: Props): VNode { - const { i18n } = useTranslationContext(); - - const { state: credentials } = useBackendState(); - const creds = - credentials.status !== "loggedIn" || !credentials.isUserAdministrator - ? undefined - : credentials; - const { api } = useBankCoreApiContext(); - - const [options, setOptions] = useState({ - compareWithPrevious: true, - dayMetric: true, - endOnFirstFail: false, - hourMetric: true, - includeHeader: true, - monthMetric: true, - yearMetric: true, - }); - const [lastStep, setLastStep] = useState<{ step: number; total: number }>(); - const [downloaded, setDownloaded] = useState(); - const referenceDates = [new Date()]; - const [notification, , handleError] = useLocalNotification(); - - if (!creds) { - return
only admin can download stats
; - } - - return ( -
-
- - -
-

- Download bank stats -

-
- -
{ - e.preventDefault(); - }} - > -
-
-
-
- - - Include hour metric - - - -
-
-
-
- - - Include day metric - - - -
-
-
-
- - - Include month metric - - - -
-
-
-
- - - Include year metric - - - -
-
-
-
- - - Include table header - - - -
-
-
-
- - - - Add previous metric for compare - - - - -
-
-
-
- - - Fail on first error - - - -
-
-
-
- -
- - Cancel - - -
-
-
- {!lastStep || lastStep.step === lastStep.total ? ( -
- ) : ( -
-
-
- - - downloading...{" "} - {Math.round((lastStep.step / lastStep.total) * 100)} - - -
-
-
- )} - {!downloaded ? ( - - ); -} - -async function fetchAllStatus( - api: TalerCoreBankHttpClient, - token: AccessToken, - options: Options, - references: Date[], - progress: (current: number, total: number) => void, -): Promise { - const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = []; - if (options.hourMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour); - } - if (options.dayMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day); - } - if (options.monthMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month); - } - if (options.yearMetric) { - allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year); - } - - /** - * convert request into frames - */ - const allFrames = allMetrics.flatMap((timeframe) => - references.map((reference) => ({ - reference, - timeframe, - moment: getTimeframesForDate(reference, timeframe), - })), - ); - const total = allFrames.length; - - /** - * call API for info - */ - const allInfo = await allFrames.reduce( - async (prev, frame, index) => { - const accumulatedMap = await prev; - progress(index, total); - // await delay() - const previous = options.compareWithPrevious - ? await api.getMonitor(token, { - timeframe: frame.timeframe, - which: frame.moment.previous, - }) - : undefined; - - if (previous && previous.type === "fail" && options.endOnFirstFail) { - throw TalerError.fromUncheckedDetail(previous.detail); - } - - const current = await api.getMonitor(token, { - timeframe: frame.timeframe, - which: frame.moment.current, - }); - - if (current.type === "fail" && options.endOnFirstFail) { - throw TalerError.fromUncheckedDetail(current.detail); - } - - const metricName = - TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]]; - accumulatedMap[metricName] = { - reference: frame.reference, - current: current.type !== "ok" ? undefined : current.body, - previous: - !previous || previous.type !== "ok" ? undefined : previous.body, - }; - return accumulatedMap; - }, - Promise.resolve({} as Record), - ); - progress(total, total); - - /** - * convert into table format - * - */ - const table: Array = []; - if (options.includeHeader) { - table.push([ - "date", - "metric", - "reference", - "talerInCount", - "talerInVolume", - "talerOutCount", - "talerOutVolume", - "cashinCount", - "cashinFiatVolume", - "cashinRegionalVolume", - "cashoutCount", - "cashoutFiatVolume", - "cashoutRegionalVolume", - ]); - } - Object.entries(allInfo).forEach(([name, data]) => { - if (data.current) { - const row: TableRow = { - date: data.reference.getTime(), - metric: name, - reference: "current", - ...dataToRow(data.current), - }; - table.push(Object.values(row) as string[]); - } - - if (data.previous) { - const row: TableRow = { - date: data.reference.getTime(), - metric: name, - reference: "previous", - ...dataToRow(data.previous), - }; - table.push(Object.values(row) as string[]); - } - }); - - const csv = table.reduce((acc, row) => { - return acc + row.join(",") + "\n"; - }, ""); - - return csv; -} - -type JustData = Omit, "date">, "reference">; -function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { - return { - talerInCount: info.talerInCount, - talerInVolume: info.talerInVolume, - talerOutCount: info.talerOutCount, - talerOutVolume: info.talerOutVolume, - cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount, - cashinFiatVolume: - info.type === "no-conversions" ? undefined : info.cashinFiatVolume, - cashinRegionalVolume: - info.type === "no-conversions" ? undefined : info.cashinRegionalVolume, - cashoutCount: - info.type === "no-conversions" ? undefined : info.cashoutCount, - cashoutFiatVolume: - info.type === "no-conversions" ? undefined : info.cashoutFiatVolume, - cashoutRegionalVolume: - info.type === "no-conversions" ? undefined : info.cashoutRegionalVolume, - }; -} - -type Data = { - reference: Date; - previous: TalerCorebankApi.MonitorResponse | undefined; - current: TalerCorebankApi.MonitorResponse | undefined; -}; -type TableRow = { - date: number; - metric: string; - reference: "current" | "previous"; - cashinCount?: number; - cashinRegionalVolume?: AmountString; - cashinFiatVolume?: AmountString; - cashoutCount?: number; - cashoutRegionalVolume?: AmountString; - cashoutFiatVolume?: AmountString; - talerInCount: number; - talerInVolume: AmountString; - talerOutCount: number; - talerOutVolume: AmountString; -}; diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx index f0ca447e1..e62759415 100644 --- a/packages/demobank-ui/src/pages/LoginForm.tsx +++ b/packages/demobank-ui/src/pages/LoginForm.tsx @@ -27,7 +27,7 @@ import { import { VNode, h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; +import { useSessionState } from "../hooks/session.js"; import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; @@ -44,10 +44,10 @@ export function LoginForm({ currentUser?: string; routeRegister?: RouteDefinition; }): VNode { - const backend = useBackendState(); + const session = useSessionState(); const sessionUser = - backend.state.status !== "loggedOut" ? backend.state.username : undefined; + session.state.status !== "loggedOut" ? session.state.username : undefined; const [username, setUsername] = useState( currentUser ?? sessionUser, ); @@ -73,7 +73,7 @@ export function LoginForm({ }); async function doLogout() { - backend.logOut(); + session.logOut(); } const loginHandler = !username || !password ? undefined : withErrorHandler( @@ -86,7 +86,7 @@ export function LoginForm({ refreshable: true, }), (result) => { - backend.logIn({ username, token: result.body.access_token }) + session.logIn({ username, token: result.body.access_token }) }, (fail) => { switch (fail.case) { @@ -173,7 +173,7 @@ export function LoginForm({
- {backend.state.status !== "loggedOut" ? ( + {session.state.status !== "loggedOut" ? (
+
+
+
+
+ + + Include day metric + + + +
+
+
+
+ + + Include month metric + + + +
+
+
+
+ + + Include year metric + + + +
+
+
+
+ + + Include table header + + + +
+
+
+
+ + + + Add previous metric for compare + + + + +
+
+
+
+ + + Fail on first error + + + +
+
+ + + +
+ + Cancel + + +
+ + + {!lastStep || lastStep.step === lastStep.total ? ( +
+ ) : ( +
+
+
+ + + downloading...{" "} + {Math.round((lastStep.step / lastStep.total) * 100)} + + +
+
+
+ )} + {!downloaded ? ( + + ); +} + +async function fetchAllStatus( + api: TalerCoreBankHttpClient, + token: AccessToken, + options: Options, + references: Date[], + progress: (current: number, total: number) => void, +): Promise { + const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = []; + if (options.hourMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour); + } + if (options.dayMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day); + } + if (options.monthMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month); + } + if (options.yearMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year); + } + + /** + * convert request into frames + */ + const allFrames = allMetrics.flatMap((timeframe) => + references.map((reference) => ({ + reference, + timeframe, + moment: getTimeframesForDate(reference, timeframe), + })), + ); + const total = allFrames.length; + + /** + * call API for info + */ + const allInfo = await allFrames.reduce( + async (prev, frame, index) => { + const accumulatedMap = await prev; + progress(index, total); + // await delay() + const previous = options.compareWithPrevious + ? await api.getMonitor(token, { + timeframe: frame.timeframe, + which: frame.moment.previous, + }) + : undefined; + + if (previous && previous.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(previous.detail); + } + + const current = await api.getMonitor(token, { + timeframe: frame.timeframe, + which: frame.moment.current, + }); + + if (current.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(current.detail); + } + + const metricName = + TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]]; + accumulatedMap[metricName] = { + reference: frame.reference, + current: current.type !== "ok" ? undefined : current.body, + previous: + !previous || previous.type !== "ok" ? undefined : previous.body, + }; + return accumulatedMap; + }, + Promise.resolve({} as Record), + ); + progress(total, total); + + /** + * convert into table format + * + */ + const table: Array = []; + if (options.includeHeader) { + table.push([ + "date", + "metric", + "reference", + "talerInCount", + "talerInVolume", + "talerOutCount", + "talerOutVolume", + "cashinCount", + "cashinFiatVolume", + "cashinRegionalVolume", + "cashoutCount", + "cashoutFiatVolume", + "cashoutRegionalVolume", + ]); + } + Object.entries(allInfo).forEach(([name, data]) => { + if (data.current) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "current", + ...dataToRow(data.current), + }; + table.push(Object.values(row) as string[]); + } + + if (data.previous) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "previous", + ...dataToRow(data.previous), + }; + table.push(Object.values(row) as string[]); + } + }); + + const csv = table.reduce((acc, row) => { + return acc + row.join(",") + "\n"; + }, ""); + + return csv; +} + +type JustData = Omit, "date">, "reference">; +function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { + return { + talerInCount: info.talerInCount, + talerInVolume: info.talerInVolume, + talerOutCount: info.talerOutCount, + talerOutVolume: info.talerOutVolume, + cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount, + cashinFiatVolume: + info.type === "no-conversions" ? undefined : info.cashinFiatVolume, + cashinRegionalVolume: + info.type === "no-conversions" ? undefined : info.cashinRegionalVolume, + cashoutCount: + info.type === "no-conversions" ? undefined : info.cashoutCount, + cashoutFiatVolume: + info.type === "no-conversions" ? undefined : info.cashoutFiatVolume, + cashoutRegionalVolume: + info.type === "no-conversions" ? undefined : info.cashoutRegionalVolume, + }; +} + +type Data = { + reference: Date; + previous: TalerCorebankApi.MonitorResponse | undefined; + current: TalerCorebankApi.MonitorResponse | undefined; +}; +type TableRow = { + date: number; + metric: string; + reference: "current" | "previous"; + cashinCount?: number; + cashinRegionalVolume?: AmountString; + cashinFiatVolume?: AmountString; + cashoutCount?: number; + cashoutRegionalVolume?: AmountString; + cashoutFiatVolume?: AmountString; + talerInCount: number; + talerInVolume: AmountString; + talerOutCount: number; + talerOutVolume: AmountString; +}; diff --git a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx index 6f02eae8f..6039db326 100644 --- a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx @@ -35,8 +35,8 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { useBankCoreApiContext } from "../../context/config.js"; -import { useAccountDetails } from "../../hooks/access.js"; -import { useBackendState } from "../../hooks/backend.js"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; import { undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; @@ -62,7 +62,7 @@ export function RemoveAccount({ const result = useAccountDetails(account); const [accountName, setAccountName] = useState(); - const { state } = useBackendState(); + const { state } = useSessionState(); const token = state.status !== "loggedIn" ? undefined : state.token; const { api } = useBankCoreApiContext(); const [notification, notify, handleError] = useLocalNotification(); diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx deleted file mode 100644 index 1a5fad1b1..000000000 --- a/packages/demobank-ui/src/pages/business/CreateCashout.tsx +++ /dev/null @@ -1,809 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AbsoluteTime, - Amounts, - HttpStatusCode, - TalerError, - TalerErrorCode, - TranslatedString, - assertUnreachable, - encodeCrock, - getRandomBytes, - parsePaytoUri, -} from "@gnu-taler/taler-util"; -import { - Attention, - Loading, - LocalNotificationBanner, - ShowInputErrorLabel, - notifyInfo, - useLocalNotification, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; -import { useAccountDetails } from "../../hooks/access.js"; -import { useBackendState } from "../../hooks/backend.js"; -import { useBankState } from "../../hooks/bank-state.js"; -import { TransferCalculation, useCashoutEstimator, useConversionInfo, useEstimator } from "../../hooks/circuit.js"; -import { RouteDefinition } from "../../route.js"; -import { TanChannel, undefinedIfEmpty } from "../../utils.js"; -import { LoginForm } from "../LoginForm.js"; -import { - InputAmount, - RenderAmount, - doAutoFocus, -} from "../PaytoWireTransferForm.js"; - -interface Props { - account: string; - focus?: boolean; - onAuthorizationRequired: () => void; - routeClose: RouteDefinition; - routeHere: RouteDefinition; -} - -type FormType = { - isDebit: boolean; - amount: string; - subject: string; - channel: TanChannel; -}; -type ErrorFrom = { - [P in keyof T]+?: string; -}; - -export function CreateCashout({ - account: accountName, - onAuthorizationRequired, - focus, - routeHere, - routeClose, -}: Props): VNode { - const { i18n } = useTranslationContext(); - const resultAccount = useAccountDetails(accountName); - const { - estimateByCredit: calculateFromCredit, - estimateByDebit: calculateFromDebit, - } = useCashoutEstimator(); - const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const [, updateBankState] = useBankState(); - - const { api, config, hints } = useBankCoreApiContext(); - const [form, setForm] = useState>({ isDebit: true }); - const [notification, notify, handleError] = useLocalNotification(); - const info = useConversionInfo(); - - if (!config.allow_conversion) { - return ( - - - - The bank configuration does not support cashout operations. - - - - - ); - } - - const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; - - if (!resultAccount) { - return ; - } - if (resultAccount instanceof TalerError) { - return ; - } - if (resultAccount.type === "fail") { - switch (resultAccount.case) { - case HttpStatusCode.Unauthorized: - return ; - case HttpStatusCode.NotFound: - return ; - default: - assertUnreachable(resultAccount); - } - } - if (!info) { - return ; - } - - if (info instanceof TalerError) { - return ; - } - if (info.type === "fail") { - switch (info.case) { - case HttpStatusCode.NotImplemented: { - return ( - - Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode. - - ); - } - default: - assertUnreachable(info.case); - } - } - - const conversionInfo = info.body.conversion_rate; - if (!conversionInfo) { - return ( -
conversion enabled but server replied without conversion_rate
- ); - } - - const account = { - balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), - balanceIsDebit: - resultAccount.body.balance.credit_debit_indicator == "debit", - debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), - }; - - const { - fiat_currency, - regional_currency, - fiat_currency_specification, - regional_currency_specification, - } = info.body; - const regionalZero = Amounts.zeroOfCurrency(regional_currency); - const fiatZero = Amounts.zeroOfCurrency(fiat_currency); - const limit = account.balanceIsDebit - ? Amounts.sub(account.debitThreshold, account.balance).amount - : Amounts.add(account.balance, account.debitThreshold).amount; - - const zeroCalc = { - debit: regionalZero, - credit: fiatZero, - beforeFee: fiatZero, - }; - const [calculationResult, setCalculation] = useState(zeroCalc); - const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); - const sellRate = conversionInfo.cashout_ratio; - /** - * can be in regional currency or fiat currency - * depending on the isDebit flag - */ - const inputAmount = Amounts.parseOrThrow( - `${form.isDebit ? regional_currency : fiat_currency}:${!form.amount ? "0" : form.amount - }`, - ); - - useEffect(() => { - async function doAsync() { - await handleError(async () => { - const higerThanMin = form.isDebit ? - Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1 : true; - const notZero = Amounts.isNonZero(inputAmount) - if (notZero && higerThanMin) { - const resp = await (form.isDebit - ? calculateFromDebit(inputAmount, sellFee) - : calculateFromCredit(inputAmount, sellFee)); - setCalculation(resp); - } else { - setCalculation(zeroCalc) - } - }); - } - doAsync(); - }, [form.amount, form.isDebit]); - - const calc = calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult - - const balanceAfter = Amounts.sub(account.balance, calc.debit).amount; - - function updateForm(newForm: typeof form): void { - setForm(newForm); - } - const errors = undefinedIfEmpty>({ - subject: !form.subject ? i18n.str`Required` : undefined, - amount: !form.amount - ? i18n.str`Required` - : !inputAmount - ? i18n.str`Invalid` - : Amounts.cmp(limit, calc.debit) === -1 - ? i18n.str`Balance is not enough` - : form.isDebit && Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1 - ? i18n.str`Needs to be higher than ${Amounts.stringifyValueWithSpec(Amounts.parseOrThrow(conversionInfo.cashout_min_amount), regional_currency_specification).normal}` - : calculationResult === "amount-is-too-small" - ? i18n.str`Amount needs to be higher` - : Amounts.isZero(calc.credit) - ? i18n.str`The total transfer at destination will be zero` - : undefined, - channel: OLD_CASHOUT_API && !form.channel ? i18n.str`Required` : undefined, - }); - const trimmedAmountStr = form.amount?.trim(); - - async function createCashout() { - const request_uid = encodeCrock(getRandomBytes(32)); - await handleError(async () => { - // new cashout api doesn't require channel - const validChannel = - !OLD_CASHOUT_API || - config.supported_tan_channels.length === 0 || - form.channel; - - if (!creds || !form.subject || !validChannel) return; - const request = { - request_uid, - amount_credit: Amounts.stringify(calc.credit), - amount_debit: Amounts.stringify(calc.debit), - subject: form.subject, - tan_channel: form.channel, - }; - const resp = await api.createCashout(creds, request); - if (resp.type === "ok") { - notifyInfo(i18n.str`Cashout created`); - } else { - switch (resp.case) { - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "create-cashout", - id: String(resp.body.challenge_id), - sent: AbsoluteTime.never(), - location: routeHere.url({}), - request, - }); - return onAuthorizationRequired(); - } - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`Account not found`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: - return notify({ - type: "error", - title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_BAD_CONVERSION: - return notify({ - type: "error", - title: i18n.str`The conversion rate was incorrectly applied`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return notify({ - type: "error", - title: i18n.str`The account does not have sufficient funds`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case HttpStatusCode.NotImplemented: - return notify({ - type: "error", - title: i18n.str`Cashout are disabled`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return notify({ - type: "error", - title: i18n.str`Missing cashout URI in the profile`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: - return notify({ - type: "error", - title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - } - assertUnreachable(resp); - } - }); - } - const cashoutDisabled = - config.supported_tan_channels.length < 1 || - !resultAccount.body.cashout_payto_uri; - - const cashoutAccount = !resultAccount.body.cashout_payto_uri - ? undefined - : parsePaytoUri(resultAccount.body.cashout_payto_uri); - const cashoutAccountName = !cashoutAccount - ? undefined - : cashoutAccount.targetPath; - - const cashoutLegalName = !cashoutAccount - ? undefined - : cashoutAccount.params["receiver-name"]; - - return ( -
- - -
-
-

- Cashout -

- -
-
-
- Conversion rate -
-
{sellRate}
-
- -
-
- - Balance - -
-
- -
-
-
-
- - Fee - -
-
- -
-
- {cashoutAccountName && cashoutLegalName ? ( - -
-
- - To account - -
-
{cashoutAccountName}
-
-
-
- - Legal name - -
-
{cashoutLegalName}
-
-

- If this name doesn't match the account holder's name your transaction may fail. -

-
- ) : ( -
- - - Before doing a cashout you need to complete your profile - - -
- )} -
-
-
{ - e.preventDefault(); - }} - > -
-
- {/* subject */} - -
- -
- { - form.subject = e.currentTarget.value; - updateForm(structuredClone(form)); - }} - autocomplete="off" - /> - -
-
- -
- - -
- - -
-
- - {/* amount */} -
-
- - {/* */} -
-
- { - form.amount = value; - updateForm(structuredClone(form)); - } - } - /> - -
-
- - {Amounts.isZero(calc.credit) ? undefined : ( -
-
-
-
- Total cost -
-
- -
-
- -
-
- - Balance left - -
-
- -
-
- {Amounts.isZero(sellFee) || - Amounts.isZero(calc.beforeFee) ? undefined : ( -
-
- - Before fee - -
-
- -
-
- )} -
-
- Total cashout transfer -
-
- -
-
-
-
- )} - - {/* channel, not shown if new cashout api */} - {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels - .length === 0 ? ( -
- - - Before doing a cashout the server need to provide an - second channel to confirm the operation - - -
- ) : ( -
- -
-
- {config.supported_tan_channels.indexOf( - TanChannel.EMAIL, - ) === -1 ? undefined : ( - - )} - - {config.supported_tan_channels.indexOf(TanChannel.SMS) === - -1 ? undefined : ( - - )} -
-
-
- )} -
-
- -
- - Cancel - - -
-
-
-
- ); -} diff --git a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx deleted file mode 100644 index 33115c16a..000000000 --- a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - AbsoluteTime, - Amounts, - Duration, - HttpStatusCode, - TalerError, - assertUnreachable, -} from "@gnu-taler/taler-util"; -import { - Attention, - Loading, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { VNode, h } from "preact"; -import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { useCashoutDetails, useConversionInfo } from "../../hooks/circuit.js"; -import { RouteDefinition } from "../../route.js"; -import { RenderAmount } from "../PaytoWireTransferForm.js"; -import { Time } from "../../components/Time.js"; - -interface Props { - id: string; - routeClose: RouteDefinition; -} -export function ShowCashoutDetails({ id, routeClose }: Props): VNode { - const { i18n, dateLocale } = useTranslationContext(); - const cid = Number.parseInt(id, 10); - - const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid); - const info = useConversionInfo(); - - if (Number.isNaN(cid)) { - return ( - - ); - } - if (!result) { - return ; - } - if (result instanceof TalerError) { - return ; - } - if (result.type === "fail") { - switch (result.case) { - case HttpStatusCode.NotFound: - return ( - - ); - case HttpStatusCode.NotImplemented: - return ( - - Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode. - - ); - default: - assertUnreachable(result); - } - } - if (!info) { - return ; - } - - if (info instanceof TalerError) { - return ; - } - if (info.type === "fail") { - switch (info.case) { - case HttpStatusCode.NotImplemented: { - return ( - - Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode. - - ); - } - default: - assertUnreachable(info.case); - } - } - - const { fiat_currency_specification, regional_currency_specification } = - info.body; - - return ( -
-
-
-

- Cashout detail -

-
-
-
- Subject -
-
{result.body.subject}
-
-
-
-
-
-
-
-
- {result.body.creation_time.t_s !== "never" ? ( -
-
- Created -
-
-
-
- ) : undefined} - -
-
- Debited -
-
- -
-
- -
-
- - Credited - -
-
- -
-
-
-
-
-
-
-
- -
- -
- ); -} diff --git a/packages/demobank-ui/src/pages/regional/ConversionConfig.tsx b/packages/demobank-ui/src/pages/regional/ConversionConfig.tsx new file mode 100644 index 000000000..63423353b --- /dev/null +++ b/packages/demobank-ui/src/pages/regional/ConversionConfig.tsx @@ -0,0 +1,980 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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, + Amounts, + HttpStatusCode, + TalerBankConversionApi, + TalerError, + TranslatedString, + assertUnreachable +} from "@gnu-taler/taler-util"; +import { + Attention, + InternationalizationAPI, + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotification, + useTranslationContext, + utils +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useSessionState } from "../../hooks/session.js"; +import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.js"; +import { undefinedIfEmpty } from "../../utils.js"; +import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js"; +import { ProfileNavigation } from "../ProfileNavigation.js"; +import { FormErrors, FormStatus, FormValues, RecursivePartial, UIField, useFormState } from "../../hooks/form.js"; + +interface Props { + routeMyAccountDetails: RouteDefinition; + routeMyAccountDelete: RouteDefinition; + routeMyAccountPassword: RouteDefinition; + routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; + routeCancel: RouteDefinition; + onUpdateSuccess: () => void; +} + +type FormType = { amount: AmountJson, conv: TalerBankConversionApi.ConversionRate } + + +function useComponentState({ + onUpdateSuccess, + routeCancel, + routeConversionConfig, + routeMyAccountCashout, + routeMyAccountDelete, + routeMyAccountDetails, + routeMyAccountPassword, +}: Props): utils.RecursiveState { + const { i18n } = useTranslationContext(); + + const result = useConversionInfo() + const info = result && !(result instanceof TalerError) && result.type === "ok" ? + result.body : undefined; + + const { state: credentials } = useSessionState(); + const creds = + credentials.status !== "loggedIn" || !credentials.isUserAdministrator + ? undefined + : credentials; + + if (!info) { + return loading... + } + + if (!creds) { + return only admin can setup conversion + } + + return () => { + const { i18n } = useTranslationContext(); + + const { api, config } = useBankCoreApiContext(); + + const [notification, notify, handleError] = useLocalNotification(); + + const initalState: FormValues = { + amount: "100", + conv: { + cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1], + cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1], + cashin_fee: info.conversion_rate.cashin_fee.split(":")[1], + cashin_ratio: info.conversion_rate.cashin_ratio, + cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode, + cashout_min_amount: info.conversion_rate.cashout_min_amount.split(":")[1], + cashout_tiny_amount: info.conversion_rate.cashout_tiny_amount.split(":")[1], + cashout_fee: info.conversion_rate.cashout_fee.split(":")[1], + cashout_ratio: info.conversion_rate.cashout_ratio, + cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode, + } + } + + const [form, status] = useFormState( + initalState, + createFormValidator(i18n, info.regional_currency, info.fiat_currency) + ) + + const { + estimateByDebit: calculateCashoutFromDebit, + } = useCashoutEstimator(); + + const { + estimateByDebit: calculateCashinFromDebit, + } = useCashinEstimator(); + + const [calculationResult, setCalc] = useState<{ cashin: TransferCalculation, cashout: TransferCalculation }>() + + useEffect(() => { + async function doAsync() { + await handleError(async () => { + if (!info) return; + if (!form.amount?.value || form.amount.error) return; + const in_amount = Amounts.parseOrThrow(`${info.fiat_currency}:${form.amount.value}`) + const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee) + const cashin = await calculateCashinFromDebit(in_amount, in_fee); + + if (cashin === "amount-is-too-small") { + setCalc(undefined) + return; + } + // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`) + const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee) + const cashout = await calculateCashoutFromDebit(cashin.credit, out_fee); + + setCalc({ cashin, cashout }); + }); + } + doAsync(); + }, [form.amount?.value, form.conv?.cashin_fee?.value, form.conv?.cashout_fee?.value]); + + const [section, setSection] = useState<"detail" | "cashout" | "cashin">("detail") + const cashinCalc = calculationResult?.cashin === "amount-is-too-small" ? undefined : calculationResult?.cashin + const cashoutCalc = calculationResult?.cashout === "amount-is-too-small" ? undefined : calculationResult?.cashout + async function doUpdate() { + if (!creds) return + await handleError(async () => { + if (status.status === "fail") return; + const resp = await api + .getConversionInfoAPI() + .updateConversionRate(creds.token, status.result.conv) + if (resp.type === "ok") { + setSection("detail") + } else { + switch (resp.case) { + case HttpStatusCode.Unauthorized: { + return notify({ + type: "error", + title: i18n.str`Wrong credentials`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + case HttpStatusCode.NotImplemented: { + return notify({ + type: "error", + title: i18n.str`Conversion is disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + default: + assertUnreachable(resp); + } + } + }); + } + + const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio) + const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio) + + const both_high = in_ratio > 1 && out_ratio > 1; + const both_low = in_ratio < 1 && out_ratio < 1; + + + return ( +
+ + + +
+ +
+

+ Conversion +

+
+ + + + +
+ +
+ +
{ + e.preventDefault(); + }} + > + {section == "cashin" && + } + + {section == "cashout" && + + } + + {section == "detail" && +
+
+
+ Cashin ratio +
+
+ {info.conversion_rate.cashin_ratio} +
+
+
+ +
+
+
+ Cashout ratio +
+
+ {info.conversion_rate.cashout_ratio} +
+
+
+ + {both_low || both_high ?
+ + + One of the ratios should be higher or equal than 1 an the other should be lower or equal than 1. + + +
: undefined} + +
+
+
+ + + +

+ Use it to test how the conversion will affect the amount. +

+
+
+
+ + {!cashoutCalc || !cashinCalc ? undefined : ( +
+
+
+
+
+ Sending to this bank +
+
+ +
+
+ + {Amounts.isZero(cashinCalc.beforeFee) ? undefined : ( +
+
+ + Converted + +
+
+ +
+
+ )} +
+
+ Cashin after fee +
+
+ +
+
+
+
+ +
+
+
+
+ Sending from this bank +
+
+ +
+
+ + {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : ( +
+
+ + Converted + +
+
+ +
+
+ )} +
+
+ Cashout after fee +
+
+ +
+
+
+
+ + {cashoutCalc && status.status === "ok" && Amounts.cmp(status.result.amount, cashoutCalc.credit) < 0 ?
+ + + This configuration allows users to cash out more of what has been cashed in. + + +
: undefined} +
+ )} +
} + + +
+ + Cancel + + {section == "cashin" || section == "cashout" ? + + :
} +
+ + + +
+
+ ); + + } +} + +export const ConversionConfig = utils.recursive(useComponentState); + +/** + * + * @param i18n + * @param regional + * @param fiat + * @returns form validator + */ +function createFormValidator(i18n: InternationalizationAPI, regional: string, fiat: string) { + return function check(state: FormValues): FormStatus { + + const cashin_min_amount = Amounts.parse(`${fiat}:${state.conv.cashin_min_amount}`) + const cashin_tiny_amount = Amounts.parse(`${regional}:${state.conv.cashin_tiny_amount}`) + const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`) + + const cashout_min_amount = Amounts.parse(`${regional}:${state.conv.cashout_min_amount}`) + const cashout_tiny_amount = Amounts.parse(`${fiat}:${state.conv.cashout_tiny_amount}`) + const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`) + + const am = Amounts.parse(`${fiat}:${state.amount}`) + + const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? "") + const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? "") + + const errors = undefinedIfEmpty>({ + conv: undefinedIfEmpty>({ + cashin_min_amount: !state.conv.cashin_min_amount ? i18n.str`required` : + !cashin_min_amount ? i18n.str`invalid` : + undefined, + cashin_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : + !cashin_tiny_amount ? i18n.str`invalid` : + undefined, + cashin_fee: !state.conv.cashin_fee ? i18n.str`required` : + !cashin_fee ? i18n.str`invalid` : + undefined, + + cashout_min_amount: !state.conv.cashout_min_amount ? i18n.str`required` : + !cashout_min_amount ? i18n.str`invalid` : + undefined, + cashout_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : + !cashout_tiny_amount ? i18n.str`invalid` : + undefined, + cashout_fee: !state.conv.cashin_fee ? i18n.str`required` : + !cashout_fee ? i18n.str`invalid` : + undefined, + + cashin_rounding_mode: !state.conv.cashin_rounding_mode ? i18n.str`required` : undefined, + cashout_rounding_mode: !state.conv.cashout_rounding_mode ? i18n.str`required` : undefined, + + cashin_ratio: !state.conv.cashin_ratio ? i18n.str`required` : Number.isNaN(cashin_ratio) ? i18n.str`invalid` : undefined, + cashout_ratio: !state.conv.cashout_ratio ? i18n.str`required` : Number.isNaN(cashout_ratio) ? i18n.str`invalid` : undefined, + }), + + amount: !state.amount ? i18n.str`required` : + !am ? i18n.str`invalid` : + undefined, + }) + + const result: RecursivePartial = { + amount: am, + conv: { + cashin_fee: !errors?.conv?.cashin_fee ? Amounts.stringify(cashin_fee!) : undefined, + cashin_min_amount: !errors?.conv?.cashin_min_amount ? Amounts.stringify(cashin_min_amount!) : undefined, + cashin_ratio: !errors?.conv?.cashin_ratio ? String(cashin_ratio!) : undefined, + cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode ? (state.conv.cashin_rounding_mode!) : undefined, + cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount ? Amounts.stringify(cashin_tiny_amount!) : undefined, + cashout_fee: !errors?.conv?.cashout_fee ? Amounts.stringify(cashout_fee!) : undefined, + cashout_min_amount: !errors?.conv?.cashout_min_amount ? Amounts.stringify(cashout_min_amount!) : undefined, + cashout_ratio: !errors?.conv?.cashout_ratio ? String(cashout_ratio!) : undefined, + cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode ? (state.conv.cashout_rounding_mode!) : undefined, + cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount ? Amounts.stringify(cashout_tiny_amount!) : undefined, + } + + } + return errors === undefined ? + { status: "ok", result: result as FormType, errors } : + { status: "fail", result, errors } + } +} + + +function ConversionForm({ id, inputCurrency, outputCurrency, fee, minimum, ratio, rounding, tiny }: { + inputCurrency: string, + outputCurrency: string, + minimum: UIField | undefined, + tiny: UIField | undefined, + fee: UIField | undefined, + rounding: UIField | undefined, + ratio: UIField | undefined, + id: string, +}): VNode { + const { i18n } = useTranslationContext(); + return +
+
+
+ + + +

+ Only cashout operation above this threshold will be allowed +

+
+
+
+ +
+ +
+ { + ratio?.onUpdate(e.currentTarget.value); + }} + autocomplete="off" + /> + +
+

+ + Conversion ratio between currencies + +

+
+ +
+ + 1 {inputCurrency} will be converted into {ratio?.value} {outputCurrency} + +
+ +
+
+
+ + + +

+ Smallest difference between two amounts after the ratio is applied. +

+
+
+
+ +
+
+
+ +
+
+ + + + +
+
+
+
+
+ +
+ +
+
+ + + Rounding an amount of 1.24 with rounding value 0.1 + + + +

+ + Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. + +

+

+ + With the "zero" mode the value will be rounded to 1.2 + +

+

+ + With the "nearest" mode the value will be rounded to 1.2 + +

+

+ + With the "up" mode the value will be rounded to 1.3 + +

+
+
+ + + Rounding an amount of 1.26 with rounding value 0.1 + + + +

+ + Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. + +

+

+ + With the "zero" mode the value will be rounded to 1.2 + +

+

+ + With the "nearest" mode the value will be rounded to 1.3 + +

+

+ + With the "up" mode the value will be rounded to 1.3 + +

+
+
+ + + Rounding an amount of 1.24 with rounding value 0.3 + + + +

+ + Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. + +

+

+ + With the "zero" mode the value will be rounded to 1.2 + +

+

+ + With the "nearest" mode the value will be rounded to 1.2 + +

+

+ + With the "up" mode the value will be rounded to 1.5 + +

+
+
+ + + Rounding an amount of 1.26 with rounding value 0.3 + + + +

+ + Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. + +

+

+ + With the "zero" mode the value will be rounded to 1.2 + +

+

+ + With the "nearest" mode the value will be rounded to 1.3 + +

+

+ + With the "up" mode the value will be rounded to 1.3 + +

+
+
+
+
+ + + +
+
+
+ + + +

+ Amount to be deducted before amount is credited. +

+
+
+
+ +
+} diff --git a/packages/demobank-ui/src/pages/regional/CreateCashout.tsx b/packages/demobank-ui/src/pages/regional/CreateCashout.tsx new file mode 100644 index 000000000..a5b8f774a --- /dev/null +++ b/packages/demobank-ui/src/pages/regional/CreateCashout.tsx @@ -0,0 +1,809 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + AbsoluteTime, + Amounts, + HttpStatusCode, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, + encodeCrock, + getRandomBytes, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + LocalNotificationBanner, + ShowInputErrorLabel, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { TransferCalculation, useCashoutEstimator, useConversionInfo, useEstimator } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.js"; +import { TanChannel, undefinedIfEmpty } from "../../utils.js"; +import { LoginForm } from "../LoginForm.js"; +import { + InputAmount, + RenderAmount, + doAutoFocus, +} from "../PaytoWireTransferForm.js"; + +interface Props { + account: string; + focus?: boolean; + onAuthorizationRequired: () => void; + routeClose: RouteDefinition; + routeHere: RouteDefinition; +} + +type FormType = { + isDebit: boolean; + amount: string; + subject: string; + channel: TanChannel; +}; +type ErrorFrom = { + [P in keyof T]+?: string; +}; + +export function CreateCashout({ + account: accountName, + onAuthorizationRequired, + focus, + routeHere, + routeClose, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const resultAccount = useAccountDetails(accountName); + const { + estimateByCredit: calculateFromCredit, + estimateByDebit: calculateFromDebit, + } = useCashoutEstimator(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const [, updateBankState] = useBankState(); + + const { api, config, hints } = useBankCoreApiContext(); + const [form, setForm] = useState>({ isDebit: true }); + const [notification, notify, handleError] = useLocalNotification(); + const info = useConversionInfo(); + + if (!config.allow_conversion) { + return ( + + + + The bank configuration does not support cashout operations. + + + + + ); + } + + const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; + + if (!resultAccount) { + return ; + } + if (resultAccount instanceof TalerError) { + return ; + } + if (resultAccount.type === "fail") { + switch (resultAccount.case) { + case HttpStatusCode.Unauthorized: + return ; + case HttpStatusCode.NotFound: + return ; + default: + assertUnreachable(resultAccount); + } + } + if (!info) { + return ; + } + + if (info instanceof TalerError) { + return ; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + + Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode. + + ); + } + default: + assertUnreachable(info.case); + } + } + + const conversionInfo = info.body.conversion_rate; + if (!conversionInfo) { + return ( +
conversion enabled but server replied without conversion_rate
+ ); + } + + const account = { + balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), + balanceIsDebit: + resultAccount.body.balance.credit_debit_indicator == "debit", + debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), + }; + + const { + fiat_currency, + regional_currency, + fiat_currency_specification, + regional_currency_specification, + } = info.body; + const regionalZero = Amounts.zeroOfCurrency(regional_currency); + const fiatZero = Amounts.zeroOfCurrency(fiat_currency); + const limit = account.balanceIsDebit + ? Amounts.sub(account.debitThreshold, account.balance).amount + : Amounts.add(account.balance, account.debitThreshold).amount; + + const zeroCalc = { + debit: regionalZero, + credit: fiatZero, + beforeFee: fiatZero, + }; + const [calculationResult, setCalculation] = useState(zeroCalc); + const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); + const sellRate = conversionInfo.cashout_ratio; + /** + * can be in regional currency or fiat currency + * depending on the isDebit flag + */ + const inputAmount = Amounts.parseOrThrow( + `${form.isDebit ? regional_currency : fiat_currency}:${!form.amount ? "0" : form.amount + }`, + ); + + useEffect(() => { + async function doAsync() { + await handleError(async () => { + const higerThanMin = form.isDebit ? + Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1 : true; + const notZero = Amounts.isNonZero(inputAmount) + if (notZero && higerThanMin) { + const resp = await (form.isDebit + ? calculateFromDebit(inputAmount, sellFee) + : calculateFromCredit(inputAmount, sellFee)); + setCalculation(resp); + } else { + setCalculation(zeroCalc) + } + }); + } + doAsync(); + }, [form.amount, form.isDebit]); + + const calc = calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult + + const balanceAfter = Amounts.sub(account.balance, calc.debit).amount; + + function updateForm(newForm: typeof form): void { + setForm(newForm); + } + const errors = undefinedIfEmpty>({ + subject: !form.subject ? i18n.str`Required` : undefined, + amount: !form.amount + ? i18n.str`Required` + : !inputAmount + ? i18n.str`Invalid` + : Amounts.cmp(limit, calc.debit) === -1 + ? i18n.str`Balance is not enough` + : form.isDebit && Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1 + ? i18n.str`Needs to be higher than ${Amounts.stringifyValueWithSpec(Amounts.parseOrThrow(conversionInfo.cashout_min_amount), regional_currency_specification).normal}` + : calculationResult === "amount-is-too-small" + ? i18n.str`Amount needs to be higher` + : Amounts.isZero(calc.credit) + ? i18n.str`The total transfer at destination will be zero` + : undefined, + channel: OLD_CASHOUT_API && !form.channel ? i18n.str`Required` : undefined, + }); + const trimmedAmountStr = form.amount?.trim(); + + async function createCashout() { + const request_uid = encodeCrock(getRandomBytes(32)); + await handleError(async () => { + // new cashout api doesn't require channel + const validChannel = + !OLD_CASHOUT_API || + config.supported_tan_channels.length === 0 || + form.channel; + + if (!creds || !form.subject || !validChannel) return; + const request = { + request_uid, + amount_credit: Amounts.stringify(calc.credit), + amount_debit: Amounts.stringify(calc.debit), + subject: form.subject, + tan_channel: form.channel, + }; + const resp = await api.createCashout(creds, request); + if (resp.type === "ok") { + notifyInfo(i18n.str`Cashout created`); + } else { + switch (resp.case) { + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "create-cashout", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + location: routeHere.url({}), + request, + }); + return onAuthorizationRequired(); + } + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: + return notify({ + type: "error", + title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_BAD_CONVERSION: + return notify({ + type: "error", + title: i18n.str`The conversion rate was incorrectly applied`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`The account does not have sufficient funds`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.NotImplemented: + return notify({ + type: "error", + title: i18n.str`Cashout are disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return notify({ + type: "error", + title: i18n.str`Missing cashout URI in the profile`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + return notify({ + type: "error", + title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + assertUnreachable(resp); + } + }); + } + const cashoutDisabled = + config.supported_tan_channels.length < 1 || + !resultAccount.body.cashout_payto_uri; + + const cashoutAccount = !resultAccount.body.cashout_payto_uri + ? undefined + : parsePaytoUri(resultAccount.body.cashout_payto_uri); + const cashoutAccountName = !cashoutAccount + ? undefined + : cashoutAccount.targetPath; + + const cashoutLegalName = !cashoutAccount + ? undefined + : cashoutAccount.params["receiver-name"]; + + return ( +
+ + +
+
+

+ Cashout +

+ +
+
+
+ Conversion rate +
+
{sellRate}
+
+ +
+
+ + Balance + +
+
+ +
+
+
+
+ + Fee + +
+
+ +
+
+ {cashoutAccountName && cashoutLegalName ? ( + +
+
+ + To account + +
+
{cashoutAccountName}
+
+
+
+ + Legal name + +
+
{cashoutLegalName}
+
+

+ If this name doesn't match the account holder's name your transaction may fail. +

+
+ ) : ( +
+ + + Before doing a cashout you need to complete your profile + + +
+ )} +
+
+
{ + e.preventDefault(); + }} + > +
+
+ {/* subject */} + +
+ +
+ { + form.subject = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + +
+
+ +
+ + +
+ + +
+
+ + {/* amount */} +
+
+ + {/* */} +
+
+ { + form.amount = value; + updateForm(structuredClone(form)); + } + } + /> + +
+
+ + {Amounts.isZero(calc.credit) ? undefined : ( +
+
+
+
+ Total cost +
+
+ +
+
+ +
+
+ + Balance left + +
+
+ +
+
+ {Amounts.isZero(sellFee) || + Amounts.isZero(calc.beforeFee) ? undefined : ( +
+
+ + Before fee + +
+
+ +
+
+ )} +
+
+ Total cashout transfer +
+
+ +
+
+
+
+ )} + + {/* channel, not shown if new cashout api */} + {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels + .length === 0 ? ( +
+ + + Before doing a cashout the server need to provide an + second channel to confirm the operation + + +
+ ) : ( +
+ +
+
+ {config.supported_tan_channels.indexOf( + TanChannel.EMAIL, + ) === -1 ? undefined : ( + + )} + + {config.supported_tan_channels.indexOf(TanChannel.SMS) === + -1 ? undefined : ( + + )} +
+
+
+ )} +
+
+ +
+ + Cancel + + +
+
+
+
+ ); +} diff --git a/packages/demobank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/regional/ShowCashoutDetails.tsx new file mode 100644 index 000000000..415f88868 --- /dev/null +++ b/packages/demobank-ui/src/pages/regional/ShowCashoutDetails.tsx @@ -0,0 +1,192 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + AbsoluteTime, + Amounts, + Duration, + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { VNode, h } from "preact"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useCashoutDetails, useConversionInfo } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.js"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; +import { Time } from "../../components/Time.js"; + +interface Props { + id: string; + routeClose: RouteDefinition; +} +export function ShowCashoutDetails({ id, routeClose }: Props): VNode { + const { i18n, dateLocale } = useTranslationContext(); + const cid = Number.parseInt(id, 10); + + const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid); + const info = useConversionInfo(); + + if (Number.isNaN(cid)) { + return ( + + ); + } + if (!result) { + return ; + } + if (result instanceof TalerError) { + return ; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: + return ( + + ); + case HttpStatusCode.NotImplemented: + return ( + + Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode. + + ); + default: + assertUnreachable(result); + } + } + if (!info) { + return ; + } + + if (info instanceof TalerError) { + return ; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + + Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode. + + ); + } + default: + assertUnreachable(info.case); + } + } + + const { fiat_currency_specification, regional_currency_specification } = + info.body; + + return ( +
+
+
+

+ Cashout detail +

+
+
+
+ Subject +
+
{result.body.subject}
+
+
+
+
+
+
+
+
+ {result.body.creation_time.t_s !== "never" ? ( +
+
+ Created +
+
+
+
+ ) : undefined} + +
+
+ Debited +
+
+ +
+
+ +
+
+ + Credited + +
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+ ); +} diff --git a/packages/demobank-ui/src/stories.test.ts b/packages/demobank-ui/src/stories.test.ts index 747bf4083..8171c6d8f 100644 --- a/packages/demobank-ui/src/stories.test.ts +++ b/packages/demobank-ui/src/stories.test.ts @@ -30,7 +30,6 @@ import * as components from "./components/index.examples.js"; import * as pages from "./pages/index.stories.js"; import { ComponentChildren, VNode, h as create } from "preact"; -import { BackendStateProviderTesting } from "./context/backend.js"; import { BankCoreApiProviderTesting } from "./context/config.js"; setupI18n("en", { en: {} }); @@ -57,15 +56,6 @@ function DefaultTestingContext({ }: { children: ComponentChildren; }): VNode { - const ctx1 = create(BackendStateProviderTesting, { - children, - state: { - status: "loggedIn", - username: "test", - token: "pwd" as AccessToken, - isUserAdministrator: false, - }, - }); const cfg: TalerCorebankApi.Config = { name: "libeufin-bank", allow_deletions: true, @@ -86,7 +76,7 @@ function DefaultTestingContext({ version: "1:0:0", }; const ctx2 = create(BankCoreApiProviderTesting, { - children: ctx1, + children: [], state: cfg, url: "http://localhost", }); -- cgit v1.2.3