taler-typescript-core

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

commit cb90e3dbc432a31aadbef135d8adf51067c79204
parent 5d7e2e78b50dac8ee79760e7a52e7a5b405d90a2
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon, 30 Jun 2025 01:15:26 -0300

conversion rate list

Diffstat:
Mpackages/bank-ui/src/Routing.tsx | 31+++++++++++++++++++++++++++----
Mpackages/bank-ui/src/hooks/regional.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/bank-ui/src/pages/admin/AdminHome.tsx | 9++++++---
Mpackages/bank-ui/src/pages/admin/ConversionClassList.tsx | 197++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mpackages/taler-util/src/http-client/bank-core.ts | 11++++++++++-
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: