diff options
author | Sebastian <sebasjm@gmail.com> | 2023-09-21 10:31:10 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-09-25 14:50:41 -0300 |
commit | 062939d9cc016a186a282f7a48492c3e01cd740c (patch) | |
tree | a52c93ef1179ece9d8621731d4a34fc654f18713 /packages/demobank-ui/src/pages/business | |
parent | b3c747151bb3f50d28bf6205cafa4b7dd6ae2b1c (diff) |
admin refactor
Diffstat (limited to 'packages/demobank-ui/src/pages/business')
-rw-r--r-- | packages/demobank-ui/src/pages/business/Home.tsx | 757 |
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> + + <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"); +} |