/* This file is part of GNU Taler (C) 2022-2024 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 { AbsoluteTime, Amounts, HttpStatusCode, TalerError, TalerErrorCode, TranslatedString, assertUnreachable, 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 "@gnu-taler/web-util/browser"; import { useAccountDetails } from "../../hooks/account.js"; import { useBankState } from "../../hooks/bank-state.js"; import { TransferCalculation, useCashoutEstimator, useConversionInfo, } from "../../hooks/regional.js"; import { useSessionState } from "../../hooks/session.js"; import { RouteDefinition } from "@gnu-taler/web-util/browser"; import { TanChannel, undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { InputAmount, RenderAmount, doAutoFocus, } from "../PaytoWireTransferForm.js"; interface Props { account: string; focus?: boolean; onAuthorizationRequired: () => void; routeClose: RouteDefinition; routeHere: RouteDefinition; } type FormType = { isDebit: boolean; amount: string; subject: string; channel: TanChannel; }; type ErrorFrom = { [P in keyof T]+?: string; }; export function CreateCashout({ account: accountName, onAuthorizationRequired, focus, routeHere, routeClose, }: Props): VNode { const { i18n } = useTranslationContext(); const resultAccount = useAccountDetails(accountName); const { estimateByCredit: calculateFromCredit, estimateByDebit: calculateFromDebit, } = useCashoutEstimator(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const [, updateBankState] = useBankState(); const { lib: { bank: api }, config, hints, } = 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 HttpStatusCode.Unauthorized: return ; case HttpStatusCode.NotFound: return ; default: assertUnreachable(resultAccount); } } if (!info) { return ; } if (info instanceof TalerError) { return ; } if (info.type === "fail") { switch (info.case) { case HttpStatusCode.NotImplemented: { return ( Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode. ); } default: assertUnreachable(info.case); } } 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 [calculationResult, setCalculation] = 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 () => { const higerThanMin = form.isDebit ? Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1 : true; const notZero = Amounts.isNonZero(inputAmount); if (notZero && higerThanMin) { const resp = await (form.isDebit ? calculateFromDebit(inputAmount, sellFee) : calculateFromCredit(inputAmount, sellFee)); setCalculation(resp); } else { setCalculation(zeroCalc); } }); } doAsync(); }, [form.amount, form.isDebit]); const calc = calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult; 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`Invalid` : Amounts.cmp(limit, calc.debit) === -1 ? i18n.str`Balance is not enough` : form.isDebit && Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1 ? i18n.str`Needs to be higher than ${ Amounts.stringifyValueWithSpec( Amounts.parseOrThrow(conversionInfo.cashout_min_amount), regional_currency_specification, ).normal }` : calculationResult === "amount-is-too-small" ? i18n.str`Amount needs to be higher` : Amounts.isZero(calc.credit) ? i18n.str`The total transfer at destination will be zero` : undefined, }); const trimmedAmountStr = form.amount?.trim(); async function createCashout() { const request_uid = encodeCrock(getRandomBytes(32)); await handleError(async () => { // new cashout api doesn't require channel const validChannel = config.supported_tan_channels.length === 0 || form.channel; if (!creds || !form.subject || !validChannel) return; const request = { request_uid, amount_credit: Amounts.stringify(calc.credit), amount_debit: Amounts.stringify(calc.debit), subject: form.subject, tan_channel: form.channel, }; const resp = await api.createCashout(creds, request); if (resp.type === "ok") { notifyInfo(i18n.str`Cashout created`); } else { switch (resp.case) { case HttpStatusCode.Accepted: { updateBankState("currentChallenge", { operation: "create-cashout", id: String(resp.body.challenge_id), sent: AbsoluteTime.never(), location: routeHere.url({}), request, }); return onAuthorizationRequired(); } case HttpStatusCode.NotFound: return notify({ type: "error", title: i18n.str`Account not found`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: return notify({ type: "error", title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_BAD_CONVERSION: return notify({ type: "error", title: i18n.str`The conversion rate was incorrectly applied`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_UNALLOWED_DEBIT: return notify({ type: "error", title: i18n.str`The account does not have sufficient funds`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); case HttpStatusCode.NotImplemented: return notify({ type: "error", title: i18n.str`Cashout are disabled`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: return notify({ type: "error", title: i18n.str`Missing cashout URI in the profile`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: return notify({ type: "error", title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); } assertUnreachable(resp); } }); } const cashoutDisabled = config.supported_tan_channels.length < 1 || !resultAccount.body.cashout_payto_uri; const cashoutAccount = !resultAccount.body.cashout_payto_uri ? undefined : parsePaytoUri(resultAccount.body.cashout_payto_uri); const cashoutAccountName = !cashoutAccount ? undefined : cashoutAccount.targetPath; const cashoutLegalName = !cashoutAccount ? undefined : cashoutAccount.params["receiver-name"]; return (

Cashout

Conversion rate
{sellRate}
Balance
Fee
{cashoutAccountName && cashoutLegalName ? (
To account
{cashoutAccountName}
Legal name
{cashoutLegalName}

If this name doesn't match the account holder's name your transaction may fail.

) : (
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
Balance left
{Amounts.isZero(sellFee) || Amounts.isZero(calc.beforeFee) ? undefined : (
Before fee
)}
Total cashout transfer
)}
Cancel
); }