aboutsummaryrefslogtreecommitdiff
path: root/packages/demobank-ui/src/pages/business
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2023-09-21 10:31:10 -0300
committerSebastian <sebasjm@gmail.com>2023-09-25 14:50:41 -0300
commit062939d9cc016a186a282f7a48492c3e01cd740c (patch)
treea52c93ef1179ece9d8621731d4a34fc654f18713 /packages/demobank-ui/src/pages/business
parentb3c747151bb3f50d28bf6205cafa4b7dd6ae2b1c (diff)
downloadwallet-core-062939d9cc016a186a282f7a48492c3e01cd740c.tar.xz
admin refactor
Diffstat (limited to 'packages/demobank-ui/src/pages/business')
-rw-r--r--packages/demobank-ui/src/pages/business/Home.tsx757
1 files changed, 757 insertions, 0 deletions
diff --git a/packages/demobank-ui/src/pages/business/Home.tsx b/packages/demobank-ui/src/pages/business/Home.tsx
new file mode 100644
index 000000000..8beea640a
--- /dev/null
+++ b/packages/demobank-ui/src/pages/business/Home.tsx
@@ -0,0 +1,757 @@
+/*
+ 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,
+ notify,
+ notifyError,
+ notifyInfo,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { Cashouts } from "../../components/Cashouts/index.js";
+import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.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 { handleNotOkResult } from "../HomePage.js";
+import { LoginForm } from "../LoginForm.js";
+import { Amount } from "../PaytoWireTransferForm.js";
+import { ShowAccountDetails } from "../ShowAccountDetails.js";
+import { UpdateAccountPassword } from "../UpdateAccountPassword.js";
+
+interface Props {
+ account: string,
+ onClose: () => void;
+ onRegister: () => void;
+ onLoadNotOk: () => void;
+}
+export function BusinessAccount({
+ onClose,
+ account,
+ onLoadNotOk,
+ onRegister,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [updatePassword, setUpdatePassword] = useState(false);
+ const [newCashout, setNewcashout] = useState(false);
+ const [showCashoutDetails, setShowCashoutDetails] = useState<
+ string | undefined
+ >();
+
+
+ if (newCashout) {
+ return (
+ <CreateCashout
+ account={account}
+ 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={account}
+ onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onUpdateSuccess={() => {
+ notifyInfo(i18n.str`Password changed`);
+ setUpdatePassword(false);
+ }}
+ onClear={() => {
+ setUpdatePassword(false);
+ }}
+ />
+ );
+ }
+ return (
+ <div>
+ <ShowAccountDetails
+ account={account}
+ 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={account}
+ 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 {
+ 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.parseOrThrow(
+ `${balance.currency}:${config.ratios_and_fees.sell_out_fee}`,
+ );
+ 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);
+ })
+ .catch((error) => {
+ notify(
+ error instanceof RequestError
+ ? buildRequestErrorMessage(i18n, error.cause)
+ : {
+ type: "error",
+ title: i18n.str`Could not estimate the cashout`,
+ description: error.message as TranslatedString
+ },
+ );
+ });
+ } else {
+ calculateFromCredit(amount, sellFee, sellRate)
+ .then((r) => {
+ setCalc(r);
+ })
+ .catch((error) => {
+ notify(
+ error instanceof RequestError
+ ? buildRequestErrorMessage(i18n, error.cause)
+ : {
+ type: "error",
+ 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>
+ <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 for="amount">
+ {form.isDebit
+ ? i18n.str`Amount to send`
+ : i18n.str`Amount to receive`}
+
+ </label>
+ <div style={{ display: "flex" }}>
+ <Amount
+ name="amount"
+ currency={amount.currency}
+ value={form.amount}
+ onChange={(v) => {
+ form.amount = v;
+ updateForm(structuredClone(form));
+ }}
+ error={errors?.amount}
+ />
+ <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}>
+ <input
+ class="toggle-checkbox"
+ type="checkbox"
+ name="asd"
+ onChange={(e): void => {
+ console.log("asdasd", form.isDebit);
+ form.isDebit = !form.isDebit;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <div class="toggle-switch"></div>
+ </label>
+ </div>
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Conversion rate`}</label>
+ <input value={sellRate} disabled />
+ </fieldset>
+ <fieldset>
+ <label for="balance-now">{i18n.str`Balance now`}</label>
+ <Amount
+ name="banace-now"
+ currency={balance.currency}
+ value={Amounts.stringifyValue(balance)}
+ />
+ </fieldset>
+ <fieldset>
+ <label for="total-cost"
+ style={{ fontWeight: "bold", color: "red" }}
+ >{i18n.str`Total cost`}</label>
+ <Amount
+ name="total-cost"
+ currency={balance.currency}
+ value={Amounts.stringifyValue(calc.debit)}
+ />
+ </fieldset>
+ <fieldset>
+ <label for="balance-after">{i18n.str`Balance after`}</label>
+ <Amount
+ name="balance-after"
+ currency={balance.currency}
+ value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""}
+ />
+ </fieldset>{" "}
+ {Amounts.isZero(sellFee) ? undefined : (
+ <Fragment>
+ <fieldset>
+ <label for="amount-conversiojn">{i18n.str`Amount after conversion`}</label>
+ <Amount
+ name="amount-conversion"
+ currency={fiatCurrency}
+ value={Amounts.stringifyValue(calc.beforeFee)}
+ />
+ </fieldset>
+
+ <fieldset>
+ <label form="cashout-fee">{i18n.str`Cashout fee`}</label>
+ <Amount
+ name="cashout-fee"
+ currency={fiatCurrency}
+ value={Amounts.stringifyValue(sellFee)}
+ />
+ </fieldset>
+ </Fragment>
+ )}
+ <fieldset>
+ <label for="total"
+ style={{ fontWeight: "bold", color: "green" }}
+ >{i18n.str`Total cashout transfer`}</label>
+ <Amount
+ name="total"
+ currency={fiatCurrency}
+ value={Amounts.stringifyValue(calc.credit)}
+ />
+ </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) {
+ notify(
+ 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 {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }}
+ >
+ {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);
+ 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>
+ <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) {
+ notify(
+ 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 {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }}
+ >
+ {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) {
+ notify(
+ 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 {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }}
+ >
+ {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");
+}