diff options
Diffstat (limited to 'packages/demobank-ui/src/pages/BusinessAccount.tsx')
-rw-r--r-- | packages/demobank-ui/src/pages/BusinessAccount.tsx | 677 |
1 files changed, 673 insertions, 4 deletions
diff --git a/packages/demobank-ui/src/pages/BusinessAccount.tsx b/packages/demobank-ui/src/pages/BusinessAccount.tsx index d845c2fa0..6651ef0f7 100644 --- a/packages/demobank-ui/src/pages/BusinessAccount.tsx +++ b/packages/demobank-ui/src/pages/BusinessAccount.tsx @@ -13,18 +13,34 @@ 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 { TranslatedString } from "@gnu-taler/taler-util"; +import { + AmountJson, + Amounts, + HttpStatusCode, + TranslatedString, +} from "@gnu-taler/taler-util"; import { HttpResponsePaginated, + RequestError, useTranslationContext, } from "@gnu-taler/web-util/lib/index.browser"; -import { h, VNode } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { Cashouts } from "../components/Cashouts/index.js"; import { useBackendContext } from "../context/backend.js"; -import { usePageContext } from "../context/pageState.js"; +import { ErrorMessage, usePageContext } from "../context/pageState.js"; +import { useAccountDetails } from "../hooks/access.js"; +import { + useCashoutDetails, + useCashouts, + useCircuitAccountAPI, + useRatiosAndFeeConfig, +} from "../hooks/circuit.js"; +import { CashoutStatus, TanChannel, undefinedIfEmpty } from "../utils.js"; import { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js"; +import { ErrorBanner } from "./BankFrame.js"; import { LoginForm } from "./LoginForm.js"; +import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; interface Props { onClose: () => void; @@ -40,6 +56,8 @@ export function BusinessAccount({ const { pageStateSetter } = usePageContext(); const backend = useBackendContext(); const [updatePassword, setUpdatePassword] = useState(false); + const [newCashout, setNewcashout] = useState(false); + const [showCashout, setShowCashout] = useState<string | undefined>(); function showInfoMessage(info: TranslatedString): void { pageStateSetter((prev) => ({ ...prev, @@ -51,6 +69,32 @@ export function BusinessAccount({ return <LoginForm onRegister={onRegister} />; } + if (newCashout) { + return ( + <CreateCashout + account={backend.state.username} + onLoadNotOk={onLoadNotOk} + onCancel={() => { + setNewcashout(false); + }} + onComplete={(id) => { + setNewcashout(false); + setShowCashout(id); + }} + /> + ); + } + if (showCashout) { + return ( + <ShowCashout + id={showCashout} + onLoadNotOk={onLoadNotOk} + onCancel={() => { + setShowCashout(undefined); + }} + /> + ); + } if (updatePassword) { return ( <UpdateAccountPassword @@ -82,9 +126,634 @@ export function BusinessAccount({ <section style={{ marginTop: "2em" }}> <div class="active"> <h3>{i18n.str`Latest cashouts`}</h3> - <Cashouts /> + <Cashouts + account={backend.state.username} + onSelected={(id) => { + setShowCashout(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, E>(error: HttpResponsePaginated<T, E>) => VNode; +} + +type FormType = { + isDebit: boolean; + amount: string; + subject: string; + channel: TanChannel; +}; +type ErrorFrom<T> = { + [P in keyof T]+?: string; +}; + +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 [form, setForm] = useState<Partial<FormType>>({}); + + const { createCashout } = useCircuitAccountAPI(); + if (!result.ok) return onLoadNotOk(result); + if (!ratiosResult.ok) return onLoadNotOk(ratiosResult); + const config = ratiosResult.data; + const maybeBalance = Amounts.parse(result.data.balance.amount); + if (!maybeBalance) return <div>error</div>; + const balance = maybeBalance; + const zero = Amounts.zeroOfCurrency(balance.currency); + + 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); + + if (!sellRate || sellRate < 0) return <div>error rate</div>; + + 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 + 3); + console.log(str, truncated); + return Amounts.parseOrThrow(truncated); + } + + const amount = Amounts.parse(`${balance.currency}:${form.amount}`); + const amount_debit = !amount + ? zero + : form.isDebit + ? amount + : truncate(Amounts.divide(Amounts.add(amount, sellFee).amount, sellRate)); + const credit_before_fee = !amount + ? zero + : form.isDebit + ? truncate(Amounts.divide(amount, 1 / sellRate)) + : Amounts.add(amount, sellFee).amount; + + const __amount_credit = Amounts.sub(credit_before_fee, sellFee).amount; + const amount_credit = Amounts.parseOrThrow( + `${config.currency}:${Amounts.stringifyValue(__amount_credit)}`, + ); + + const balanceAfter = Amounts.sub(balance, amount_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(balance, amount_debit) === -1 + ? i18n.str`balance is not enough` + : Amounts.cmp(credit_before_fee, sellFee) === -1 + ? i18n.str`amount is not enough` + : Amounts.isZero(amount_credit) + ? i18n.str`amount is not enough` + : undefined, + channel: !form.channel ? i18n.str`required` : undefined, + }); + + // setErrors(validationResult); + + return ( + <div> + {error && ( + <ErrorBanner 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={balance.currency.length} + maxLength={balance.currency.length} + tabIndex={-1} + value={balance.currency} + /> + + <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)); + }} + /> + + <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} + /> + + <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} + /> + + <input + type="number" + // ref={ref} + id="withdraw-amount" + disabled + name="withdraw-amount" + value={amount_debit ? Amounts.stringifyValue(amount_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} + /> + + <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`Transfer before fee`}</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} + /> + + <input + // type="number" + style={{ color: "black" }} + disabled + value={Amounts.stringifyValue(credit_before_fee)} + /> + </div> + </fieldset> + + <fieldset> + <label>{i18n.str`Cashout fee`}</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} + /> + + <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={balance.currency.length} + maxLength={balance.currency.length} + tabIndex={-1} + value={balance.currency} + /> + + <input + type="number" + // ref={ref} + id="withdraw-amount" + disabled + name="withdraw-amount" + value={amount_credit ? Amounts.stringifyValue(amount_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(amount_credit), + amount_debit: Amounts.stringify(amount_debit), + subject: form.subject, + tan_channel: form.channel, + }); + onComplete(res.data.uuid); + } catch (error) { + if (error instanceof RequestError) { + const errorData: SandboxBackend.SandboxError = + error.info.error; + if (error.info.status === HttpStatusCode.PreconditionFailed) { + saveError({ + title: i18n.str`The account does not have sufficient funds`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else if ( + error.info.status === HttpStatusCode.ServiceUnavailable + ) { + saveError({ + title: i18n.str`The bank does not support the TAN channel for this operation`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else if (error.info.status === HttpStatusCode.Conflict) { + saveError({ + title: i18n.str`No contact information for this channel`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else { + saveError({ + title: i18n.str`New cashout gave response error`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } + } else if (error instanceof Error) { + saveError({ + title: i18n.str`Cashout failed, please report`, + description: error.message, + }); + } + } + }} + > + {i18n.str`Create`} + </button> + </div> + </form> + </div> + ); +} + +interface ShowCashoutProps { + id: string; + onCancel: () => void; + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; +} +function ShowCashout({ 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 && ( + <ErrorBanner 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> + {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 { + const rest = await abortCashout(id); + onCancel(); + } catch (error) { + if (error instanceof RequestError) { + const errorData: SandboxBackend.SandboxError = + error.info.error; + if ( + error.info.status === HttpStatusCode.PreconditionFailed + ) { + saveError({ + title: i18n.str`Cashout was already aborted`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else { + saveError({ + title: i18n.str`Aborting cashout gave response error`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } + } else if (error instanceof Error) { + saveError({ + title: i18n.str`Aborting failed, please report`, + description: error.message, + }); + } + } + }} + > + {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) { + const errorData: SandboxBackend.SandboxError = + error.info.error; + saveError({ + title: i18n.str`Confirmation of cashout gave response error`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else if (error instanceof Error) { + saveError({ + title: i18n.str`Confirmation failed, please report`, + description: error.message, + }); + } + } + }} + > + {i18n.str`Confirm`} + </button> + </div> + ) : ( + <div /> + )} + </div> + </div> + ); +} |