diff options
Diffstat (limited to 'packages/demobank-ui/src/pages/business/CreateCashout.tsx')
-rw-r--r-- | packages/demobank-ui/src/pages/business/CreateCashout.tsx | 422 |
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> + ); +} |