taler-typescript-core

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

commit 23b999c4e248e2c4e31755fb1b8b349ff873e7fe
parent e7055cac42b76459d30e94329133b55a470c5e9f
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon, 17 Feb 2025 17:48:59 -0300

working on transfers view #9548

Diffstat:
Mpackages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx | 6++++++
Mpackages/aml-backoffice-ui/src/Routing.tsx | 6++++++
Apackages/aml-backoffice-ui/src/hooks/transfers.ts | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/aml-backoffice-ui/src/pages/Cases.tsx | 17+++++++++++++++++
Apackages/aml-backoffice-ui/src/pages/Transfers.tsx | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 546 insertions(+), 0 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx @@ -39,6 +39,7 @@ import { PeopleIcon, SearchIcon, ToInvestigateIcon, + TransfersIcon, } from "./pages/Cases.js"; /** @@ -251,6 +252,11 @@ function Navigation(): VNode { Icon: ToInvestigateIcon, label: i18n.str`Investigation`, }, + { + route: privatePages.transfers, + Icon: TransfersIcon, + label: i18n.str`Transfers`, + }, { route: privatePages.active, Icon: HomeIcon, label: i18n.str`Active` }, { route: privatePages.search, diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx @@ -45,6 +45,7 @@ import { import { useCurrentDecisionRequest } from "./hooks/decision-request.js"; import { Dashboard } from "./pages/Dashboard.js"; import { NewMeasure } from "./pages/NewMeasure.js"; +import { Transfers } from "./pages/Transfers.js"; export function Routing(): VNode { const session = useOfficer(); @@ -127,6 +128,7 @@ export const privatePages = { measures: urlPattern(/\/measures/, () => "#/measures"), search: urlPattern(/\/search/, () => "#/search"), investigation: urlPattern(/\/investigation/, () => "#/investigation"), + transfers: urlPattern(/\/transfers/, () => "#/transfers"), active: urlPattern(/\/active/, () => "#/active"), caseUpdate: urlPattern<{ cid: string; type: string }>( /\/case\/(?<cid>[a-zA-Z0-9]+)\/new\/(?<type>[a-zA-Z0-9_.]+)/, @@ -293,6 +295,10 @@ function PrivateRouting(): VNode { case "dashboard": { return <Dashboard routeToDownloadStats={privatePages.statsDownload} />; } + case "transfers": { + return <Transfers />; + } + default: assertUnreachable(location); } diff --git a/packages/aml-backoffice-ui/src/hooks/transfers.ts b/packages/aml-backoffice-ui/src/hooks/transfers.ts @@ -0,0 +1,205 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { useState } from "preact/hooks"; + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import { + OfficerAccount, + OperationOk, + opFixedSuccess, + opSuccessFromHttp, + TalerExchangeResultByMethod, + TalerHttpError, +} from "@gnu-taler/taler-util"; +import { useExchangeApiContext } from "@gnu-taler/web-util/browser"; +import _useSWR, { SWRHook, mutate } from "swr"; +import { useOfficer } from "./officer.js"; +const useSWR = _useSWR as unknown as SWRHook; + +export const PAGINATED_LIST_SIZE = 10; +// when doing paginated request, ask for one more +// and use it to know if there are more to request +export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1; + +export function revalidateAccountDecisions() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getAmlDecisions", + undefined, + { revalidate: true }, + ); +} + +/** + * @param args + * @returns + */ +export function useTransferDebit() { + const officer = useOfficer(); + const session = officer.state === "ready" ? officer.account : undefined; + const { + lib: { exchange: api }, + } = useExchangeApiContext(); + + const [offset, setOffset] = useState<string>(); + + async function fetcher([officer, offset]: [ + OfficerAccount, + string, + string | undefined, + ]) { + return await api.getTransfersDebit(officer, { + order: "dec", + offset, + limit: PAGINATED_LIST_REQUEST, + }); + } + + const { data, error } = useSWR< + TalerExchangeResultByMethod<"getTransfersDebit">, + TalerHttpError + >(!session ? undefined : [session, offset, "getTransfersDebit"], fetcher); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return buildPaginatedResult(data.body.transfers, offset, setOffset, (d) => + String(d.rowid), + ); +} + +/** + * @param args + * @returns + */ +export function useTransferCredit() { + const officer = useOfficer(); + const session = officer.state === "ready" ? officer.account : undefined; + const { + lib: { exchange: api }, + } = useExchangeApiContext(); + + const [offset, setOffset] = useState<string>(); + + async function fetcher([officer, offset]: [ + OfficerAccount, + string, + string | undefined, + ]) { + return await api.getTransfersCredit(officer, { + order: "dec", + offset, + limit: PAGINATED_LIST_REQUEST, + }); + } + + const { data, error } = useSWR< + TalerExchangeResultByMethod<"getTransfersCredit">, + TalerHttpError + >(!session ? undefined : [session, offset, "getTransfersCredit"], fetcher); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return buildPaginatedResult(data.body.transfers, offset, setOffset, (d) => + String(d.rowid), + ); +} + +/** + * @param account + * @param args + * @returns + */ +export function useAccountActiveDecision(accountStr?: string) { + const officer = useOfficer(); + const session = + accountStr !== undefined && officer.state === "ready" + ? officer.account + : undefined; + const { + lib: { exchange: api }, + } = useExchangeApiContext(); + + const [offset, setOffset] = useState<string>(); + + async function fetcher([officer, account, offset]: [ + OfficerAccount, + string, + string | undefined, + ]) { + return await api.getAmlDecisions(officer, { + order: "dec", + offset, + account, + active: true, + limit: PAGINATED_LIST_REQUEST, + }); + } + + const { data, error } = useSWR< + TalerExchangeResultByMethod<"getAmlDecisions">, + TalerHttpError + >( + !session ? undefined : [session, accountStr, offset, "getAmlDecisions"], + fetcher, + ); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + if (!data.body.records.length) return opFixedSuccess(undefined); + return opFixedSuccess(data.body.records[0]); +} + +type PaginatedResult<T> = OperationOk<T> & { + isLastPage: boolean; + isFirstPage: boolean; + loadNext(): void; + loadFirst(): void; +}; + +//TODO: consider sending this to web-util +export function buildPaginatedResult<R, OffId>( + data: R[], + offset: OffId | undefined, + setOffset: (o: OffId | undefined) => void, + getId: (r: R) => OffId, +): PaginatedResult<R[]> { + const isLastPage = data.length < PAGINATED_LIST_REQUEST; + const isFirstPage = offset === undefined; + + const result = structuredClone(data); + if (result.length == PAGINATED_LIST_REQUEST) { + result.pop(); + } + return { + type: "ok", + body: result, + isLastPage, + isFirstPage, + loadNext: () => { + if (!result.length) return; + const id = getId(result[result.length - 1]); + setOffset(id); + }, + loadFirst: () => { + setOffset(undefined); + }, + }; +} diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -358,6 +358,23 @@ export const ToInvestigateIcon = () => ( </svg> ); +export const TransfersIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="size-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" + /> + </svg> +); + export const PeopleIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" diff --git a/packages/aml-backoffice-ui/src/pages/Transfers.tsx b/packages/aml-backoffice-ui/src/pages/Transfers.tsx @@ -0,0 +1,312 @@ +import { + Attention, + Loading, + Time, + useExchangeApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useTransferCredit } from "../hooks/transfers.js"; +import { + AbsoluteTime, + AmountJson, + Amounts, + assertUnreachable, + CurrencySpecification, + HttpStatusCode, + TalerError, +} from "@gnu-taler/taler-util"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { Officer } from "./Officer.js"; +import { format } from "date-fns"; + +export function Transfers(): VNode { + const { i18n, dateLocale } = useTranslationContext(); + const { config } = useExchangeApiContext(); + const resp = useTransferCredit(); + const isDebit = true; //FIXME: shoud be an option debit/credit + + if (!resp) { + return <Loading />; + } + if (resp instanceof TalerError) { + return <ErrorLoadingWithDebug error={resp} />; + } + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Forbidden: + return ( + <Fragment> + <Attention type="danger" title={i18n.str`Operation denied`}> + <i18n.Translate> + This account signature is invalid, contact administrator or + create a new one. + </i18n.Translate> + </Attention> + <Officer /> + </Fragment> + ); + case HttpStatusCode.NotFound: + return ( + <Fragment> + <Attention type="danger" title={i18n.str`Operation denied`}> + <i18n.Translate> + The designated AML account is not known, contact administrator + or create a new one. + </i18n.Translate> + </Attention> + <Officer /> + </Fragment> + ); + case HttpStatusCode.Conflict: + return ( + <Fragment> + <Attention type="danger" title={i18n.str`Operation denied`}> + <i18n.Translate> + The designated AML account is not enabled, contact administrator + or create a new one. + </i18n.Translate> + </Attention> + <Officer /> + </Fragment> + ); + default: + assertUnreachable(resp); + } + } + const transactions = resp.body; + + if (!transactions.length) { + return ( + <div class="px-4 mt-4"> + <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>Transfers history</i18n.Translate> + </h1> + </div> + </div> + + <Attention type="low" title={i18n.str`No transfers yet.`}> + <i18n.Translate> + There are no transfer reported by the exchange with the current + threshold. + </i18n.Translate> + </Attention> + </div> + ); + } + + const txByDate = transactions.reduce( + (prev, cur) => { + const d = + cur.execution_time.t_s === "never" + ? "" + : format(cur.execution_time.t_s * 1000, "dd/MM/yyyy", { + locale: dateLocale, + }); + if (!prev[d]) { + prev[d] = []; + } + prev[d].push(cur); + return prev; + }, + {} as Record<string, typeof transactions>, + ); + + const onGoNext = resp.isLastPage ? undefined : resp.loadNext; + const onGoStart = resp.isFirstPage ? undefined : resp.loadFirst; + return ( + <div class="px-4 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>Transfers history</i18n.Translate> + </h1> + </div> + </div> + <div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white"> + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " + >{i18n.str`Date`}</th> + <th + scope="col" + class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " + >{i18n.str`Amount`}</th> + <th + scope="col" + class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " + >{i18n.str`Counterpart`}</th> + {/* <th + scope="col" + class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " + >{i18n.str`Subject`}</th> */} + </tr> + </thead> + <tbody> + {Object.entries(txByDate).map(([date, txs], idx) => { + return ( + <Fragment key={idx}> + <tr class="border-t border-gray-200"> + <th + colSpan={4} + scope="colgroup" + class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3" + > + {date} + </th> + </tr> + {txs.map((item) => { + return ( + <tr + key={idx} + class="border-b border-gray-200 last:border-none" + > + <td class="relative py-2 pl-2 pr-2 text-sm "> + <div class="font-medium text-gray-900"> + <Time + format="HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp( + item.execution_time, + )} + // relative={Duration.fromSpec({ days: 1 })} + /> + </div> + <dl class="font-normal sm:hidden"> + <dt class="sr-only sm:hidden"> + <i18n.Translate>Amount</i18n.Translate> + </dt> + <dd class="mt-1 truncate text-gray-700"> + {isDebit ? i18n.str`sent` : i18n.str`received`}{" "} + {item.amount ? ( + <span + data-negative={isDebit ? "true" : "false"} + class="data-[negative=false]:text-green-600 data-[negative=true]:text-red-600" + > + <RenderAmount + value={Amounts.parseOrThrow(item.amount)} + spec={config.config.currency_specification} + /> + </span> + ) : ( + <span style={{ color: "grey" }}> + &lt;{i18n.str`Invalid value`}&gt; + </span> + )} + </dd> + + <dt class="sr-only sm:hidden"> + <i18n.Translate>Counterpart</i18n.Translate> + </dt> + <dd class="mt-1 truncate text-gray-500 sm:hidden"> + {isDebit ? i18n.str`to` : i18n.str`from`}{" "} + {item.payto_uri} + </dd> + {/* <dd class="mt-1 text-gray-500 sm:hidden"> + <pre class="break-words w-56 whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100"> + {item.subject} + </pre> + </dd> */} + </dl> + </td> + <td + data-negative={isDebit ? "true" : "false"} + class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 " + > + {item.amount ? ( + <RenderAmount + value={Amounts.parseOrThrow(item.amount)} + negative={isDebit} + withColor + spec={config.config.currency_specification} + /> + ) : ( + <span style={{ color: "grey" }}> + &lt; + {i18n.str`Invalid value`}&gt; + </span> + )} + </td> + <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500"> + {item.payto_uri} + </td> + {/* <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> + {item.subject} + </td> */} + </tr> + ); + })} + </Fragment> + ); + })} + </tbody> + </table> + + <nav + class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" + aria-label="Pagination" + > + <div class="flex flex-1 justify-between sm:justify-end"> + <button + name="first page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoStart} + onClick={onGoStart} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + name="next page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoNext} + onClick={onGoNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> + </div> + </div> + ); +} + +/** + * send to web-utils + * @param param0 + * @returns + */ +export function RenderAmount({ + value, + spec, + negative, + withColor, + hideSmall, +}: { + spec: CurrencySpecification; + value: AmountJson; + hideSmall?: boolean; + negative?: boolean; + withColor?: boolean; +}): VNode { + const neg = !!negative; // convert to true or false + + const { currency, normal, small } = Amounts.stringifyValueWithSpec( + value, + spec, + ); + + return ( + <span + data-negative={withColor ? neg : undefined} + class="whitespace-nowrap data-[negative=false]:text-green-600 data-[negative=true]:text-red-600" + > + {negative ? "- " : undefined} + {currency} {normal}{" "} + {!hideSmall && small && <sup class="-ml-1">{small}</sup>} + </span> + ); +}