/* 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 */ import { AmountJson, Amounts, HttpStatusCode, TranslatedString, } from "@gnu-taler/taler-util"; import { HttpResponse, HttpResponsePaginated, RequestError, useTranslationContext, } from "@gnu-taler/web-util/lib/index.browser"; import { Fragment, h, VNode } from "preact"; import { useEffect, useMemo, useState } from "preact/hooks"; import { Cashouts } from "../components/Cashouts/index.js"; import { useBackendContext } from "../context/backend.js"; import { ErrorMessage, usePageContext } from "../context/pageState.js"; import { useAccountDetails } from "../hooks/access.js"; import { useCashoutDetails, useCircuitAccountAPI, useEstimator, useRatiosAndFeeConfig, } from "../hooks/circuit.js"; import { buildRequestErrorMessage, TanChannel, undefinedIfEmpty, } from "../utils.js"; import { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js"; import { ErrorBannerFloat } from "./BankFrame.js"; import { LoginForm } from "./LoginForm.js"; import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; interface Props { onClose: () => void; onRegister: () => void; onLoadNotOk: ( error: HttpResponsePaginated, ) => VNode; } export function BusinessAccount({ onClose, onLoadNotOk, onRegister, }: Props): VNode { const { i18n } = useTranslationContext(); const { pageStateSetter } = usePageContext(); const backend = useBackendContext(); const [updatePassword, setUpdatePassword] = useState(false); const [newCashout, setNewcashout] = useState(false); const [showCashoutDetails, setShowCashoutDetails] = useState< string | undefined >(); function showInfoMessage(info: TranslatedString): void { pageStateSetter((prev) => ({ ...prev, info, })); } if (backend.state.status === "loggedOut") { return ; } if (newCashout) { return ( { setNewcashout(false); }} onComplete={(id) => { showInfoMessage( i18n.str`Cashout created. You need to confirm the operation to complete the transaction.`, ); setNewcashout(false); setShowCashoutDetails(id); }} /> ); } if (showCashoutDetails) { return ( { setShowCashoutDetails(undefined); }} /> ); } if (updatePassword) { return ( { showInfoMessage(i18n.str`Password changed`); setUpdatePassword(false); }} onClear={() => { setUpdatePassword(false); }} /> ); } return (
{ showInfoMessage(i18n.str`Account updated`); }} onChangePassword={() => { setUpdatePassword(true); }} onClear={onClose} />

{i18n.str`Latest cashouts`}

{ setShowCashoutDetails(id); }} />

{ e.preventDefault(); setNewcashout(true); }} />
); } interface PropsCashout { account: string; onComplete: (id: string) => void; onCancel: () => void; onLoadNotOk: ( error: HttpResponsePaginated, ) => VNode; } type FormType = { isDebit: boolean; amount: string; subject: string; channel: TanChannel; }; type ErrorFrom = { [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 [error, saveError] = useState(); const { estimateByCredit: calculateFromCredit, estimateByDebit: calculateFromDebit, } = useEstimator(); const [form, setForm] = useState>({ 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.fromFloat(config.ratios_and_fees.sell_out_fee, balance.currency); const fiatCurrency = config.fiat_currency; if (!sellRate || sellRate < 0) return
error rate
; 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); saveError(undefined); }) .catch((error) => { saveError( error instanceof RequestError ? buildRequestErrorMessage(i18n, error.cause) : { title: i18n.str`Could not estimate the cashout`, description: error.message, }, ); }); } else { calculateFromCredit(amount, sellFee, sellRate) .then((r) => { setCalc(r); saveError(undefined); }) .catch((error) => { saveError( error instanceof RequestError ? buildRequestErrorMessage(i18n, error.cause) : { 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>({ 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 (
{error && ( saveError(undefined)} /> )}

New cashout

{ form.subject = e.currentTarget.value; updateForm(structuredClone(form)); }} />
  { form.amount = e.currentTarget.value; updateForm(structuredClone(form)); }} />  
 
 
 
{" "} {Amounts.isZero(sellFee) ? undefined : (
 
 
)}
 
{ e.preventDefault(); form.channel = TanChannel.EMAIL; updateForm(structuredClone(form)); }} /> { e.preventDefault(); form.channel = TanChannel.SMS; updateForm(structuredClone(form)); }} /> { e.preventDefault(); form.channel = TanChannel.FILE; updateForm(structuredClone(form)); }} />

); } interface ShowCashoutProps { id: string; onCancel: () => void; onLoadNotOk: ( error: HttpResponsePaginated, ) => VNode; } export function ShowCashoutDetails({ id, onCancel, onLoadNotOk, }: ShowCashoutProps): VNode { const { i18n } = useTranslationContext(); const result = useCashoutDetails(id); const { abortCashout, confirmCashout } = useCircuitAccountAPI(); const [code, setCode] = useState(undefined); const [error, saveError] = useState(); if (!result.ok) return onLoadNotOk(result); const errors = undefinedIfEmpty({ code: !code ? i18n.str`required` : undefined, }); const isPending = String(result.data.status).toUpperCase() === "PENDING"; return (

Cashout details {id}

{error && ( saveError(undefined)} /> )}
{isPending ? (
{ setCode(e.currentTarget.value); }} />
) : undefined}

{isPending ? (
 
) : (
)}
); } 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"); }