aboutsummaryrefslogtreecommitdiff
path: root/packages/demobank-ui/src/pages/business/CreateCashout.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/demobank-ui/src/pages/business/CreateCashout.tsx')
-rw-r--r--packages/demobank-ui/src/pages/business/CreateCashout.tsx422
1 files changed, 422 insertions, 0 deletions
diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx
new file mode 100644
index 000000000..4696c899e
--- /dev/null
+++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx
@@ -0,0 +1,422 @@
+/*
+ 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,
+ TalerError,
+ TranslatedString
+} from "@gnu-taler/taler-util";
+import {
+ notify,
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { mutate } from "swr";
+import { ErrorLoading } from "../../components/ErrorLoading.js";
+import { Loading } from "../../components/Loading.js";
+import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
+import { useBankCoreApiContext } from "../../context/config.js";
+import { useAccountDetails } from "../../hooks/access.js";
+import { useBackendState } from "../../hooks/backend.js";
+import {
+ useCashoutDetails,
+ useEstimator,
+ useRatiosAndFeeConfig,
+} from "../../hooks/circuit.js";
+import {
+ TanChannel,
+ undefinedIfEmpty,
+ withRuntimeErrorHandling
+} from "../../utils.js";
+import { LoginForm } from "../LoginForm.js";
+import { InputAmount } from "../PaytoWireTransferForm.js";
+import { assertUnreachable } from "../HomePage.js";
+import { Attention } from "../../components/Attention.js";
+
+interface Props {
+ account: string;
+ onComplete: (id: string) => void;
+ onCancel: () => void;
+}
+
+type FormType = {
+ isDebit: boolean;
+ amount: string;
+ subject: string;
+ channel: TanChannel;
+};
+type ErrorFrom<T> = {
+ [P in keyof T]+?: string;
+};
+
+
+export function CreateCashout({
+ account: accountName,
+ onComplete,
+ onCancel,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const resultRatios = useRatiosAndFeeConfig();
+ const resultAccount = useAccountDetails(accountName);
+ const {
+ estimateByCredit: calculateFromCredit,
+ estimateByDebit: calculateFromDebit,
+ } = useEstimator();
+ const { state } = useBackendState()
+ const creds = state.status !== "loggedIn" ? undefined : state
+ const { api, config } = useBankCoreApiContext()
+ const [form, setForm] = useState<Partial<FormType>>({ isDebit: true });
+
+ if (!config.have_cashout) {
+ return <Attention type="warning" title={i18n.str`Unable to create a cashout`} onClose={onCancel}>
+ <i18n.Translate>The bank configuration does not support cashout operations.</i18n.Translate>
+ </Attention>
+ }
+ if (!config.fiat_currency) {
+ return <Attention type="warning" title={i18n.str`Unable to create a cashout`} onClose={onCancel}>
+ <i18n.Translate>The bank configuration support cashout operations but there is no fiat currency.</i18n.Translate>
+ </Attention>
+ }
+
+ if (!resultAccount || !resultRatios) {
+ return <Loading />
+ }
+ if (resultAccount instanceof TalerError) {
+ return <ErrorLoading error={resultAccount} />
+ }
+ if (resultRatios instanceof TalerError) {
+ return <ErrorLoading error={resultRatios} />
+ }
+ if (resultAccount.type === "fail") {
+ switch (resultAccount.case) {
+ case "unauthorized": return <LoginForm reason="forbidden" />
+ case "not-found": return <LoginForm reason="not-found" />
+ default: assertUnreachable(resultAccount)
+ }
+ }
+
+ if (resultRatios.type === "fail") {
+ switch (resultRatios.case) {
+ case "not-supported": return <div>cashout operations are not supported</div>
+ default: assertUnreachable(resultRatios.case)
+ }
+ }
+
+ const ratio = resultRatios.body
+
+ const account = {
+ balance: Amounts.parseOrThrow(resultAccount.body.balance.amount),
+ balanceIsDebit: resultAccount.body.balance.credit_debit_indicator == "debit",
+ debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold)
+ }
+
+ const zero = Amounts.zeroOfCurrency(account.balance.currency);
+ const limit = account.balanceIsDebit
+ ? Amounts.sub(account.debitThreshold, account.balance).amount
+ : Amounts.add(account.balance, account.debitThreshold).amount;
+
+ const zeroCalc = { debit: zero, credit: zero, beforeFee: zero };
+ const [calc, setCalc] = useState(zeroCalc);
+
+ const sellRate = ratio.sell_at_ratio;
+ const sellFee = !ratio.sell_out_fee
+ ? zero
+ : Amounts.parseOrThrow(
+ `${account.balance.currency}:${ratio.sell_out_fee}`,
+ );
+
+ if (!sellRate || sellRate < 0) return <div>error rate</div>;
+
+ const amount = Amounts.parseOrThrow(
+ `${!form.isDebit ? config.fiat_currency.name : account.balance.currency}:${!form.amount ? "0" : form.amount
+ }`,
+ );
+
+ useEffect(() => {
+ async function doAsync() {
+ await withRuntimeErrorHandling(i18n, async () => {
+ const resp = await (form.isDebit ?
+ calculateFromDebit(amount, sellFee, sellRate) :
+ calculateFromCredit(amount, sellFee, sellRate));
+ setCalc(resp)
+ })
+ }
+ doAsync()
+ }, [form.amount, form.isDebit]);
+
+ const balanceAfter = Amounts.sub(account.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" }}>
+ <InputAmount
+ 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 => {
+ 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>
+ <InputAmount
+ name="banace-now"
+ currency={account.balance.currency}
+ value={Amounts.stringifyValue(account.balance)}
+ />
+ </fieldset>
+ <fieldset>
+ <label for="total-cost"
+ style={{ fontWeight: "bold", color: "red" }}
+ >{i18n.str`Total cost`}</label>
+ <InputAmount
+ name="total-cost"
+ currency={account.balance.currency}
+ value={Amounts.stringifyValue(calc.debit)}
+ />
+ </fieldset>
+ <fieldset>
+ <label for="balance-after">{i18n.str`Balance after`}</label>
+ <InputAmount
+ name="balance-after"
+ currency={account.balance.currency}
+ value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""}
+ />
+ </fieldset>{" "}
+ {Amounts.isZero(sellFee) ? undefined : (
+ <Fragment>
+ <fieldset>
+ <label for="amount-conversiojn">{i18n.str`Amount after conversion`}</label>
+ <InputAmount
+ name="amount-conversion"
+ currency={config.fiat_currency.name}
+ value={Amounts.stringifyValue(calc.beforeFee)}
+ />
+ </fieldset>
+
+ <fieldset>
+ <label form="cashout-fee">{i18n.str`Cashout fee`}</label>
+ <InputAmount
+ name="cashout-fee"
+ currency={config.fiat_currency.name}
+ value={Amounts.stringifyValue(sellFee)}
+ />
+ </fieldset>
+ </Fragment>
+ )}
+ <fieldset>
+ <label for="total"
+ style={{ fontWeight: "bold", color: "green" }}
+ >{i18n.str`Total cashout transfer`}</label>
+ <InputAmount
+ name="total"
+ currency={config.fiat_currency.name}
+ 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 || !creds) return;
+ await withRuntimeErrorHandling(i18n, async () => {
+ const resp = await api.createCashout(creds, {
+ amount_credit: Amounts.stringify(calc.credit),
+ amount_debit: Amounts.stringify(calc.debit),
+ subject: form.subject,
+ tan_channel: form.channel,
+ });
+ if (resp.type === "ok") {
+ mutate(() => true)// clean cashout list
+ onComplete(resp.body.cashout_id);
+ } else {
+ switch (resp.case) {
+ case "incorrect-exchange-rate": return notify({
+ type: "error",
+ title: i18n.str`The exchange rate was incorrectly applied`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case "no-allowed": return notify({
+ type: "error",
+ title: i18n.str`This user is not allowed to make a cashout`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case "no-contact-info": return notify({
+ type: "error",
+ title: i18n.str`Need a contact data where to send the TAN`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case "no-enough-balance": return notify({
+ type: "error",
+ title: i18n.str`The account does not have sufficient funds`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ case "tan-not-supported": return notify({
+ type: "error",
+ title: i18n.str`The bank does not support the TAN channel for this operation`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ });
+ default: assertUnreachable(resp)
+ }
+ }
+ })
+ }}
+ >
+ {i18n.str`Create`}
+ </button>
+ </div>
+ </form>
+ </div>
+ );
+}