taler-typescript-core

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

commit 605906ec4b4c0b84aff6ceb63cb02733d7c30935
parent ee4d9d0337207af3b4b2ddcdc57d14b797ce1a92
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 02:29:48 +0200

bank web UI: serialize conversion quotes and cashout details

Diffstat:
Mpackages/libeufin-bank-webui/src/components/Cashouts/index.ts | 2++
Mpackages/libeufin-bank-webui/src/components/Cashouts/state.ts | 1+
Mpackages/libeufin-bank-webui/src/components/Cashouts/views.tsx | 45++++++++++++++++++++++++++++++---------------
Mpackages/libeufin-bank-webui/src/hooks/regional.ts | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx | 263+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx | 4++--
Mpackages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx | 100+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
8 files changed, 399 insertions(+), 202 deletions(-)

diff --git a/packages/libeufin-bank-webui/src/components/Cashouts/index.ts b/packages/libeufin-bank-webui/src/components/Cashouts/index.ts @@ -22,6 +22,7 @@ import { TalerError, TranslatedString, } from "@gnu-taler/taler-util"; +import { CashoutDetailFailure } from "../../hooks/regional.js"; import { ErrorLoading, Loading, @@ -68,6 +69,7 @@ export namespace State { status: "ready"; error: undefined; cashouts: (TalerCorebankApi.CashoutStatusResponse & { id: number })[]; + failures: CashoutDetailFailure[]; routeCashoutDetails: RouteDefinition<{ cid: string }>; } } diff --git a/packages/libeufin-bank-webui/src/components/Cashouts/state.ts b/packages/libeufin-bank-webui/src/components/Cashouts/state.ts @@ -49,6 +49,7 @@ export function useComponentState({ status: "ready", error: undefined, cashouts: result.body.cashouts, + failures: result.body.failures, routeCashoutDetails, }; } diff --git a/packages/libeufin-bank-webui/src/components/Cashouts/views.tsx b/packages/libeufin-bank-webui/src/components/Cashouts/views.tsx @@ -56,11 +56,13 @@ export function FailedView({ error }: State.Failed) { export function ReadyView({ cashouts, + failures, routeCashoutDetails, }: State.Ready): VNode { const { i18n, dateLocale } = useTranslationContext(); + const conversionResp = useConversionInfo(); - if (!cashouts.length) return <div />; + if (!cashouts.length && failures.length === 0) return <div />; const txByDate = cashouts.reduce( (prev, cur) => { const d = @@ -77,7 +79,6 @@ export function ReadyView({ }, {} as Record<string, typeof cashouts>, ); - const conversionResp = useConversionInfo(); if (!conversionResp) { return <Loading />; } else if (conversionResp instanceof TalerError) { @@ -108,6 +109,19 @@ export function ReadyView({ return ( <div class="px-4 mt-4"> + {failures.length > 0 && ( + <Attention + type="danger" + title={i18n.str`Some cashout details could not be loaded.`} + > + <p> + <i18n.Translate> + The partial list omits cashout IDs:{" "} + {failures.map(({ id }) => id).join(", ")}. + </i18n.Translate> + </p> + </Attention> + )} <div class="sm:flex sm:items-center"> <div class="sm:flex-auto"> <h1 class="text-base font-semibold leading-6 text-gray-900"> @@ -138,9 +152,9 @@ export function ReadyView({ </tr> </thead> <tbody> - {Object.entries(txByDate).map(([date, txs], idx) => { + {Object.entries(txByDate).map(([date, txs]) => { return ( - <Fragment key={idx}> + <Fragment key={date}> <tr class="border-t border-gray-200"> <th colSpan={6} @@ -152,24 +166,25 @@ export function ReadyView({ </tr> {txs.map((item) => { return ( - <a - name="cashout details" - key={idx} - class="table-row border-b border-gray-200 hover:bg-gray-200 last:border-none" - // class="table-row" - href={routeCashoutDetails.url({ - cid: String(item.id), - })} + <tr + key={item.id} + class="border-b border-gray-200 hover:bg-gray-200 last:border-none" > <td class="relative py-2 pl-2 pr-2 text-sm "> - <div class="font-medium text-gray-900"> + <a + name="cashout details" + class="font-medium text-indigo-700 underline" + href={routeCashoutDetails.url({ + cid: String(item.id), + })} + > <Time format="HH:mm:ss" timestamp={AbsoluteTime.fromProtocolTimestamp( item.creation_time, )} /> - </div> + </a> </td> <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-red-600 cursor-pointer"> <RenderAmount @@ -187,7 +202,7 @@ export function ReadyView({ <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> {item.subject} </td> - </a> + </tr> ); })} </Fragment> diff --git a/packages/libeufin-bank-webui/src/hooks/regional.ts b/packages/libeufin-bank-webui/src/hooks/regional.ts @@ -35,7 +35,7 @@ import { } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import _useSWR, { SWRHook, mutate } from "swr"; import { PAGINATED_LIST_REQUEST } from "../utils.js"; import { buildPaginatedResult } from "./account.js"; @@ -107,7 +107,7 @@ export function useConversionRateForUser( config, } = useBankCoreApiContext(); - async function fetcher(usr: string, tkn: AccessToken | null) { + async function fetcher([usr, tkn]: [string, AccessToken | undefined]) { return await conversionForUser(usr).getRate( tkn != null ? { type: "bearer", token: tkn } : undefined, ); @@ -195,7 +195,7 @@ function buildEstimatorWithTheBackend( }; } -function buildConversionEstimatorsWithTheBackend( +function useConversionEstimatorsWithTheBackend( conversion: TalerBankConversionHttpClient, direction: "cashin" | "cashout", ): ConversionEstimators { @@ -224,14 +224,14 @@ export function useCashinEstimator(): ConversionEstimators { lib: { conversion }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend(conversion, "cashin"); + return useConversionEstimatorsWithTheBackend(conversion, "cashin"); } export function useCashoutEstimator(): ConversionEstimators { const { lib: { conversion }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend(conversion, "cashout"); + return useConversionEstimatorsWithTheBackend(conversion, "cashout"); } export function useCashinEstimatorForClass( @@ -240,7 +240,7 @@ export function useCashinEstimatorForClass( const { lib: { conversionForClass }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend( + return useConversionEstimatorsWithTheBackend( conversionForClass(classId), "cashin", ); @@ -252,7 +252,7 @@ export function useCashoutEstimatorForClass( const { lib: { conversionForClass }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend( + return useConversionEstimatorsWithTheBackend( conversionForClass(classId), "cashout", ); @@ -264,7 +264,7 @@ export function useCashinEstimatorByUser( const { lib: { conversionForUser }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend( + return useConversionEstimatorsWithTheBackend( conversionForUser(username), "cashin", ); @@ -276,7 +276,7 @@ export function useCashoutEstimatorByUser( const { lib: { conversionForUser }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend( + return useConversionEstimatorsWithTheBackend( conversionForUser(username), "cashout", ); @@ -336,8 +336,28 @@ export function useBusinessAccounts() { } type CashoutWithId = TalerCorebankApi.CashoutStatusResponse & { id: number }; -function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { - return c !== undefined; +export interface CashoutDetailFailure { + id: number; + case: number; +} + +async function mapWithConcurrency<Input, Output>( + input: readonly Input[], + concurrency: number, + mapper: (item: Input) => Promise<Output>, +): Promise<Output[]> { + const output = new Array<Output>(input.length); + let next = 0; + async function worker(): Promise<void> { + while (next < input.length) { + const index = next++; + output[index] = await mapper(input[index]); + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, input.length) }, () => worker()), + ); + return output; } export function revalidateOnePendingCashouts() { return mutate( @@ -424,20 +444,42 @@ export function useCashouts(account: string) { if (list.type !== "ok") { return list; } - const all: Array<CashoutWithId | undefined> = await Promise.all( - list.body.cashouts.map(async (c) => { + const all = await mapWithConcurrency( + list.body.cashouts, + 4, + async ( + c, + ): Promise< + | { type: "ok"; cashout: CashoutWithId } + | { type: "fail"; failure: CashoutDetailFailure } + > => { const r = await api.getCashoutById({ username, token }, c.cashout_id); if (r.type === "fail") { - return undefined; + return { + type: "fail", + failure: { id: c.cashout_id, case: r.case as number }, + }; } - return { ...r.body, id: c.cashout_id }; - }), + return { + type: "ok", + cashout: { ...r.body, id: c.cashout_id }, + }; + }, ); - const cashouts = all.filter(notUndefined); - return opFixedSuccess(dummyHttpResponse, { cashouts }); + return opFixedSuccess(dummyHttpResponse, { + cashouts: all.flatMap((result) => + result.type === "ok" ? [result.cashout] : [], + ), + failures: all.flatMap((result) => + result.type === "fail" ? [result.failure] : [], + ), + }); } const { data, error } = useSWR< - | OperationOk<{ cashouts: CashoutWithId[] }> + | OperationOk<{ + cashouts: CashoutWithId[]; + failures: CashoutDetailFailure[]; + }> | TalerCoreBankErrorsByMethod<"getAccountCashouts">, TalerHttpError >( @@ -677,6 +719,8 @@ export function useConversionRateClassUsers( const [offset, setOffset] = useState<number | undefined>(); + useEffect(() => setOffset(undefined), [classId, username]); + function fetcher([token, aid, username, classId]: [ AccessToken, number, diff --git a/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx b/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx @@ -25,7 +25,7 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { AccessToken, @@ -81,14 +81,8 @@ export function ConversionRateClassDetails({ const detailsResult = useConversionRateClassDetails(classId); const conversionInfoResult = useConversionInfo(); - const conversionInfo = - conversionInfoResult && - !(conversionInfoResult instanceof TalerError) && - conversionInfoResult.type === "ok" - ? conversionInfoResult.body - : undefined; - if (!detailsResult || !conversionInfo) { + if (!detailsResult || !conversionInfoResult) { return <Loading />; } if (detailsResult instanceof TalerError) { @@ -99,6 +93,24 @@ export function ConversionRateClassDetails({ /> ); } + if (conversionInfoResult instanceof TalerError) { + return ( + <ErrorLoading + error={conversionInfoResult} + title={i18n.str`Failed to load conversion rate information.`} + /> + ); + } + if (conversionInfoResult.type === "fail") { + return ( + <Attention type="danger" title={i18n.str`Conversion is disabled`}> + <i18n.Translate> + Conversion should be enabled in the configuration, the conversion rate + should be initialized with fee(s), rates and a rounding mode. + </i18n.Translate> + </Attention> + ); + } if (detailsResult.type === "fail") { switch (detailsResult.case) { case HttpStatusCode.Unauthorized: @@ -119,7 +131,7 @@ export function ConversionRateClassDetails({ } return ( <Form - conversionInfo={conversionInfo} + conversionInfo={conversionInfoResult.body} detailsResult={detailsResult.body} routeCancel={routeCancel} classId={classId} @@ -354,10 +366,9 @@ function Form({ <input type="radio" name="project-type" - value="Newsletter" + value="detail" + checked={section === "detail"} class="sr-only" - aria-labelledby="project-type-0-label" - aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => { setSection("detail"); }} @@ -377,10 +388,9 @@ function Form({ <input type="radio" name="project-type" - value="Existing Customers" + value="cashout" + checked={section === "cashout"} class="sr-only" - aria-labelledby="project-type-1-label" - aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => { setSection("cashout"); }} @@ -400,10 +410,9 @@ function Form({ <input type="radio" name="project-type" - value="Existing Customers" + value="cashin" + checked={section === "cashin"} class="sr-only" - aria-labelledby="project-type-1-label" - aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => { setSection("cashin"); }} @@ -423,10 +432,9 @@ function Form({ <input type="radio" name="project-type" - value="Newsletter" + value="users" + checked={section === "users"} class="sr-only" - aria-labelledby="project-type-0-label" - aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => { setSection("users"); }} @@ -446,10 +454,9 @@ function Form({ <input type="radio" name="project-type" - value="Newsletter" + value="test" + checked={section === "test"} class="sr-only" - aria-labelledby="project-type-0-label" - aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => { setSection("test"); }} @@ -469,10 +476,9 @@ function Form({ <input type="radio" name="project-type" - value="Newsletter" + value="delete" + checked={section === "delete"} class="sr-only" - aria-labelledby="project-type-0-label" - aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => { setSection("delete"); }} @@ -761,15 +767,15 @@ export function createFormValidator( ); const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`); - const cashin_ratio_f = Number.parseFloat(state.conv.cashin_ratio ?? ""); - const cashout_ratio_f = Number.parseFloat(state.conv.cashout_ratio ?? ""); - - const cashin_ratio = Number.isNaN(cashin_ratio_f) - ? undefined - : cashin_ratio_f; - const cashout_ratio = Number.isNaN(cashout_ratio_f) - ? undefined - : cashout_ratio_f; + const cashinRatioText = state.conv.cashin_ratio?.trim() ?? ""; + const cashoutRatioText = state.conv.cashout_ratio?.trim() ?? ""; + const cashin_ratio = Number(cashinRatioText); + const cashout_ratio = Number(cashoutRatioText); + const cashinRatioValid = + !cashinRatioText || (Number.isFinite(cashin_ratio) && cashin_ratio > 0); + const cashoutRatioValid = + !cashoutRatioText || + (Number.isFinite(cashout_ratio) && cashout_ratio > 0); const errors = undefinedIfEmpty<FormErrors<FormType>>({ conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({ @@ -789,7 +795,7 @@ export function createFormValidator( : !cashout_min_amount ? i18n.str`Invalid` : undefined, - cashout_fee: !state.conv.cashin_fee + cashout_fee: !state.conv.cashout_fee ? undefined : !cashout_fee ? i18n.str`Invalid` @@ -802,16 +808,8 @@ export function createFormValidator( ? undefined : undefined, - cashin_ratio: !state.conv.cashin_ratio - ? undefined - : Number.isNaN(cashin_ratio) - ? i18n.str`Invalid` - : undefined, - cashout_ratio: !state.conv.cashout_ratio - ? undefined - : Number.isNaN(cashout_ratio) - ? i18n.str`Invalid` - : undefined, + cashin_ratio: cashinRatioValid ? undefined : i18n.str`Invalid`, + cashout_ratio: cashoutRatioValid ? undefined : i18n.str`Invalid`, }), description: undefined, @@ -831,7 +829,7 @@ export function createFormValidator( ? Amounts.stringify(cashin_min_amount) : undefined, cashin_ratio: - !errors?.conv?.cashin_ratio && cashin_ratio + !errors?.conv?.cashin_ratio && cashinRatioText ? String(cashin_ratio) : undefined, cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode @@ -846,7 +844,7 @@ export function createFormValidator( ? Amounts.stringify(cashout_min_amount) : undefined, cashout_ratio: - !errors?.conv?.cashout_ratio && cashout_ratio + !errors?.conv?.cashout_ratio && cashoutRatioText ? String(cashout_ratio) : undefined, cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode @@ -876,22 +874,26 @@ function TestConversionClass({ useCashinEstimatorForClass(classId); const [amount, setAmount] = useState<string>("100"); - const [error, setError] = useState<string>(); - const [calculationResult, setCalc] = useState<{ + key: string; cashin: TransferCalculation; cashout: TransferCalculation; }>(); - const in_amount = !amount - ? undefined - : Amounts.parseOrThrow(`${info.fiat_currency}:${amount}`); + const calculationKey = amount.trim(); + const in_amount = Amounts.parse(`${info.fiat_currency}:${calculationKey}`); + const error = !calculationKey + ? i18n.str`Required` + : !in_amount + ? i18n.str`Invalid` + : undefined; const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); async function calculateOperation( _ct: CancellationToken, + key: string, amount: AmountJson, ) { const respCashin = await calculateCashinFromDebit(amount, in_fee); @@ -904,13 +906,13 @@ function TestConversionClass({ return respCashout; } const cashout = respCashout.body; - return opFixedSuccess(dummyHttpResponse, { cashin, cashout }); + return opFixedSuccess(dummyHttpResponse, { key, cashin, cashout }); } // i18n.str`calculate cashout fee`, const calculate = useNotifiedOperation< Awaited<ReturnType<typeof calculateOperation>>, - [AmountJson] + [string, AmountJson] >(calculateOperation, { onSuccess: (resp) => setCalc(resp), onFail: showError( @@ -933,15 +935,30 @@ function TestConversionClass({ }, ), }); + const calculationStartedFor = useRef<string>(); + + useEffect(() => { + calculationStartedFor.current = undefined; + setCalc(undefined); + if (calculate.running) calculate.cancel(); + }, [calculationKey]); useEffect(() => { - if (in_amount && !error) { - calculate.run(in_amount); + if ( + !calculate.running && + calculationStartedFor.current !== calculationKey && + in_amount && + !error + ) { + calculationStartedFor.current = calculationKey; + void calculate.run(calculationKey, in_amount); } - }, [amount]); + }, [calculationKey, calculate.running, error]); - const cashinCalc = calculationResult?.cashin; - const cashoutCalc = calculationResult?.cashout; + const currentCalculation = + calculationResult?.key === calculationKey ? calculationResult : undefined; + const cashinCalc = currentCalculation?.cashin; + const cashoutCalc = currentCalculation?.cashout; return ( <Fragment> @@ -1112,14 +1129,10 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { const { lib: { bank }, - config, } = useBankCoreApiContext(); + const { showError } = useNotificationContext(); 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<{ @@ -1134,7 +1147,29 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { filter.classId, filter.account, ); - if (!userListResult) { + const membership = useNotifiedOperation< + Awaited<ReturnType<typeof bank.updateAccount>>, + [string, AccessToken, number | null] + >( + (_ct, username, accessToken, conversionRateClassId) => + bank.updateAccount( + { username, token: accessToken }, + { conversion_rate_class_id: conversionRateClassId }, + ), + { + onSuccess: async () => { + await Promise.all([ + revalidateConversionRateClassUsers(), + revalidateConversionRateClassDetails(), + ]); + }, + onFail: showError( + i18n.str`Failed to update conversion class membership.`, + () => i18n.str`The account could not be updated. Please try again.`, + ), + }, + ); + if (!userListResult || !resultInfo) { return <Loading />; } if (userListResult instanceof TalerError) { @@ -1145,6 +1180,24 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { /> ); } + if (resultInfo instanceof TalerError) { + return ( + <ErrorLoading + error={resultInfo} + title={i18n.str`Failed to load conversion rate information.`} + /> + ); + } + if (resultInfo.type === "fail") { + return ( + <Attention type="danger" title={i18n.str`Conversion is disabled`}> + <i18n.Translate> + Conversion rate information is unavailable for these accounts. + </i18n.Translate> + </Attention> + ); + } + const convInfo = resultInfo.body; if (userListResult.type === "fail") { switch (userListResult.case) { case HttpStatusCode.Unauthorized: @@ -1254,10 +1307,10 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { </tr> </thead> <tbody class="divide-y divide-gray-200"> - {userListResult.body.map((item, idx) => { + {userListResult.body.map((item) => { return ( <tr - key={idx} + key={item.username} class="data-[status=deleted]:bg-gray-100" data-status={item.status} > @@ -1268,42 +1321,50 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { {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} - /> + {item.conversion_rate ? ( + <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} + /> + ) : ( + <i18n.Translate>Not configured</i18n.Translate> + )} </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} - /> + {item.conversion_rate ? ( + <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} + /> + ) : ( + <i18n.Translate>Not configured</i18n.Translate> + )} </td> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> {classId === item.conversion_rate_class_id ? ( <button type="button" + disabled={membership.running || !token} 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 () => { + onClick={() => { if (token) { - await bank.updateAccount( - { username: item.username, token }, - { conversion_rate_class_id: null }, + void membership.run( + item.username, + token, + null, ); - await revalidateConversionRateClassUsers(); - await revalidateConversionRateClassDetails(); } }} > @@ -1312,15 +1373,15 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { ) : ( <button type="button" + disabled={membership.running || !token} class="disabled:opacity-50 disabled:bg-gray-600 disabled:hover:bg-gray-600 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={async () => { + onClick={() => { if (token) { - await bank.updateAccount( - { username: item.username, token }, - { conversion_rate_class_id: classId }, + void membership.run( + item.username, + token, + classId, ); - await revalidateConversionRateClassUsers(); - await revalidateConversionRateClassDetails(); } }} > diff --git a/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx b/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx @@ -159,9 +159,9 @@ export function ConversionClassList({ </tr> </thead> <tbody class="divide-y divide-gray-200"> - {classes.map((row, idx) => { + {classes.map((row) => { return ( - <tr key={idx} class=""> + <tr key={row.conversion_rate_class_id} class=""> <td class="whitespace-nowrap py-3 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> <a href={routeShowDetails.url({ diff --git a/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx b/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx @@ -39,7 +39,7 @@ import { utils, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { FormErrors, FormStatus, @@ -50,6 +50,7 @@ import { } from "../../hooks/form.js"; import { TransferCalculation, + revalidateConversionInfo, useCashinEstimator, useCashoutEstimator, useConversionInfo, @@ -87,6 +88,7 @@ function useComponentState({ routeMyAccountDelete, routeMyAccountDetails, routeMyAccountPassword, + onUpdateSuccess, }: Props): utils.RecursiveState<VNode> { const { i18n } = useTranslationContext(); @@ -95,12 +97,12 @@ function useComponentState({ credentials.status !== "loggedIn" || !credentials.isUserAdministrator ? undefined : credentials; + const resp = useConversionInfo(); if (!creds) { return <i18n.Translate>only admin can setup conversion</i18n.Translate>; } - const resp = useConversionInfo(); if (!resp) { return <Loading />; } @@ -168,41 +170,55 @@ function useComponentState({ const { estimateByDebit: calculateCashinFromDebit } = useCashinEstimator(); const [calculationResult, setCalc] = useState<{ + key: string; cashin: TransferCalculation; cashout: TransferCalculation; }>(); - const in_amount = !form.amount - ? undefined - : Amounts.parseOrThrow(`${info.fiat_currency}:${form.amount.value}`); - - const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); - const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); + const amountValue = form.amount?.value?.trim() ?? ""; + const inAmount = amountValue + ? Amounts.parse(`${info.fiat_currency}:${amountValue}`) + : undefined; + const calculationKey = [ + amountValue, + form.conv?.cashin_fee?.value ?? "", + form.conv?.cashout_fee?.value ?? "", + ].join("\u0000"); + + const inFee = Amounts.parse( + `${info.regional_currency}:${form.conv?.cashin_fee?.value ?? ""}`, + ); + const outFee = Amounts.parse( + `${info.fiat_currency}:${form.conv?.cashout_fee?.value ?? ""}`, + ); async function calculateOperation( _ct: CancellationToken, + key: string, amount: AmountJson, + cashinFee: AmountJson, + cashoutFee: AmountJson, ) { - const respCashin = await calculateCashinFromDebit(amount, in_fee); + const respCashin = await calculateCashinFromDebit(amount, cashinFee); if (respCashin.type === "fail") { return respCashin; } const cashin = respCashin.body; const respCashout = await calculateCashoutFromDebit( cashin.credit, - out_fee, + cashoutFee, ); if (respCashout.type === "fail") { return respCashout; } const cashout = respCashout.body; - return opFixedSuccess(dummyHttpResponse, { cashin, cashout }); + return opFixedSuccess(dummyHttpResponse, { key, cashin, cashout }); } // i18n.str`calculate cashout fee`, const calculate = useNotifiedOperation< Awaited<ReturnType<typeof calculateOperation>>, - [AmountJson] + [string, AmountJson, AmountJson, AmountJson] >(calculateOperation, { onSuccess: (resp) => setCalc(resp), onFail: showError(i18n.str`Failed to calculate cashout fee.`, (fail) => { @@ -222,30 +238,45 @@ function useComponentState({ } }), }); + const calculationStartedFor = useRef<string>(); + + useEffect(() => { + calculationStartedFor.current = undefined; + setCalc(undefined); + if (calculate.running) calculate.cancel(); + }, [calculationKey, status.status]); useEffect(() => { - if (in_amount && status.status !== "fail") { - calculate.run(in_amount); + if ( + !calculate.running && + calculationStartedFor.current !== calculationKey && + inAmount && + inFee && + outFee && + status.status !== "fail" + ) { + calculationStartedFor.current = calculationKey; + void calculate.run(calculationKey, inAmount, inFee, outFee); } - }, [ - form.amount?.value, - form.conv?.cashin_fee?.value, - form.conv?.cashout_fee?.value, - ]); + }, [calculationKey, calculate.running, status.status]); const [section, setSection] = useState<"detail" | "cashout" | "cashin">( "detail", ); - const cashinCalc = calculationResult?.cashin; - const cashoutCalc = calculationResult?.cashout; + const currentCalculation = + calculationResult?.key === calculationKey ? calculationResult : undefined; + const cashinCalc = currentCalculation?.cashin; + const cashoutCalc = currentCalculation?.cashout; // i18n.str`update conversion rate`, const update = useNotifiedOperation< Awaited<ReturnType<typeof conversion.updateConversionRate>>, Parameters<typeof conversion.updateConversionRate> >((ct, s, c) => conversion.updateConversionRate(s, c), { - onSuccess: () => { + onSuccess: async () => { + await revalidateConversionInfo(); setSection("detail"); + onUpdateSuccess(); }, onFail: showError( i18n.str`Failed to update the conversion rate.`, @@ -292,10 +323,9 @@ function useComponentState({ <input type="radio" name="project-type" - value="Newsletter" + value="detail" + checked={section === "detail"} class="sr-only" - aria-labelledby="project-type-0-label" - aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => { setSection("detail"); }} @@ -316,10 +346,9 @@ function useComponentState({ <input type="radio" name="project-type" - value="Existing Customers" + value="cashout" + checked={section === "cashout"} class="sr-only" - aria-labelledby="project-type-1-label" - aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => { setSection("cashout"); }} @@ -339,10 +368,9 @@ function useComponentState({ <input type="radio" name="project-type" - value="Existing Customers" + value="cashin" + checked={section === "cashin"} class="sr-only" - aria-labelledby="project-type-1-label" - aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => { setSection("cashin"); }} @@ -659,8 +687,10 @@ function createFormValidator( 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 cashinRatioText = state.conv.cashin_ratio?.trim() ?? ""; + const cashoutRatioText = state.conv.cashout_ratio?.trim() ?? ""; + const cashin_ratio = Number(cashinRatioText); + const cashout_ratio = Number(cashoutRatioText); const errors = undefinedIfEmpty<FormErrors<FormType>>({ conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({ @@ -695,12 +725,12 @@ function createFormValidator( cashin_ratio: !state.conv.cashin_ratio ? i18n.str`Required` - : Number.isNaN(cashin_ratio) + : !Number.isFinite(cashin_ratio) || cashin_ratio <= 0 ? i18n.str`Invalid` : undefined, cashout_ratio: !state.conv.cashout_ratio ? i18n.str`Required` - : Number.isNaN(cashout_ratio) + : !Number.isFinite(cashout_ratio) || cashout_ratio <= 0 ? i18n.str`Invalid` : undefined, diff --git a/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx b/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx @@ -40,7 +40,7 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { Paytos, @@ -52,7 +52,6 @@ import { useBankChallengeHandlerContext } from "../../context/challenge.js"; import { useAccountDetails } from "../../hooks/account.js"; import { TransCalc, - TransferCalculation, useCashoutEstimatorByUser, useConversionInfo, useConversionRateForUser, @@ -89,6 +88,12 @@ export function CreateCashout({ }: Props): VNode { const { i18n } = useTranslationContext(); const { config } = useBankCoreApiContext(); + const resultAccount = useAccountDetails(accountName); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const rateResp = useConversionRateForUser(accountName, creds?.token); + const conversionResp = useConversionInfo(); + if (!config.allow_conversion) { return ( <Fragment> @@ -110,12 +115,6 @@ export function CreateCashout({ ); } - const resultAccount = useAccountDetails(accountName); - const { state: credentials } = useSessionState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const rateResp = useConversionRateForUser(accountName, creds?.token); - const conversionResp = useConversionInfo(); - if (!resultAccount) { return <Loading />; } else if (resultAccount instanceof TalerError) { @@ -190,11 +189,20 @@ export function CreateCashout({ const rate = rateResp.body; if (!rate) { return ( - <div>conversion enabled but server replied without conversion_rate</div> + <Attention + type="danger" + title={i18n.str`The bank returned incomplete conversion information.`} + > + <i18n.Translate>Please contact the bank administrator.</i18n.Translate> + </Attention> ); } if (!creds) { - return <div>authentication required</div>; + return ( + <Attention type="warning" title={i18n.str`Authentication required`}> + <i18n.Translate>Please sign in to create a cashout.</i18n.Translate> + </Attention> + ); } return ( @@ -264,32 +272,38 @@ function CreateCashoutInternal({ credit: fiatZero, beforeFee: fiatZero, }; - const [calculationResult, setCalculation] = - useState<TransferCalculation>(zeroCalc); + const [calculationQuote, setCalculationQuote] = useState<{ + key: string; + result: TransCalc; + }>(); 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 */ - const inputAmount = Amounts.parseOrThrow( - `${form.isDebit ? regional_currency : fiat_currency}:${ - !form.amount ? "0" : form.amount - }`, - ); - - const higerThanMin = form.isDebit - ? Amounts.cmp(inputAmount, rate.cashout_min_amount) >= 0 - : true; - const notZero = Amounts.isNonZero(inputAmount); + const inputAmount = + Amounts.parse( + `${form.isDebit ? regional_currency : fiat_currency}:${ + !form.amount ? "0" : form.amount + }`, + ) ?? (form.isDebit ? regionalZero : fiatZero); + const calculationKey = `${form.isDebit === true ? "debit" : "credit"}:${ + form.amount?.trim() ?? "" + }:${rate.cashout_fee}:${rate.cashout_ratio}`; async function calculateOperation( _ct: CancellationToken, + _key: string, isDebit: boolean, input: AmountJson, fee: AmountJson, ) { - if (notZero && higerThanMin) { + const isNonZero = Amounts.isNonZero(input); + const highEnough = isDebit + ? Amounts.cmp(input, rate.cashout_min_amount) >= 0 + : true; + if (isNonZero && highEnough) { return isDebit ? calculateFromDebit(input, fee) : calculateFromCredit(input, fee); @@ -301,9 +315,9 @@ function CreateCashoutInternal({ // i18n.str`calculate conversion fee`, const conversionCalculator = useNotifiedOperation< Awaited<ReturnType<typeof calculateOperation>>, - [boolean, AmountJson, AmountJson] + [string, boolean, AmountJson, AmountJson] >(calculateOperation, { - onSuccess: (success) => setCalculation(success), + onSuccess: (success, key) => setCalculationQuote({ key, result: success }), onFail: showError( i18n.str`Failed to calculate the conversion fee.`, (fail) => { @@ -324,12 +338,30 @@ function CreateCashoutInternal({ }, ), }); + const lastCalculationAttempt = useRef<string>(); useEffect(() => { - conversionCalculator.run(form.isDebit ?? false, inputAmount, sellFee); - }, [form.amount, form.isDebit, notZero, higerThanMin, rate.cashout_fee]); + if ( + conversionCalculator.running || + lastCalculationAttempt.current === calculationKey + ) { + return; + } + lastCalculationAttempt.current = calculationKey; + setCalculationQuote(undefined); + void conversionCalculator.run( + calculationKey, + form.isDebit ?? false, + inputAmount, + sellFee, + ); + }, [calculationKey, conversionCalculator.running]); - const calc = !calculationResult ? zeroCalc : calculationResult; + const calculationResult = + calculationQuote?.key === calculationKey + ? calculationQuote.result + : undefined; + const calc = calculationResult ?? zeroCalc; const balanceAfter = IntAmounts.toIntAmount( account.balance, @@ -337,6 +369,10 @@ function CreateCashoutInternal({ ).deduce(calc.debit).result; function updateForm(newForm: typeof form): void { + if (form.amount !== newForm.amount || form.isDebit !== newForm.isDebit) { + conversionCalculator.cancel(); + setCalculationQuote(undefined); + } setForm(newForm); } const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({ @@ -778,8 +814,16 @@ function CreateCashoutInternal({ submit name="cashout" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!!errors || !subject} - onClick={() => cashout.run(session, calc, subject!)} + disabled={ + !!errors || + !subject || + !calculationResult || + conversionCalculator.running + } + onClick={() => { + if (!calculationResult) return; + void cashout.run(session, calculationResult, subject!); + }} > <i18n.Translate>Cashout</i18n.Translate> </AsyncButton>