wallet-core/packages/demobank-ui/src/pages/BusinessAccount.tsx
2023-05-05 08:52:57 -03:00

864 lines
27 KiB
TypeScript

/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
AmountJson,
Amounts,
HttpStatusCode,
TranslatedString,
} from "@gnu-taler/taler-util";
import {
HttpResponse,
HttpResponsePaginated,
RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { StateUpdater, useEffect, useState } from "preact/hooks";
import { Cashouts } from "../components/Cashouts/index.js";
import { useBackendContext } from "../context/backend.js";
import { useAccountDetails } from "../hooks/access.js";
import {
useCashoutDetails,
useCircuitAccountAPI,
useEstimator,
useRatiosAndFeeConfig,
} from "../hooks/circuit.js";
import {
TanChannel,
buildRequestErrorMessage,
undefinedIfEmpty,
} from "../utils.js";
import { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js";
import { ErrorBannerFloat } from "./BankFrame.js";
import { LoginForm } from "./LoginForm.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
import { handleNotOkResult } from "./HomePage.js";
import { ErrorMessage, notifyInfo } from "../hooks/notification.js";
interface Props {
onClose: () => void;
onRegister: () => void;
onLoadNotOk: () => void;
}
export function BusinessAccount({
onClose,
onLoadNotOk,
onRegister,
}: Props): VNode {
const { i18n } = useTranslationContext();
const backend = useBackendContext();
const [updatePassword, setUpdatePassword] = useState(false);
const [newCashout, setNewcashout] = useState(false);
const [showCashoutDetails, setShowCashoutDetails] = useState<
string | undefined
>();
if (backend.state.status === "loggedOut") {
return <LoginForm onRegister={onRegister} />;
}
if (newCashout) {
return (
<CreateCashout
account={backend.state.username}
onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onCancel={() => {
setNewcashout(false);
}}
onComplete={(id) => {
notifyInfo(
i18n.str`Cashout created. You need to confirm the operation to complete the transaction.`,
);
setNewcashout(false);
setShowCashoutDetails(id);
}}
/>
);
}
if (showCashoutDetails) {
return (
<ShowCashoutDetails
id={showCashoutDetails}
onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onCancel={() => {
setShowCashoutDetails(undefined);
}}
/>
);
}
if (updatePassword) {
return (
<UpdateAccountPassword
account={backend.state.username}
onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Password changed`);
setUpdatePassword(false);
}}
onClear={() => {
setUpdatePassword(false);
}}
/>
);
}
return (
<div>
<ShowAccountDetails
account={backend.state.username}
onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Account updated`);
}}
onChangePassword={() => {
setUpdatePassword(true);
}}
onClear={onClose}
/>
<section style={{ marginTop: "2em" }}>
<div class="active">
<h3>{i18n.str`Latest cashouts`}</h3>
<Cashouts
account={backend.state.username}
onSelected={(id) => {
setShowCashoutDetails(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>(
error:
| HttpResponsePaginated<T, SandboxBackend.SandboxError>
| HttpResponse<T, SandboxBackend.SandboxError>,
) => VNode;
}
type FormType = {
isDebit: boolean;
amount: string;
subject: string;
channel: TanChannel;
};
type ErrorFrom<T> = {
[P in keyof T]+?: string;
};
// check #7719
function useRatiosAndFeeConfigWithChangeDetection(): HttpResponse<
SandboxBackend.Circuit.Config & { hasChanged?: boolean },
SandboxBackend.SandboxError
> {
const result = useRatiosAndFeeConfig();
const [oldResult, setOldResult] = useState<
SandboxBackend.Circuit.Config | undefined
>(undefined);
const dataFromBackend = result.ok ? result.data : undefined;
useEffect(() => {
// save only the first result of /config to the backend
if (!dataFromBackend || oldResult !== undefined) return;
setOldResult(dataFromBackend);
}, [dataFromBackend]);
if (!result.ok) return result;
const data = !oldResult ? result.data : oldResult;
const hasChanged =
oldResult &&
(result.data.name !== oldResult.name ||
result.data.version !== oldResult.version ||
result.data.ratios_and_fees.buy_at_ratio !==
oldResult.ratios_and_fees.buy_at_ratio ||
result.data.ratios_and_fees.buy_in_fee !==
oldResult.ratios_and_fees.buy_in_fee ||
result.data.ratios_and_fees.sell_at_ratio !==
oldResult.ratios_and_fees.sell_at_ratio ||
result.data.ratios_and_fees.sell_out_fee !==
oldResult.ratios_and_fees.sell_out_fee ||
result.data.fiat_currency !== oldResult.fiat_currency);
return {
...result,
data: { ...data, hasChanged },
};
}
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 {
estimateByCredit: calculateFromCredit,
estimateByDebit: calculateFromDebit,
} = useEstimator();
const [form, setForm] = useState<Partial<FormType>>({ isDebit: true });
const { createCashout } = useCircuitAccountAPI();
if (!result.ok) return onLoadNotOk(result);
if (!ratiosResult.ok) return onLoadNotOk(ratiosResult);
const config = ratiosResult.data;
const balance = Amounts.parseOrThrow(result.data.balance.amount);
const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
const zero = Amounts.zeroOfCurrency(balance.currency);
const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount
: Amounts.add(balance, debitThreshold).amount;
const zeroCalc = { debit: zero, credit: zero, beforeFee: zero };
const [calc, setCalc] = useState(zeroCalc);
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);
const fiatCurrency = config.fiat_currency;
if (!sellRate || sellRate < 0) return <div>error rate</div>;
const amount = Amounts.parseOrThrow(
`${!form.isDebit ? fiatCurrency : balance.currency}:${
!form.amount ? "0" : form.amount
}`,
);
useEffect(() => {
if (form.isDebit) {
calculateFromDebit(amount, sellFee, sellRate)
.then((r) => {
setCalc(r);
saveError(undefined);
})
.catch((error) => {
saveError(
error instanceof RequestError
? buildRequestErrorMessage(i18n, error.cause)
: {
title: i18n.str`Could not estimate the cashout`,
description: error.message,
},
);
});
} else {
calculateFromCredit(amount, sellFee, sellRate)
.then((r) => {
setCalc(r);
saveError(undefined);
})
.catch((error) => {
saveError(
error instanceof RequestError
? buildRequestErrorMessage(i18n, error.cause)
: {
title: i18n.str`Could not estimate the cashout`,
description: error.message,
},
);
});
}
}, [form.amount, form.isDebit]);
const balanceAfter = Amounts.sub(balance, calc.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(limit, calc.debit) === -1
? i18n.str`balance is not enough`
: Amounts.cmp(calc.beforeFee, sellFee) === -1
? i18n.str`the total amount to transfer does not cover the fees`
: Amounts.isZero(calc.credit)
? i18n.str`the total transfer at destination will be zero`
: undefined,
channel: !form.channel ? i18n.str`required` : undefined,
});
return (
<div>
{error && (
<ErrorBannerFloat 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={amount?.currency.length ?? 0}
maxLength={amount?.currency.length ?? 0}
tabIndex={-1}
value={amount?.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={Amounts.stringifyValue(calc.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`Amount after conversion`}</label>
<div style={{ width: "max-content" }}>
<input
type="text"
readonly
class="currency-indicator"
size={fiatCurrency.length}
maxLength={fiatCurrency.length}
tabIndex={-1}
value={fiatCurrency}
/>
&nbsp;
<input
// type="number"
style={{ color: "black" }}
disabled
value={Amounts.stringifyValue(calc.beforeFee)}
/>
</div>
</fieldset>
<fieldset>
<label>{i18n.str`Cashout fee`}</label>
<div style={{ width: "max-content" }}>
<input
type="text"
readonly
class="currency-indicator"
size={fiatCurrency.length}
maxLength={fiatCurrency.length}
tabIndex={-1}
value={fiatCurrency}
/>
&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={fiatCurrency.length}
maxLength={fiatCurrency.length}
tabIndex={-1}
value={fiatCurrency}
/>
&nbsp;
<input
type="number"
// ref={ref}
id="withdraw-amount"
disabled
name="withdraw-amount"
value={Amounts.stringifyValue(calc.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(calc.credit),
amount_debit: Amounts.stringify(calc.debit),
subject: form.subject,
tan_channel: form.channel,
});
onComplete(res.data.uuid);
} catch (error) {
if (error instanceof RequestError) {
saveError(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.BadRequest
? i18n.str`The exchange rate was incorrectly applied`
: status === HttpStatusCode.Forbidden
? i18n.str`A institutional user tried the operation`
: status === HttpStatusCode.Conflict
? i18n.str`Need a contact data where to send the TAN`
: status === HttpStatusCode.PreconditionFailed
? i18n.str`The account does not have sufficient funds`
: undefined,
onServerError: (status) =>
status === HttpStatusCode.ServiceUnavailable
? i18n.str`The bank does not support the TAN channel for this operation`
: undefined,
}),
);
} else {
saveError({
title: i18n.str`Operation failed, please report`,
description:
error instanceof Error
? error.message
: JSON.stringify(error),
});
}
}
}}
>
{i18n.str`Create`}
</button>
</div>
</form>
</div>
);
}
interface ShowCashoutProps {
id: string;
onCancel: () => void;
onLoadNotOk: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => VNode;
}
export function ShowCashoutDetails({
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 && (
<ErrorBannerFloat 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>
<fieldset>
<label>
<i18n.Translate>Destination</i18n.Translate>
</label>
<input readOnly value={result.data.cashout_address} />
</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 {
await abortCashout(id);
onCancel();
} catch (error) {
if (error instanceof RequestError) {
saveError(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.NotFound
? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
: status === HttpStatusCode.PreconditionFailed
? i18n.str`Cashout was already confimed`
: undefined,
}),
);
} else {
saveError({
title: i18n.str`Operation failed, please report`,
description:
error instanceof Error
? error.message
: JSON.stringify(error),
});
}
}
}}
>
{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) {
saveError(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.NotFound
? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
: status === HttpStatusCode.PreconditionFailed
? i18n.str`Cashout was already confimed`
: status === HttpStatusCode.Conflict
? i18n.str`Confirmation failed. Maybe the user changed their cash-out address between the creation and the confirmation`
: status === HttpStatusCode.Forbidden
? i18n.str`Invalid code`
: undefined,
}),
);
} else {
saveError({
title: i18n.str`Operation failed, please report`,
description:
error instanceof Error
? error.message
: JSON.stringify(error),
});
}
}
}}
>
{i18n.str`Confirm`}
</button>
</div>
) : (
<div />
)}
</div>
</div>
);
}
const MAX_AMOUNT_DIGIT = 2;
/**
* Truncate the amount of digits to display
* in the form based on the fee calculations
*
* Backend must have the same truncation
* @param a
* @returns
*/
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 + 1 + MAX_AMOUNT_DIGIT);
return Amounts.parseOrThrow(truncated);
}
export function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
}