cashout for business accounts

This commit is contained in:
Sebastian 2023-02-17 16:23:37 -03:00
parent 8b83f729d7
commit 9697e953f5
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
18 changed files with 1117 additions and 98 deletions

View File

@ -23,7 +23,8 @@ import { useComponentState } from "./state.js";
import { LoadingUriView, ReadyView } from "./views.js"; import { LoadingUriView, ReadyView } from "./views.js";
export interface Props { export interface Props {
empty?: boolean; account: string;
onSelected: (id: string) => void;
} }
export type State = State.Loading | State.LoadingUriError | State.Ready; export type State = State.Loading | State.LoadingUriError | State.Ready;
@ -45,7 +46,8 @@ export namespace State {
export interface Ready extends BaseInfo { export interface Ready extends BaseInfo {
status: "ready"; status: "ready";
error: undefined; error: undefined;
cashouts: SandboxBackend.Circuit.CashoutStatusResponse[]; cashouts: SandboxBackend.Circuit.CashoutStatusResponseWithId[];
onSelected: (id: string) => void;
} }
} }

View File

@ -14,12 +14,11 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 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 { useCashouts } from "../../hooks/circuit.js";
import { Props, State, Transaction } from "./index.js"; import { Props, State } from "./index.js";
export function useComponentState({ empty }: Props): State { export function useComponentState({ account, onSelected }: Props): State {
const result = useCashouts(); const result = useCashouts(account);
if (result.loading) { if (result.loading) {
return { return {
status: "loading", status: "loading",
@ -37,5 +36,6 @@ export function useComponentState({ empty }: Props): State {
status: "ready", status: "ready",
error: undefined, error: undefined,
cashouts: result.data, cashouts: result.data,
onSelected,
}; };
} }

View File

@ -31,7 +31,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment(); const env = new SwrMockEnvironment();
const props: Props = { const props: Props = {
account: "123",
}; };
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, { env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, {
@ -115,6 +115,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment(); const env = new SwrMockEnvironment();
const props: Props = { const props: Props = {
account: "123",
}; };
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {}); env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {});
@ -147,6 +148,7 @@ describe("Transaction states", () => {
const env = new SwrMockEnvironment(false); const env = new SwrMockEnvironment(false);
const props: Props = { const props: Props = {
account: "123",
}; };
env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {}); env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {});

View File

@ -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(); const { i18n } = useTranslationContext();
if (!cashouts.length) {
return (
<div>
<i18n.Translate>No cashout at the moment</i18n.Translate>
</div>
);
}
return ( return (
<div class="results"> <div class="results">
<table class="pure-table pure-table-striped"> <table class="pure-table pure-table-striped">
@ -39,6 +46,8 @@ export function ReadyView({ cashouts }: State.Ready): VNode {
<tr> <tr>
<th>{i18n.str`Created`}</th> <th>{i18n.str`Created`}</th>
<th>{i18n.str`Confirmed`}</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`Status`}</th>
<th>{i18n.str`Subject`}</th> <th>{i18n.str`Subject`}</th>
</tr> </tr>
@ -56,7 +65,17 @@ export function ReadyView({ cashouts }: State.Ready): VNode {
<td>{Amounts.stringifyValue(item.amount_debit)}</td> <td>{Amounts.stringifyValue(item.amount_debit)}</td>
<td>{Amounts.stringifyValue(item.amount_credit)}</td> <td>{Amounts.stringifyValue(item.amount_credit)}</td>
<td>{item.status}</td> <td>{item.status}</td>
<td>{item.subject}</td> <td>
<a
href="#"
onClick={(e) => {
e.preventDefault();
onSelected(item.id);
}}
>
{item.subject}
</a>
</td>
</tr> </tr>
); );
})} })}

View File

@ -322,11 +322,6 @@ namespace SandboxBackend {
// where to send cashouts. // where to send cashouts.
cashout_address: string; cashout_address: string;
} }
enum TanChannel {
SMS = "sms",
EMAIL = "email",
FILE = "file",
}
interface CashoutRequest { interface CashoutRequest {
// Optional subject to associate to the // Optional subject to associate to the
// cashout operation. This data will appear // cashout operation. This data will appear
@ -369,6 +364,7 @@ namespace SandboxBackend {
// Contains ratios and fees related to buying // Contains ratios and fees related to buying
// and selling the circuit currency. // and selling the circuit currency.
ratios_and_fees: RatiosAndFees; ratios_and_fees: RatiosAndFees;
currency: string;
} }
interface RatiosAndFees { interface RatiosAndFees {
// Exchange rate to buy the circuit currency from fiat. // 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. // Missing or null, when the operation wasn't confirmed yet.
confirmation_time?: number | null; // milliseconds since the Unix epoch confirmation_time?: number | null; // milliseconds since the Unix epoch
} }
enum CashoutStatus { type CashoutStatusResponseWithId = CashoutStatusResponse & { id: string };
// 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",
}
} }
} }

View File

@ -18,7 +18,7 @@ import {
HttpResponse, HttpResponse,
HttpResponseOk, HttpResponseOk,
HttpResponsePaginated, HttpResponsePaginated,
RequestError RequestError,
} from "@gnu-taler/web-util/lib/index.browser"; } from "@gnu-taler/web-util/lib/index.browser";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js"; import { useBackendContext } from "../context/backend.js";
@ -26,12 +26,12 @@ import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js";
import { import {
useAuthenticatedBackend, useAuthenticatedBackend,
useMatchMutate, useMatchMutate,
usePublicBackend usePublicBackend,
} from "./backend.js"; } from "./backend.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189 // FIX default import https://github.com/microsoft/TypeScript/issues/49189
import _useSWR, { SWRHook } from 'swr'; import _useSWR, { SWRHook } from "swr";
const useSWR = _useSWR as unknown as SWRHook const useSWR = _useSWR as unknown as SWRHook;
export function useAccessAPI(): AccessAPI { export function useAccessAPI(): AccessAPI {
const mutateAll = useMatchMutate(); const mutateAll = useMatchMutate();

View File

@ -118,6 +118,7 @@ interface useBackendType {
sandboxAccountsFetcher: <T>( sandboxAccountsFetcher: <T>(
args: [string, number, number, string], args: [string, number, number, string],
) => Promise<HttpResponseOk<T>>; ) => Promise<HttpResponseOk<T>>;
sandboxCashoutFetcher: <T>(endpoint: string[]) => Promise<HttpResponseOk<T>>;
} }
export function usePublicBackend(): useBackendType { export function usePublicBackend(): useBackendType {
const { state } = useBackendContext(); const { state } = useBackendContext();
@ -176,12 +177,21 @@ export function usePublicBackend(): useBackendType {
}, },
[baseUrl], [baseUrl],
); );
const sandboxCashoutFetcher = useCallback(
function fetcherImpl<T>([endpoint, account]: string[]): Promise<
HttpResponseOk<T>
> {
return requestHandler<T>(baseUrl, endpoint);
},
[baseUrl],
);
return { return {
request, request,
fetcher, fetcher,
paginatedFetcher, paginatedFetcher,
multiFetcher, multiFetcher,
sandboxAccountsFetcher, sandboxAccountsFetcher,
sandboxCashoutFetcher,
}; };
} }
@ -225,7 +235,6 @@ export function useAuthenticatedBackend(): useBackendType {
function multiFetcherImpl<T>([endpoints]: string[][]): Promise< function multiFetcherImpl<T>([endpoints]: string[][]): Promise<
HttpResponseOk<T>[] HttpResponseOk<T>[]
> { > {
console.log("list size", endpoints.length, endpoints);
return Promise.all( return Promise.all(
endpoints.map((endpoint) => endpoints.map((endpoint) =>
requestHandler<T>(baseUrl, endpoint, { basicAuth: creds }), requestHandler<T>(baseUrl, endpoint, { basicAuth: creds }),
@ -249,12 +258,24 @@ export function useAuthenticatedBackend(): useBackendType {
[baseUrl], [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 { return {
request, request,
fetcher, fetcher,
paginatedFetcher, paginatedFetcher,
multiFetcher, multiFetcher,
sandboxAccountsFetcher, sandboxAccountsFetcher,
sandboxCashoutFetcher,
}; };
} }

View File

@ -27,8 +27,8 @@ import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js";
import { useAuthenticatedBackend, useMatchMutate } from "./backend.js"; import { useAuthenticatedBackend, useMatchMutate } from "./backend.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189 // FIX default import https://github.com/microsoft/TypeScript/issues/49189
import _useSWR, { SWRHook } from 'swr'; import _useSWR, { SWRHook } from "swr";
const useSWR = _useSWR as unknown as SWRHook const useSWR = _useSWR as unknown as SWRHook;
export function useAdminAccountAPI(): AdminAccountAPI { export function useAdminAccountAPI(): AdminAccountAPI {
const { request } = useAuthenticatedBackend(); const { request } = useAuthenticatedBackend();
@ -118,7 +118,54 @@ export function useCircuitAccountAPI(): CircuitAccountAPI {
return res; 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 { export interface AdminAccountAPI {
@ -144,11 +191,14 @@ export interface CircuitAccountAPI {
changePassword: ( changePassword: (
data: SandboxBackend.Circuit.AccountPasswordChange, data: SandboxBackend.Circuit.AccountPasswordChange,
) => Promise<HttpResponseOk<void>>; ) => Promise<HttpResponseOk<void>>;
} createCashout: (
data: SandboxBackend.Circuit.CashoutRequest,
export interface InstanceTemplateFilter { ) => Promise<HttpResponseOk<SandboxBackend.Circuit.CashoutPending>>;
//FIXME: add filter to the template list confirmCashout: (
position?: string; id: string,
data: SandboxBackend.Circuit.CashoutConfirm,
) => Promise<HttpResponseOk<void>>;
abortCashout: (id: string) => Promise<HttpResponseOk<void>>;
} }
async function getBusinessStatus( async function getBusinessStatus(
@ -217,6 +267,35 @@ export function useBusinessAccountDetails(
return { loading: true }; 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 { interface PaginationFilter {
account?: string; account?: string;
page?: number; page?: number;
@ -299,17 +378,18 @@ export function useBusinessAccounts(
return { loading: true }; return { loading: true };
} }
export function useCashouts(): HttpResponse< export function useCashouts(
(SandboxBackend.Circuit.CashoutStatusResponse & WithId)[], account: string,
): HttpResponse<
SandboxBackend.Circuit.CashoutStatusResponseWithId[],
SandboxBackend.SandboxError SandboxBackend.SandboxError
> { > {
const { fetcher, multiFetcher } = useAuthenticatedBackend(); const { sandboxCashoutFetcher, multiFetcher } = useAuthenticatedBackend();
const { data: list, error: listError } = useSWR< const { data: list, error: listError } = useSWR<
HttpResponseOk<SandboxBackend.Circuit.Cashouts>, HttpResponseOk<SandboxBackend.Circuit.Cashouts>,
RequestError<SandboxBackend.SandboxError> RequestError<SandboxBackend.SandboxError>
>([`circuit-api/cashouts`], fetcher, { >([`circuit-api/cashouts`, account], sandboxCashoutFetcher, {
refreshInterval: 0, refreshInterval: 0,
refreshWhenHidden: false, refreshWhenHidden: false,
revalidateOnFocus: false, revalidateOnFocus: false,
@ -317,7 +397,7 @@ export function useCashouts(): HttpResponse<
refreshWhenOffline: false, refreshWhenOffline: false,
}); });
const paths = (list?.data.cashouts || []).map( const paths = ((list?.data && list?.data.cashouts) || []).map(
(cashoutId) => `circuit-api/cashouts/${cashoutId}`, (cashoutId) => `circuit-api/cashouts/${cashoutId}`,
); );
const { data: cashouts, error: productError } = useSWR< const { data: cashouts, error: productError } = useSWR<
@ -346,3 +426,31 @@ export function useCashouts(): HttpResponse<
} }
return { loading: true }; 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 };
}

View File

@ -112,40 +112,3 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
</Fragment> </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>
// );
// }

View File

@ -14,7 +14,11 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 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 { import {
HttpResponsePaginated, HttpResponsePaginated,
RequestError, RequestError,
@ -22,7 +26,9 @@ import {
} from "@gnu-taler/web-util/lib/index.browser"; } from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Cashouts } from "../components/Cashouts/index.js";
import { ErrorMessage, usePageContext } from "../context/pageState.js"; import { ErrorMessage, usePageContext } from "../context/pageState.js";
import { useAccountDetails } from "../hooks/access.js";
import { import {
useBusinessAccountDetails, useBusinessAccountDetails,
useBusinessAccounts, useBusinessAccounts,
@ -60,7 +66,10 @@ interface Props {
export function AdminPage({ onLoadNotOk }: Props): VNode { export function AdminPage({ onLoadNotOk }: Props): VNode {
const [account, setAccount] = useState<string | undefined>(); const [account, setAccount] = useState<string | undefined>();
const [showDetails, setShowDetails] = useState<string | undefined>(); const [showDetails, setShowDetails] = useState<string | undefined>();
const [showCashouts, setShowCashouts] = useState<string | undefined>();
const [updatePassword, setUpdatePassword] = useState<string | undefined>(); const [updatePassword, setUpdatePassword] = useState<string | undefined>();
const [removeAccount, setRemoveAccount] = useState<string | undefined>();
const [createAccount, setCreateAccount] = useState(false); const [createAccount, setCreateAccount] = useState(false);
const { pageStateSetter } = usePageContext(); const { pageStateSetter } = usePageContext();
@ -81,6 +90,23 @@ export function AdminPage({ onLoadNotOk }: Props): VNode {
const { customers } = result.data; 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) { if (showDetails) {
return ( return (
<ShowAccountDetails <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) { if (updatePassword) {
return ( return (
<UpdateAccountPassword <UpdateAccountPassword
@ -164,6 +205,7 @@ export function AdminPage({ onLoadNotOk }: Props): VNode {
<th>{i18n.str`Username`}</th> <th>{i18n.str`Username`}</th>
<th>{i18n.str`Name`}</th> <th>{i18n.str`Name`}</th>
<th></th> <th></th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -193,6 +235,28 @@ export function AdminPage({ onLoadNotOk }: Props): VNode {
change password change password
</a> </a>
</td> </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> </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 * Create valid account object to update or create
* Take template as initial values for the form * Take template as initial values for the form

View File

@ -128,7 +128,7 @@ export function BankFrame({
<StatusBanner /> <StatusBanner />
{backend.state.status === "loggedIn" ? ( {backend.state.status === "loggedIn" ? (
<div class="top-right"> <div class="top-right">
{goToBusinessAccount ? ( {goToBusinessAccount && !backend.state.isUserAdministrator ? (
<MaybeBusinessButton <MaybeBusinessButton
account={backend.state.username} account={backend.state.username}
onClick={goToBusinessAccount} onClick={goToBusinessAccount}
@ -187,7 +187,7 @@ export function ErrorBanner({
onClear, onClear,
}: { }: {
error: ErrorMessage; error: ErrorMessage;
onClear: () => void; onClear?: () => void;
}): VNode | null { }): VNode | null {
return ( return (
<div class="informational informational-fail" style={{ marginTop: 8 }}> <div class="informational informational-fail" style={{ marginTop: 8 }}>
@ -196,6 +196,7 @@ export function ErrorBanner({
<b>{error.title}</b> <b>{error.title}</b>
</p> </p>
<div> <div>
{onClear && (
<input <input
type="button" type="button"
class="pure-button" class="pure-button"
@ -205,6 +206,7 @@ export function ErrorBanner({
onClear(); onClear();
}} }}
/> />
)}
</div> </div>
</div> </div>
<p>{error.description}</p> <p>{error.description}</p>

View File

@ -13,18 +13,34 @@
You should have received a copy of the GNU General Public License along with 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/> 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 { import {
HttpResponsePaginated, HttpResponsePaginated,
RequestError,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser"; } 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 { useState } from "preact/hooks";
import { Cashouts } from "../components/Cashouts/index.js"; import { Cashouts } from "../components/Cashouts/index.js";
import { useBackendContext } from "../context/backend.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 { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js";
import { ErrorBanner } from "./BankFrame.js";
import { LoginForm } from "./LoginForm.js"; import { LoginForm } from "./LoginForm.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
@ -40,6 +56,8 @@ export function BusinessAccount({
const { pageStateSetter } = usePageContext(); const { pageStateSetter } = usePageContext();
const backend = useBackendContext(); const backend = useBackendContext();
const [updatePassword, setUpdatePassword] = useState(false); const [updatePassword, setUpdatePassword] = useState(false);
const [newCashout, setNewcashout] = useState(false);
const [showCashout, setShowCashout] = useState<string | undefined>();
function showInfoMessage(info: TranslatedString): void { function showInfoMessage(info: TranslatedString): void {
pageStateSetter((prev) => ({ pageStateSetter((prev) => ({
...prev, ...prev,
@ -51,6 +69,32 @@ export function BusinessAccount({
return <LoginForm onRegister={onRegister} />; 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) { if (updatePassword) {
return ( return (
<UpdateAccountPassword <UpdateAccountPassword
@ -82,9 +126,634 @@ export function BusinessAccount({
<section style={{ marginTop: "2em" }}> <section style={{ marginTop: "2em" }}>
<div class="active"> <div class="active">
<h3>{i18n.str`Latest cashouts`}</h3> <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> </div>
</section> </section>
</div> </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}
/>
&nbsp;
<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));
}}
/>
&nbsp;
<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}
/>
&nbsp;
<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}
/>
&nbsp;
<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}
/>
&nbsp;
<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}
/>
&nbsp;
<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}
/>
&nbsp;
<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}
/>
&nbsp;
<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>
&nbsp;
<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>
);
}

View File

@ -50,7 +50,6 @@ export function HomePage({ onRegister }: { onRegister: () => void }): VNode {
} }
function saveErrorAndLogout(error: PageStateType["error"]): void { function saveErrorAndLogout(error: PageStateType["error"]): void {
console.log("rrot", error);
saveError(error); saveError(error);
backend.logOut(); backend.logOut();
} }
@ -124,7 +123,6 @@ function handleNotOkResult(
return function handleNotOkResult2<T, E>( return function handleNotOkResult2<T, E>(
result: HttpResponsePaginated<T, E>, result: HttpResponsePaginated<T, E>,
): VNode { ): VNode {
console.log("qweqwe", JSON.stringify(result, undefined, 2));
if (result.clientError && result.isUnauthorized) { if (result.clientError && result.isUnauthorized) {
onErrorHandler({ onErrorHandler({
title: i18n.str`Wrong credentials for "${account}"`, title: i18n.str`Wrong credentials for "${account}"`,

View File

@ -19,7 +19,7 @@ import {
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser"; } from "@gnu-taler/web-util/lib/index.browser";
import { createHashHistory } from "history"; import { createHashHistory } from "history";
import { h, VNode, } from "preact"; import { h, VNode } from "preact";
import { Router, route, Route } from "preact-router"; import { Router, route, Route } from "preact-router";
import { useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import { Loading } from "../components/Loading.js"; import { Loading } from "../components/Loading.js";

View File

@ -278,3 +278,35 @@ h1.nav {
.pure-form > fieldset > input[disabled] { .pure-form > fieldset > input[disabled] {
color: black !important; 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 */
}

View File

@ -1,4 +1,5 @@
@use "pure"; @use "pure";
@use "bank"; @use "bank";
@use "demo"; @use "demo";
@use "toggle";
@use "colors-bank"; @use "colors-bank";

View File

@ -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;
}

View File

@ -59,6 +59,21 @@ export type WithIntermediate<Type extends object> = {
: Type[prop] | undefined; : 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> { // export function partialWithObjects<T extends object>(obj: T | undefined, () => complete): WithIntermediate<T> {
// const root = obj === undefined ? {} : obj; // const root = obj === undefined ? {} : obj;
// return Object.entries(root).([key, value]) => { // return Object.entries(root).([key, value]) => {