commit cb90e3dbc432a31aadbef135d8adf51067c79204
parent 5d7e2e78b50dac8ee79760e7a52e7a5b405d90a2
Author: Sebastian <sebasjm@gmail.com>
Date: Mon, 30 Jun 2025 01:15:26 -0300
conversion rate list
Diffstat:
5 files changed, 215 insertions(+), 90 deletions(-)
diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx
@@ -21,7 +21,7 @@ import {
useCurrentLocation,
useLocalNotification,
useNavigationContext,
- useTranslationContext
+ useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
@@ -33,11 +33,14 @@ import {
TokenRequest,
TranslatedString,
assertUnreachable,
- createRFC8959AccessTokenEncoded
+ createRFC8959AccessTokenEncoded,
} from "@gnu-taler/taler-util";
import { useEffect } from "preact/hooks";
import { useBankState } from "./hooks/bank-state.js";
-import { useRefreshSessionBeforeExpires, useSessionState } from "./hooks/session.js";
+import {
+ useRefreshSessionBeforeExpires,
+ useSessionState,
+} from "./hooks/session.js";
import { AccountPage } from "./pages/AccountPage/index.js";
import { BankFrame } from "./pages/BankFrame.js";
import { LoginForm, SESSION_DURATION } from "./pages/LoginForm.js";
@@ -64,7 +67,7 @@ Routing.SCREEN_ID = TALER_SCREEN_ID;
export function Routing(): VNode {
const session = useSessionState();
- useRefreshSessionBeforeExpires()
+ useRefreshSessionBeforeExpires();
if (session.state.status === "loggedIn") {
const { isUserAdministrator, username } = session.state;
@@ -319,6 +322,14 @@ const privatePages = {
/\/operation\/(?<wopid>[a-zA-Z0-9-]+)/,
({ wopid }) => `#/operation/${wopid}`,
),
+ conversionRateClassCreate: urlPattern(
+ /\/new-conversion-rate-class/,
+ () => "#/new-conversion-rate-class",
+ ),
+ conversionRateClassDetails: urlPattern<{ classId: string }>(
+ /\/conversion-rate-class\/(?<id>[a-zA-Z0-9_-]+)\/details/,
+ ({ classId }) => `#/conversion-rate-class/${classId}/details`,
+ ),
};
function PrivateRouting({
@@ -540,6 +551,12 @@ function PrivateRouting({
routeUpdatePasswordAccount={privatePages.accountChangePassword}
routeCreateWireTransfer={privatePages.wireTranserCreate}
routeDownloadStats={privatePages.statsDownload}
+ routeCreateConversionRateClass={
+ privatePages.conversionRateClassCreate
+ }
+ routeShowConversionRateClass={
+ privatePages.conversionRateClassDetails
+ }
/>
);
}
@@ -662,6 +679,12 @@ function PrivateRouting({
/>
);
}
+ case "conversionRateClassCreate": {
+ return <ShowNotifications />;
+ }
+ case "conversionRateClassDetails": {
+ return <ShowNotifications />;
+ }
case "notifications": {
return <ShowNotifications />;
}
diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts
@@ -530,3 +530,59 @@ export function useLastMonitorInfo(
if (error) return error;
return undefined;
}
+
+
+export function revalidateConversionRateClasses() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "useConversionRateClasses",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useConversionRateClasses() {
+ const { state: credentials } = useSessionState();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const [offset, setOffset] = useState<number | undefined>();
+
+ function fetcher([token, aid]: [AccessToken, number]) {
+ return api.listConversionRateClasses(
+ token,
+ {
+ limit: PAGINATED_LIST_REQUEST,
+ offset: aid ? String(aid) : undefined,
+ order: "asc",
+ },
+ );
+ }
+
+ const { data, error } = useSWR<
+ TalerCoreBankResultByMethod<"listConversionRateClasses">,
+ TalerHttpError
+ >([token, offset ?? 0, "useConversionRateClasses"], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return buildPaginatedResult(
+ data.body.classes,
+ offset,
+ setOffset,
+ (d) => d.conversion_rate_class_id,
+ );
+}
+\ No newline at end of file
diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx
@@ -60,6 +60,8 @@ interface Props {
routeUpdatePasswordAccount: RouteDefinition<{ account: string }>;
routeShowCashoutsAccount: RouteDefinition<{ account: string }>;
onAuthorizationRequired: () => void;
+ routeCreateConversionRateClass: RouteDefinition;
+ routeShowConversionRateClass: RouteDefinition<{ classId: string }>;
}
export function AdminHome({
routeCreateAccount,
@@ -68,6 +70,8 @@ export function AdminHome({
routeUpdatePasswordAccount,
routeDownloadStats,
routeCreateWireTransfer,
+ routeCreateConversionRateClass,
+ routeShowConversionRateClass,
onAuthorizationRequired,
}: Props): VNode {
return (
@@ -88,9 +92,8 @@ export function AdminHome({
routeUpdatePasswordAccount={routeUpdatePasswordAccount}
/>
<ConversionClassList
- routeCreate={routeCreateAccount}
- routeRemove={routeRemoveAccount}
- routeShowDetails={routeShowAccount}
+ routeCreate={routeCreateConversionRateClass}
+ routeShowDetails={routeShowConversionRateClass}
/>
</Fragment>
);
diff --git a/packages/bank-ui/src/pages/admin/ConversionClassList.tsx b/packages/bank-ui/src/pages/admin/ConversionClassList.tsx
@@ -20,6 +20,7 @@ import {
assertUnreachable,
} from "@gnu-taler/taler-util";
import {
+ Attention,
Loading,
RouteDefinition,
useBankCoreApiContext,
@@ -27,25 +28,25 @@ import {
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
-import { useBusinessAccounts } from "../../hooks/regional.js";
+import {
+ useBusinessAccounts,
+ useConversionRateClasses,
+} from "../../hooks/regional.js";
import { RenderAmount } from "../PaytoWireTransferForm.js";
+import { LoginForm } from "../LoginForm.js";
const TALER_SCREEN_ID = 121;
interface Props {
routeCreate: RouteDefinition;
- routeRemove: RouteDefinition<{ account: string }>;
-
- routeShowDetails: RouteDefinition<{ account: string }>;
- // routeUpdatePasswordAccount: RouteDefinition<{ account: string }>;
+ routeShowDetails: RouteDefinition<{ classId: string }>;
}
export function ConversionClassList({
routeCreate,
- routeRemove,
routeShowDetails,
}: Props): VNode {
- const result = useBusinessAccounts();
+ const result = useConversionRateClasses();
const { i18n } = useTranslationContext();
const { config } = useBankCoreApiContext();
@@ -55,26 +56,53 @@ export function ConversionClassList({
if (result instanceof TalerError) {
return <ErrorLoadingWithDebug error={result} />;
}
- switch (result.case) {
- case "ok":
- break;
- case HttpStatusCode.Unauthorized:
- return <Fragment />;
- default:
- assertUnreachable(result);
+
+ if (result.type !== "ok") {
+ switch (result.case) {
+ case HttpStatusCode.Forbidden:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`No enough permission to access the conversion rate list.`}
+ ></Attention>
+ );
+ case HttpStatusCode.NotFound:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Conversion list not found. Maybe conversion rate is not supported.`}
+ ></Attention>
+ );
+ case HttpStatusCode.NotImplemented:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Conversion list not implemented.`}
+ ></Attention>
+ );
+ case HttpStatusCode.Unauthorized:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`No enough permission to access the conversion rate list.`}
+ ></Attention>
+ );
+ default:
+ assertUnreachable(result);
+ }
}
const onGoStart = result.isFirstPage ? undefined : result.loadFirst;
const onGoNext = result.isLastPage ? undefined : result.loadNext;
- const accounts = result.body;
+ const classes = result.body;
return (
<Fragment>
<div class="px-4 sm:px-6 lg:px-8 mt-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold leading-6 text-gray-900">
- <i18n.Translate>Accounts</i18n.Translate>
+ <i18n.Translate>Conversion classes</i18n.Translate>
</h1>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
@@ -84,15 +112,17 @@ export function ConversionClassList({
type="button"
class="block rounded-md bg-indigo-600 px-3 py-2 text-center 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"
>
- <i18n.Translate>Create account</i18n.Translate>
+ <i18n.Translate>New conversion class</i18n.Translate>
</a>
</div>
</div>
<div class="mt-4 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
- {!accounts.length ? (
- <div>{/* FIXME: ADD empty list */}</div>
+ {!classes.length ? (
+ <div>
+ <i18n.Translate>No conversion classes</i18n.Translate>
+ </div>
) : (
<table class="min-w-full divide-y divide-gray-300">
<thead>
@@ -100,92 +130,95 @@ export function ConversionClassList({
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0"
- >{i18n.str`Username`}</th>
+ >{i18n.str`Fee`}</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
- >{i18n.str`Name`}</th>
+ >{i18n.str`Ratio`}</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
- >{i18n.str`Balance`}</th>
+ >{i18n.str`Min amount`}</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
- <span class="sr-only">{i18n.str`Actions`}</span>
+ <span class="sr-only">{i18n.str`Rounding`}</span>
+ </th>
+ <th
+ scope="col"
+ class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0"
+ >{i18n.str`Fee`}</th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Ratio`}</th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Min amount`}</th>
+ <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
+ <span class="sr-only">{i18n.str`Rounding`}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
- {accounts.map((item, idx) => {
- const balance = !item.balance
- ? undefined
- : Amounts.parse(item.balance.amount);
- const noBalance = Amounts.isZero(item.balance.amount);
- const balanceIsDebit =
- item.balance &&
- item.balance.credit_debit_indicator == "debit";
-
+ {classes.map((row, idx) => {
return (
- <tr
- key={idx}
- class="data-[status=deleted]:bg-gray-100"
- data-status={item.status}
- >
+ <tr key={idx} class="">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
- <a
- name={`show account ${item.username}`}
- href={routeShowDetails.url({
- account: item.username,
- })}
- class="text-indigo-600 hover:text-indigo-900"
- >
- {item.username}
- </a>
+ {!row.cashin_fee ? (
+ "-"
+ ) : (
+ <RenderAmount
+ spec={config.currency_specification}
+ value={Amounts.parseOrThrow(row.cashin_fee)}
+ />
+ )}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
- {item.name}
+ {row.cashin_ratio}
</td>
- <td
- data-negative={
- noBalance
- ? undefined
- : balanceIsDebit
- ? "true"
- : "false"
- }
- class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 data-[negative=false]:text-green-600 data-[negative=true]:text-red-600 "
- >
- {!balance ? (
- i18n.str`Unknown`
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
+ {!row.cashin_min_amount ? (
+ "-"
) : (
- <span class="amount">
- <RenderAmount
- value={balance}
- negative={balanceIsDebit}
- spec={config.currency_specification}
- />
- </span>
+ <RenderAmount
+ spec={config.currency_specification}
+ value={Amounts.parseOrThrow(
+ row.cashin_min_amount,
+ )}
+ />
)}
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
- {item.status === "deleted" ? (
- <p class="text-gray-600">removed</p>
+ {row.cashin_rounding_mode}
+ </td>
+ <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
+ {!row.cashout_fee ? (
+ "-"
) : (
- <Fragment>
-
- {noBalance ? (
- <a
- name={`remove account ${item.username}`}
- href={routeRemove.url({
- account: item.username,
- })}
- class="text-indigo-600 hover:text-indigo-900"
- >
- <i18n.Translate>Remove</i18n.Translate>
- </a>
- ) : undefined}
- </Fragment>
+ <RenderAmount
+ spec={config.currency_specification}
+ value={Amounts.parseOrThrow(row.cashout_fee)}
+ />
+ )}
+ </td>
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
+ {row.cashout_ratio}
+ </td>
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
+ {!row.cashout_min_amount ? (
+ "-"
+ ) : (
+ <RenderAmount
+ spec={config.currency_specification}
+ value={Amounts.parseOrThrow(
+ row.cashout_min_amount,
+ )}
+ />
)}
</td>
+ <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
+ {row.cashout_rounding_mode}
+ </td>
</tr>
);
})}
diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts
@@ -1258,8 +1258,15 @@ export class TalerCoreBankHttpClient {
* https://docs.taler.net/core/api-corebank.html#get--conversion-rate-classes
*
*/
- async listConversionRateClasses(auth: AccessToken) {
+ async listConversionRateClasses(
+ auth: AccessToken,
+ params: PaginationParams & { className?: string } = {},
+ ) {
const url = new URL(`conversion-rate-classes`, this.baseUrl);
+ addPaginationParams(url, params);
+ if (params.className) {
+ url.searchParams.set("filter_name", params.className);
+ }
const resp = await this.httpLib.fetch(url.href, {
method: "GET",
headers: {
@@ -1269,6 +1276,8 @@ export class TalerCoreBankHttpClient {
switch (resp.status) {
case HttpStatusCode.Ok:
return opSuccessFromHttp(resp, codecForConversionRateClasses());
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({ classes: [], default: {} as any });
case HttpStatusCode.Unauthorized:
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.Forbidden: