/* 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 { 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 { useBackendState } from "../hooks/backend.js"; import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo } from "../hooks/circuit.js"; import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { InputAmount, RenderAmount } from "./PaytoWireTransferForm.js"; import { ProfileNavigation } from "./ProfileNavigation.js"; interface Props { routeMyAccountDetails: RouteDefinition; routeMyAccountDelete: RouteDefinition; routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; routeConversionConfig: RouteDefinition; routeCancel: RouteDefinition; onUpdateSuccess: () => void; } type UIField = { value: string | undefined; onUpdate: (s: string) => void; error: TranslatedString | undefined; } type FormHandler = { [k in keyof T]?: T[k] extends string ? UIField : T[k] extends AmountJson ? UIField : FormHandler; } type FormValues = { [k in keyof T]: T[k] extends string ? (string | undefined) : T[k] extends AmountJson ? (string | undefined) : FormValues; } type RecursivePartial = { [k in keyof T]?: T[k] extends string ? (string) : T[k] extends AmountJson ? (AmountJson) : RecursivePartial; } type FormErrors = { [k in keyof T]?: T[k] extends string ? (TranslatedString) : T[k] extends AmountJson ? (TranslatedString) : FormErrors; } type FormStatus = { status: "ok", result: T, errors: undefined, } | { status: "fail", result: RecursivePartial, errors: FormErrors, } type FormType = { amount: AmountJson, conv: TalerBankConversionApi.ConversionRate } function constructFormHandler(form: FormValues, updateForm: (d: FormValues) => void, errors: FormErrors | undefined): FormHandler { const keys = (Object.keys(form) as Array) const handler = keys.reduce((prev, fieldName) => { const currentValue: any = form[fieldName]; const currentError: any = errors ? errors[fieldName] : undefined; function updater(newValue: any) { updateForm({ ...form, [fieldName]: newValue }) } if (typeof currentValue === "object") { const group = constructFormHandler(currentValue, updater, currentError) // @ts-expect-error asdasd prev[fieldName] = group return prev; } const field: UIField = { error: currentError, value: currentValue, onUpdate: updater } // @ts-expect-error asdasd prev[fieldName] = field return prev }, {} as FormHandler) return handler; } function useFormState(defaultValue: FormValues, check: (f: FormValues) => FormStatus): [FormHandler, FormStatus] { const [form, updateForm] = useState>(defaultValue) const status = check(form) const handler = constructFormHandler(form, updateForm, status.errors) return [handler, status] } function useComponentState({ onUpdateSuccess, routeCancel, routeConversionConfig, routeMyAccountCashout, routeMyAccountDelete, routeMyAccountDetails, routeMyAccountPassword, }: Props): utils.RecursiveState { const result = useConversionInfo() const info = result && !(result instanceof TalerError) && result.type === "ok" ? result.body : undefined; const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" || !credentials.isUserAdministrator ? undefined : credentials; if (!info) { return
waiting...
} if (!creds) { return
only admin can setup conversion
; } return () => { const { i18n } = useTranslationContext(); const { api, config } = 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, checkConversionForm(i18n, info.regional_currency, info.fiat_currency) ) const { estimateByDebit: calculateCashoutFromDebit, } = useCashoutEstimator(); const { estimateByDebit: calculateCashinFromDebit, } = useCashinEstimator(); const [calc, 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); // 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") async function doUpdate() { if (!creds) return await handleError(async () => { if (status.status === "fail") return; const resp = await api .getConversionInfoAPI() .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, }); } case HttpStatusCode.NotImplemented: { return notify({ type: "error", title: i18n.str`Conversion is disabled`, description: resp.detail.hint as TranslatedString, debug: resp.detail, }); } 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.

{!calc ? undefined : (
Sending to this bank
{Amounts.isZero(calc.cashin.beforeFee) ? undefined : (
Converted
)}
Cashin after fee
Sending from this bank
{Amounts.isZero(calc.cashout.beforeFee) ? undefined : (
Converted
)}
Cashout after fee
{calc && status.status === "ok" && Amounts.cmp(status.result.amount, calc.cashout.credit) < 0 ?
This configuration allows users to cash out more of what has been cashed in.
: undefined}
)}
}
Cancel {section == "cashin" || section == "cashout" ? :
}
); } } /** * Show histories of public accounts. */ export const ConversionConfig = utils.recursive(useComponentState); function checkConversionForm(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.

}