/* 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, AmountJson, Amounts, HttpStatusCode, TalerBankConversionApi, TalerError, TranslatedString, assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, InternationalizationAPI, LocalNotificationBanner, ShowInputErrorLabel, useLocalNotification, useTranslationContext, utils, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { useBankCoreApiContext } from "../../context/config.js"; import { useSessionState } from "../../hooks/session.js"; import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo, } from "../../hooks/regional.js"; import { RouteDefinition } from "../../route.js"; import { undefinedIfEmpty } from "../../utils.js"; import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; import { FormErrors, FormStatus, FormValues, RecursivePartial, UIField, useFormState, } from "../../hooks/form.js"; interface Props { routeMyAccountDetails: RouteDefinition; routeMyAccountDelete: RouteDefinition; routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; routeConversionConfig: RouteDefinition; routeCancel: RouteDefinition; onUpdateSuccess: () => void; } type FormType = { amount: AmountJson; conv: TalerBankConversionApi.ConversionRate; }; function useComponentState({ routeCancel, routeConversionConfig, routeMyAccountCashout, routeMyAccountDelete, routeMyAccountDetails, routeMyAccountPassword, }: Props): utils.RecursiveState { const { i18n } = useTranslationContext(); const result = useConversionInfo(); const info = result && !(result instanceof TalerError) && result.type === "ok" ? result.body : undefined; const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" || !credentials.isUserAdministrator ? undefined : credentials; if (!info) { return loading...; } if (!creds) { return only admin can setup conversion; } return function afterComponentLoads() { const { i18n } = useTranslationContext(); const { conversion } = useBankCoreApiContext(); const [notification, notify, handleError] = useLocalNotification(); const initalState: FormValues = { amount: "100", conv: { cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1], cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1], cashin_fee: info.conversion_rate.cashin_fee.split(":")[1], cashin_ratio: info.conversion_rate.cashin_ratio, cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode, cashout_min_amount: info.conversion_rate.cashout_min_amount.split(":")[1], cashout_tiny_amount: info.conversion_rate.cashout_tiny_amount.split(":")[1], cashout_fee: info.conversion_rate.cashout_fee.split(":")[1], cashout_ratio: info.conversion_rate.cashout_ratio, cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode, }, }; const [form, status] = useFormState( initalState, createFormValidator(i18n, info.regional_currency, info.fiat_currency), ); const { estimateByDebit: calculateCashoutFromDebit } = useCashoutEstimator(); const { estimateByDebit: calculateCashinFromDebit } = useCashinEstimator(); const [calculationResult, setCalc] = useState<{ cashin: TransferCalculation; cashout: TransferCalculation; }>(); useEffect(() => { async function doAsync() { await handleError(async () => { if (!info) return; if (!form.amount?.value || form.amount.error) return; const in_amount = Amounts.parseOrThrow( `${info.fiat_currency}:${form.amount.value}`, ); const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); const cashin = await calculateCashinFromDebit(in_amount, in_fee); if (cashin === "amount-is-too-small") { setCalc(undefined); return; } // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`) const out_fee = Amounts.parseOrThrow( info.conversion_rate.cashout_fee, ); const cashout = await calculateCashoutFromDebit( cashin.credit, out_fee, ); setCalc({ cashin, cashout }); }); } doAsync(); }, [ form.amount?.value, form.conv?.cashin_fee?.value, form.conv?.cashout_fee?.value, ]); const [section, setSection] = useState<"detail" | "cashout" | "cashin">( "detail", ); const cashinCalc = calculationResult?.cashin === "amount-is-too-small" ? undefined : calculationResult?.cashin; const cashoutCalc = calculationResult?.cashout === "amount-is-too-small" ? undefined : calculationResult?.cashout; async function doUpdate() { if (!creds) return; await handleError(async () => { if (status.status === "fail") return; const resp = await conversion.updateConversionRate( creds.token, status.result.conv, ); if (resp.type === "ok") { setSection("detail"); } else { switch (resp.case) { case HttpStatusCode.Unauthorized: { return notify({ type: "error", title: i18n.str`Wrong credentials`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); } case HttpStatusCode.NotImplemented: { return notify({ type: "error", title: i18n.str`Conversion is disabled`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); } default: assertUnreachable(resp); } } }); } const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio); const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio); const both_high = in_ratio > 1 && out_ratio > 1; const both_low = in_ratio < 1 && out_ratio < 1; return (

Conversion

{ e.preventDefault(); }} > {section == "cashin" && ( )} {section == "cashout" && ( )} {section == "detail" && (
Cashin ratio
{info.conversion_rate.cashin_ratio}
Cashout ratio
{info.conversion_rate.cashout_ratio}
{both_low || both_high ? (
One of the ratios should be higher or equal than 1 an the other should be lower or equal than 1.
) : undefined}

Use it to test how the conversion will affect the amount.

{!cashoutCalc || !cashinCalc ? undefined : (
Sending to this bank
{Amounts.isZero(cashinCalc.beforeFee) ? undefined : (
Converted
)}
Cashin after fee
Sending from this bank
{Amounts.isZero(cashoutCalc.beforeFee) ? undefined : (
Converted
)}
Cashout after fee
{cashoutCalc && status.status === "ok" && Amounts.cmp(status.result.amount, cashoutCalc.credit) < 0 ? (
This configuration allows users to cash out more of what has been cashed in.
) : undefined}
)}
)}
Cancel {section == "cashin" || section == "cashout" ? ( ) : (
)}
); }; } export const ConversionConfig = utils.recursive(useComponentState); /** * * @param i18n * @param regional * @param fiat * @returns form validator */ function createFormValidator( i18n: InternationalizationAPI, regional: string, fiat: string, ) { return function check(state: FormValues): FormStatus { const cashin_min_amount = Amounts.parse( `${fiat}:${state.conv.cashin_min_amount}`, ); const cashin_tiny_amount = Amounts.parse( `${regional}:${state.conv.cashin_tiny_amount}`, ); const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`); const cashout_min_amount = Amounts.parse( `${regional}:${state.conv.cashout_min_amount}`, ); const cashout_tiny_amount = Amounts.parse( `${fiat}:${state.conv.cashout_tiny_amount}`, ); const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`); const am = Amounts.parse(`${fiat}:${state.amount}`); const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? ""); const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? ""); const errors = undefinedIfEmpty>({ conv: undefinedIfEmpty>({ cashin_min_amount: !state.conv.cashin_min_amount ? i18n.str`required` : !cashin_min_amount ? i18n.str`invalid` : undefined, cashin_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : !cashin_tiny_amount ? i18n.str`invalid` : undefined, cashin_fee: !state.conv.cashin_fee ? i18n.str`required` : !cashin_fee ? i18n.str`invalid` : undefined, cashout_min_amount: !state.conv.cashout_min_amount ? i18n.str`required` : !cashout_min_amount ? i18n.str`invalid` : undefined, cashout_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : !cashout_tiny_amount ? i18n.str`invalid` : undefined, cashout_fee: !state.conv.cashin_fee ? i18n.str`required` : !cashout_fee ? i18n.str`invalid` : undefined, cashin_rounding_mode: !state.conv.cashin_rounding_mode ? i18n.str`required` : undefined, cashout_rounding_mode: !state.conv.cashout_rounding_mode ? i18n.str`required` : undefined, cashin_ratio: !state.conv.cashin_ratio ? i18n.str`required` : Number.isNaN(cashin_ratio) ? i18n.str`invalid` : undefined, cashout_ratio: !state.conv.cashout_ratio ? i18n.str`required` : Number.isNaN(cashout_ratio) ? i18n.str`invalid` : undefined, }), amount: !state.amount ? i18n.str`required` : !am ? i18n.str`invalid` : undefined, }); const result: RecursivePartial = { amount: am, conv: { cashin_fee: !errors?.conv?.cashin_fee ? Amounts.stringify(cashin_fee!) : undefined, cashin_min_amount: !errors?.conv?.cashin_min_amount ? Amounts.stringify(cashin_min_amount!) : undefined, cashin_ratio: !errors?.conv?.cashin_ratio ? String(cashin_ratio!) : undefined, cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode ? state.conv.cashin_rounding_mode! : undefined, cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount ? Amounts.stringify(cashin_tiny_amount!) : undefined, cashout_fee: !errors?.conv?.cashout_fee ? Amounts.stringify(cashout_fee!) : undefined, cashout_min_amount: !errors?.conv?.cashout_min_amount ? Amounts.stringify(cashout_min_amount!) : undefined, cashout_ratio: !errors?.conv?.cashout_ratio ? String(cashout_ratio!) : undefined, cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode ? state.conv.cashout_rounding_mode! : undefined, cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount ? Amounts.stringify(cashout_tiny_amount!) : undefined, }, }; return errors === undefined ? { status: "ok", result: result as FormType, errors } : { status: "fail", result, errors }; }; } function ConversionForm({ id, inputCurrency, outputCurrency, fee, minimum, ratio, rounding, tiny, }: { inputCurrency: string; outputCurrency: string; minimum: UIField | undefined; tiny: UIField | undefined; fee: UIField | undefined; rounding: UIField | undefined; ratio: UIField | undefined; id: string; }): VNode { const { i18n } = useTranslationContext(); return (

Only cashout operation above this threshold will be allowed

{ ratio?.onUpdate(e.currentTarget.value); }} autocomplete="off" />

Conversion ratio between currencies

1 {inputCurrency} will be converted into {ratio?.value}{" "} {outputCurrency}

Smallest difference between two amounts after the ratio is applied.

Rounding an amount of 1.24 with rounding value 0.1

Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4.

With the "zero" mode the value will be rounded to 1.2

With the "nearest" mode the value will be rounded to 1.2

With the "up" mode the value will be rounded to 1.3

Rounding an amount of 1.26 with rounding value 0.1

Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4.

With the "zero" mode the value will be rounded to 1.2

With the "nearest" mode the value will be rounded to 1.3

With the "up" mode the value will be rounded to 1.3

Rounding an amount of 1.24 with rounding value 0.3

Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8.

With the "zero" mode the value will be rounded to 1.2

With the "nearest" mode the value will be rounded to 1.2

With the "up" mode the value will be rounded to 1.5

Rounding an amount of 1.26 with rounding value 0.3

Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8.

With the "zero" mode the value will be rounded to 1.2

With the "nearest" mode the value will be rounded to 1.3

With the "up" mode the value will be rounded to 1.3

Amount to be deducted before amount is credited.

); }