taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 6778769ddb0d12a40edb5e668071879d664ddff8
parent 725c4f06b5cec6e96eb07fd85c84fd99183095c6
Author: Antoine A <>
Date:   Fri, 18 Jul 2025 12:16:29 +0200

WIP UI for conversion rate classes

Diffstat:
Mpackages/bank-ui/src/components/Cashouts/state.ts | 2+-
Mpackages/bank-ui/src/components/Cashouts/views.tsx | 33++++-----------------------------
Mpackages/bank-ui/src/hooks/regional.ts | 119++++++++++++++++++++++---------------------------------------------------------
Mpackages/bank-ui/src/pages/ConversionRateClassDetails.tsx | 123++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mpackages/bank-ui/src/pages/PaytoWireTransferForm.tsx | 4+++-
Mpackages/bank-ui/src/pages/admin/ConversionClassList.tsx | 6+++---
Mpackages/bank-ui/src/pages/regional/ConversionConfig.tsx | 110+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mpackages/bank-ui/src/pages/regional/CreateCashout.tsx | 68++++++++++++++++++++++++++++----------------------------------------
Mpackages/taler-util/src/http-client/bank-conversion.ts | 26++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-corebank.ts | 28++++++++++++++++++++++------
10 files changed, 248 insertions(+), 271 deletions(-)

diff --git a/packages/bank-ui/src/components/Cashouts/state.ts b/packages/bank-ui/src/components/Cashouts/state.ts @@ -46,6 +46,6 @@ export function useComponentState({ status: "ready", error: undefined, cashouts: result.body.cashouts, - routeCashoutDetails, + routeCashoutDetails }; } diff --git a/packages/bank-ui/src/components/Cashouts/views.tsx b/packages/bank-ui/src/components/Cashouts/views.tsx @@ -18,20 +18,17 @@ import { AbsoluteTime, Amounts, HttpStatusCode, - TalerError, assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, - Loading, Time, + useBankCoreApiContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; -import { useConversionInfo } from "../../hooks/regional.js"; import { RenderAmount } from "../../pages/PaytoWireTransferForm.js"; -import { ErrorLoadingWithDebug } from "../ErrorLoadingWithDebug.js"; import { State } from "./index.js"; const TALER_SCREEN_ID = 3; @@ -59,29 +56,7 @@ export function ReadyView({ routeCashoutDetails, }: State.Ready): VNode { const { i18n, dateLocale } = useTranslationContext(); - const resp = useConversionInfo(); - if (!resp) { - return <Loading />; - } - if (resp instanceof TalerError) { - return <ErrorLoadingWithDebug error={resp} />; - } - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.NotImplemented: { - return ( - <Attention type="danger" title={i18n.str`Cashout is disabled`}> - <i18n.Translate> - Cashout should be enabled in the configuration, the conversion - rate should be initialized with fee(s), rates and a rounding mode. - </i18n.Translate> - </Attention> - ); - } - default: - assertUnreachable(resp); - } - } + const { config } = useBankCoreApiContext(); if (!cashouts.length) return <div />; const txByDate = cashouts.reduce( @@ -192,13 +167,13 @@ export function ReadyView({ <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-red-600 cursor-pointer"> <RenderAmount value={Amounts.parseOrThrow(item.amount_debit)} - spec={resp.body.regional_currency_specification} + spec={config.currency_specification} /> </td> <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-green-600 cursor-pointer"> <RenderAmount value={Amounts.parseOrThrow(item.amount_credit)} - spec={resp.body.fiat_currency_specification} + spec={config.fiat_currency_specification!} /> </td> diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts @@ -44,10 +44,10 @@ const useSWR = _useSWR as unknown as SWRHook; export type TransferCalculation = | { - debit: AmountJson; - credit: AmountJson; - beforeFee: AmountJson; - } + debit: AmountJson; + credit: AmountJson; + beforeFee: AmountJson; + } | "amount-is-too-small"; type EstimatorFunction = ( amount: AmountJson, @@ -94,17 +94,20 @@ export function useConversionInfo() { return undefined; } -export function useConversionInfoForUser(username: string) { +export function useConversionRateForUser( + username: string, + token: AccessToken | undefined, +) { const { lib: { conversionForUser }, config, } = useBankCoreApiContext(); async function fetcher() { - return await conversionForUser(username).getConfig(); + return await conversionForUser(username).getRate(token); } const { data, error } = useSWR< - TalerBankConversionResultByMethod<"getConfig">, + TalerBankConversionResultByMethod<"getRate">, TalerHttpError >( !config.allow_conversion ? undefined : ["useConversionInfoForUser"], @@ -229,42 +232,40 @@ function buildEstimatorWithTheBackend( }; } -export function useCashinEstimator(): ConversionEstimators { - const { - lib: { conversion }, - } = useBankCoreApiContext(); +function buildConversionEstimatorsWithTheBackend( + conversion: TalerBankConversionHttpClient, + direction: "cashin" | "cashout" +): ConversionEstimators { + const { state } = useSessionState(); + const token = state.status === "loggedIn" ? state.token : undefined; return { estimateByCredit: buildEstimatorWithTheBackend( conversion, - undefined, - "cashin-rate-from-credit", + token, + direction == "cashin" ? "cashin-rate-from-credit" : "cashout-rate-from-credit", ), estimateByDebit: buildEstimatorWithTheBackend( conversion, - undefined, - "cashin-rate-from-debit", + token, + direction == "cashin" ? "cashin-rate-from-debit" : "cashout-rate-from-debit", ), }; } -export function useCashoutEstimator(): ConversionEstimators { +export function useCashinEstimator(): ConversionEstimators { const { lib: { conversion }, } = useBankCoreApiContext(); - return { - estimateByCredit: buildEstimatorWithTheBackend( - conversion, - undefined, - "cashout-rate-from-credit", - ), - estimateByDebit: buildEstimatorWithTheBackend( - conversion, - undefined, - "cashout-rate-from-debit", - ), - }; + return buildConversionEstimatorsWithTheBackend(conversion, "cashin") +} + +export function useCashoutEstimator(): ConversionEstimators { + const { + lib: { conversion }, + } = useBankCoreApiContext(); + return buildConversionEstimatorsWithTheBackend(conversion, "cashout") } export function useCashinEstimatorForClass( @@ -273,21 +274,7 @@ export function useCashinEstimatorForClass( const { lib: { conversionForClass }, } = useBankCoreApiContext(); - const { state } = useSessionState(); - const token = state.status === "loggedIn" ? state.token : undefined; - - return { - estimateByCredit: buildEstimatorWithTheBackend( - conversionForClass(classId), - token, - "cashin-rate-from-credit", - ), - estimateByDebit: buildEstimatorWithTheBackend( - conversionForClass(classId), - token, - "cashin-rate-from-debit", - ), - }; + return buildConversionEstimatorsWithTheBackend(conversionForClass(classId), "cashin") } export function useCashoutEstimatorForClass( @@ -296,20 +283,7 @@ export function useCashoutEstimatorForClass( const { lib: { conversionForClass }, } = useBankCoreApiContext(); - const { state } = useSessionState(); - const token = state.status === "loggedIn" ? state.token : undefined; - return { - estimateByCredit: buildEstimatorWithTheBackend( - conversionForClass(classId), - token, - "cashout-rate-from-credit", - ), - estimateByDebit: buildEstimatorWithTheBackend( - conversionForClass(classId), - token, - "cashout-rate-from-debit", - ), - }; + return buildConversionEstimatorsWithTheBackend(conversionForClass(classId), "cashout") } export function useCashinEstimatorByUser( @@ -318,21 +292,7 @@ export function useCashinEstimatorByUser( const { lib: { conversionForUser }, } = useBankCoreApiContext(); - const { state } = useSessionState(); - const token = state.status === "loggedIn" ? state.token : undefined; - - return { - estimateByCredit: buildEstimatorWithTheBackend( - conversionForUser(username), - token, - "cashin-rate-from-credit", - ), - estimateByDebit: buildEstimatorWithTheBackend( - conversionForUser(username), - token, - "cashin-rate-from-debit", - ), - }; + return buildConversionEstimatorsWithTheBackend(conversionForUser(username), "cashin") } export function useCashoutEstimatorByUser( @@ -341,20 +301,7 @@ export function useCashoutEstimatorByUser( const { lib: { conversionForUser }, } = useBankCoreApiContext(); - const { state } = useSessionState(); - const token = state.status === "loggedIn" ? state.token : undefined; - return { - estimateByCredit: buildEstimatorWithTheBackend( - conversionForUser(username), - token, - "cashout-rate-from-credit", - ), - estimateByDebit: buildEstimatorWithTheBackend( - conversionForUser(username), - token, - "cashout-rate-from-debit", - ), - }; + return buildConversionEstimatorsWithTheBackend(conversionForUser(username), "cashout") } export async function revalidateBusinessAccounts() { diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx @@ -48,6 +48,7 @@ import { RecursivePartial, undefinedIfEmpty } from "../utils.js"; import { doAutoFocus, InputAmount } from "./PaytoWireTransferForm.js"; import { ConversionForm } from "./regional/ConversionConfig.js"; import { AmountJson } from "@gnu-taler/taler-util"; +import { DescribeConversion } from "./admin/ConversionClassList.js"; interface Props { classId: number; @@ -67,7 +68,7 @@ type FormType = { export function ConversionRateClassDetails({ routeCancel, classId, - onClassDeleted + onClassDeleted, }: Props): VNode { const { i18n } = useTranslationContext(); @@ -139,7 +140,7 @@ function Form({ const [section, setSection] = useState< "detail" | "cashout" | "cashin" | "users" | "test" | "delete" - >("delete"); + >("detail"); const initalState: FormValues<FormType> = { name: detailsResult.name, @@ -460,16 +461,6 @@ function Form({ <div class="px-6 pt-6"> <div class="justify-between items-center flex "> <dt class="text-sm text-gray-600"> - <i18n.Translate>Users</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - {detailsResult.num_users} - </dd> - </div> - </div> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> <i18n.Translate>Name</i18n.Translate> </dt> <dd class="text-sm text-gray-900"> @@ -525,37 +516,19 @@ function Form({ </dd> </div> </div> - <div class="px-6 pt-6"> <div class="justify-between items-center flex "> <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashin minimum</i18n.Translate> + <i18n.Translate>Cashin</i18n.Translate> </dt> <dd class="text-sm text-gray-900"> - <RenderAmount - value={Amounts.parseOrThrow(final_cashin_min)} - spec={conversionInfo.fiat_currency_specification} - /> - </dd> - </div> - </div> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashin ratio</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900">{final_cashin_ratio}</dd> - </div> - </div> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashin fee</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - <RenderAmount - value={Amounts.parseOrThrow(final_cashin_fee)} - spec={conversionInfo.regional_currency_specification} + <DescribeConversion + ratio={final_cashin_ratio} + fee={final_cashin_fee} + min={final_cashin_min} + rounding={final_cashin_rounding} + minSpec={conversionInfo.fiat_currency_specification} + feeSpec={conversionInfo.regional_currency_specification} /> </dd> </div> @@ -564,37 +537,28 @@ function Form({ <div class="px-6 pt-6"> <div class="justify-between items-center flex "> <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashout minimum</i18n.Translate> + <i18n.Translate>Cashout</i18n.Translate> </dt> <dd class="text-sm text-gray-900"> - <RenderAmount - value={Amounts.parseOrThrow(final_cashout_min)} - spec={conversionInfo.regional_currency_specification} + <DescribeConversion + ratio={final_cashout_ratio} + fee={final_cashout_fee} + min={final_cashout_min} + rounding={final_cashout_rounding} + minSpec={conversionInfo.regional_currency_specification} + feeSpec={conversionInfo.fiat_currency_specification} /> </dd> </div> </div> - <div class="px-6 pt-6"> - <div class="justify-between items-center flex "> - <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashout ratio</i18n.Translate> - </dt> - <dd class="text-sm text-gray-900"> - {conversionInfo.conversion_rate.cashout_ratio} - </dd> - </div> - </div> <div class="px-6 pt-6"> <div class="justify-between items-center flex "> <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashout fee</i18n.Translate> + <i18n.Translate>Users</i18n.Translate> </dt> <dd class="text-sm text-gray-900"> - <RenderAmount - value={Amounts.parseOrThrow(final_cashout_fee)} - spec={conversionInfo.fiat_currency_specification} - /> + {detailsResult.num_users} </dd> </div> </div> @@ -1040,6 +1004,11 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { config, } = useBankCoreApiContext(); const { state } = useSessionState(); + const resultInfo = useConversionInfo(); + const convInfo = + !resultInfo || resultInfo instanceof Error || resultInfo.type === "fail" + ? undefined + : resultInfo.body; const token = state.status === "loggedIn" ? state.token : undefined; const [filter, setFilter] = useState<{ @@ -1097,6 +1066,8 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { filter.showAll = !!v; if (!v) { filter.classId = classId; + } else { + filter.classId = undefined; } setFilter(structuredClone(filter)); }, @@ -1151,7 +1122,15 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - >{i18n.str`Conversion rate`}</th> + >{i18n.str`Class`}</th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{i18n.str`Cashin`}</th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{i18n.str`Cashout`}</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" @@ -1170,10 +1149,34 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { {item.name} </td> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - {"<pending>"} + {item.conversion_rate_class_id} + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + <DescribeConversion + ratio={item.conversion_rate!.cashin_ratio} + fee={item.conversion_rate!.cashin_fee} + min={item.conversion_rate!.cashin_min_amount} + rounding={ + item.conversion_rate!.cashin_rounding_mode + } + minSpec={convInfo!.fiat_currency_specification} + feeSpec={convInfo!.regional_currency_specification} + /> + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + <DescribeConversion + ratio={item.conversion_rate!.cashout_ratio} + fee={item.conversion_rate!.cashout_fee} + min={item.conversion_rate!.cashout_min_amount} + rounding={ + item.conversion_rate!.cashout_rounding_mode + } + minSpec={convInfo!.fiat_currency_specification} + feeSpec={convInfo!.regional_currency_specification} + /> </td> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - {classId === filter.classId ? ( + {classId === item.conversion_rate_class_id ? ( <button class="disabled:opacity-50 disabled:bg-gray-600 disabled:hover:bg-gray-600 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" onClick={async () => { diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -739,12 +739,14 @@ export function InputAmount( name, value, left, + placeholder, onChange, }: { currency: string; name: string; left?: boolean | undefined; value: string | undefined; + placeholder?: string | undefined; onChange?: (s: string) => void; }, ref: Ref<HTMLInputElement>, @@ -760,7 +762,7 @@ export function InputAmount( type="number" data-left={left} class="disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6" - placeholder="0.00" + placeholder={placeholder ?? "0.00"} aria-describedby="price-currency" ref={ref} name={name} diff --git a/packages/bank-ui/src/pages/admin/ConversionClassList.tsx b/packages/bank-ui/src/pages/admin/ConversionClassList.tsx @@ -182,7 +182,7 @@ export function ConversionClassList({ classId: String(row.conversion_rate_class_id), })} > - <DescribeRatio + <DescribeConversion ratio={ row.cashin_ratio ?? convInfo.conversion_rate.cashin_ratio @@ -212,7 +212,7 @@ export function ConversionClassList({ classId: String(row.conversion_rate_class_id), })} > - <DescribeRatio + <DescribeConversion ratio={ row.cashout_ratio ?? convInfo.conversion_rate.cashout_ratio @@ -273,7 +273,7 @@ export function ConversionClassList({ ); } -function DescribeRatio({ +export function DescribeConversion({ fee, min, ratio, diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -27,6 +27,7 @@ import { import { Attention, InternationalizationAPI, + Loading, LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, @@ -55,6 +56,8 @@ import { useSessionState } from "../../hooks/session.js"; import { undefinedIfEmpty } from "../../utils.js"; import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { DescribeConversion } from "../admin/ConversionClassList.js"; const TALER_SCREEN_ID = 126; @@ -83,29 +86,43 @@ function useComponentState({ }: Props): utils.RecursiveState<VNode> { 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 <i18n.Translate>loading...</i18n.Translate>; - } - if (!creds) { return <i18n.Translate>only admin can setup conversion</i18n.Translate>; } - return function afterComponentLoads() { - const { i18n } = useTranslationContext(); + const resp = useConversionInfo(); + if (!resp) { + return <Loading />; + } + if (resp instanceof TalerError) { + return <ErrorLoadingWithDebug error={resp} />; + } + + if (resp.type !== "ok") { + switch (resp.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention type="danger" title={i18n.str`Cashout is disabled`}> + <i18n.Translate> + Cashout should be enabled in the configuration, the conversion + rate should be initialized with fee(s), rates and a rounding mode. + </i18n.Translate> + </Attention> + ); + } + default: + assertUnreachable(resp); + } + } + const info = resp.body; + return function afterComponentLoads() { const { lib: { conversion }, } = useBankCoreApiContext(); @@ -119,9 +136,8 @@ function useComponentState({ 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, - cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1], - - + cashin_tiny_amount: + info.conversion_rate.cashin_tiny_amount.split(":")[1], cashout_min_amount: info.conversion_rate.cashout_min_amount.split(":")[1], cashout_fee: info.conversion_rate.cashout_fee.split(":")[1], @@ -365,10 +381,17 @@ function useComponentState({ <div class="px-6 pt-6"> <div class="justify-between items-center flex "> <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashin ratio</i18n.Translate> + <i18n.Translate>Cashin</i18n.Translate> </dt> <dd class="text-sm text-gray-900"> - {info.conversion_rate.cashin_ratio} + <DescribeConversion + ratio={info.conversion_rate.cashin_ratio} + fee={info.conversion_rate.cashin_fee} + min={info.conversion_rate.cashin_min_amount} + rounding={info.conversion_rate.cashin_rounding_mode} + minSpec={info.fiat_currency_specification} + feeSpec={info.regional_currency_specification} + /> </dd> </div> </div> @@ -376,10 +399,17 @@ function useComponentState({ <div class="px-6 pt-6"> <div class="justify-between items-center flex "> <dt class="text-sm text-gray-600"> - <i18n.Translate>Cashout ratio</i18n.Translate> + <i18n.Translate>Cashout</i18n.Translate> </dt> <dd class="text-sm text-gray-900"> - {info.conversion_rate.cashout_ratio} + <DescribeConversion + ratio={info.conversion_rate.cashout_ratio} + fee={info.conversion_rate.cashout_fee} + min={info.conversion_rate.cashout_min_amount} + rounding={info.conversion_rate.cashout_rounding_mode} + minSpec={info.regional_currency_specification} + feeSpec={info.fiat_currency_specification} + /> </dd> </div> </div> @@ -592,14 +622,17 @@ function createFormValidator( 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_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}`); @@ -666,6 +699,9 @@ function createFormValidator( cashin_min_amount: !errors?.conv?.cashin_min_amount ? Amounts.stringify(cashin_min_amount!) : undefined, + cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount + ? Amounts.stringify(cashin_tiny_amount!) + : undefined, cashin_ratio: !errors?.conv?.cashin_ratio ? String(cashin_ratio!) : undefined, @@ -678,6 +714,9 @@ function createFormValidator( cashout_min_amount: !errors?.conv?.cashout_min_amount ? Amounts.stringify(cashout_min_amount!) : undefined, + cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount + ? Amounts.stringify(cashout_tiny_amount!) + : undefined, cashout_ratio: !errors?.conv?.cashout_ratio ? String(cashout_ratio!) : undefined, @@ -733,6 +772,7 @@ export function ConversionForm({ currency={inputCurrency} value={minimum?.value ?? ""} onChange={minimum?.onUpdate} + placeholder={fallback_minimum} /> <ShowInputErrorLabel message={minimum?.error} @@ -744,13 +784,6 @@ export function ConversionForm({ </i18n.Translate> &nbsp; </p> - {!fallback_minimum ? undefined : ( - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - If none specified the fallback value is {fallback_minimum}. - </i18n.Translate> - </p> - )} </div> </div> </div> @@ -774,6 +807,7 @@ export function ConversionForm({ ratio?.onUpdate(e.currentTarget.value); }} autocomplete="off" + placeholder={fallback_ratio ?? "1.0"} /> <ShowInputErrorLabel message={ratio?.error} @@ -783,13 +817,6 @@ export function ConversionForm({ <p class="mt-2 text-sm text-gray-500"> <i18n.Translate>Conversion ratio between currencies</i18n.Translate> </p> - {!fallback_ratio ? undefined : ( - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - If none specified the fallback value is {fallback_ratio}. - </i18n.Translate> - </p> - )} </div> <div class="px-6 pt-4"> @@ -1115,7 +1142,7 @@ export function ConversionForm({ <p class="text-gray-900 my-4"> <i18n.Translate> With the "up" mode the value will be rounded to 1.3 - </i18n.Translate> + </i18n.Translate>.0 </p> </details> </section> @@ -1135,6 +1162,7 @@ export function ConversionForm({ currency={outputCurrency} value={fee?.value ?? ""} onChange={fee?.onUpdate} + placeholder={fallback_fee} /> <ShowInputErrorLabel message={fee?.error} @@ -1147,14 +1175,6 @@ export function ConversionForm({ </p> </div> </div> - {!fallback_fee ? undefined : ( - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - If none specified the fallback value is "{fallback_fee} - ". - </i18n.Translate> - </p> - )} </div> </Fragment> ); diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -43,9 +43,8 @@ import { useAccountDetails } from "../../hooks/account.js"; import { useBankState } from "../../hooks/bank-state.js"; import { TransferCalculation, - useCashoutEstimator, - useConversionInfo, - useConversionInfoForUser, + useCashoutEstimatorByUser, + useConversionRateForUser, } from "../../hooks/regional.js"; import { useSessionState } from "../../hooks/session.js"; import { TanChannel, undefinedIfEmpty } from "../../utils.js"; @@ -90,7 +89,7 @@ export function CreateCashout({ const { estimateByCredit: calculateFromCredit, estimateByDebit: calculateFromDebit, - } = useCashoutEstimator(); + } = useCashoutEstimatorByUser(accountName); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const [, updateBankState] = useBankState(); @@ -101,7 +100,7 @@ export function CreateCashout({ } = useBankCoreApiContext(); const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); const [notification, notify, handleError] = useLocalNotification(); - const info = useConversionInfoForUser(accountName); + const resp = useConversionRateForUser(accountName, creds?.token); if (!config.allow_conversion) { return ( @@ -150,15 +149,15 @@ export function CreateCashout({ assertUnreachable(resultAccount); } } - if (!info) { + if (!resp) { return <Loading />; } - if (info instanceof TalerError) { - return <ErrorLoadingWithDebug error={info} />; + if (resp instanceof TalerError) { + return <ErrorLoadingWithDebug error={resp} />; } - if (info.type === "fail") { - switch (info.case) { + if (resp.type === "fail") { + switch (resp.case) { case HttpStatusCode.NotImplemented: { return ( <Attention type="danger" title={i18n.str`Cashout is disabled`}> @@ -170,12 +169,12 @@ export function CreateCashout({ ); } default: - assertUnreachable(info); + assertUnreachable(resp); } } + const rate = resp.body; - const conversionInfo = info.body.conversion_rate; - if (!conversionInfo) { + if (!rate) { return ( <div>conversion enabled but server replied without conversion_rate</div> ); @@ -183,22 +182,18 @@ export function CreateCashout({ const { fiat_currency, - regional_currency, + currency: regional_currency, fiat_currency_specification, - regional_currency_specification, - } = info.body; + currency_specification: regional_currency_specification, + } = config; const regionalZero = Amounts.zeroOfCurrency(regional_currency); - const fiatZero = Amounts.zeroOfCurrency(fiat_currency); + const fiatZero = Amounts.zeroOfCurrency(fiat_currency!); const account = { balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), balanceIsDebit: resultAccount.body.balance.credit_debit_indicator == "debit", debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), - minCashout: - conversionInfo.cashin_min_amount === undefined - ? regionalZero - : Amounts.parseOrThrow(conversionInfo.cashin_min_amount), }; const limit = account.balanceIsDebit @@ -212,8 +207,8 @@ export function CreateCashout({ }; const [calculationResult, setCalculation] = useState<TransferCalculation>(zeroCalc); - const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); - const sellRate = conversionInfo.cashout_ratio; + const sellFee = Amounts.parseOrThrow(rate.cashout_fee); + const sellRate = rate.cashout_ratio; /** * can be in regional currency or fiat currency * depending on the isDebit flag @@ -228,7 +223,7 @@ export function CreateCashout({ async function doAsync() { await handleError(async () => { const higerThanMin = form.isDebit - ? Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1 + ? Amounts.cmp(inputAmount, rate.cashout_min_amount) === 1 : true; const notZero = Amounts.isNonZero(inputAmount); if (notZero && higerThanMin) { @@ -262,23 +257,16 @@ export function CreateCashout({ ? i18n.str`Balance is not enough` : calculationResult === "amount-is-too-small" ? i18n.str`Amount needs to be higher` - : Amounts.cmp(calc.debit, conversionInfo.cashout_min_amount) < 0 + : Amounts.cmp(calc.debit, rate.cashout_min_amount) < 0 ? i18n.str`It is not possible to cashout less than ${ Amounts.stringifyValueWithSpec( - Amounts.parseOrThrow(conversionInfo.cashout_min_amount), + Amounts.parseOrThrow(rate.cashout_min_amount), regional_currency_specification, ).normal }` - : Amounts.cmp(calc.debit, account.minCashout) < 0 - ? i18n.str`Your account have a cashout limit. Minimum account cashout is ${ - Amounts.stringifyValueWithSpec( - Amounts.parseOrThrow(account.minCashout), - regional_currency_specification, - ).normal - }` - : Amounts.isZero(calc.credit) - ? i18n.str`The total transfer to the destination will be zero` - : undefined, + : Amounts.isZero(calc.credit) + ? i18n.str`The total transfer to the destination will be zero` + : undefined, }); const trimmedAmountStr = form.amount?.trim(); @@ -441,7 +429,7 @@ export function CreateCashout({ <dd class="text-sm text-gray-900"> <RenderAmount value={sellFee} - spec={fiat_currency_specification} + spec={fiat_currency_specification!} /> </dd> </div> @@ -645,7 +633,7 @@ export function CreateCashout({ <InputAmount name="amount" left - currency={form.isDebit ? regional_currency : fiat_currency} + currency={form.isDebit ? regional_currency : fiat_currency!} value={trimmedAmountStr} onChange={ cashoutDisabled @@ -704,7 +692,7 @@ export function CreateCashout({ <dd class="text-sm text-gray-900"> <RenderAmount value={calc.beforeFee} - spec={fiat_currency_specification} + spec={fiat_currency_specification!} /> </dd> </div> @@ -717,7 +705,7 @@ export function CreateCashout({ <RenderAmount value={calc.credit} withColor - spec={fiat_currency_specification} + spec={fiat_currency_specification!} /> </dd> </div> diff --git a/packages/taler-util/src/http-client/bank-conversion.ts b/packages/taler-util/src/http-client/bank-conversion.ts @@ -37,6 +37,7 @@ import { codecForCashinConversionResponse, codecForCashoutConversionResponse, codecForConversionBankConfig, + codecForConversionRate, } from "../types-taler-bank-conversion.js"; import { AccessToken } from "../types-taler-common.js"; import { codecForTalerErrorDetail } from "../types-taler-wallet.js"; @@ -100,6 +101,31 @@ export class TalerBankConversionHttpClient { } /** + * https://docs.taler.net/core/api-bank-conversion-info.html#get--rate + * + */ + async getRate(auth: AccessToken | undefined) { + const url = new URL(`rate`, this.baseUrl); + const headers: Record<string, string> = {}; + if (auth) { + headers.Authorization = makeBearerTokenAuthHeader(auth); + } + console.log(auth) + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + headers + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForConversionRate()); + case HttpStatusCode.NotImplemented: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** * https://docs.taler.net/core/api-bank-conversion-info.html#get--cashin-rate * */ diff --git a/packages/taler-util/src/types-taler-corebank.ts b/packages/taler-util/src/types-taler-corebank.ts @@ -118,6 +118,14 @@ export interface TalerCorebankConfigResponse { // How the bank SPA should render this currency. currency_specification: CurrencySpecification; + // External currency used during conversion. + // None if conversion is disabled + fiat_currency?: string; + + // How the bank SPA should render this currency. + // None if conversion is disabled + fiat_currency_specification?: CurrencySpecification; + // TAN channels supported by the server supported_tan_channels?: TanChannel[]; @@ -451,6 +459,12 @@ export interface AccountMinimalData { // Defaults to 'active' is missing // @since **v4**, will become mandatory in the next version. status?: AccountStatus; + + // Conversion rate class of the user + conversion_rate_class_id?: Integer; + + // Applied conversion rate + conversion_rate?: ConversionRate; } export type AccountStatus = "active" | "locked" | "deleted"; @@ -468,9 +482,6 @@ export interface ConversionRateClass { // Number of users affected to this class num_users: Integer; - // Applied conversion rate - conversion_rate?: ConversionRate; - // Minimum fiat amount authorised for cashin before conversion cashin_min_amount?: AmountString; @@ -497,7 +508,6 @@ export interface ConversionRateClass { } export interface ConversionRateClasses { - default: ConversionRate; classes: ConversionRateClass[]; } @@ -626,6 +636,9 @@ export interface AccountData { // @since **v4**, will become mandatory in the next version. status?: AccountStatus; + // Conversion rate class of the user + conversion_rate_class_id?: Integer; + // Conversion rate available to the user // Only present if conversion is activated on the server // @since **v9** @@ -849,6 +862,8 @@ export const codecForCoreBankConfig = (): Codec<TalerCorebankConfigResponse> => .property("default_debit_threshold", codecOptional(codecForAmountString())) .property("currency", codecForString()) .property("currency_specification", codecForCurrencySpecificiation()) + .property("fiat_currency", codecOptional(codecForString())) + .property("fiat_currency_specification", codecOptional(codecForCurrencySpecificiation())) .property( "supported_tan_channels", codecOptional( @@ -913,6 +928,8 @@ export const codecForAccountMinimalData = (): Codec<AccountMinimalData> => ), ), ) + .property("conversion_rate_class_id", codecOptional(codecForNumber())) + .property("conversion_rate", codecOptional(codecForConversionRate())) .build("TalerCorebankApi.AccountMinimalData"); export const codecForListBankAccountsResponse = @@ -931,6 +948,7 @@ export const codecForAccountData = (): Codec<AccountData> => .property("cashout_payto_uri", codecOptional(codecForPaytoString())) .property("is_public", codecForBoolean()) .property("is_taler_exchange", codecForBoolean()) + .property("conversion_rate_class_id", codecOptional(codecForNumber())) .property("conversion_rate", codecOptional(codecForConversionRate())) .property( "tan_channel", @@ -985,7 +1003,6 @@ export const codecForConversionRateClass = (): Codec<ConversionRateClass> => codecForConstString("nearest"), )), ) - .property("conversion_rate", codecOptional(codecForConversionRate())) .property("conversion_rate_class_id", codecForNumber()) .property("description", codecOptional(codecForString())) .property("name", codecForString()) @@ -995,7 +1012,6 @@ export const codecForConversionRateClass = (): Codec<ConversionRateClass> => export const codecForConversionRateClasses = (): Codec<ConversionRateClasses> => buildCodecForObject<ConversionRateClasses>() .property("classes", codecForList(codecForConversionRateClass())) - .property("default", codecForConversionRate()) .build("TalerCorebankApi.ConversionRateClasses"); export const codecForChallengeContactData = (): Codec<ChallengeContactData> =>