summaryrefslogtreecommitdiff
path: root/packages/auditor-backoffice-ui/src/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'packages/auditor-backoffice-ui/src/hooks')
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/async.ts77
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/backend.ts477
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/bank.ts217
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts161
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/index.ts151
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/instance.test.ts741
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/instance.ts313
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/listener.ts85
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/notifications.ts56
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/order.test.ts587
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/order.ts289
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/otp.ts223
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/product.test.ts362
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/product.ts177
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/reserve.test.ts448
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/reserves.ts181
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/templates.ts266
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/testing.tsx180
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/transfer.test.ts254
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/transfer.ts188
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/urls.ts303
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/useSettings.ts73
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/webhooks.ts178
23 files changed, 5987 insertions, 0 deletions
diff --git a/packages/auditor-backoffice-ui/src/hooks/async.ts b/packages/auditor-backoffice-ui/src/hooks/async.ts
new file mode 100644
index 000000000..f22badc88
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/async.ts
@@ -0,0 +1,77 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useState } from "preact/hooks";
+
+export interface Options {
+ slowTolerance: number;
+}
+
+export interface AsyncOperationApi<T> {
+ request: (...a: any) => void;
+ cancel: () => void;
+ data: T | undefined;
+ isSlow: boolean;
+ isLoading: boolean;
+ error: string | undefined;
+}
+
+export function useAsync<T>(
+ fn?: (...args: any) => Promise<T>,
+ { slowTolerance: tooLong }: Options = { slowTolerance: 1000 },
+): AsyncOperationApi<T> {
+ const [data, setData] = useState<T | undefined>(undefined);
+ const [isLoading, setLoading] = useState<boolean>(false);
+ const [error, setError] = useState<any>(undefined);
+ const [isSlow, setSlow] = useState(false);
+
+ const request = async (...args: any) => {
+ if (!fn) return;
+ setLoading(true);
+
+ const handler = setTimeout(() => {
+ setSlow(true);
+ }, tooLong);
+
+ try {
+ const result = await fn(...args);
+ setData(result);
+ } catch (error) {
+ setError(error);
+ }
+ setLoading(false);
+ setSlow(false);
+ clearTimeout(handler);
+ };
+
+ function cancel(): void {
+ setLoading(false);
+ setSlow(false);
+ }
+
+ return {
+ request,
+ cancel,
+ data,
+ isSlow,
+ isLoading,
+ error,
+ };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/backend.ts b/packages/auditor-backoffice-ui/src/hooks/backend.ts
new file mode 100644
index 000000000..8d99546a8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/backend.ts
@@ -0,0 +1,477 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util";
+import {
+ ErrorType,
+ HttpError,
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+ RequestOptions,
+ useApiContext,
+} from "@gnu-taler/web-util/browser";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import { useSWRConfig } from "swr";
+import { useBackendContext } from "../context/backend.js";
+import { useInstanceContext } from "../context/instance.js";
+import { AccessToken, LoginToken, MerchantBackend, Timestamp } from "../declaration.js";
+
+
+export function useMatchMutate(): (
+ re?: RegExp,
+ value?: unknown,
+) => Promise<any> {
+ const { cache, mutate } = useSWRConfig();
+
+ if (!(cache instanceof Map)) {
+ throw new Error(
+ "matchMutate requires the cache provider to be a Map instance",
+ );
+ }
+
+ return function matchRegexMutate(re?: RegExp) {
+ return mutate((key) => {
+ // evict if no key or regex === all
+ if (!key || !re) return true
+ // match string
+ if (typeof key === 'string' && re.test(key)) return true
+ // record or object have the path at [0]
+ if (typeof key === 'object' && re.test(key[0])) return true
+ //key didn't match regex
+ return false
+ }, undefined, {
+ revalidate: true,
+ });
+ };
+}
+
+export function useBackendInstancesTestForAdmin(): HttpResponse<
+ MerchantBackend.Instances.InstancesResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { request } = useBackendBaseRequest();
+
+ type Type = MerchantBackend.Instances.InstancesResponse;
+
+ const [result, setResult] = useState<
+ HttpResponse<Type, MerchantBackend.ErrorDetail>
+ >({ loading: true });
+
+ useEffect(() => {
+ request<Type>(`/management/instances`)
+ .then((data) => setResult(data))
+ .catch((error: RequestError<MerchantBackend.ErrorDetail>) =>
+ setResult(error.cause),
+ );
+ }, [request]);
+
+ return result;
+}
+
+const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000;
+const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000;
+
+export function useBackendConfig(): HttpResponse<
+ MerchantBackend.VersionResponse | undefined,
+ RequestError<MerchantBackend.ErrorDetail>
+> {
+ const { request } = useBackendBaseRequest();
+
+ type Type = MerchantBackend.VersionResponse;
+ type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number }
+ const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 });
+
+ useEffect(() => {
+ if (result.timer) {
+ clearTimeout(result.timer)
+ }
+ function tryConfig(): void {
+ request<Type>(`/config`)
+ .then((data) => {
+ const timer: any = setTimeout(() => {
+ tryConfig()
+ }, CHECK_CONFIG_INTERVAL_OK)
+ setResult({ data, timer })
+ })
+ .catch((error) => {
+ const timer: any = setTimeout(() => {
+ tryConfig()
+ }, CHECK_CONFIG_INTERVAL_FAIL)
+ const data = error.cause
+ setResult({ data, timer })
+ });
+ }
+ tryConfig()
+ }, [request]);
+
+ return result.data;
+}
+
+interface useBackendInstanceRequestType {
+ request: <T>(
+ endpoint: string,
+ options?: RequestOptions,
+ ) => Promise<HttpResponseOk<T>>;
+ fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
+ reserveDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
+ rewardsDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
+ multiFetcher: <T>(params: [url: string[]]) => Promise<HttpResponseOk<T>[]>;
+ orderFetcher: <T>(
+ params: [endpoint: string,
+ paid?: YesOrNo,
+ refunded?: YesOrNo,
+ wired?: YesOrNo,
+ searchDate?: Date,
+ delta?: number,]
+ ) => Promise<HttpResponseOk<T>>;
+ transferFetcher: <T>(
+ params: [endpoint: string,
+ payto_uri?: string,
+ verified?: string,
+ position?: string,
+ delta?: number,]
+ ) => Promise<HttpResponseOk<T>>;
+ templateFetcher: <T>(
+ params: [endpoint: string,
+ position?: string,
+ delta?: number]
+ ) => Promise<HttpResponseOk<T>>;
+ webhookFetcher: <T>(
+ params: [endpoint: string,
+ position?: string,
+ delta?: number]
+ ) => Promise<HttpResponseOk<T>>;
+}
+interface useBackendBaseRequestType {
+ request: <T>(
+ endpoint: string,
+ options?: RequestOptions,
+ ) => Promise<HttpResponseOk<T>>;
+}
+
+type YesOrNo = "yes" | "no";
+type LoginResult = {
+ valid: true;
+ token: string;
+ expiration: Timestamp;
+} | {
+ valid: false;
+ cause: HttpError<{}>;
+}
+
+export function useCredentialsChecker() {
+ const { request } = useApiContext();
+ //check against instance details endpoint
+ //while merchant backend doesn't have a login endpoint
+ async function requestNewLoginToken(
+ baseUrl: string,
+ token: AccessToken,
+ ): Promise<LoginResult> {
+ const data: MerchantBackend.Instances.LoginTokenRequest = {
+ scope: "write",
+ duration: {
+ d_us: "forever"
+ },
+ refreshable: true,
+ }
+ try {
+ const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, {
+ method: "POST",
+ token,
+ data
+ });
+ return { valid: true, token: response.data.token, expiration: response.data.expiration };
+ } catch (error) {
+ if (error instanceof RequestError) {
+ return { valid: false, cause: error.cause };
+ }
+
+ return {
+ valid: false, cause: {
+ type: ErrorType.UNEXPECTED,
+ loading: false,
+ info: {
+ hasToken: true,
+ status: 0,
+ options: {},
+ url: `/private/token`,
+ payload: {}
+ },
+ exception: error,
+ message: (error instanceof Error ? error.message : "unpexepected error")
+ }
+ };
+ }
+ };
+
+ async function refreshLoginToken(
+ baseUrl: string,
+ token: LoginToken
+ ): Promise<LoginResult> {
+
+ if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) {
+ return {
+ valid: false, cause: {
+ type: ErrorType.CLIENT,
+ status: HttpStatusCode.Unauthorized,
+ message: "login token expired, login again.",
+ info: {
+ hasToken: true,
+ status: 401,
+ options: {},
+ url: `/private/token`,
+ payload: {}
+ },
+ payload: {}
+ },
+ }
+ }
+
+ return requestNewLoginToken(baseUrl, token.token as AccessToken)
+ }
+ return { requestNewLoginToken, refreshLoginToken }
+}
+
+/**
+ *
+ * @param root the request is intended to the base URL and no the instance URL
+ * @returns request handler to
+ */
+export function useBackendBaseRequest(): useBackendBaseRequestType {
+ const { url: backend, token: loginToken } = useBackendContext();
+ const { request: requestHandler } = useApiContext();
+ const token = loginToken?.token;
+
+ const request = useCallback(
+ function requestImpl<T>(
+ endpoint: string,
+ options: RequestOptions = {},
+ ): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(backend, endpoint, { ...options, token }).then(res => {
+ return res
+ }).catch(err => {
+ throw err
+ });
+ },
+ [backend, token],
+ );
+
+ return { request };
+}
+
+export function useBackendInstanceRequest(): useBackendInstanceRequestType {
+ const { url: rootBackendUrl, token: rootToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+ const { request: requestHandler } = useApiContext();
+
+ const { baseUrl, token: loginToken } = !admin
+ ? { baseUrl: rootBackendUrl, token: rootToken }
+ : { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken };
+
+ const token = loginToken?.token;
+
+ const request = useCallback(
+ function requestImpl<T>(
+ endpoint: string,
+ options: RequestOptions = {},
+ ): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, { token, ...options });
+ },
+ [baseUrl, token],
+ );
+
+ const multiFetcher = useCallback(
+ function multiFetcherImpl<T>(
+ args: [endpoints: string[]],
+ ): Promise<HttpResponseOk<T>[]> {
+ const [endpoints] = args
+ return Promise.all(
+ endpoints.map((endpoint) =>
+ requestHandler<T>(baseUrl, endpoint, { token }),
+ ),
+ );
+ },
+ [baseUrl, token],
+ );
+
+ const fetcher = useCallback(
+ function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, { token });
+ },
+ [baseUrl, token],
+ );
+
+ const orderFetcher = useCallback(
+ function orderFetcherImpl<T>(
+ args: [endpoint: string,
+ paid?: YesOrNo,
+ refunded?: YesOrNo,
+ wired?: YesOrNo,
+ searchDate?: Date,
+ delta?: number,]
+ ): Promise<HttpResponseOk<T>> {
+ const [endpoint, paid, refunded, wired, searchDate, delta] = args
+ const date_s =
+ delta && delta < 0 && searchDate
+ ? Math.floor(searchDate.getTime() / 1000) + 1
+ : searchDate !== undefined ? Math.floor(searchDate.getTime() / 1000) : undefined;
+ const params: any = {};
+ if (paid !== undefined) params.paid = paid;
+ if (delta !== undefined) params.delta = delta;
+ if (refunded !== undefined) params.refunded = refunded;
+ if (wired !== undefined) params.wired = wired;
+ if (date_s !== undefined) params.date_s = date_s;
+ if (delta === 0) {
+ //in this case we can already assume the response
+ //and avoid network
+ return Promise.resolve({
+ ok: true,
+ data: { orders: [] } as T,
+ })
+ }
+ return requestHandler<T>(baseUrl, endpoint, { params, token });
+ },
+ [baseUrl, token],
+ );
+
+ const reserveDetailFetcher = useCallback(
+ function reserveDetailFetcherImpl<T>(
+ endpoint: string,
+ ): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, {
+ params: {
+ rewards: "yes",
+ },
+ token,
+ });
+ },
+ [baseUrl, token],
+ );
+
+ const rewardsDetailFetcher = useCallback(
+ function rewardsDetailFetcherImpl<T>(
+ endpoint: string,
+ ): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, {
+ params: {
+ pickups: "yes",
+ },
+ token,
+ });
+ },
+ [baseUrl, token],
+ );
+
+ const transferFetcher = useCallback(
+ function transferFetcherImpl<T>(
+ args: [endpoint: string,
+ payto_uri?: string,
+ verified?: string,
+ position?: string,
+ delta?: number,]
+ ): Promise<HttpResponseOk<T>> {
+ const [endpoint, payto_uri, verified, position, delta] = args
+ const params: any = {};
+ if (payto_uri !== undefined) params.payto_uri = payto_uri;
+ if (verified !== undefined) params.verified = verified;
+ if (delta === 0) {
+ //in this case we can already assume the response
+ //and avoid network
+ return Promise.resolve({
+ ok: true,
+ data: { transfers: [] } as T,
+ })
+ }
+ if (delta !== undefined) {
+ params.limit = delta;
+ }
+ if (position !== undefined) params.offset = position;
+
+ return requestHandler<T>(baseUrl, endpoint, { params, token });
+ },
+ [baseUrl, token],
+ );
+
+ const templateFetcher = useCallback(
+ function templateFetcherImpl<T>(
+ args: [endpoint: string,
+ position?: string,
+ delta?: number,]
+ ): Promise<HttpResponseOk<T>> {
+ const [endpoint, position, delta] = args
+ const params: any = {};
+ if (delta === 0) {
+ //in this case we can already assume the response
+ //and avoid network
+ return Promise.resolve({
+ ok: true,
+ data: { templates: [] } as T,
+ })
+ }
+ if (delta !== undefined) {
+ params.limit = delta;
+ }
+ if (position !== undefined) params.offset = position;
+
+ return requestHandler<T>(baseUrl, endpoint, { params, token });
+ },
+ [baseUrl, token],
+ );
+
+ const webhookFetcher = useCallback(
+ function webhookFetcherImpl<T>(
+ args: [endpoint: string,
+ position?: string,
+ delta?: number,]
+ ): Promise<HttpResponseOk<T>> {
+ const [endpoint, position, delta] = args
+ const params: any = {};
+ if (delta === 0) {
+ //in this case we can already assume the response
+ //and avoid network
+ return Promise.resolve({
+ ok: true,
+ data: { webhooks: [] } as T,
+ })
+ }
+ if (delta !== undefined) {
+ params.limit = delta;
+ }
+ if (position !== undefined) params.offset = position;
+
+ return requestHandler<T>(baseUrl, endpoint, { params, token });
+ },
+ [baseUrl, token],
+ );
+
+ return {
+ request,
+ fetcher,
+ multiFetcher,
+ orderFetcher,
+ reserveDetailFetcher,
+ rewardsDetailFetcher,
+ transferFetcher,
+ templateFetcher,
+ webhookFetcher,
+ };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/bank.ts b/packages/auditor-backoffice-ui/src/hooks/bank.ts
new file mode 100644
index 000000000..03b064646
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/bank.ts
@@ -0,0 +1,217 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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 {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+// const MOCKED_ACCOUNTS: Record<string, MerchantBackend.BankAccounts.AccountAddDetails> = {
+// "hwire1": {
+// h_wire: "hwire1",
+// payto_uri: "payto://fake/iban/123",
+// salt: "qwe",
+// },
+// "hwire2": {
+// h_wire: "hwire2",
+// payto_uri: "payto://fake/iban/123",
+// salt: "qwe2",
+// },
+// }
+
+export function useBankAccountAPI(): BankAccountAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createBankAccount = async (
+ data: MerchantBackend.BankAccounts.AccountAddDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ // MOCKED_ACCOUNTS[data.h_wire] = data
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/accounts`, {
+ method: "POST",
+ data,
+ });
+ await mutateAll(/.*private\/accounts.*/);
+ return res;
+ };
+
+ const updateBankAccount = async (
+ h_wire: string,
+ data: MerchantBackend.BankAccounts.AccountPatchDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ // MOCKED_ACCOUNTS[h_wire].credit_facade_credentials = data.credit_facade_credentials
+ // MOCKED_ACCOUNTS[h_wire].credit_facade_url = data.credit_facade_url
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/accounts/${h_wire}`, {
+ method: "PATCH",
+ data,
+ });
+ await mutateAll(/.*private\/accounts.*/);
+ return res;
+ };
+
+ const deleteBankAccount = async (
+ h_wire: string,
+ ): Promise<HttpResponseOk<void>> => {
+ // delete MOCKED_ACCOUNTS[h_wire]
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/accounts/${h_wire}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/accounts.*/);
+ return res;
+ };
+
+ return {
+ createBankAccount,
+ updateBankAccount,
+ deleteBankAccount,
+ };
+}
+
+export interface BankAccountAPI {
+ createBankAccount: (
+ data: MerchantBackend.BankAccounts.AccountAddDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ updateBankAccount: (
+ id: string,
+ data: MerchantBackend.BankAccounts.AccountPatchDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ deleteBankAccount: (id: string) => Promise<HttpResponseOk<void>>;
+}
+
+export interface InstanceBankAccountFilter {
+}
+
+export function useInstanceBankAccounts(
+ args?: InstanceBankAccountFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.BankAccounts.AccountsSummaryResponse,
+ MerchantBackend.ErrorDetail
+> {
+ // return {
+ // ok: true,
+ // loadMore() { },
+ // loadMorePrev() { },
+ // data: {
+ // accounts: Object.values(MOCKED_ACCOUNTS).map(e => ({
+ // ...e,
+ // active: true,
+ // }))
+ // }
+ // }
+ const { fetcher } = useBackendInstanceRequest();
+
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.BankAccounts.AccountsSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/accounts`], fetcher);
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.BankAccounts.AccountsSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ }, [afterData /*, beforeData*/]);
+
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.accounts.length < totalAfter;
+ const isReachingStart = false;
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.accounts.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${afterData.data.accounts[afterData.data.accounts.length - 1]
+ .h_wire
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ },
+ };
+
+ const accounts = !afterData ? [] : (afterData || lastAfter).data.accounts;
+ if (loadingAfter /* || loadingBefore */)
+ return { loading: true, data: { accounts } };
+ if (/*beforeData &&*/ afterData) {
+ return { ok: true, data: { accounts }, ...pagination };
+ }
+ return { loading: true };
+}
+
+export function useBankAccountDetails(
+ h_wire: string,
+): HttpResponse<
+ MerchantBackend.BankAccounts.BankAccountEntry,
+ MerchantBackend.ErrorDetail
+> {
+ // return {
+ // ok: true,
+ // data: {
+ // ...MOCKED_ACCOUNTS[h_wire],
+ // active: true,
+ // }
+ // }
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.BankAccounts.BankAccountEntry>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/accounts/${h_wire}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) {
+ return data;
+ }
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts b/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts
new file mode 100644
index 000000000..e4ec9a2f2
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts
@@ -0,0 +1,161 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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 {
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { AuditorBackend, MerchantBackend, WithId } from "../declaration.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, useSWRConfig } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface DepositConfirmationAPI {
+ getDepositConfirmation: (
+ id: string,
+ ) => Promise<void>;
+ createDepositConfirmation: (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ) => Promise<void>;
+ updateDepositConfirmation: (
+ id: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ) => Promise<void>;
+ deleteDepositConfirmation: (id: string) => Promise<void>;
+}
+
+export function useDepositConfirmationAPI(): DepositConfirmationAPI {
+ const mutateAll = useMatchMutate();
+ const { mutate } = useSWRConfig();
+
+ const { request } = useBackendInstanceRequest();
+
+ const createDepositConfirmation = async (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ): Promise<void> => {
+ const res = await request(`/private/products`, {
+ method: "POST",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const updateDepositConfirmation = async (
+ productId: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ): Promise<void> => {
+ const r = await request(`/private/products/${productId}`, {
+ method: "PATCH",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const deleteDepositConfirmation = async (productId: string): Promise<void> => {
+ await request(`/private/products/${productId}`, {
+ method: "DELETE",
+ });
+ await mutate([`/private/products`]);
+ };
+
+ const getDepositConfirmation = async (
+ serialId: string,
+ ): Promise<void> => {
+ await request(`/deposit-confirmation/${serialId}`, {
+ method: "GET",
+ });
+
+ return
+ };
+
+ return {createDepositConfirmation, updateDepositConfirmation, deleteDepositConfirmation, getDepositConfirmation};
+}
+
+export function useDepositConfirmation(): HttpResponse<
+ (AuditorBackend.DepositConfirmation.DepositConfirmationDetail & WithId)[],
+ AuditorBackend.ErrorDetail
+> {
+ const { fetcher, multiFetcher } = useBackendInstanceRequest();
+
+ const { data: list, error: listError } = useSWR<
+ HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationList>,
+ RequestError<AuditorBackend.ErrorDetail>
+ >([`/deposit-confirmation`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ const paths = (list?.data.depositConfirmations || []).map(
+ (p) => `/deposit-confirmation/${p.serial_id}`,
+ );
+ const { data: depositConfirmations, error: depositConfirmationError } = useSWR<
+ HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationDetail>[],
+ RequestError<AuditorBackend.ErrorDetail>
+ >([paths], multiFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (listError) return listError.cause;
+ if (depositConfirmationError) return depositConfirmationError.cause;
+
+ if (depositConfirmations) {
+ const dataWithId = depositConfirmations.map((d) => {
+ //take the id from the queried url
+ return {
+ ...d.data,
+ id: d.info?.url.replace(/.*\/deposit-confirmation\//, "") || "",
+ };
+ });
+ return { ok: true, data: dataWithId };
+ }
+ return { loading: true };
+}
+
+export function useDepositConfirmationDetails(
+ serialId: string,
+): HttpResponse<
+ AuditorBackend.DepositConfirmation.DepositConfirmationDetail,
+ AuditorBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationDetail>,
+ RequestError<AuditorBackend.ErrorDetail>
+ >([`/deposit-confirmation/${serialId}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/index.ts b/packages/auditor-backoffice-ui/src/hooks/index.ts
new file mode 100644
index 000000000..61afbc94a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/index.ts
@@ -0,0 +1,151 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { buildCodecForObject, codecForMap, codecForString, codecForTimestamp } from "@gnu-taler/taler-util";
+import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
+import { StateUpdater, useEffect, useState } from "preact/hooks";
+import { LoginToken } from "../declaration.js";
+import { ValueOrFunction } from "../utils/types.js";
+import { useMatchMutate } from "./backend.js";
+
+const calculateRootPath = () => {
+ const rootPath =
+ typeof window !== undefined
+ ? window.location.origin + window.location.pathname
+ : "/";
+
+ /**
+ * By default, merchant backend serves the html content
+ * from the /webui root. This should cover most of the
+ * cases and the rootPath will be the merchant backend
+ * URL where the instances are
+ */
+ return rootPath.replace("/webui/", "");
+};
+
+const loginTokenCodec = buildCodecForObject<LoginToken>()
+ .property("token", codecForString())
+ .property("expiration", codecForTimestamp)
+ .build("loginToken")
+const TOKENS_KEY = buildStorageKey("auditor-token", codecForMap(loginTokenCodec));
+
+
+export function useBackendURL(
+ url?: string,
+): [string, StateUpdater<string>] {
+ const [value, setter] = useSimpleLocalStorage(
+ "auditor-base-url",
+ url || calculateRootPath(),
+ );
+
+ const checkedSetter = (v: ValueOrFunction<string>) => {
+ return setter((p) => (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, ""));
+ };
+
+ return [value!, checkedSetter];
+}
+
+export function useBackendDefaultToken(
+): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
+ const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
+
+ const tokenOfDefaultInstance = tokenMap["default"]
+ const clearCache = useMatchMutate()
+ useEffect(() => {
+ clearCache()
+ }, [tokenOfDefaultInstance])
+
+ function updateToken(
+ value: (LoginToken | undefined)
+ ): void {
+ if (value === undefined) {
+ reset()
+ } else {
+ const res = { ...tokenMap, "default": value }
+ setToken(res)
+ }
+ }
+ return [tokenMap["default"], updateToken];
+}
+
+export function useBackendInstanceToken(
+ id: string,
+): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
+ const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
+ const [defaultToken, defaultSetToken] = useBackendDefaultToken();
+
+ // instance named 'default' use the default token
+ if (id === "default") {
+ return [defaultToken, defaultSetToken];
+ }
+ function updateToken(
+ value: (LoginToken | undefined)
+ ): void {
+ if (value === undefined) {
+ reset()
+ } else {
+ const res = { ...tokenMap, [id]: value }
+ setToken(res)
+ }
+ }
+
+ return [tokenMap[id], updateToken];
+}
+
+export function useLang(initial?: string): [string, StateUpdater<string>] {
+ const browserLang =
+ typeof window !== "undefined"
+ ? navigator.language || (navigator as any).userLanguage
+ : undefined;
+ const defaultLang = (browserLang || initial || "en").substring(0, 2);
+ return useSimpleLocalStorage("lang-preference", defaultLang) as [string, StateUpdater<string>];
+}
+
+export function useSimpleLocalStorage(
+ key: string,
+ initialValue?: string,
+): [string | undefined, StateUpdater<string | undefined>] {
+ const [storedValue, setStoredValue] = useState<string | undefined>(
+ (): string | undefined => {
+ return typeof window !== "undefined"
+ ? window.localStorage.getItem(key) || initialValue
+ : initialValue;
+ },
+ );
+
+ const setValue = (
+ value?: string | ((val?: string) => string | undefined),
+ ) => {
+ setStoredValue((p) => {
+ const toStore = value instanceof Function ? value(p) : value;
+ if (typeof window !== "undefined") {
+ if (!toStore) {
+ window.localStorage.removeItem(key);
+ } else {
+ window.localStorage.setItem(key, toStore);
+ }
+ }
+ return toStore;
+ });
+ };
+
+ return [storedValue, setValue];
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/instance.test.ts b/packages/auditor-backoffice-ui/src/hooks/instance.test.ts
new file mode 100644
index 000000000..ee1576764
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/instance.test.ts
@@ -0,0 +1,741 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { AccessToken, MerchantBackend } from "../declaration.js";
+import {
+ useAdminAPI,
+ useBackendInstances,
+ useInstanceAPI,
+ useInstanceDetails,
+ useManagementAPI,
+} from "./instance.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_CREATE_INSTANCE,
+ API_DELETE_INSTANCE,
+ API_GET_CURRENT_INSTANCE,
+ API_LIST_INSTANCES,
+ API_NEW_LOGIN,
+ API_UPDATE_CURRENT_INSTANCE,
+ API_UPDATE_CURRENT_INSTANCE_AUTH,
+ API_UPDATE_INSTANCE_BY_ID,
+} from "./urls.js";
+
+describe("instance api interaction with details", () => {
+ it("should evict cache when updating an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useInstanceAPI();
+ const query = useInstanceDetails();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ });
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE, {
+ request: {
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceReconfigurationMessage,
+ });
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "other_name",
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+ api.updateInstance({
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceReconfigurationMessage);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "other_name",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when setting the instance's token", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "not-secret",
+ },
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useInstanceAPI();
+ const query = useInstanceDetails();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ auth: {
+ method: "token",
+ },
+ });
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, {
+ request: {
+ method: "token",
+ token: "secret",
+ } as MerchantBackend.Instances.InstanceAuthConfigurationMessage,
+ });
+ env.addRequestExpectation(API_NEW_LOGIN, {
+ auth: "secret",
+ request: {
+ scope: "write",
+ duration: {
+ "d_us": "forever",
+ },
+ refreshable: true,
+ },
+ });
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "secret",
+ },
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+ api.setNewAccessToken(undefined, "secret" as AccessToken);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "secret",
+ },
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when clearing the instance's token", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "not-secret",
+ },
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useInstanceAPI();
+ const query = useInstanceDetails();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "not-secret",
+ },
+ });
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, {
+ request: {
+ method: "external",
+ } as MerchantBackend.Instances.InstanceAuthConfigurationMessage,
+ });
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "external",
+ },
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+
+ api.clearAccessToken(undefined);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ auth: {
+ method: "external",
+ },
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ // const { result, waitForNextUpdate } = renderHook(
+ // () => {
+ // const api = useInstanceAPI();
+ // const query = useInstanceDetails();
+
+ // return { query, api };
+ // },
+ // { wrapper: TestingContext }
+ // );
+
+ // expect(result.current).not.undefined;
+ // if (!result.current) {
+ // return;
+ // }
+ // expect(result.current.query.loading).true;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+
+ // expect(result.current?.query.ok).true;
+ // if (!result.current?.query.ok) return;
+
+ // expect(result.current.query.data).equals({
+ // name: 'instance_name',
+ // auth: {
+ // method: 'token',
+ // token: 'not-secret',
+ // }
+ // });
+
+ // act(async () => {
+ // await result.current?.api.clearToken();
+ // });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+ // expect(result.current.query.ok).true;
+
+ // expect(result.current.query.data).equals({
+ // name: 'instance_name',
+ // auth: {
+ // method: 'external',
+ // }
+ // });
+ });
+});
+
+describe("instance admin api interaction with listing", () => {
+ it("should evict cache when creating a new instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useAdminAPI();
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ name: "instance_name",
+ },
+ ],
+ });
+
+ env.addRequestExpectation(API_CREATE_INSTANCE, {
+ request: {
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage,
+ });
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ {
+ name: "other_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ api.createInstance({
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ name: "instance_name",
+ },
+ {
+ name: "other_name",
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ {
+ id: "the_id",
+ name: "second_instance",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useAdminAPI();
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ },
+ {
+ id: "the_id",
+ name: "second_instance",
+ },
+ ],
+ });
+
+ env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), {});
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ api.deleteInstance("the_id");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // const { result, waitForNextUpdate } = renderHook(
+ // () => {
+ // const api = useAdminAPI();
+ // const query = useBackendInstances();
+
+ // return { query, api };
+ // },
+ // { wrapper: TestingContext }
+ // );
+
+ // expect(result.current).not.undefined;
+ // if (!result.current) {
+ // return;
+ // }
+ // expect(result.current.query.loading).true;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+
+ // expect(result.current?.query.ok).true;
+ // if (!result.current?.query.ok) return;
+
+ // expect(result.current.query.data).equals({
+ // instances: [{
+ // id: 'default',
+ // name: 'instance_name'
+ // }, {
+ // id: 'the_id',
+ // name: 'second_instance'
+ // }]
+ // });
+
+ // env.addRequestExpectation(API_DELETE_INSTANCE('the_id'), {});
+
+ // act(async () => {
+ // await result.current?.api.deleteInstance('the_id');
+ // });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // env.addRequestExpectation(API_LIST_INSTANCES, {
+ // response: {
+ // instances: [{
+ // id: 'default',
+ // name: 'instance_name'
+ // } as MerchantBackend.Instances.Instance]
+ // },
+ // });
+
+ // expect(result.current.query.loading).false;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+ // expect(result.current.query.ok).true;
+
+ // expect(result.current.query.data).equals({
+ // instances: [{
+ // id: 'default',
+ // name: 'instance_name'
+ // }]
+ // });
+ });
+
+ it("should evict cache when deleting (purge) an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ {
+ id: "the_id",
+ name: "second_instance",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useAdminAPI();
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ },
+ {
+ id: "the_id",
+ name: "second_instance",
+ },
+ ],
+ });
+
+ env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), {
+ qparam: {
+ purge: "YES",
+ },
+ });
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ api.purgeInstance("the_id");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("instance management api interaction with listing", () => {
+ it("should evict cache when updating an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "managed",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useManagementAPI("managed");
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "managed",
+ name: "instance_name",
+ },
+ ],
+ });
+
+ env.addRequestExpectation(API_UPDATE_INSTANCE_BY_ID("managed"), {
+ request: {
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceReconfigurationMessage,
+ });
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "managed",
+ name: "other_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ api.updateInstance({
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "managed",
+ name: "other_name",
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/instance.ts b/packages/auditor-backoffice-ui/src/hooks/instance.ts
new file mode 100644
index 000000000..0677191db
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/instance.ts
@@ -0,0 +1,313 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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 {
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useBackendContext } from "../context/backend.js";
+import { AccessToken, MerchantBackend } from "../declaration.js";
+import {
+ useBackendBaseRequest,
+ useBackendInstanceRequest,
+ useCredentialsChecker,
+ useMatchMutate,
+} from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, useSWRConfig } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+interface InstanceAPI {
+ updateInstance: (
+ data: MerchantBackend.Instances.InstanceReconfigurationMessage,
+ ) => Promise<void>;
+ deleteInstance: () => Promise<void>;
+ clearAccessToken: (currentToken: AccessToken | undefined) => Promise<void>;
+ setNewAccessToken: (currentToken: AccessToken | undefined, token: AccessToken) => Promise<void>;
+}
+
+export function useAdminAPI(): AdminAPI {
+ const { request } = useBackendBaseRequest();
+ const mutateAll = useMatchMutate();
+
+ const createInstance = async (
+ instance: MerchantBackend.Instances.InstanceConfigurationMessage,
+ ): Promise<void> => {
+ await request(`/management/instances`, {
+ method: "POST",
+ data: instance,
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const deleteInstance = async (id: string): Promise<void> => {
+ await request(`/management/instances/${id}`, {
+ method: "DELETE",
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const purgeInstance = async (id: string): Promise<void> => {
+ await request(`/management/instances/${id}`, {
+ method: "DELETE",
+ params: {
+ purge: "YES",
+ },
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ return { createInstance, deleteInstance, purgeInstance };
+}
+
+export interface AdminAPI {
+ createInstance: (
+ data: MerchantBackend.Instances.InstanceConfigurationMessage,
+ ) => Promise<void>;
+ deleteInstance: (id: string) => Promise<void>;
+ purgeInstance: (id: string) => Promise<void>;
+}
+
+export function useManagementAPI(instanceId: string): InstanceAPI {
+ const mutateAll = useMatchMutate();
+ const { url: backendURL } = useBackendContext()
+ const { updateToken } = useBackendContext();
+ const { request } = useBackendBaseRequest();
+ const { requestNewLoginToken } = useCredentialsChecker()
+
+ const updateInstance = async (
+ instance: MerchantBackend.Instances.InstanceReconfigurationMessage,
+ ): Promise<void> => {
+ await request(`/management/instances/${instanceId}`, {
+ method: "PATCH",
+ data: instance,
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const deleteInstance = async (): Promise<void> => {
+ await request(`/management/instances/${instanceId}`, {
+ method: "DELETE",
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => {
+ await request(`/management/instances/${instanceId}/auth`, {
+ method: "POST",
+ token: currentToken,
+ data: { method: "external" },
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => {
+ await request(`/management/instances/${instanceId}/auth`, {
+ method: "POST",
+ token: currentToken,
+ data: { method: "token", token: newToken },
+ });
+
+ const resp = await requestNewLoginToken(backendURL, newToken)
+ if (resp.valid) {
+ const { token, expiration } = resp
+ updateToken({ token, expiration });
+ } else {
+ updateToken(undefined)
+ }
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken };
+}
+
+export function useInstanceAPI(): InstanceAPI {
+ const { mutate } = useSWRConfig();
+ const { url: backendURL, updateToken } = useBackendContext()
+
+ const {
+ token: adminToken,
+ } = useBackendContext();
+ const { request } = useBackendInstanceRequest();
+ const { requestNewLoginToken } = useCredentialsChecker()
+
+ const updateInstance = async (
+ instance: MerchantBackend.Instances.InstanceReconfigurationMessage,
+ ): Promise<void> => {
+ await request(`/private/`, {
+ method: "PATCH",
+ data: instance,
+ });
+
+ if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);
+ mutate([`/private/`], null);
+ };
+
+ const deleteInstance = async (): Promise<void> => {
+ await request(`/private/`, {
+ method: "DELETE",
+ // token: adminToken,
+ });
+
+ if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);
+ mutate([`/private/`], null);
+ };
+
+ const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => {
+ await request(`/private/auth`, {
+ method: "POST",
+ token: currentToken,
+ data: { method: "external" },
+ });
+
+ mutate([`/private/`], null);
+ };
+
+ const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => {
+ await request(`/private/auth`, {
+ method: "POST",
+ token: currentToken,
+ data: { method: "token", token: newToken },
+ });
+
+ const resp = await requestNewLoginToken(backendURL, newToken)
+ if (resp.valid) {
+ const { token, expiration } = resp
+ updateToken({ token, expiration });
+ } else {
+ updateToken(undefined)
+ }
+
+ mutate([`/private/`], null);
+ };
+
+ return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken };
+}
+
+export function useInstanceDetails(): HttpResponse<
+ MerchantBackend.Instances.QueryInstancesResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ revalidateIfStale: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+type KYCStatus =
+ | { type: "ok" }
+ | { type: "redirect"; status: MerchantBackend.KYC.AccountKycRedirects };
+
+export function useInstanceKYCDetails(): HttpResponse<
+ KYCStatus,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error } = useSWR<
+ HttpResponseOk<MerchantBackend.KYC.AccountKycRedirects>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/kyc`], fetcher, {
+ refreshInterval: 60 * 1000,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateIfStale: false,
+ revalidateOnMount: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ });
+
+ if (data) {
+ if (data.info?.status === 202)
+ return { ok: true, data: { type: "redirect", status: data.data } };
+ return { ok: true, data: { type: "ok" } };
+ }
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export function useManagedInstanceDetails(
+ instanceId: string,
+): HttpResponse<
+ MerchantBackend.Instances.QueryInstancesResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { request } = useBackendBaseRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/management/instances/${instanceId}`], request, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export function useBackendInstances(): HttpResponse<
+ MerchantBackend.Instances.InstancesResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { request } = useBackendBaseRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.InstancesResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(["/management/instances"], request);
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/listener.ts b/packages/auditor-backoffice-ui/src/hooks/listener.ts
new file mode 100644
index 000000000..d101f7bb8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/listener.ts
@@ -0,0 +1,85 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useState } from "preact/hooks";
+
+/**
+ * This component is used when a component wants one child to have a trigger for
+ * an action (a button) and other child have the action implemented (like
+ * gathering information with a form). The difference with other approaches is
+ * that in this case the parent component is not holding the state.
+ *
+ * It will return a subscriber and activator.
+ *
+ * The activator may be undefined, if it is undefined it is indicating that the
+ * subscriber is not ready to be called.
+ *
+ * The subscriber will receive a function (the listener) that will be call when the
+ * activator runs. The listener must return the collected information.
+ *
+ * As a result, when the activator is triggered by a child component, the
+ * @action function is called receives the information from the listener defined by other
+ * child component
+ *
+ * @param action from <T> to <R>
+ * @returns activator and subscriber, undefined activator means that there is not subscriber
+ */
+
+export function useListener<T, R = any>(
+ action: (r: T) => Promise<R>,
+): [undefined | (() => Promise<R>), (listener?: () => T) => void] {
+ type RunnerHandler = { toBeRan?: () => Promise<R> };
+ const [state, setState] = useState<RunnerHandler>({});
+
+ /**
+ * subscriber will receive a method that will be call when the activator runs
+ *
+ * @param listener function to be run when the activator runs
+ */
+ const subscriber = (listener?: () => T) => {
+ if (listener) {
+ setState({
+ toBeRan: () => {
+ const whatWeGetFromTheListener = listener();
+ return action(whatWeGetFromTheListener);
+ },
+ });
+ } else {
+ setState({
+ toBeRan: undefined,
+ });
+ }
+ };
+
+ /**
+ * activator will call runner if there is someone subscribed
+ */
+ const activator = state.toBeRan
+ ? async () => {
+ if (state.toBeRan) {
+ return state.toBeRan();
+ }
+ return Promise.reject();
+ }
+ : undefined;
+
+ return [activator, subscriber];
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/notifications.ts b/packages/auditor-backoffice-ui/src/hooks/notifications.ts
new file mode 100644
index 000000000..133ddd80b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/notifications.ts
@@ -0,0 +1,56 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useState } from "preact/hooks";
+import { Notification } from "../utils/types.js";
+
+interface Result {
+ notifications: Notification[];
+ pushNotification: (n: Notification) => void;
+ removeNotification: (n: Notification) => void;
+}
+
+type NotificationWithDate = Notification & { since: Date };
+
+export function useNotifications(
+ initial: Notification[] = [],
+ timeout = 3000,
+): Result {
+ const [notifications, setNotifications] = useState<NotificationWithDate[]>(
+ initial.map((i) => ({ ...i, since: new Date() })),
+ );
+
+ const pushNotification = (n: Notification): void => {
+ const entry = { ...n, since: new Date() };
+ setNotifications((ns) => [...ns, entry]);
+ if (n.type !== "ERROR")
+ setTimeout(() => {
+ setNotifications((ns) => ns.filter((x) => x.since !== entry.since));
+ }, timeout);
+ };
+
+ const removeNotification = (notif: Notification) => {
+ setNotifications((ns: NotificationWithDate[]) =>
+ ns.filter((n) => n !== notif),
+ );
+ };
+ return { notifications, pushNotification, removeNotification };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/order.test.ts b/packages/auditor-backoffice-ui/src/hooks/order.test.ts
new file mode 100644
index 000000000..c243309a8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/order.test.ts
@@ -0,0 +1,587 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { MerchantBackend } from "../declaration.js";
+import { useInstanceOrders, useOrderAPI, useOrderDetails } from "./order.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_CREATE_ORDER,
+ API_DELETE_ORDER,
+ API_FORGET_ORDER_BY_ID,
+ API_GET_ORDER_BY_ID,
+ API_LIST_ORDERS,
+ API_REFUND_ORDER_BY_ID,
+} from "./urls.js";
+
+describe("order api interaction with listing", () => {
+ it("should evict cache when creating an order", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "1" }, { order_id: "2" } as MerchantBackend.Orders.OrderHistoryEntry],
+ },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceOrders({ paid: "yes" }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "1" }, { order_id: "2" }],
+ });
+
+ env.addRequestExpectation(API_CREATE_ORDER, {
+ request: {
+ order: { amount: "ARS:12", summary: "pay me" },
+ },
+ response: { order_id: "3" },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "1" }, { order_id: "2" } as any, { order_id: "3" } as any],
+ },
+ });
+
+ api.createOrder({
+ order: { amount: "ARS:12", summary: "pay me" },
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "1" }, { order_id: "2" }, { order_id: "3" }],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when doing a refund", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: { orders: [{
+ order_id: "1",
+ amount: "EUR:12",
+ refundable: true,
+ } as MerchantBackend.Orders.OrderHistoryEntry] },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceOrders({ paid: "yes" }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [
+ {
+ order_id: "1",
+ amount: "EUR:12",
+ refundable: true,
+ },
+ ],
+ });
+ env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), {
+ request: {
+ reason: "double pay",
+ refund: "EUR:1",
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: { orders: [
+ { order_id: "1", amount: "EUR:12", refundable: false } as any,
+ ] },
+ });
+
+ api.refundOrder("1", {
+ reason: "double pay",
+ refund: "EUR:1",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [
+ {
+ order_id: "1",
+ amount: "EUR:12",
+ refundable: false,
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting an order", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "1" }, { order_id: "2" } as MerchantBackend.Orders.OrderHistoryEntry],
+ },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceOrders({ paid: "yes" }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "1" }, { order_id: "2" }],
+ });
+
+ env.addRequestExpectation(API_DELETE_ORDER("1"), {});
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "2" } as any],
+ },
+ });
+
+ api.deleteOrder("1");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "2" }],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("order api interaction with details", () => {
+ it("should evict cache when doing a refund", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ // qparam: { delta: 0, paid: "yes" },
+ response: {
+ summary: "description",
+ refund_amount: "EUR:0",
+ } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse,
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useOrderDetails("1");
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ summary: "description",
+ refund_amount: "EUR:0",
+ });
+ env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), {
+ request: {
+ reason: "double pay",
+ refund: "EUR:1",
+ },
+ });
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ response: {
+ summary: "description",
+ refund_amount: "EUR:1",
+ } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse,
+ });
+
+ api.refundOrder("1", {
+ reason: "double pay",
+ refund: "EUR:1",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ summary: "description",
+ refund_amount: "EUR:1",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when doing a forget", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ // qparam: { delta: 0, paid: "yes" },
+ response: {
+ summary: "description",
+ refund_amount: "EUR:0",
+ } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse,
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useOrderDetails("1");
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ summary: "description",
+ refund_amount: "EUR:0",
+ });
+ env.addRequestExpectation(API_FORGET_ORDER_BY_ID("1"), {
+ request: {
+ fields: ["$.summary"],
+ },
+ });
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ response: {
+ summary: undefined,
+ } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse,
+ });
+
+ api.forgetOrder("1", {
+ fields: ["$.summary"],
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ summary: undefined,
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("order listing pagination", () => {
+ it("should not load more if has reach the end", async () => {
+ const env = new ApiMockEnvironment();
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: 20, wired: "yes", date_s: 12 },
+ response: {
+ orders: [{ order_id: "1" } as any],
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, wired: "yes", date_s: 13 },
+ response: {
+ orders: [{ order_id: "2" } as any],
+ },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const date = new Date(12000);
+ const query = useInstanceOrders({ wired: "yes", date }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "1" }, { order_id: "2" }],
+ });
+ expect(query.isReachingEnd).true;
+ expect(query.isReachingStart).true;
+
+ // should not trigger new state update or query
+ query.loadMore();
+ query.loadMorePrev();
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should load more if result brings more that PAGE_SIZE", async () => {
+ const env = new ApiMockEnvironment();
+
+ const ordersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({
+ order_id: String(i),
+ }));
+ const ordersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({
+ order_id: String(i + 20),
+ }));
+ const ordersFrom20to0 = [...ordersFrom0to20].reverse();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: 20, wired: "yes", date_s: 12 },
+ response: {
+ orders: ordersFrom0to20,
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, wired: "yes", date_s: 13 },
+ response: {
+ orders: ordersFrom20to40,
+ },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const date = new Date(12000);
+ const query = useInstanceOrders({ wired: "yes", date }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [...ordersFrom20to0, ...ordersFrom20to40],
+ });
+ expect(query.isReachingEnd).false;
+ expect(query.isReachingStart).false;
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -40, wired: "yes", date_s: 13 },
+ response: {
+ orders: [...ordersFrom20to40, { order_id: "41" }],
+ },
+ });
+
+ query.loadMore();
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [
+ ...ordersFrom20to0,
+ ...ordersFrom20to40,
+ { order_id: "41" },
+ ],
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: 40, wired: "yes", date_s: 12 },
+ response: {
+ orders: [...ordersFrom0to20, { order_id: "-1" }],
+ },
+ });
+
+ query.loadMorePrev();
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [
+ { order_id: "-1" },
+ ...ordersFrom20to0,
+ ...ordersFrom20to40,
+ { order_id: "41" },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/order.ts b/packages/auditor-backoffice-ui/src/hooks/order.ts
new file mode 100644
index 000000000..e7a893f2c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/order.ts
@@ -0,0 +1,289 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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 {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface OrderAPI {
+ //FIXME: add OutOfStockResponse on 410
+ createOrder: (
+ data: MerchantBackend.Orders.PostOrderRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>>;
+ forgetOrder: (
+ id: string,
+ data: MerchantBackend.Orders.ForgetRequest,
+ ) => Promise<HttpResponseOk<void>>;
+ refundOrder: (
+ id: string,
+ data: MerchantBackend.Orders.RefundRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>>;
+ deleteOrder: (id: string) => Promise<HttpResponseOk<void>>;
+ getPaymentURL: (id: string) => Promise<HttpResponseOk<string>>;
+}
+
+type YesOrNo = "yes" | "no";
+
+export function useOrderAPI(): OrderAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createOrder = async (
+ data: MerchantBackend.Orders.PostOrderRequest,
+ ): Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>> => {
+ const res = await request<MerchantBackend.Orders.PostOrderResponse>(
+ `/private/orders`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+ await mutateAll(/.*private\/orders.*/);
+ // mutate('')
+ return res;
+ };
+ const refundOrder = async (
+ orderId: string,
+ data: MerchantBackend.Orders.RefundRequest,
+ ): Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>> => {
+ mutateAll(/@"\/private\/orders"@/);
+ const res = request<MerchantBackend.Orders.MerchantRefundResponse>(
+ `/private/orders/${orderId}/refund`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+
+ // order list returns refundable information, so we must evict everything
+ await mutateAll(/.*private\/orders.*/);
+ return res;
+ };
+
+ const forgetOrder = async (
+ orderId: string,
+ data: MerchantBackend.Orders.ForgetRequest,
+ ): Promise<HttpResponseOk<void>> => {
+ mutateAll(/@"\/private\/orders"@/);
+ const res = request<void>(`/private/orders/${orderId}/forget`, {
+ method: "PATCH",
+ data,
+ });
+ // we may be forgetting some fields that are pare of the listing, so we must evict everything
+ await mutateAll(/.*private\/orders.*/);
+ return res;
+ };
+ const deleteOrder = async (
+ orderId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ mutateAll(/@"\/private\/orders"@/);
+ const res = request<void>(`/private/orders/${orderId}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/orders.*/);
+ return res;
+ };
+
+ const getPaymentURL = async (
+ orderId: string,
+ ): Promise<HttpResponseOk<string>> => {
+ return request<MerchantBackend.Orders.MerchantOrderStatusResponse>(
+ `/private/orders/${orderId}`,
+ {
+ method: "GET",
+ },
+ ).then((res) => {
+ const url =
+ res.data.order_status === "unpaid"
+ ? res.data.taler_pay_uri
+ : res.data.contract_terms.fulfillment_url;
+ const response: HttpResponseOk<string> = res as any;
+ response.data = url || "";
+ return response;
+ });
+ };
+
+ return { createOrder, forgetOrder, deleteOrder, refundOrder, getPaymentURL };
+}
+
+export function useOrderDetails(
+ oderId: string,
+): HttpResponse<
+ MerchantBackend.Orders.MerchantOrderStatusResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Orders.MerchantOrderStatusResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/orders/${oderId}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export interface InstanceOrderFilter {
+ paid?: YesOrNo;
+ refunded?: YesOrNo;
+ wired?: YesOrNo;
+ date?: Date;
+}
+
+export function useInstanceOrders(
+ args?: InstanceOrderFilter,
+ updateFilter?: (d: Date) => void,
+): HttpResponsePaginated<
+ MerchantBackend.Orders.OrderHistory,
+ MerchantBackend.ErrorDetail
+> {
+ const { orderFetcher } = useBackendInstanceRequest();
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.date ? pageBefore * PAGE_SIZE : 0;
+
+ /**
+ * FIXME: this can be cleaned up a little
+ *
+ * the logic of double query should be inside the orderFetch so from the hook perspective and cache
+ * is just one query and one error status
+ */
+ const {
+ data: beforeData,
+ error: beforeError,
+ isValidating: loadingBefore,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Orders.OrderHistory>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(
+ [
+ `/private/orders`,
+ args?.paid,
+ args?.refunded,
+ args?.wired,
+ args?.date,
+ totalBefore,
+ ],
+ orderFetcher,
+ );
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Orders.OrderHistory>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(
+ [
+ `/private/orders`,
+ args?.paid,
+ args?.refunded,
+ args?.wired,
+ args?.date,
+ -totalAfter,
+ ],
+ orderFetcher,
+ );
+
+ //this will save last result
+ const [lastBefore, setLastBefore] = useState<
+ HttpResponse<
+ MerchantBackend.Orders.OrderHistory,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.Orders.OrderHistory,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ if (beforeData) setLastBefore(beforeData);
+ }, [afterData, beforeData]);
+
+ if (beforeError) return beforeError.cause;
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd = afterData && afterData.data.orders.length < totalAfter;
+ const isReachingStart =
+ args?.date === undefined ||
+ (beforeData && beforeData.data.orders.length < totalBefore);
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.orders.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from =
+ afterData.data.orders[afterData.data.orders.length - 1].timestamp.t_s;
+ if (from && from !== "never" && updateFilter)
+ updateFilter(new Date(from * 1000));
+ }
+ },
+ loadMorePrev: () => {
+ if (!beforeData || isReachingStart) return;
+ if (beforeData.data.orders.length < MAX_RESULT_SIZE) {
+ setPageBefore(pageBefore + 1);
+ } else if (beforeData) {
+ const from =
+ beforeData.data.orders[beforeData.data.orders.length - 1].timestamp
+ .t_s;
+ if (from && from !== "never" && updateFilter)
+ updateFilter(new Date(from * 1000));
+ }
+ },
+ };
+
+ const orders =
+ !beforeData || !afterData
+ ? []
+ : (beforeData || lastBefore).data.orders
+ .slice()
+ .reverse()
+ .concat((afterData || lastAfter).data.orders);
+ if (loadingAfter || loadingBefore) return { loading: true, data: { orders } };
+ if (beforeData && afterData) {
+ return { ok: true, data: { orders }, ...pagination };
+ }
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/otp.ts b/packages/auditor-backoffice-ui/src/hooks/otp.ts
new file mode 100644
index 000000000..b045e365a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/otp.ts
@@ -0,0 +1,223 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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 {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+const MOCKED_DEVICES: Record<string, MerchantBackend.OTP.OtpDeviceAddDetails> = {
+ "1": {
+ otp_device_description: "first device",
+ otp_algorithm: 1,
+ otp_device_id: "1",
+ otp_key: "123",
+ },
+ "2": {
+ otp_device_description: "second device",
+ otp_algorithm: 0,
+ otp_device_id: "2",
+ otp_key: "456",
+ }
+}
+
+export function useOtpDeviceAPI(): OtpDeviceAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createOtpDevice = async (
+ data: MerchantBackend.OTP.OtpDeviceAddDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ // MOCKED_DEVICES[data.otp_device_id] = data
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/otp-devices`, {
+ method: "POST",
+ data,
+ });
+ await mutateAll(/.*private\/otp-devices.*/);
+ return res;
+ };
+
+ const updateOtpDevice = async (
+ deviceId: string,
+ data: MerchantBackend.OTP.OtpDevicePatchDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ // MOCKED_DEVICES[deviceId].otp_algorithm = data.otp_algorithm
+ // MOCKED_DEVICES[deviceId].otp_ctr = data.otp_ctr
+ // MOCKED_DEVICES[deviceId].otp_device_description = data.otp_device_description
+ // MOCKED_DEVICES[deviceId].otp_key = data.otp_key
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/otp-devices/${deviceId}`, {
+ method: "PATCH",
+ data,
+ });
+ await mutateAll(/.*private\/otp-devices.*/);
+ return res;
+ };
+
+ const deleteOtpDevice = async (
+ deviceId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ // delete MOCKED_DEVICES[deviceId]
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/otp-devices/${deviceId}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/otp-devices.*/);
+ return res;
+ };
+
+ return {
+ createOtpDevice,
+ updateOtpDevice,
+ deleteOtpDevice,
+ };
+}
+
+export interface OtpDeviceAPI {
+ createOtpDevice: (
+ data: MerchantBackend.OTP.OtpDeviceAddDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ updateOtpDevice: (
+ id: string,
+ data: MerchantBackend.OTP.OtpDevicePatchDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ deleteOtpDevice: (id: string) => Promise<HttpResponseOk<void>>;
+}
+
+export interface InstanceOtpDeviceFilter {
+}
+
+export function useInstanceOtpDevices(
+ args?: InstanceOtpDeviceFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.OTP.OtpDeviceSummaryResponse,
+ MerchantBackend.ErrorDetail
+> {
+ // return {
+ // ok: true,
+ // loadMore: () => { },
+ // loadMorePrev: () => { },
+ // data: {
+ // otp_devices: Object.values(MOCKED_DEVICES).map(d => ({
+ // device_description: d.otp_device_description,
+ // otp_device_id: d.otp_device_id
+ // }))
+ // }
+ // }
+
+ const { fetcher } = useBackendInstanceRequest();
+
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.OTP.OtpDeviceSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/otp-devices`], fetcher);
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.OTP.OtpDeviceSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ }, [afterData /*, beforeData*/]);
+
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.otp_devices.length < totalAfter;
+ const isReachingStart = true;
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.otp_devices.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${afterData.data.otp_devices[afterData.data.otp_devices.length - 1]
+ .otp_device_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ },
+ };
+
+ const otp_devices = !afterData ? [] : (afterData || lastAfter).data.otp_devices;
+ if (loadingAfter /* || loadingBefore */)
+ return { loading: true, data: { otp_devices } };
+ if (/*beforeData &&*/ afterData) {
+ return { ok: true, data: { otp_devices }, ...pagination };
+ }
+ return { loading: true };
+}
+
+export function useOtpDeviceDetails(
+ deviceId: string,
+): HttpResponse<
+ MerchantBackend.OTP.OtpDeviceDetails,
+ MerchantBackend.ErrorDetail
+> {
+ // return {
+ // ok: true,
+ // data: {
+ // device_description: MOCKED_DEVICES[deviceId].otp_device_description,
+ // otp_algorithm: MOCKED_DEVICES[deviceId].otp_algorithm,
+ // otp_ctr: MOCKED_DEVICES[deviceId].otp_ctr
+ // }
+ // }
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.OTP.OtpDeviceDetails>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/otp-devices/${deviceId}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) {
+ return data;
+ }
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/product.test.ts b/packages/auditor-backoffice-ui/src/hooks/product.test.ts
new file mode 100644
index 000000000..7cac10e25
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/product.test.ts
@@ -0,0 +1,362 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { MerchantBackend } from "../declaration.js";
+import {
+ useInstanceProducts,
+ useProductAPI,
+ useProductDetails,
+} from "./product.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_CREATE_PRODUCT,
+ API_DELETE_PRODUCT,
+ API_GET_PRODUCT_BY_ID,
+ API_LIST_PRODUCTS,
+ API_UPDATE_PRODUCT_BY_ID,
+} from "./urls.js";
+
+describe("product api interaction with listing", () => {
+ it("should evict cache when creating a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceProducts();
+ const api = useProductAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]);
+
+ env.addRequestExpectation(API_CREATE_PRODUCT, {
+ request: {
+ price: "ARS:23",
+ } as MerchantBackend.Products.ProductAddDetail,
+ });
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }, { product_id: "2345" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:12",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:12",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), {
+ response: {
+ price: "ARS:23",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+
+ api.createProduct({
+ price: "ARS:23",
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([
+ {
+ id: "1234",
+ price: "ARS:12",
+ },
+ {
+ id: "2345",
+ price: "ARS:23",
+ },
+ ]);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when updating a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceProducts();
+ const api = useProductAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]);
+
+ env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("1234"), {
+ request: {
+ price: "ARS:13",
+ } as MerchantBackend.Products.ProductPatchDetail,
+ });
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:13",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+
+ api.updateProduct("1234", {
+ price: "ARS:13",
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([
+ {
+ id: "1234",
+ price: "ARS:13",
+ },
+ ]);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }, { product_id: "2345" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail,
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), {
+ response: { price: "ARS:23" } as MerchantBackend.Products.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceProducts();
+ const api = useProductAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([
+ { id: "1234", price: "ARS:12" },
+ { id: "2345", price: "ARS:23" },
+ ]);
+
+ env.addRequestExpectation(API_DELETE_PRODUCT("2345"), {});
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }],
+ },
+ });
+
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:12",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+ api.deleteProduct("2345");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("product api interaction with details", () => {
+ it("should evict cache when updating a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), {
+ response: {
+ description: "this is a description",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useProductDetails("12");
+ const api = useProductAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ description: "this is a description",
+ });
+
+ env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("12"), {
+ request: {
+ description: "other description",
+ } as MerchantBackend.Products.ProductPatchDetail,
+ });
+
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), {
+ response: {
+ description: "other description",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+
+ api.updateProduct("12", {
+ description: "other description",
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ description: "other description",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/product.ts b/packages/auditor-backoffice-ui/src/hooks/product.ts
new file mode 100644
index 000000000..8ca8d2724
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/product.ts
@@ -0,0 +1,177 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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 {
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { MerchantBackend, WithId } from "../declaration.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, useSWRConfig } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface ProductAPI {
+ getProduct: (
+ id: string,
+ ) => Promise<void>;
+ createProduct: (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ) => Promise<void>;
+ updateProduct: (
+ id: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ) => Promise<void>;
+ deleteProduct: (id: string) => Promise<void>;
+ lockProduct: (
+ id: string,
+ data: MerchantBackend.Products.LockRequest,
+ ) => Promise<void>;
+}
+
+export function useProductAPI(): ProductAPI {
+ const mutateAll = useMatchMutate();
+ const { mutate } = useSWRConfig();
+
+ const { request } = useBackendInstanceRequest();
+
+ const createProduct = async (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ): Promise<void> => {
+ const res = await request(`/private/products`, {
+ method: "POST",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const updateProduct = async (
+ productId: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ): Promise<void> => {
+ const r = await request(`/private/products/${productId}`, {
+ method: "PATCH",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const deleteProduct = async (productId: string): Promise<void> => {
+ await request(`/private/products/${productId}`, {
+ method: "DELETE",
+ });
+ await mutate([`/private/products`]);
+ };
+
+ const lockProduct = async (
+ productId: string,
+ data: MerchantBackend.Products.LockRequest,
+ ): Promise<void> => {
+ await request(`/private/products/${productId}/lock`, {
+ method: "POST",
+ data,
+ });
+
+ return await mutateAll(/.*"\/private\/products.*/);
+ };
+
+ const getProduct = async (
+ productId: string,
+ ): Promise<void> => {
+ await request(`/private/products/${productId}`, {
+ method: "GET",
+ });
+
+ return
+ };
+
+ return { createProduct, updateProduct, deleteProduct, lockProduct, getProduct };
+}
+
+export function useInstanceProducts(): HttpResponse<
+ (MerchantBackend.Products.ProductDetail & WithId)[],
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher, multiFetcher } = useBackendInstanceRequest();
+
+ const { data: list, error: listError } = useSWR<
+ HttpResponseOk<MerchantBackend.Products.InventorySummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/products`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ const paths = (list?.data.products || []).map(
+ (p) => `/private/products/${p.product_id}`,
+ );
+ const { data: products, error: productError } = useSWR<
+ HttpResponseOk<MerchantBackend.Products.ProductDetail>[],
+ RequestError<MerchantBackend.ErrorDetail>
+ >([paths], multiFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (listError) return listError.cause;
+ if (productError) return productError.cause;
+
+ if (products) {
+ const dataWithId = products.map((d) => {
+ //take the id from the queried url
+ return {
+ ...d.data,
+ id: d.info?.url.replace(/.*\/private\/products\//, "") || "",
+ };
+ });
+ return { ok: true, data: dataWithId };
+ }
+ return { loading: true };
+}
+
+export function useProductDetails(
+ productId: string,
+): HttpResponse<
+ MerchantBackend.Products.ProductDetail,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Products.ProductDetail>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/products/${productId}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts b/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts
new file mode 100644
index 000000000..b3eecd754
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts
@@ -0,0 +1,448 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+import { MerchantBackend } from "../declaration.js";
+import {
+ useInstanceReserves,
+ useReserveDetails,
+ useReservesAPI,
+ useRewardDetails,
+} from "./reserves.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_AUTHORIZE_REWARD,
+ API_AUTHORIZE_REWARD_FOR_RESERVE,
+ API_CREATE_RESERVE,
+ API_DELETE_RESERVE,
+ API_GET_RESERVE_BY_ID,
+ API_GET_REWARD_BY_ID,
+ API_LIST_RESERVES,
+} from "./urls.js";
+import * as tests from "@gnu-taler/web-util/testing";
+
+describe("reserve api interaction with listing", () => {
+ it("should evict cache when creating a reserve", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_RESERVES, {
+ response: {
+ reserves: [
+ {
+ reserve_pub: "11",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useReservesAPI();
+ const query = useInstanceReserves();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ reserves: [{ reserve_pub: "11" }],
+ });
+
+ env.addRequestExpectation(API_CREATE_RESERVE, {
+ request: {
+ initial_balance: "ARS:3333",
+ exchange_url: "http://url",
+ wire_method: "iban",
+ },
+ response: {
+ reserve_pub: "22",
+ accounts: [],
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_RESERVES, {
+ response: {
+ reserves: [
+ {
+ reserve_pub: "11",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "22",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ },
+ });
+
+ api.createReserve({
+ initial_balance: "ARS:3333",
+ exchange_url: "http://url",
+ wire_method: "iban",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+
+ expect(query.data).deep.equals({
+ reserves: [
+ {
+ reserve_pub: "11",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "22",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting a reserve", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_RESERVES, {
+ response: {
+ reserves: [
+ {
+ reserve_pub: "11",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "22",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "33",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useReservesAPI();
+ const query = useInstanceReserves();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ reserves: [
+ { reserve_pub: "11" },
+ { reserve_pub: "22" },
+ { reserve_pub: "33" },
+ ],
+ });
+
+ env.addRequestExpectation(API_DELETE_RESERVE("11"), {});
+ env.addRequestExpectation(API_LIST_RESERVES, {
+ response: {
+ reserves: [
+ {
+ reserve_pub: "22",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "33",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ },
+ });
+
+ api.deleteReserve("11");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ reserves: [{ reserve_pub: "22" }, { reserve_pub: "33" }],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("reserve api interaction with details", () => {
+ it("should evict cache when adding a reward for a specific reserve", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
+ response: {
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
+ } as MerchantBackend.Rewards.ReserveDetail,
+ qparam: {
+ rewards: "yes",
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useReservesAPI();
+ const query = useReserveDetails("11");
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
+ });
+
+ env.addRequestExpectation(API_AUTHORIZE_REWARD_FOR_RESERVE("11"), {
+ request: {
+ amount: "USD:12",
+ justification: "not",
+ next_url: "http://taler.net",
+ },
+ response: {
+ reward_id: "id2",
+ taler_reward_uri: "uri",
+ reward_expiration: { t_s: 1 },
+ reward_status_url: "url",
+ },
+ });
+
+ env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
+ response: {
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [
+ { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
+ { reason: "not", reward_id: "id2", total_amount: "USD:12" },
+ ],
+ } as MerchantBackend.Rewards.ReserveDetail,
+ qparam: {
+ rewards: "yes",
+ },
+ });
+
+ api.authorizeRewardReserve("11", {
+ amount: "USD:12",
+ justification: "not",
+ next_url: "http://taler.net",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+
+ expect(query.data).deep.equals({
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [
+ { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
+ { reason: "not", reward_id: "id2", total_amount: "USD:12" },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when adding a reward for a random reserve", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
+ response: {
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
+ } as MerchantBackend.Rewards.ReserveDetail,
+ qparam: {
+ rewards: "yes",
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useReservesAPI();
+ const query = useReserveDetails("11");
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
+ });
+
+ env.addRequestExpectation(API_AUTHORIZE_REWARD, {
+ request: {
+ amount: "USD:12",
+ justification: "not",
+ next_url: "http://taler.net",
+ },
+ response: {
+ reward_id: "id2",
+ taler_reward_uri: "uri",
+ reward_expiration: { t_s: 1 },
+ reward_status_url: "url",
+ },
+ });
+
+ env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
+ response: {
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [
+ { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
+ { reason: "not", reward_id: "id2", total_amount: "USD:12" },
+ ],
+ } as MerchantBackend.Rewards.ReserveDetail,
+ qparam: {
+ rewards: "yes",
+ },
+ });
+
+ api.authorizeReward({
+ amount: "USD:12",
+ justification: "not",
+ next_url: "http://taler.net",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+
+ expect(query.data).deep.equals({
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [
+ { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
+ { reason: "not", reward_id: "id2", total_amount: "USD:12" },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("reserve api interaction with reward details", () => {
+ it("should list rewards", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_REWARD_BY_ID("11"), {
+ response: {
+ total_picked_up: "USD:12",
+ reason: "not",
+ } as MerchantBackend.Rewards.RewardDetails,
+ qparam: {
+ pickups: "yes",
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useRewardDetails("11");
+ return { query };
+ },
+ {},
+ [
+ ({ query }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query }) => {
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ total_picked_up: "USD:12",
+ reason: "not",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/reserves.ts b/packages/auditor-backoffice-ui/src/hooks/reserves.ts
new file mode 100644
index 000000000..b719bfbe6
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/reserves.ts
@@ -0,0 +1,181 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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 {
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { MerchantBackend } from "../declaration.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, useSWRConfig } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function useReservesAPI(): ReserveMutateAPI {
+ const mutateAll = useMatchMutate();
+ const { mutate } = useSWRConfig();
+ const { request } = useBackendInstanceRequest();
+
+ const createReserve = async (
+ data: MerchantBackend.Rewards.ReserveCreateRequest,
+ ): Promise<
+ HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation>
+ > => {
+ const res = await request<MerchantBackend.Rewards.ReserveCreateConfirmation>(
+ `/private/reserves`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+
+ //evict reserve list query
+ await mutateAll(/.*private\/reserves.*/);
+
+ return res;
+ };
+
+ const authorizeRewardReserve = async (
+ pub: string,
+ data: MerchantBackend.Rewards.RewardCreateRequest,
+ ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => {
+ const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>(
+ `/private/reserves/${pub}/authorize-reward`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+
+ //evict reserve details query
+ await mutate([`/private/reserves/${pub}`]);
+
+ return res;
+ };
+
+ const authorizeReward = async (
+ data: MerchantBackend.Rewards.RewardCreateRequest,
+ ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => {
+ const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>(
+ `/private/rewards`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+
+ //evict all details query
+ await mutateAll(/.*private\/reserves\/.*/);
+
+ return res;
+ };
+
+ const deleteReserve = async (
+ pub: string,
+ ): Promise<HttpResponse<void, MerchantBackend.ErrorDetail>> => {
+ const res = await request<void>(`/private/reserves/${pub}`, {
+ method: "DELETE",
+ });
+
+ //evict reserve list query
+ await mutateAll(/.*private\/reserves.*/);
+
+ return res;
+ };
+
+ return { createReserve, authorizeReward, authorizeRewardReserve, deleteReserve };
+}
+
+export interface ReserveMutateAPI {
+ createReserve: (
+ data: MerchantBackend.Rewards.ReserveCreateRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation>>;
+ authorizeRewardReserve: (
+ id: string,
+ data: MerchantBackend.Rewards.RewardCreateRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>;
+ authorizeReward: (
+ data: MerchantBackend.Rewards.RewardCreateRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>;
+ deleteReserve: (
+ id: string,
+ ) => Promise<HttpResponse<void, MerchantBackend.ErrorDetail>>;
+}
+
+export function useInstanceReserves(): HttpResponse<
+ MerchantBackend.Rewards.RewardReserveStatus,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Rewards.RewardReserveStatus>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/reserves`], fetcher);
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export function useReserveDetails(
+ reserveId: string,
+): HttpResponse<
+ MerchantBackend.Rewards.ReserveDetail,
+ MerchantBackend.ErrorDetail
+> {
+ const { reserveDetailFetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Rewards.ReserveDetail>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/reserves/${reserveId}`], reserveDetailFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export function useRewardDetails(
+ rewardId: string,
+): HttpResponse<MerchantBackend.Rewards.RewardDetails, MerchantBackend.ErrorDetail> {
+ const { rewardsDetailFetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Rewards.RewardDetails>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/rewards/${rewardId}`], rewardsDetailFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/templates.ts b/packages/auditor-backoffice-ui/src/hooks/templates.ts
new file mode 100644
index 000000000..ee8728cc8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/templates.ts
@@ -0,0 +1,266 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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 {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function useTemplateAPI(): TemplateAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createTemplate = async (
+ data: MerchantBackend.Template.TemplateAddDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/templates`, {
+ method: "POST",
+ data,
+ });
+ await mutateAll(/.*private\/templates.*/);
+ return res;
+ };
+
+ const updateTemplate = async (
+ templateId: string,
+ data: MerchantBackend.Template.TemplatePatchDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/templates/${templateId}`, {
+ method: "PATCH",
+ data,
+ });
+ await mutateAll(/.*private\/templates.*/);
+ return res;
+ };
+
+ const deleteTemplate = async (
+ templateId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/templates/${templateId}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/templates.*/);
+ return res;
+ };
+
+ const createOrderFromTemplate = async (
+ templateId: string,
+ data: MerchantBackend.Template.UsingTemplateDetails,
+ ): Promise<
+ HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>
+ > => {
+ const res = await request<MerchantBackend.Template.UsingTemplateResponse>(
+ `/templates/${templateId}`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+ await mutateAll(/.*private\/templates.*/);
+ return res;
+ };
+
+ const testTemplateExist = async (
+ templateId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/templates/${templateId}`, { method: "GET", });
+ return res;
+ };
+
+
+ return {
+ createTemplate,
+ updateTemplate,
+ deleteTemplate,
+ testTemplateExist,
+ createOrderFromTemplate,
+ };
+}
+
+export interface TemplateAPI {
+ createTemplate: (
+ data: MerchantBackend.Template.TemplateAddDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ updateTemplate: (
+ id: string,
+ data: MerchantBackend.Template.TemplatePatchDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ testTemplateExist: (
+ id: string
+ ) => Promise<HttpResponseOk<void>>;
+ deleteTemplate: (id: string) => Promise<HttpResponseOk<void>>;
+ createOrderFromTemplate: (
+ id: string,
+ data: MerchantBackend.Template.UsingTemplateDetails,
+ ) => Promise<HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>>;
+}
+
+export interface InstanceTemplateFilter {
+ //FIXME: add filter to the template list
+ position?: string;
+}
+
+export function useInstanceTemplates(
+ args?: InstanceTemplateFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.Template.TemplateSummaryResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { templateFetcher } = useBackendInstanceRequest();
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.position ? pageBefore * PAGE_SIZE : 0;
+
+ /**
+ * FIXME: this can be cleaned up a little
+ *
+ * the logic of double query should be inside the orderFetch so from the hook perspective and cache
+ * is just one query and one error status
+ */
+ const {
+ data: beforeData,
+ error: beforeError,
+ isValidating: loadingBefore,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>>(
+ [
+ `/private/templates`,
+ args?.position,
+ totalBefore,
+ ],
+ templateFetcher,
+ );
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/templates`, args?.position, -totalAfter], templateFetcher);
+
+ //this will save last result
+ const [lastBefore, setLastBefore] = useState<
+ HttpResponse<
+ MerchantBackend.Template.TemplateSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.Template.TemplateSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ if (beforeData) setLastBefore(beforeData);
+ }, [afterData, beforeData]);
+
+ if (beforeError) return beforeError.cause;
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.templates.length < totalAfter;
+ const isReachingStart = args?.position === undefined
+ ||
+ (beforeData && beforeData.data.templates.length < totalBefore);
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.templates.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${afterData.data.templates[afterData.data.templates.length - 1]
+ .template_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ if (!beforeData || isReachingStart) return;
+ if (beforeData.data.templates.length < MAX_RESULT_SIZE) {
+ setPageBefore(pageBefore + 1);
+ } else if (beforeData) {
+ const from = `${beforeData.data.templates[beforeData.data.templates.length - 1]
+ .template_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ };
+
+ // const templates = !afterData ? [] : (afterData || lastAfter).data.templates;
+ const templates =
+ !beforeData || !afterData
+ ? []
+ : (beforeData || lastBefore).data.templates
+ .slice()
+ .reverse()
+ .concat((afterData || lastAfter).data.templates);
+ if (loadingAfter || loadingBefore)
+ return { loading: true, data: { templates } };
+ if (beforeData && afterData) {
+ return { ok: true, data: { templates }, ...pagination };
+ }
+ return { loading: true };
+}
+
+export function useTemplateDetails(
+ templateId: string,
+): HttpResponse<
+ MerchantBackend.Template.TemplateDetails,
+ MerchantBackend.ErrorDetail
+> {
+ const { templateFetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Template.TemplateDetails>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/templates/${templateId}`], templateFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) {
+ return data;
+ }
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/testing.tsx b/packages/auditor-backoffice-ui/src/hooks/testing.tsx
new file mode 100644
index 000000000..7955f832a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/testing.tsx
@@ -0,0 +1,180 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { MockEnvironment } from "@gnu-taler/web-util/testing";
+import { ComponentChildren, FunctionalComponent, h, VNode } from "preact";
+import { HttpRequestLibrary, HttpRequestOptions, HttpResponse } from "@gnu-taler/taler-util/http";
+import { SWRConfig } from "swr";
+import { ApiContextProvider } from "@gnu-taler/web-util/browser";
+import { BackendContextProvider } from "../context/backend.js";
+import { InstanceContextProvider } from "../context/instance.js";
+import { HttpResponseOk, RequestOptions } from "@gnu-taler/web-util/browser";
+import { TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient } from "@gnu-taler/taler-util";
+
+export class ApiMockEnvironment extends MockEnvironment {
+ constructor(debug = false) {
+ super(debug);
+ }
+
+ mockApiIfNeeded(): void {
+ null; // do nothing
+ }
+
+ public buildTestingContext(): FunctionalComponent<{
+ children: ComponentChildren;
+ }> {
+ const __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE =
+ this.saveRequestAndGetMockedResponse.bind(this);
+
+ return function TestingContext({
+ children,
+ }: {
+ children: ComponentChildren;
+ }): VNode {
+
+ async function request<T>(
+ base: string,
+ path: string,
+ options: RequestOptions = {},
+ ): Promise<HttpResponseOk<T>> {
+ const _url = new URL(`${base}${path}`);
+ // Object.entries(options.params ?? {}).forEach(([key, value]) => {
+ // _url.searchParams.set(key, String(value));
+ // });
+
+ const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE(
+ {
+ method: options.method ?? "GET",
+ url: _url.href,
+ },
+ {
+ qparam: options.params,
+ auth: options.token,
+ request: options.data,
+ },
+ );
+ const status = mocked.expectedQuery?.query.code ?? 200;
+ const requestPayload = mocked.expectedQuery?.params?.request;
+ const responsePayload = mocked.expectedQuery?.params?.response;
+
+ return {
+ ok: true,
+ data: responsePayload as T,
+ loading: false,
+ clientError: false,
+ serverError: false,
+ info: {
+ hasToken: !!options.token,
+ status,
+ url: _url.href,
+ payload: options.data,
+ options: {},
+ },
+ };
+ }
+ const SC: any = SWRConfig;
+
+ const mockHttpClient = new class implements HttpRequestLibrary {
+ async fetch(url: string, options?: HttpRequestOptions | undefined): Promise<HttpResponse> {
+ const _url = new URL(url);
+ const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE(
+ {
+ method: options?.method ?? "GET",
+ url: _url.href,
+ },
+ {
+ qparam: _url.searchParams,
+ auth: options as any,
+ request: options?.body as any,
+ },
+ );
+ const status = mocked.expectedQuery?.query.code ?? 200;
+ const requestPayload = mocked.expectedQuery?.params?.request;
+ const responsePayload = mocked.expectedQuery?.params?.response;
+
+ // FIXME: complete this implementation to mock any query
+ const resp: HttpResponse = {
+ requestUrl: _url.href,
+ status: status,
+ headers: {} as any,
+ requestMethod: options?.method ?? "GET",
+ json: async () => responsePayload,
+ text: async () => responsePayload as any as string,
+ bytes: async () => responsePayload as ArrayBuffer,
+ };
+ return resp
+ }
+ get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
+ return this.fetch(url, {
+ method: "GET",
+ ...opt,
+ });
+ }
+
+ postJson(
+ url: string,
+ body: any,
+ opt?: HttpRequestOptions,
+ ): Promise<HttpResponse> {
+ return this.fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ ...opt,
+ });
+ }
+
+ }
+ const bankCore = new TalerCoreBankHttpClient("http://localhost", mockHttpClient)
+ const bankIntegration = new TalerBankIntegrationHttpClient(bankCore.getIntegrationAPI().href, mockHttpClient)
+ const bankRevenue = new TalerRevenueHttpClient(bankCore.getRevenueAPI("a").href, mockHttpClient)
+ const bankWire = new TalerWireGatewayHttpClient(bankCore.getWireGatewayAPI("b").href, "b", mockHttpClient)
+
+ return (
+ <BackendContextProvider defaultUrl="http://backend">
+ <InstanceContextProvider
+ value={{
+ token: undefined,
+ id: "default",
+ admin: true,
+ changeToken: () => null,
+ }}
+ >
+ <ApiContextProvider value={{ request, bankCore, bankIntegration, bankRevenue, bankWire }}>
+ <SC
+ value={{
+ loadingTimeout: 0,
+ dedupingInterval: 0,
+ shouldRetryOnError: false,
+ errorRetryInterval: 0,
+ errorRetryCount: 0,
+ provider: () => new Map(),
+ }}
+ >
+ {children}
+ </SC>
+ </ApiContextProvider>
+ </InstanceContextProvider>
+ </BackendContextProvider>
+ );
+ };
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts b/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts
new file mode 100644
index 000000000..a7187af27
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts
@@ -0,0 +1,254 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { MerchantBackend } from "../declaration.js";
+import { API_INFORM_TRANSFERS, API_LIST_TRANSFERS } from "./urls.js";
+import { ApiMockEnvironment } from "./testing.js";
+import { useInstanceTransfers, useTransferAPI } from "./transfer.js";
+
+describe("transfer api interaction with listing", () => {
+ it("should evict cache when informing a transfer", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20 },
+ response: {
+ transfers: [{ wtid: "2" } as MerchantBackend.Transfers.TransferDetails],
+ },
+ });
+
+ const moveCursor = (d: string) => {
+ console.log("new position", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceTransfers({}, moveCursor);
+ const api = useTransferAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ transfers: [{ wtid: "2" }],
+ });
+
+ env.addRequestExpectation(API_INFORM_TRANSFERS, {
+ request: {
+ wtid: "3",
+ credit_amount: "EUR:1",
+ exchange_url: "exchange.url",
+ payto_uri: "payto://",
+ },
+ response: { total: "" } as any,
+ });
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20 },
+ response: {
+ transfers: [{ wtid: "3" } as any, { wtid: "2" } as any],
+ },
+ });
+
+ api.informTransfer({
+ wtid: "3",
+ credit_amount: "EUR:1",
+ exchange_url: "exchange.url",
+ payto_uri: "payto://",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+
+ expect(query.data).deep.equals({
+ transfers: [{ wtid: "3" }, { wtid: "2" }],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("transfer listing pagination", () => {
+ it("should not load more if has reach the end", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20, payto_uri: "payto://" },
+ response: {
+ transfers: [{ wtid: "2" }, { wtid: "1" } as any],
+ },
+ });
+
+ const moveCursor = (d: string) => {
+ console.log("new position", d);
+ };
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ return useInstanceTransfers({ payto_uri: "payto://" }, moveCursor);
+ },
+ {},
+ [
+ (query) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ (query) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ transfers: [{ wtid: "2" }, { wtid: "1" }],
+ });
+ expect(query.isReachingEnd).true;
+ expect(query.isReachingStart).true;
+
+ //check that this button won't trigger more updates since
+ //has reach end and start
+ query.loadMore();
+ query.loadMorePrev();
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ });
+
+ it("should load more if result brings more that PAGE_SIZE", async () => {
+ const env = new ApiMockEnvironment();
+
+ const transfersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({
+ wtid: String(i),
+ }));
+ const transfersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({
+ wtid: String(i + 20),
+ }));
+ const transfersFrom20to0 = [...transfersFrom0to20].reverse();
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: 20, payto_uri: "payto://", offset: "1" },
+ response: {
+ transfers: transfersFrom0to20,
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20, payto_uri: "payto://", offset: "1" },
+ response: {
+ transfers: transfersFrom20to40,
+ },
+ });
+
+ const moveCursor = (d: string) => {
+ console.log("new position", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ return useInstanceTransfers(
+ { payto_uri: "payto://", position: "1" },
+ moveCursor,
+ );
+ },
+ {},
+ [
+ (result) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(result.loading).true;
+ },
+ (result) => {
+ expect(result.loading).undefined;
+ expect(result.ok).true;
+ if (!result.ok) return;
+ expect(result.data).deep.equals({
+ transfers: [...transfersFrom20to0, ...transfersFrom20to40],
+ });
+ expect(result.isReachingEnd).false;
+ expect(result.isReachingStart).false;
+
+ //query more
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -40, payto_uri: "payto://", offset: "1" },
+ response: {
+ transfers: [...transfersFrom20to40, { wtid: "41" }],
+ },
+ });
+ result.loadMore();
+ },
+ (result) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(result.loading).true;
+ },
+ (result) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(result.loading).undefined;
+ expect(result.ok).true;
+ if (!result.ok) return;
+ expect(result.data).deep.equals({
+ transfers: [
+ ...transfersFrom20to0,
+ ...transfersFrom20to40,
+ { wtid: "41" },
+ ],
+ });
+ expect(result.isReachingEnd).true;
+ expect(result.isReachingStart).false;
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/transfer.ts b/packages/auditor-backoffice-ui/src/hooks/transfer.ts
new file mode 100644
index 000000000..27c3bdc75
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/transfer.ts
@@ -0,0 +1,188 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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 {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function useTransferAPI(): TransferAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const informTransfer = async (
+ data: MerchantBackend.Transfers.TransferInformation,
+ ): Promise<HttpResponseOk<{}>> => {
+ const res = await request<{}>(`/private/transfers`, {
+ method: "POST",
+ data,
+ });
+
+ await mutateAll(/.*private\/transfers.*/);
+ return res;
+ };
+
+ return { informTransfer };
+}
+
+export interface TransferAPI {
+ informTransfer: (
+ data: MerchantBackend.Transfers.TransferInformation,
+ ) => Promise<HttpResponseOk<{}>>;
+}
+
+export interface InstanceTransferFilter {
+ payto_uri?: string;
+ verified?: "yes" | "no";
+ position?: string;
+}
+
+export function useInstanceTransfers(
+ args?: InstanceTransferFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.Transfers.TransferList,
+ MerchantBackend.ErrorDetail
+> {
+ const { transferFetcher } = useBackendInstanceRequest();
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.position !== undefined ? pageBefore * PAGE_SIZE : 0;
+
+ /**
+ * FIXME: this can be cleaned up a little
+ *
+ * the logic of double query should be inside the orderFetch so from the hook perspective and cache
+ * is just one query and one error status
+ */
+ const {
+ data: beforeData,
+ error: beforeError,
+ isValidating: loadingBefore,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Transfers.TransferList>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(
+ [
+ `/private/transfers`,
+ args?.payto_uri,
+ args?.verified,
+ args?.position,
+ totalBefore,
+ ],
+ transferFetcher,
+ );
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Transfers.TransferList>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(
+ [
+ `/private/transfers`,
+ args?.payto_uri,
+ args?.verified,
+ args?.position,
+ -totalAfter,
+ ],
+ transferFetcher,
+ );
+
+ //this will save last result
+ const [lastBefore, setLastBefore] = useState<
+ HttpResponse<
+ MerchantBackend.Transfers.TransferList,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.Transfers.TransferList,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ if (beforeData) setLastBefore(beforeData);
+ }, [afterData, beforeData]);
+
+ if (beforeError) return beforeError.cause;
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.transfers.length < totalAfter;
+ const isReachingStart =
+ args?.position === undefined ||
+ (beforeData && beforeData.data.transfers.length < totalBefore);
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.transfers.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${
+ afterData.data.transfers[afterData.data.transfers.length - 1]
+ .transfer_serial_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ if (!beforeData || isReachingStart) return;
+ if (beforeData.data.transfers.length < MAX_RESULT_SIZE) {
+ setPageBefore(pageBefore + 1);
+ } else if (beforeData) {
+ const from = `${
+ beforeData.data.transfers[beforeData.data.transfers.length - 1]
+ .transfer_serial_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ };
+
+ const transfers =
+ !beforeData || !afterData
+ ? []
+ : (beforeData || lastBefore).data.transfers
+ .slice()
+ .reverse()
+ .concat((afterData || lastAfter).data.transfers);
+ if (loadingAfter || loadingBefore)
+ return { loading: true, data: { transfers } };
+ if (beforeData && afterData) {
+ return { ok: true, data: { transfers }, ...pagination };
+ }
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/urls.ts b/packages/auditor-backoffice-ui/src/hooks/urls.ts
new file mode 100644
index 000000000..b6485259f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/urls.ts
@@ -0,0 +1,303 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { Query } from "@gnu-taler/web-util/testing";
+import { MerchantBackend } from "../declaration.js";
+
+////////////////////
+// ORDER
+////////////////////
+
+export const API_CREATE_ORDER: Query<
+ MerchantBackend.Orders.PostOrderRequest,
+ MerchantBackend.Orders.PostOrderResponse
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/orders",
+};
+
+export const API_GET_ORDER_BY_ID = (
+ id: string,
+): Query<unknown, MerchantBackend.Orders.MerchantOrderStatusResponse> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/orders/${id}`,
+});
+
+export const API_LIST_ORDERS: Query<
+ unknown,
+ MerchantBackend.Orders.OrderHistory
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/orders",
+};
+
+export const API_REFUND_ORDER_BY_ID = (
+ id: string,
+): Query<
+ MerchantBackend.Orders.RefundRequest,
+ MerchantBackend.Orders.MerchantRefundResponse
+> => ({
+ method: "POST",
+ url: `http://backend/instances/default/private/orders/${id}/refund`,
+});
+
+export const API_FORGET_ORDER_BY_ID = (
+ id: string,
+): Query<MerchantBackend.Orders.ForgetRequest, unknown> => ({
+ method: "PATCH",
+ url: `http://backend/instances/default/private/orders/${id}/forget`,
+});
+
+export const API_DELETE_ORDER = (
+ id: string,
+): Query<MerchantBackend.Orders.ForgetRequest, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/instances/default/private/orders/${id}`,
+});
+
+////////////////////
+// TRANSFER
+////////////////////
+
+export const API_LIST_TRANSFERS: Query<
+ unknown,
+ MerchantBackend.Transfers.TransferList
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/transfers",
+};
+
+export const API_INFORM_TRANSFERS: Query<
+ MerchantBackend.Transfers.TransferInformation,
+ {}
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/transfers",
+};
+
+////////////////////
+// PRODUCT
+////////////////////
+
+export const API_CREATE_PRODUCT: Query<
+ MerchantBackend.Products.ProductAddDetail,
+ unknown
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/products",
+};
+
+export const API_LIST_PRODUCTS: Query<
+ unknown,
+ MerchantBackend.Products.InventorySummaryResponse
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/products",
+};
+
+export const API_GET_PRODUCT_BY_ID = (
+ id: string,
+): Query<unknown, MerchantBackend.Products.ProductDetail> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/products/${id}`,
+});
+
+export const API_UPDATE_PRODUCT_BY_ID = (
+ id: string,
+): Query<
+ MerchantBackend.Products.ProductPatchDetail,
+ MerchantBackend.Products.InventorySummaryResponse
+> => ({
+ method: "PATCH",
+ url: `http://backend/instances/default/private/products/${id}`,
+});
+
+export const API_DELETE_PRODUCT = (id: string): Query<unknown, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/instances/default/private/products/${id}`,
+});
+
+////////////////////
+// RESERVES
+////////////////////
+
+export const API_CREATE_RESERVE: Query<
+ MerchantBackend.Rewards.ReserveCreateRequest,
+ MerchantBackend.Rewards.ReserveCreateConfirmation
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/reserves",
+};
+export const API_LIST_RESERVES: Query<
+ unknown,
+ MerchantBackend.Rewards.RewardReserveStatus
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/reserves",
+};
+
+export const API_GET_RESERVE_BY_ID = (
+ pub: string,
+): Query<unknown, MerchantBackend.Rewards.ReserveDetail> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/reserves/${pub}`,
+});
+
+export const API_GET_REWARD_BY_ID = (
+ pub: string,
+): Query<unknown, MerchantBackend.Rewards.RewardDetails> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/rewards/${pub}`,
+});
+
+export const API_AUTHORIZE_REWARD_FOR_RESERVE = (
+ pub: string,
+): Query<
+ MerchantBackend.Rewards.RewardCreateRequest,
+ MerchantBackend.Rewards.RewardCreateConfirmation
+> => ({
+ method: "POST",
+ url: `http://backend/instances/default/private/reserves/${pub}/authorize-reward`,
+});
+
+export const API_AUTHORIZE_REWARD: Query<
+ MerchantBackend.Rewards.RewardCreateRequest,
+ MerchantBackend.Rewards.RewardCreateConfirmation
+> = {
+ method: "POST",
+ url: `http://backend/instances/default/private/rewards`,
+};
+
+export const API_DELETE_RESERVE = (id: string): Query<unknown, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/instances/default/private/reserves/${id}`,
+});
+
+////////////////////
+// INSTANCE ADMIN
+////////////////////
+
+export const API_CREATE_INSTANCE: Query<
+ MerchantBackend.Instances.InstanceConfigurationMessage,
+ unknown
+> = {
+ method: "POST",
+ url: "http://backend/management/instances",
+};
+
+export const API_GET_INSTANCE_BY_ID = (
+ id: string,
+): Query<unknown, MerchantBackend.Instances.QueryInstancesResponse> => ({
+ method: "GET",
+ url: `http://backend/management/instances/${id}`,
+});
+
+export const API_GET_INSTANCE_KYC_BY_ID = (
+ id: string,
+): Query<unknown, MerchantBackend.KYC.AccountKycRedirects> => ({
+ method: "GET",
+ url: `http://backend/management/instances/${id}/kyc`,
+});
+
+export const API_LIST_INSTANCES: Query<
+ unknown,
+ MerchantBackend.Instances.InstancesResponse
+> = {
+ method: "GET",
+ url: "http://backend/management/instances",
+};
+
+export const API_UPDATE_INSTANCE_BY_ID = (
+ id: string,
+): Query<
+ MerchantBackend.Instances.InstanceReconfigurationMessage,
+ unknown
+> => ({
+ method: "PATCH",
+ url: `http://backend/management/instances/${id}`,
+});
+
+export const API_UPDATE_INSTANCE_AUTH_BY_ID = (
+ id: string,
+): Query<
+ MerchantBackend.Instances.InstanceAuthConfigurationMessage,
+ unknown
+> => ({
+ method: "POST",
+ url: `http://backend/management/instances/${id}/auth`,
+});
+
+export const API_DELETE_INSTANCE = (id: string): Query<unknown, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/management/instances/${id}`,
+});
+
+////////////////////
+// AUTH
+////////////////////
+
+export const API_NEW_LOGIN: Query<
+ MerchantBackend.Instances.LoginTokenRequest,
+ unknown
+> = ({
+ method: "POST",
+ url: `http://backend/private/token`,
+});
+
+////////////////////
+// INSTANCE
+////////////////////
+
+export const API_GET_CURRENT_INSTANCE: Query<
+ unknown,
+ MerchantBackend.Instances.QueryInstancesResponse
+> = {
+ method: "GET",
+ url: `http://backend/instances/default/private/`,
+};
+
+export const API_GET_CURRENT_INSTANCE_KYC: Query<
+ unknown,
+ MerchantBackend.KYC.AccountKycRedirects
+> = {
+ method: "GET",
+ url: `http://backend/instances/default/private/kyc`,
+};
+
+export const API_UPDATE_CURRENT_INSTANCE: Query<
+ MerchantBackend.Instances.InstanceReconfigurationMessage,
+ unknown
+> = {
+ method: "PATCH",
+ url: `http://backend/instances/default/private/`,
+};
+
+export const API_UPDATE_CURRENT_INSTANCE_AUTH: Query<
+ MerchantBackend.Instances.InstanceAuthConfigurationMessage,
+ unknown
+> = {
+ method: "POST",
+ url: `http://backend/instances/default/private/auth`,
+};
+
+export const API_DELETE_CURRENT_INSTANCE: Query<unknown, unknown> = {
+ method: "DELETE",
+ url: `http://backend/instances/default/private`,
+};
diff --git a/packages/auditor-backoffice-ui/src/hooks/useSettings.ts b/packages/auditor-backoffice-ui/src/hooks/useSettings.ts
new file mode 100644
index 000000000..8c1ebd9f6
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/useSettings.ts
@@ -0,0 +1,73 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 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 { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
+import {
+ Codec,
+ buildCodecForObject,
+ codecForBoolean,
+ codecForConstString,
+ codecForEither,
+ codecForString,
+} from "@gnu-taler/taler-util";
+
+export interface Settings {
+ advanceOrderMode: boolean;
+ dateFormat: "ymd" | "dmy" | "mdy";
+}
+
+const defaultSettings: Settings = {
+ advanceOrderMode: false,
+ dateFormat: "ymd",
+}
+
+export const codecForSettings = (): Codec<Settings> =>
+ buildCodecForObject<Settings>()
+ .property("advanceOrderMode", codecForBoolean())
+ .property("dateFormat", codecForEither(
+ codecForConstString("ymd"),
+ codecForConstString("dmy"),
+ codecForConstString("mdy"),
+ ))
+ .build("Settings");
+
+const SETTINGS_KEY = buildStorageKey("merchant-settings", codecForSettings());
+
+export function useSettings(): [
+ Readonly<Settings>,
+ (s: Settings) => void,
+] {
+ const { value, update } = useLocalStorage(SETTINGS_KEY, defaultSettings);
+
+ // const parsed: Settings = value ?? defaultSettings;
+ // function updateField<T extends keyof Settings>(k: T, v: Settings[T]) {
+ // const next = { ...parsed, [k]: v }
+ // update(next);
+ // }
+ return [value, update];
+}
+
+export function dateFormatForSettings(s: Settings): string {
+ switch (s.dateFormat) {
+ case "ymd": return "yyyy/MM/dd"
+ case "dmy": return "dd/MM/yyyy"
+ case "mdy": return "MM/dd/yyyy"
+ }
+}
+
+export function datetimeFormatForSettings(s: Settings): string {
+ return dateFormatForSettings(s) + " HH:mm:ss"
+} \ No newline at end of file
diff --git a/packages/auditor-backoffice-ui/src/hooks/webhooks.ts b/packages/auditor-backoffice-ui/src/hooks/webhooks.ts
new file mode 100644
index 000000000..ad6bf96e2
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/webhooks.ts
@@ -0,0 +1,178 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 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 {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function useWebhookAPI(): WebhookAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createWebhook = async (
+ data: MerchantBackend.Webhooks.WebhookAddDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/webhooks`, {
+ method: "POST",
+ data,
+ });
+ await mutateAll(/.*private\/webhooks.*/);
+ return res;
+ };
+
+ const updateWebhook = async (
+ webhookId: string,
+ data: MerchantBackend.Webhooks.WebhookPatchDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/webhooks/${webhookId}`, {
+ method: "PATCH",
+ data,
+ });
+ await mutateAll(/.*private\/webhooks.*/);
+ return res;
+ };
+
+ const deleteWebhook = async (
+ webhookId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/webhooks/${webhookId}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/webhooks.*/);
+ return res;
+ };
+
+ return { createWebhook, updateWebhook, deleteWebhook };
+}
+
+export interface WebhookAPI {
+ createWebhook: (
+ data: MerchantBackend.Webhooks.WebhookAddDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ updateWebhook: (
+ id: string,
+ data: MerchantBackend.Webhooks.WebhookPatchDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ deleteWebhook: (id: string) => Promise<HttpResponseOk<void>>;
+}
+
+export interface InstanceWebhookFilter {
+ //FIXME: add filter to the webhook list
+ position?: string;
+}
+
+export function useInstanceWebhooks(
+ args?: InstanceWebhookFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.Webhooks.WebhookSummaryResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { webhookFetcher } = useBackendInstanceRequest();
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.position ? pageBefore * PAGE_SIZE : 0;
+
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Webhooks.WebhookSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/webhooks`, args?.position, -totalAfter], webhookFetcher);
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.Webhooks.WebhookSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ }, [afterData]);
+
+ if (afterError) return afterError.cause;
+
+ const isReachingEnd =
+ afterData && afterData.data.webhooks.length < totalAfter;
+ const isReachingStart = true;
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.webhooks.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${
+ afterData.data.webhooks[afterData.data.webhooks.length - 1].webhook_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ return;
+ },
+ };
+
+ const webhooks = !afterData ? [] : (afterData || lastAfter).data.webhooks;
+
+ if (loadingAfter) return { loading: true, data: { webhooks } };
+ if (afterData) {
+ return { ok: true, data: { webhooks }, ...pagination };
+ }
+ return { loading: true };
+}
+
+export function useWebhookDetails(
+ webhookId: string,
+): HttpResponse<
+ MerchantBackend.Webhooks.WebhookDetails,
+ MerchantBackend.ErrorDetail
+> {
+ const { webhookFetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Webhooks.WebhookDetails>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/webhooks/${webhookId}`], webhookFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}