/* 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

Smallest difference between two amounts after the ratio is applied.

Amount to be deducted before amount is credited.

}