aboutsummaryrefslogtreecommitdiff
path: root/packages/demobank-ui/src/pages/BusinessAccount.tsx
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2023-02-17 16:23:37 -0300
committerSebastian <sebasjm@gmail.com>2023-02-17 16:23:49 -0300
commit9697e953f56dc37208c2852d686d1854256f71ef (patch)
treefbbe6e5934c1a8dd438da76d37b719372811b542 /packages/demobank-ui/src/pages/BusinessAccount.tsx
parent8b83f729d7394837a3be231bbeeea44f6a01e9a1 (diff)
downloadwallet-core-9697e953f56dc37208c2852d686d1854256f71ef.tar.xz
cashout for business accounts
Diffstat (limited to 'packages/demobank-ui/src/pages/BusinessAccount.tsx')
-rw-r--r--packages/demobank-ui/src/pages/BusinessAccount.tsx677
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}
+ />
+ &nbsp;
+ <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));
+ }}
+ />
+ &nbsp;
+ <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}
+ />
+ &nbsp;
+ <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}
+ />
+ &nbsp;
+ <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}
+ />
+ &nbsp;
+ <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}
+ />
+ &nbsp;
+ <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}
+ />
+ &nbsp;
+ <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}
+ />
+ &nbsp;
+ <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>
+ &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) {
+ 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>
+ );
+}