diff options
author | Sebastian <sebasjm@gmail.com> | 2023-02-17 16:23:37 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-02-17 16:23:49 -0300 |
commit | 9697e953f56dc37208c2852d686d1854256f71ef (patch) | |
tree | fbbe6e5934c1a8dd438da76d37b719372811b542 | |
parent | 8b83f729d7394837a3be231bbeeea44f6a01e9a1 (diff) |
cashout for business accounts
18 files changed, 1117 insertions, 98 deletions
diff --git a/packages/demobank-ui/src/components/Cashouts/index.ts b/packages/demobank-ui/src/components/Cashouts/index.ts index 1410267be..3ca7d9026 100644 --- a/packages/demobank-ui/src/components/Cashouts/index.ts +++ b/packages/demobank-ui/src/components/Cashouts/index.ts @@ -23,7 +23,8 @@ import { useComponentState } from "./state.js"; import { LoadingUriView, ReadyView } from "./views.js"; export interface Props { - empty?: boolean; + account: string; + onSelected: (id: string) => void; } export type State = State.Loading | State.LoadingUriError | State.Ready; @@ -45,7 +46,8 @@ export namespace State { export interface Ready extends BaseInfo { status: "ready"; error: undefined; - cashouts: SandboxBackend.Circuit.CashoutStatusResponse[]; + cashouts: SandboxBackend.Circuit.CashoutStatusResponseWithId[]; + onSelected: (id: string) => void; } } diff --git a/packages/demobank-ui/src/components/Cashouts/state.ts b/packages/demobank-ui/src/components/Cashouts/state.ts index 178a1e815..124f9bf9c 100644 --- a/packages/demobank-ui/src/components/Cashouts/state.ts +++ b/packages/demobank-ui/src/components/Cashouts/state.ts @@ -14,12 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util"; import { useCashouts } from "../../hooks/circuit.js"; -import { Props, State, Transaction } from "./index.js"; +import { Props, State } from "./index.js"; -export function useComponentState({ empty }: Props): State { - const result = useCashouts(); +export function useComponentState({ account, onSelected }: Props): State { + const result = useCashouts(account); if (result.loading) { return { status: "loading", @@ -37,5 +36,6 @@ export function useComponentState({ empty }: Props): State { status: "ready", error: undefined, cashouts: result.data, + onSelected, }; } diff --git a/packages/demobank-ui/src/components/Cashouts/test.ts b/packages/demobank-ui/src/components/Cashouts/test.ts index 78450ed2d..e91116378 100644 --- a/packages/demobank-ui/src/components/Cashouts/test.ts +++ b/packages/demobank-ui/src/components/Cashouts/test.ts @@ -31,7 +31,7 @@ describe("Transaction states", () => { const env = new SwrMockEnvironment(); const props: Props = { - + account: "123", }; env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, { @@ -115,6 +115,7 @@ describe("Transaction states", () => { const env = new SwrMockEnvironment(); const props: Props = { + account: "123", }; env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {}); @@ -147,6 +148,7 @@ describe("Transaction states", () => { const env = new SwrMockEnvironment(false); const props: Props = { + account: "123", }; env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {}); diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx index 16ae8a58f..af1d9ed2c 100644 --- a/packages/demobank-ui/src/components/Cashouts/views.tsx +++ b/packages/demobank-ui/src/components/Cashouts/views.tsx @@ -30,8 +30,15 @@ export function LoadingUriView({ error }: State.LoadingUriError): VNode { ); } -export function ReadyView({ cashouts }: State.Ready): VNode { +export function ReadyView({ cashouts, onSelected }: State.Ready): VNode { const { i18n } = useTranslationContext(); + if (!cashouts.length) { + return ( + <div> + <i18n.Translate>No cashout at the moment</i18n.Translate> + </div> + ); + } return ( <div class="results"> <table class="pure-table pure-table-striped"> @@ -39,6 +46,8 @@ export function ReadyView({ cashouts }: State.Ready): VNode { <tr> <th>{i18n.str`Created`}</th> <th>{i18n.str`Confirmed`}</th> + <th>{i18n.str`Total debit`}</th> + <th>{i18n.str`Total credit`}</th> <th>{i18n.str`Status`}</th> <th>{i18n.str`Subject`}</th> </tr> @@ -56,7 +65,17 @@ export function ReadyView({ cashouts }: State.Ready): VNode { <td>{Amounts.stringifyValue(item.amount_debit)}</td> <td>{Amounts.stringifyValue(item.amount_credit)}</td> <td>{item.status}</td> - <td>{item.subject}</td> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onSelected(item.id); + }} + > + {item.subject} + </a> + </td> </tr> ); })} diff --git a/packages/demobank-ui/src/declaration.d.ts b/packages/demobank-ui/src/declaration.d.ts index c46fcc9ed..e3160d9ae 100644 --- a/packages/demobank-ui/src/declaration.d.ts +++ b/packages/demobank-ui/src/declaration.d.ts @@ -322,11 +322,6 @@ namespace SandboxBackend { // where to send cashouts. cashout_address: string; } - enum TanChannel { - SMS = "sms", - EMAIL = "email", - FILE = "file", - } interface CashoutRequest { // Optional subject to associate to the // cashout operation. This data will appear @@ -369,6 +364,7 @@ namespace SandboxBackend { // Contains ratios and fees related to buying // and selling the circuit currency. ratios_and_fees: RatiosAndFees; + currency: string; } interface RatiosAndFees { // Exchange rate to buy the circuit currency from fiat. @@ -400,14 +396,6 @@ namespace SandboxBackend { // Missing or null, when the operation wasn't confirmed yet. confirmation_time?: number | null; // milliseconds since the Unix epoch } - enum CashoutStatus { - // The payment was initiated after a valid - // TAN was received by the bank. - CONFIRMED = "confirmed", - - // The cashout was created and now waits - // for the TAN by the author. - PENDING = "pending", - } + type CashoutStatusResponseWithId = CashoutStatusResponse & { id: string }; } } diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts index 0379de27d..6046146ba 100644 --- a/packages/demobank-ui/src/hooks/access.ts +++ b/packages/demobank-ui/src/hooks/access.ts @@ -18,7 +18,7 @@ import { HttpResponse, HttpResponseOk, HttpResponsePaginated, - RequestError + RequestError, } from "@gnu-taler/web-util/lib/index.browser"; import { useEffect, useState } from "preact/hooks"; import { useBackendContext } from "../context/backend.js"; @@ -26,12 +26,12 @@ import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js"; import { useAuthenticatedBackend, useMatchMutate, - usePublicBackend + usePublicBackend, } from "./backend.js"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import _useSWR, { SWRHook } from 'swr'; -const useSWR = _useSWR as unknown as SWRHook +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; export function useAccessAPI(): AccessAPI { const mutateAll = useMatchMutate(); diff --git a/packages/demobank-ui/src/hooks/backend.ts b/packages/demobank-ui/src/hooks/backend.ts index e87bdd5fe..e0649f5fe 100644 --- a/packages/demobank-ui/src/hooks/backend.ts +++ b/packages/demobank-ui/src/hooks/backend.ts @@ -118,6 +118,7 @@ interface useBackendType { sandboxAccountsFetcher: <T>( args: [string, number, number, string], ) => Promise<HttpResponseOk<T>>; + sandboxCashoutFetcher: <T>(endpoint: string[]) => Promise<HttpResponseOk<T>>; } export function usePublicBackend(): useBackendType { const { state } = useBackendContext(); @@ -176,12 +177,21 @@ export function usePublicBackend(): useBackendType { }, [baseUrl], ); + const sandboxCashoutFetcher = useCallback( + function fetcherImpl<T>([endpoint, account]: string[]): Promise< + HttpResponseOk<T> + > { + return requestHandler<T>(baseUrl, endpoint); + }, + [baseUrl], + ); return { request, fetcher, paginatedFetcher, multiFetcher, sandboxAccountsFetcher, + sandboxCashoutFetcher, }; } @@ -225,7 +235,6 @@ export function useAuthenticatedBackend(): useBackendType { function multiFetcherImpl<T>([endpoints]: string[][]): Promise< HttpResponseOk<T>[] > { - console.log("list size", endpoints.length, endpoints); return Promise.all( endpoints.map((endpoint) => requestHandler<T>(baseUrl, endpoint, { basicAuth: creds }), @@ -249,12 +258,24 @@ export function useAuthenticatedBackend(): useBackendType { [baseUrl], ); + const sandboxCashoutFetcher = useCallback( + function fetcherImpl<T>([endpoint, account]: string[]): Promise< + HttpResponseOk<T> + > { + return requestHandler<T>(baseUrl, endpoint, { + basicAuth: creds, + params: { account }, + }); + }, + [baseUrl, creds], + ); return { request, fetcher, paginatedFetcher, multiFetcher, sandboxAccountsFetcher, + sandboxCashoutFetcher, }; } diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index 21e5ce852..c7170309b 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -27,8 +27,8 @@ import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js"; import { useAuthenticatedBackend, useMatchMutate } from "./backend.js"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import _useSWR, { SWRHook } from 'swr'; -const useSWR = _useSWR as unknown as SWRHook +import _useSWR, { SWRHook } from "swr"; +const useSWR = _useSWR as unknown as SWRHook; export function useAdminAccountAPI(): AdminAccountAPI { const { request } = useAuthenticatedBackend(); @@ -118,7 +118,54 @@ export function useCircuitAccountAPI(): CircuitAccountAPI { return res; }; - return { updateAccount, changePassword }; + const createCashout = async ( + data: SandboxBackend.Circuit.CashoutRequest, + ): Promise<HttpResponseOk<SandboxBackend.Circuit.CashoutPending>> => { + const res = await request<SandboxBackend.Circuit.CashoutPending>( + `circuit-api/cashouts`, + { + method: "POST", + data, + contentType: "json", + }, + ); + return res; + }; + + const confirmCashout = async ( + cashoutId: string, + data: SandboxBackend.Circuit.CashoutConfirm, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>( + `circuit-api/cashouts/${cashoutId}/confirm`, + { + method: "POST", + data, + contentType: "json", + }, + ); + await mutateAll(/.*circuit-api\/cashout.*/); + return res; + }; + + const abortCashout = async ( + cashoutId: string, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`circuit-api/cashouts/${cashoutId}/abort`, { + method: "POST", + contentType: "json", + }); + await mutateAll(/.*circuit-api\/cashout.*/); + return res; + }; + + return { + updateAccount, + changePassword, + createCashout, + confirmCashout, + abortCashout, + }; } export interface AdminAccountAPI { @@ -144,11 +191,14 @@ export interface CircuitAccountAPI { changePassword: ( data: SandboxBackend.Circuit.AccountPasswordChange, ) => Promise<HttpResponseOk<void>>; -} - -export interface InstanceTemplateFilter { - //FIXME: add filter to the template list - position?: string; + createCashout: ( + data: SandboxBackend.Circuit.CashoutRequest, + ) => Promise<HttpResponseOk<SandboxBackend.Circuit.CashoutPending>>; + confirmCashout: ( + id: string, + data: SandboxBackend.Circuit.CashoutConfirm, + ) => Promise<HttpResponseOk<void>>; + abortCashout: (id: string) => Promise<HttpResponseOk<void>>; } async function getBusinessStatus( @@ -217,6 +267,35 @@ export function useBusinessAccountDetails( return { loading: true }; } +export function useRatiosAndFeeConfig(): HttpResponse< + SandboxBackend.Circuit.Config, + SandboxBackend.SandboxError +> { + const { fetcher } = useAuthenticatedBackend(); + + const { data, error } = useSWR< + HttpResponseOk<SandboxBackend.Circuit.Config>, + RequestError<SandboxBackend.SandboxError> + >([`circuit-api/config`], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (data) { + data.data.currency = "FIAT"; + } + if (data) return data; + if (error) return error.info; + return { loading: true }; +} + interface PaginationFilter { account?: string; page?: number; @@ -299,17 +378,18 @@ export function useBusinessAccounts( return { loading: true }; } -export function useCashouts(): HttpResponse< - (SandboxBackend.Circuit.CashoutStatusResponse & WithId)[], +export function useCashouts( + account: string, +): HttpResponse< + SandboxBackend.Circuit.CashoutStatusResponseWithId[], SandboxBackend.SandboxError > { - const { fetcher, multiFetcher } = useAuthenticatedBackend(); - + const { sandboxCashoutFetcher, multiFetcher } = useAuthenticatedBackend(); const { data: list, error: listError } = useSWR< HttpResponseOk<SandboxBackend.Circuit.Cashouts>, RequestError<SandboxBackend.SandboxError> - >([`circuit-api/cashouts`], fetcher, { + >([`circuit-api/cashouts`, account], sandboxCashoutFetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -317,7 +397,7 @@ export function useCashouts(): HttpResponse< refreshWhenOffline: false, }); - const paths = (list?.data.cashouts || []).map( + const paths = ((list?.data && list?.data.cashouts) || []).map( (cashoutId) => `circuit-api/cashouts/${cashoutId}`, ); const { data: cashouts, error: productError } = useSWR< @@ -346,3 +426,31 @@ export function useCashouts(): HttpResponse< } return { loading: true }; } + +export function useCashoutDetails( + id: string, +): HttpResponse< + SandboxBackend.Circuit.CashoutStatusResponse, + SandboxBackend.SandboxError +> { + const { fetcher } = useAuthenticatedBackend(); + + const { data, error } = useSWR< + HttpResponseOk<SandboxBackend.Circuit.CashoutStatusResponse>, + RequestError<SandboxBackend.SandboxError> + >([`circuit-api/cashouts/${id}`], 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.info; + return { loading: true }; +} diff --git a/packages/demobank-ui/src/pages/AccountPage.tsx b/packages/demobank-ui/src/pages/AccountPage.tsx index 370605871..ae0c2b1f8 100644 --- a/packages/demobank-ui/src/pages/AccountPage.tsx +++ b/packages/demobank-ui/src/pages/AccountPage.tsx @@ -112,40 +112,3 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode { </Fragment> ); } - -// function Moves({ account }: { account: string }): VNode { -// const [tab, setTab] = useState<"transactions" | "cashouts">("transactions"); -// const { i18n } = useTranslationContext(); -// return ( -// <article> -// <div class="payments"> -// <div class="tab"> -// <button -// class={tab === "transactions" ? "tablinks active" : "tablinks"} -// onClick={(): void => { -// setTab("transactions"); -// }} -// > -// {i18n.str`Transactions`} -// </button> -// <button -// class={tab === "cashouts" ? "tablinks active" : "tablinks"} -// onClick={(): void => { -// setTab("cashouts"); -// }} -// > -// {i18n.str`Cashouts`} -// </button> -// </div> -// {tab === "transactions" && ( -// )} -// {tab === "cashouts" && ( -// <div class="active"> -// <h3>{i18n.str`Latest cashouts`}</h3> -// <Cashouts account={account} /> -// </div> -// )} -// </div> -// </article> -// ); -// } diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx index f8efddd80..d15ac02c4 100644 --- a/packages/demobank-ui/src/pages/AdminPage.tsx +++ b/packages/demobank-ui/src/pages/AdminPage.tsx @@ -14,7 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { parsePaytoUri, TranslatedString } from "@gnu-taler/taler-util"; +import { + Amounts, + parsePaytoUri, + TranslatedString, +} from "@gnu-taler/taler-util"; import { HttpResponsePaginated, RequestError, @@ -22,7 +26,9 @@ import { } from "@gnu-taler/web-util/lib/index.browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { Cashouts } from "../components/Cashouts/index.js"; import { ErrorMessage, usePageContext } from "../context/pageState.js"; +import { useAccountDetails } from "../hooks/access.js"; import { useBusinessAccountDetails, useBusinessAccounts, @@ -60,7 +66,10 @@ interface Props { export function AdminPage({ onLoadNotOk }: Props): VNode { const [account, setAccount] = useState<string | undefined>(); const [showDetails, setShowDetails] = useState<string | undefined>(); + const [showCashouts, setShowCashouts] = useState<string | undefined>(); const [updatePassword, setUpdatePassword] = useState<string | undefined>(); + const [removeAccount, setRemoveAccount] = useState<string | undefined>(); + const [createAccount, setCreateAccount] = useState(false); const { pageStateSetter } = usePageContext(); @@ -81,6 +90,23 @@ export function AdminPage({ onLoadNotOk }: Props): VNode { const { customers } = result.data; + if (showCashouts) { + return ( + <div> + <Cashouts account={showCashouts} /> + <input + class="pure-button" + type="submit" + value={i18n.str`Close`} + onClick={async (e) => { + e.preventDefault(); + setShowCashouts(undefined); + }} + /> + </div> + ); + } + if (showDetails) { return ( <ShowAccountDetails @@ -100,6 +126,21 @@ export function AdminPage({ onLoadNotOk }: Props): VNode { /> ); } + if (removeAccount) { + return ( + <RemoveAccount + account={removeAccount} + onLoadNotOk={onLoadNotOk} + onUpdateSuccess={() => { + showInfoMessage(i18n.str`Account removed`); + setRemoveAccount(undefined); + }} + onClear={() => { + setRemoveAccount(undefined); + }} + /> + ); + } if (updatePassword) { return ( <UpdateAccountPassword @@ -164,6 +205,7 @@ export function AdminPage({ onLoadNotOk }: Props): VNode { <th>{i18n.str`Username`}</th> <th>{i18n.str`Name`}</th> <th></th> + <th></th> </tr> </thead> <tbody> @@ -193,6 +235,28 @@ export function AdminPage({ onLoadNotOk }: Props): VNode { change password </a> </td> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setShowCashouts(item.username); + }} + > + cashouts + </a> + </td> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setRemoveAccount(item.username); + }} + > + remove + </a> + </td> </tr> ); })} @@ -536,6 +600,90 @@ export function ShowAccountDetails({ ); } +function RemoveAccount({ + account, + onClear, + onUpdateSuccess, + onLoadNotOk, +}: { + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; + onClear: () => void; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + const { deleteAccount } = useAdminAccountAPI(); + const [error, saveError] = useState<ErrorMessage | undefined>(); + + if (result.clientError) { + if (result.isNotfound) return <div>account not found</div>; + } + if (!result.ok) { + return onLoadNotOk(result); + } + + const balance = Amounts.parse(result.data.balance.amount); + if (!balance) { + return <div>there was an error reading the balance</div>; + } + const isBalanceEmpty = Amounts.isZero(balance); + return ( + <div> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate>Remove account: {account}</i18n.Translate> + </h1> + </div> + {!isBalanceEmpty && ( + <ErrorBanner + error={{ + title: i18n.str`Can't delete the account`, + description: i18n.str`Balance is not empty`, + }} + /> + )} + {error && ( + <ErrorBanner error={error} onClear={() => saveError(undefined)} /> + )} + + <p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div> + <input + class="pure-button" + type="submit" + value={i18n.str`Cancel`} + onClick={async (e) => { + e.preventDefault(); + onClear(); + }} + /> + </div> + <div> + <input + id="select-exchange" + class="pure-button pure-button-primary content" + disabled={!isBalanceEmpty} + type="submit" + value={i18n.str`Confirm`} + onClick={async (e) => { + e.preventDefault(); + try { + const r = await deleteAccount(account); + onUpdateSuccess(); + } catch (error) { + handleError(error, saveError, i18n); + } + }} + /> + </div> + </div> + </p> + </div> + ); +} + /** * Create valid account object to update or create * Take template as initial values for the form diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx index 0fb75b87b..fe7571c38 100644 --- a/packages/demobank-ui/src/pages/BankFrame.tsx +++ b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -128,7 +128,7 @@ export function BankFrame({ <StatusBanner /> {backend.state.status === "loggedIn" ? ( <div class="top-right"> - {goToBusinessAccount ? ( + {goToBusinessAccount && !backend.state.isUserAdministrator ? ( <MaybeBusinessButton account={backend.state.username} onClick={goToBusinessAccount} @@ -187,7 +187,7 @@ export function ErrorBanner({ onClear, }: { error: ErrorMessage; - onClear: () => void; + onClear?: () => void; }): VNode | null { return ( <div class="informational informational-fail" style={{ marginTop: 8 }}> @@ -196,15 +196,17 @@ export function ErrorBanner({ <b>{error.title}</b> </p> <div> - <input - type="button" - class="pure-button" - value="Clear" - onClick={(e) => { - e.preventDefault(); - onClear(); - }} - /> + {onClear && ( + <input + type="button" + class="pure-button" + value="Clear" + onClick={(e) => { + e.preventDefault(); + onClear(); + }} + /> + )} </div> </div> <p>{error.description}</p> diff --git a/packages/demobank-ui/src/pages/BusinessAccount.tsx b/packages/demobank-ui/src/pages/BusinessAccount.tsx index d845c2fa0..6651ef0f7 100644 --- a/packages/demobank-ui/src/pages/BusinessAccount.tsx +++ b/packages/demobank-ui/src/pages/BusinessAccount.tsx @@ -13,18 +13,34 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { TranslatedString } from "@gnu-taler/taler-util"; +import { + AmountJson, + Amounts, + HttpStatusCode, + TranslatedString, +} from "@gnu-taler/taler-util"; import { HttpResponsePaginated, + RequestError, useTranslationContext, } from "@gnu-taler/web-util/lib/index.browser"; -import { h, VNode } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { Cashouts } from "../components/Cashouts/index.js"; import { useBackendContext } from "../context/backend.js"; -import { usePageContext } from "../context/pageState.js"; +import { ErrorMessage, usePageContext } from "../context/pageState.js"; +import { useAccountDetails } from "../hooks/access.js"; +import { + useCashoutDetails, + useCashouts, + useCircuitAccountAPI, + useRatiosAndFeeConfig, +} from "../hooks/circuit.js"; +import { CashoutStatus, TanChannel, undefinedIfEmpty } from "../utils.js"; import { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js"; +import { ErrorBanner } from "./BankFrame.js"; import { LoginForm } from "./LoginForm.js"; +import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; interface Props { onClose: () => void; @@ -40,6 +56,8 @@ export function BusinessAccount({ const { pageStateSetter } = usePageContext(); const backend = useBackendContext(); const [updatePassword, setUpdatePassword] = useState(false); + const [newCashout, setNewcashout] = useState(false); + const [showCashout, setShowCashout] = useState<string | undefined>(); function showInfoMessage(info: TranslatedString): void { pageStateSetter((prev) => ({ ...prev, @@ -51,6 +69,32 @@ export function BusinessAccount({ return <LoginForm onRegister={onRegister} />; } + if (newCashout) { + return ( + <CreateCashout + account={backend.state.username} + onLoadNotOk={onLoadNotOk} + onCancel={() => { + setNewcashout(false); + }} + onComplete={(id) => { + setNewcashout(false); + setShowCashout(id); + }} + /> + ); + } + if (showCashout) { + return ( + <ShowCashout + id={showCashout} + onLoadNotOk={onLoadNotOk} + onCancel={() => { + setShowCashout(undefined); + }} + /> + ); + } if (updatePassword) { return ( <UpdateAccountPassword @@ -82,9 +126,634 @@ export function BusinessAccount({ <section style={{ marginTop: "2em" }}> <div class="active"> <h3>{i18n.str`Latest cashouts`}</h3> - <Cashouts /> + <Cashouts + account={backend.state.username} + onSelected={(id) => { + setShowCashout(id); + }} + /> + </div> + <br /> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div /> + <input + class="pure-button pure-button-primary content" + type="submit" + value={i18n.str`New cashout`} + onClick={async (e) => { + e.preventDefault(); + setNewcashout(true); + }} + /> </div> </section> </div> ); } + +interface PropsCashout { + account: string; + onComplete: (id: string) => void; + onCancel: () => void; + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; +} + +type FormType = { + isDebit: boolean; + amount: string; + subject: string; + channel: TanChannel; +}; +type ErrorFrom<T> = { + [P in keyof T]+?: string; +}; + +function CreateCashout({ + account, + onComplete, + onCancel, + onLoadNotOk, +}: PropsCashout): VNode { + const { i18n } = useTranslationContext(); + const ratiosResult = useRatiosAndFeeConfig(); + const result = useAccountDetails(account); + const [error, saveError] = useState<ErrorMessage | undefined>(); + + const [form, setForm] = useState<Partial<FormType>>({}); + + const { createCashout } = useCircuitAccountAPI(); + if (!result.ok) return onLoadNotOk(result); + if (!ratiosResult.ok) return onLoadNotOk(ratiosResult); + const config = ratiosResult.data; + const maybeBalance = Amounts.parse(result.data.balance.amount); + if (!maybeBalance) return <div>error</div>; + const balance = maybeBalance; + const zero = Amounts.zeroOfCurrency(balance.currency); + + const sellRate = config.ratios_and_fees.sell_at_ratio; + const sellFee = !config.ratios_and_fees.sell_out_fee + ? zero + : Amounts.fromFloat(config.ratios_and_fees.sell_out_fee, balance.currency); + + if (!sellRate || sellRate < 0) return <div>error rate</div>; + + function truncate(a: AmountJson): AmountJson { + const str = Amounts.stringify(a); + const idx = str.indexOf("."); + if (idx === -1) return a; + const truncated = str.substring(0, idx + 3); + console.log(str, truncated); + return Amounts.parseOrThrow(truncated); + } + + const amount = Amounts.parse(`${balance.currency}:${form.amount}`); + const amount_debit = !amount + ? zero + : form.isDebit + ? amount + : truncate(Amounts.divide(Amounts.add(amount, sellFee).amount, sellRate)); + const credit_before_fee = !amount + ? zero + : form.isDebit + ? truncate(Amounts.divide(amount, 1 / sellRate)) + : Amounts.add(amount, sellFee).amount; + + const __amount_credit = Amounts.sub(credit_before_fee, sellFee).amount; + const amount_credit = Amounts.parseOrThrow( + `${config.currency}:${Amounts.stringifyValue(__amount_credit)}`, + ); + + const balanceAfter = Amounts.sub(balance, amount_debit).amount; + + function updateForm(newForm: typeof form): void { + setForm(newForm); + } + const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({ + amount: !form.amount + ? i18n.str`required` + : !amount + ? i18n.str`could not be parsed` + : Amounts.cmp(balance, amount_debit) === -1 + ? i18n.str`balance is not enough` + : Amounts.cmp(credit_before_fee, sellFee) === -1 + ? i18n.str`amount is not enough` + : Amounts.isZero(amount_credit) + ? i18n.str`amount is not enough` + : undefined, + channel: !form.channel ? i18n.str`required` : undefined, + }); + + // setErrors(validationResult); + + return ( + <div> + {error && ( + <ErrorBanner error={error} onClear={() => saveError(undefined)} /> + )} + <h1>New cashout</h1> + <form class="pure-form"> + <fieldset> + <label>{i18n.str`Subject`}</label> + <input + value={form.subject ?? ""} + onChange={(e) => { + form.subject = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.subject} + isDirty={form.subject !== undefined} + /> + </fieldset> + <fieldset> + <label> + {form.isDebit + ? i18n.str`Amount to send` + : i18n.str`Amount to receive`} + </label> + <div style={{ width: "max-content" }}> + <input + type="text" + readonly + class="currency-indicator" + size={balance.currency.length} + maxLength={balance.currency.length} + tabIndex={-1} + value={balance.currency} + /> + + <input + type="number" + // ref={ref} + id="withdraw-amount" + name="withdraw-amount" + value={form.amount ?? ""} + onChange={(e): void => { + form.amount = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + + <label class="toggle"> + <input + class="toggle-checkbox" + type="checkbox" + onChange={(e): void => { + form.isDebit = !form.isDebit; + updateForm(structuredClone(form)); + }} + /> + <div class="toggle-switch"></div> + </label> + </div> + <ShowInputErrorLabel + message={errors?.amount} + isDirty={form.amount !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Conversion rate`}</label> + <input value={sellRate} disabled /> + </fieldset> + <fieldset> + <label>{i18n.str`Balance now`}</label> + <div style={{ width: "max-content" }}> + <input + type="text" + readonly + class="currency-indicator" + size={balance.currency.length} + maxLength={balance.currency.length} + tabIndex={-1} + value={balance.currency} + /> + + <input + type="number" + id="withdraw-amount" + disabled + name="withdraw-amount" + value={Amounts.stringifyValue(balance)} + /> + </div> + </fieldset> + <fieldset> + <label + style={{ fontWeight: "bold", color: "red" }} + >{i18n.str`Total cost`}</label> + <div style={{ width: "max-content" }}> + <input + type="text" + readonly + class="currency-indicator" + size={balance.currency.length} + maxLength={balance.currency.length} + tabIndex={-1} + value={balance.currency} + /> + + <input + type="number" + // ref={ref} + id="withdraw-amount" + disabled + name="withdraw-amount" + value={amount_debit ? Amounts.stringifyValue(amount_debit) : ""} + /> + </div> + </fieldset> + <fieldset> + <label>{i18n.str`Balance after`}</label> + <div style={{ width: "max-content" }}> + <input + type="text" + readonly + class="currency-indicator" + size={balance.currency.length} + maxLength={balance.currency.length} + tabIndex={-1} + value={balance.currency} + /> + + <input + type="number" + // ref={ref} + id="withdraw-amount" + disabled + name="withdraw-amount" + value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""} + /> + </div> + </fieldset>{" "} + {Amounts.isZero(sellFee) ? undefined : ( + <Fragment> + <fieldset> + <label>{i18n.str`Transfer before fee`}</label> + <div style={{ width: "max-content" }}> + <input + type="text" + readonly + class="currency-indicator" + size={balance.currency.length} + maxLength={balance.currency.length} + tabIndex={-1} + value={balance.currency} + /> + + <input + // type="number" + style={{ color: "black" }} + disabled + value={Amounts.stringifyValue(credit_before_fee)} + /> + </div> + </fieldset> + + <fieldset> + <label>{i18n.str`Cashout fee`}</label> + <div style={{ width: "max-content" }}> + <input + type="text" + readonly + class="currency-indicator" + size={balance.currency.length} + maxLength={balance.currency.length} + tabIndex={-1} + value={balance.currency} + /> + + <input + // type="number" + style={{ color: "black" }} + disabled + value={Amounts.stringifyValue(sellFee)} + /> + </div> + </fieldset> + </Fragment> + )} + <fieldset> + <label + style={{ fontWeight: "bold", color: "green" }} + >{i18n.str`Total cashout transfer`}</label> + <div style={{ width: "max-content" }}> + <input + type="text" + readonly + class="currency-indicator" + size={balance.currency.length} + maxLength={balance.currency.length} + tabIndex={-1} + value={balance.currency} + /> + + <input + type="number" + // ref={ref} + id="withdraw-amount" + disabled + name="withdraw-amount" + value={amount_credit ? Amounts.stringifyValue(amount_credit) : ""} + /> + </div> + </fieldset> + <fieldset> + <label>{i18n.str`Confirmation channel`}</label> + + <div class="channel"> + <input + class={ + "pure-button content " + + (form.channel === TanChannel.EMAIL + ? "pure-button-primary" + : "pure-button-secondary") + } + type="submit" + value={i18n.str`Email`} + onClick={async (e) => { + e.preventDefault(); + form.channel = TanChannel.EMAIL; + updateForm(structuredClone(form)); + }} + /> + <input + class={ + "pure-button content " + + (form.channel === TanChannel.SMS + ? "pure-button-primary" + : "pure-button-secondary") + } + type="submit" + value={i18n.str`SMS`} + onClick={async (e) => { + e.preventDefault(); + form.channel = TanChannel.SMS; + updateForm(structuredClone(form)); + }} + /> + <input + class={ + "pure-button content " + + (form.channel === TanChannel.FILE + ? "pure-button-primary" + : "pure-button-secondary") + } + type="submit" + value={i18n.str`FILE`} + onClick={async (e) => { + e.preventDefault(); + form.channel = TanChannel.FILE; + updateForm(structuredClone(form)); + }} + /> + </div> + <ShowInputErrorLabel + message={errors?.channel} + isDirty={form.channel !== undefined} + /> + </fieldset> + <br /> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <button + class="pure-button pure-button-secondary btn-cancel" + onClick={(e) => { + e.preventDefault(); + onCancel(); + }} + > + {i18n.str`Cancel`} + </button> + + <button + class="pure-button pure-button-primary btn-register" + type="submit" + disabled={!!errors} + onClick={async (e) => { + e.preventDefault(); + + if (errors) return; + try { + const res = await createCashout({ + amount_credit: Amounts.stringify(amount_credit), + amount_debit: Amounts.stringify(amount_debit), + subject: form.subject, + tan_channel: form.channel, + }); + onComplete(res.data.uuid); + } catch (error) { + if (error instanceof RequestError) { + const errorData: SandboxBackend.SandboxError = + error.info.error; + if (error.info.status === HttpStatusCode.PreconditionFailed) { + saveError({ + title: i18n.str`The account does not have sufficient funds`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else if ( + error.info.status === HttpStatusCode.ServiceUnavailable + ) { + saveError({ + title: i18n.str`The bank does not support the TAN channel for this operation`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else if (error.info.status === HttpStatusCode.Conflict) { + saveError({ + title: i18n.str`No contact information for this channel`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else { + saveError({ + title: i18n.str`New cashout gave response error`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } + } else if (error instanceof Error) { + saveError({ + title: i18n.str`Cashout failed, please report`, + description: error.message, + }); + } + } + }} + > + {i18n.str`Create`} + </button> + </div> + </form> + </div> + ); +} + +interface ShowCashoutProps { + id: string; + onCancel: () => void; + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; +} +function ShowCashout({ id, onCancel, onLoadNotOk }: ShowCashoutProps): VNode { + const { i18n } = useTranslationContext(); + const result = useCashoutDetails(id); + const { abortCashout, confirmCashout } = useCircuitAccountAPI(); + const [code, setCode] = useState<string | undefined>(undefined); + const [error, saveError] = useState<ErrorMessage | undefined>(); + if (!result.ok) return onLoadNotOk(result); + const errors = undefinedIfEmpty({ + code: !code ? i18n.str`required` : undefined, + }); + const isPending = String(result.data.status).toUpperCase() === "PENDING"; + return ( + <div> + <h1>Cashout details {id}</h1> + {error && ( + <ErrorBanner error={error} onClear={() => saveError(undefined)} /> + )} + <form class="pure-form"> + <fieldset> + <label> + <i18n.Translate>Subject</i18n.Translate> + </label> + <input readOnly value={result.data.subject} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Created</i18n.Translate> + </label> + <input readOnly value={result.data.creation_time ?? ""} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Confirmed</i18n.Translate> + </label> + <input readOnly value={result.data.confirmation_time ?? ""} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Debited</i18n.Translate> + </label> + <input readOnly value={result.data.amount_debit} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Credit</i18n.Translate> + </label> + <input readOnly value={result.data.amount_credit} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Status</i18n.Translate> + </label> + <input readOnly value={result.data.status} /> + </fieldset> + {isPending ? ( + <fieldset> + <label> + <i18n.Translate>Code</i18n.Translate> + </label> + <input + value={code ?? ""} + onChange={(e) => { + setCode(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.code} + isDirty={code !== undefined} + /> + </fieldset> + ) : undefined} + </form> + <br /> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <button + class="pure-button pure-button-secondary btn-cancel" + onClick={(e) => { + e.preventDefault(); + onCancel(); + }} + > + {i18n.str`Back`} + </button> + {isPending ? ( + <div> + <button + type="submit" + class="pure-button pure-button-primary button-error" + onClick={async (e) => { + e.preventDefault(); + try { + const rest = await abortCashout(id); + onCancel(); + } catch (error) { + if (error instanceof RequestError) { + const errorData: SandboxBackend.SandboxError = + error.info.error; + if ( + error.info.status === HttpStatusCode.PreconditionFailed + ) { + saveError({ + title: i18n.str`Cashout was already aborted`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else { + saveError({ + title: i18n.str`Aborting cashout gave response error`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } + } else if (error instanceof Error) { + saveError({ + title: i18n.str`Aborting failed, please report`, + description: error.message, + }); + } + } + }} + > + {i18n.str`Abort`} + </button> + + <button + type="submit" + disabled={!code} + class="pure-button pure-button-primary " + onClick={async (e) => { + e.preventDefault(); + try { + if (!code) return; + const rest = await confirmCashout(id, { + tan: code, + }); + } catch (error) { + if (error instanceof RequestError) { + const errorData: SandboxBackend.SandboxError = + error.info.error; + saveError({ + title: i18n.str`Confirmation of cashout gave response error`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else if (error instanceof Error) { + saveError({ + title: i18n.str`Confirmation failed, please report`, + description: error.message, + }); + } + } + }} + > + {i18n.str`Confirm`} + </button> + </div> + ) : ( + <div /> + )} + </div> + </div> + ); +} diff --git a/packages/demobank-ui/src/pages/HomePage.tsx b/packages/demobank-ui/src/pages/HomePage.tsx index 76eb8d515..5af195f48 100644 --- a/packages/demobank-ui/src/pages/HomePage.tsx +++ b/packages/demobank-ui/src/pages/HomePage.tsx @@ -50,7 +50,6 @@ export function HomePage({ onRegister }: { onRegister: () => void }): VNode { } function saveErrorAndLogout(error: PageStateType["error"]): void { - console.log("rrot", error); saveError(error); backend.logOut(); } @@ -124,7 +123,6 @@ function handleNotOkResult( return function handleNotOkResult2<T, E>( result: HttpResponsePaginated<T, E>, ): VNode { - console.log("qweqwe", JSON.stringify(result, undefined, 2)); if (result.clientError && result.isUnauthorized) { onErrorHandler({ title: i18n.str`Wrong credentials for "${account}"`, diff --git a/packages/demobank-ui/src/pages/Routing.tsx b/packages/demobank-ui/src/pages/Routing.tsx index 55317f4ed..48f226574 100644 --- a/packages/demobank-ui/src/pages/Routing.tsx +++ b/packages/demobank-ui/src/pages/Routing.tsx @@ -19,7 +19,7 @@ import { useTranslationContext, } from "@gnu-taler/web-util/lib/index.browser"; import { createHashHistory } from "history"; -import { h, VNode, } from "preact"; +import { h, VNode } from "preact"; import { Router, route, Route } from "preact-router"; import { useEffect } from "preact/hooks"; import { Loading } from "../components/Loading.js"; diff --git a/packages/demobank-ui/src/scss/bank.scss b/packages/demobank-ui/src/scss/bank.scss index 2bd5f317a..16370227b 100644 --- a/packages/demobank-ui/src/scss/bank.scss +++ b/packages/demobank-ui/src/scss/bank.scss @@ -278,3 +278,35 @@ h1.nav { .pure-form > fieldset > input[disabled] { color: black !important; } +.pure-form > fieldset > div > input[disabled] { + color: black !important; +} + +.pure-form > fieldset > div.channel > div { + display: inline-block; + margin: 1em; + border: 1px black solid; + width: fit-content; + padding: 0.4em; + cursor: pointer; +} + +.button-success { + background: rgb(28, 184, 65); + /* this is a green */ +} + +.button-error { + background: rgb(202, 60, 60); + /* this is a maroon */ +} + +.button-warning { + background: rgb(223, 117, 20); + /* this is an orange */ +} + +.button-secondary { + background: rgb(66, 184, 221); + /* this is a light blue */ +} diff --git a/packages/demobank-ui/src/scss/main.scss b/packages/demobank-ui/src/scss/main.scss index b92260af0..b9a46718f 100644 --- a/packages/demobank-ui/src/scss/main.scss +++ b/packages/demobank-ui/src/scss/main.scss @@ -1,4 +1,5 @@ @use "pure"; @use "bank"; @use "demo"; +@use "toggle"; @use "colors-bank"; diff --git a/packages/demobank-ui/src/scss/toggle.scss b/packages/demobank-ui/src/scss/toggle.scss new file mode 100644 index 000000000..24636da2f --- /dev/null +++ b/packages/demobank-ui/src/scss/toggle.scss @@ -0,0 +1,51 @@ +$green: #56c080; + +.toggle { + cursor: pointer; + display: inline-block; +} +.toggle-switch { + display: inline-block; + background: #ccc; + border-radius: 16px; + width: 58px; + height: 32px; + position: relative; + vertical-align: middle; + transition: background 0.25s; + &:before, + &:after { + content: ""; + } + &:before { + display: block; + background: linear-gradient(to bottom, #fff 0%, #eee 100%); + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); + width: 24px; + height: 24px; + position: absolute; + top: 4px; + left: 4px; + transition: left 0.25s; + } + .toggle:hover &:before { + background: linear-gradient(to bottom, #fff 0%, #fff 100%); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5); + } + .toggle-checkbox:checked + & { + background: $green; + &:before { + left: 30px; + } + } +} +.toggle-checkbox { + position: absolute; + visibility: hidden; +} +.toggle-label { + margin-left: 5px; + position: relative; + top: 2px; +} diff --git a/packages/demobank-ui/src/utils.ts b/packages/demobank-ui/src/utils.ts index 642b3c68d..49b9ac276 100644 --- a/packages/demobank-ui/src/utils.ts +++ b/packages/demobank-ui/src/utils.ts @@ -59,6 +59,21 @@ export type WithIntermediate<Type extends object> = { : Type[prop] | undefined; }; +export enum TanChannel { + SMS = "sms", + EMAIL = "email", + FILE = "file", +} +export enum CashoutStatus { + // The payment was initiated after a valid + // TAN was received by the bank. + CONFIRMED = "confirmed", + + // The cashout was created and now waits + // for the TAN by the author. + PENDING = "pending", +} + // export function partialWithObjects<T extends object>(obj: T | undefined, () => complete): WithIntermediate<T> { // const root = obj === undefined ? {} : obj; // return Object.entries(root).([key, value]) => { |