/* 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 { Amounts, TalerError, TranslatedString, encodeCrock, getRandomBytes, parsePaytoUri } from "@gnu-taler/taler-util"; import { Attention, Loading, LocalNotificationBanner, ShowInputErrorLabel, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { useBankCoreApiContext } from "../../context/config.js"; import { useAccountDetails } from "../../hooks/access.js"; import { useBackendState } from "../../hooks/backend.js"; import { useConversionInfo, useEstimator } from "../../hooks/circuit.js"; import { TanChannel, undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { InputAmount, RenderAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; import { assertUnreachable } from "../WithdrawalOperationPage.js"; interface Props { account: string; focus?: boolean, onComplete: (id: string) => void; onCancel?: () => void; } type FormType = { isDebit: boolean; amount: string; subject: string; channel: TanChannel; }; type ErrorFrom = { [P in keyof T]+?: string; }; export function CreateCashout({ account: accountName, onComplete, focus, onCancel, }: Props): VNode { const { i18n } = useTranslationContext(); const resultAccount = useAccountDetails(accountName); const { estimateByCredit: calculateFromCredit, estimateByDebit: calculateFromDebit, } = useEstimator(); const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials const { api, config } = useBankCoreApiContext() const [form, setForm] = useState>({ isDebit: true, }); const [notification, notify, handleError] = useLocalNotification() const info = useConversionInfo(); if (!config.allow_conversion) { return The bank configuration does not support cashout operations. } if (!resultAccount) { return } if (resultAccount instanceof TalerError) { return } if (resultAccount.type === "fail") { switch (resultAccount.case) { case "unauthorized": return case "not-found": return default: assertUnreachable(resultAccount) } } if (!info) { return } if (info instanceof TalerError) { return } const conversionInfo = info.body.conversion_rate if (!conversionInfo) { return
conversion enabled but server replied without conversion_rate
} 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 { fiat_currency, regional_currency, fiat_currency_specification, regional_currency_specification } = info.body const regionalZero = Amounts.zeroOfCurrency(regional_currency); const fiatZero = Amounts.zeroOfCurrency(fiat_currency); const limit = account.balanceIsDebit ? Amounts.sub(account.debitThreshold, account.balance).amount : Amounts.add(account.balance, account.debitThreshold).amount; const zeroCalc = { debit: regionalZero, credit: fiatZero, beforeFee: fiatZero }; const [calc, setCalc] = useState(zeroCalc); const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); const sellRate = conversionInfo.cashout_ratio /** * can be in regional currency or fiat currency * depending on the isDebit flag */ const inputAmount = Amounts.parseOrThrow( `${form.isDebit ? regional_currency : fiat_currency}:${!form.amount ? "0" : form.amount}`, ); useEffect(() => { async function doAsync() { await handleError(async () => { if (Amounts.isNonZero(inputAmount)) { const resp = await (form.isDebit ? calculateFromDebit(inputAmount, sellFee) : calculateFromCredit(inputAmount, sellFee)); 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>({ subject: !form.subject ? i18n.str`required` : undefined, amount: !form.amount ? i18n.str`required` : !inputAmount ? i18n.str`could not be parsed` : Amounts.cmp(limit, calc.debit) === -1 ? i18n.str`balance is not enough` : Amounts.cmp(calc.credit, sellFee) === -1 ? i18n.str`need to be higher due to fees` : Amounts.isZero(calc.credit) ? i18n.str`the total transfer at destination will be zero` : undefined, channel: !form.channel ? i18n.str`required` : undefined, }); const trimmedAmountStr = form.amount?.trim(); async function createCashout() { const request_uid = encodeCrock(getRandomBytes(32)) await handleError(async () => { if (!creds || !form.subject || !form.channel) return; const resp = await api.createCashout(creds, { request_uid, amount_credit: Amounts.stringify(calc.credit), amount_debit: Amounts.stringify(calc.debit), subject: form.subject, tan_channel: form.channel, }) if (resp.type === "ok") { notifyInfo(i18n.str`Cashout created`) } else { switch (resp.case) { case "account-not-found": return notify({ type: "error", title: i18n.str`Account not found`, description: resp.detail.hint as TranslatedString, debug: resp.detail, }); case "request-already-used": return notify({ type: "error", title: i18n.str`Duplicated request detected, check if the operation succeded or try again.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, }); 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-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 "cashout-not-supported": return notify({ type: "error", title: i18n.str`Cashouts are not supported`, description: resp.detail.hint as TranslatedString, debug: resp.detail, }); case "no-cashout-uri": return notify({ type: "error", title: i18n.str`Missing cashout URI in the profile`, description: resp.detail.hint as TranslatedString, debug: resp.detail, }); } assertUnreachable(resp) } }) } const cashoutAccount = !resultAccount.body.cashout_payto_uri ? undefined : parsePaytoUri(resultAccount.body.cashout_payto_uri); const cashoutAccountName = !cashoutAccount ? undefined : cashoutAccount.targetPath return (

Cashout

Convertion rate
{sellRate}
Balance
Fee
{cashoutAccountName ?
To account
{cashoutAccountName}
:
Before doing a cashout you need to complete your profile
}
{ e.preventDefault() }} >
{/* subject */}
{ form.subject = e.currentTarget.value; updateForm(structuredClone(form)); }} autocomplete="off" />
{/* amount */}
{ form.amount = value; updateForm(structuredClone(form)); }} />
{Amounts.isZero(calc.credit) ? undefined : (
Total cost
{Amounts.isZero(sellFee) || Amounts.isZero(calc.beforeFee) ? undefined : ( )}
Total cashout transfer
)} {/* channel */} {config.supported_tan_channels.length === 0 ? undefined :
{config.supported_tan_channels.indexOf(TanChannel.EMAIL) === -1 ? undefined : } {config.supported_tan_channels.indexOf(TanChannel.SMS) === -1 ? undefined : }
}
{onCancel ? :
}
); }