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:
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>