commit b8c8a914fc31918fa8e8ed40359bf168476a946a
parent 17020c9cb78963269de13b2e715eb23bfaf78615
Author: Sebastian <sebasjm@taler-systems.com>
Date: Mon, 8 Dec 2025 15:10:23 -0300
fix #9681
Diffstat:
4 files changed, 82 insertions(+), 22 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts
@@ -29,6 +29,7 @@ import _useSWR, { SWRHook, mutate } from "swr";
import { useSessionContext } from "../context/session.js";
import { useState, useMemo } from "preact/hooks";
import { useEffect } from "preact/compat";
+import { delayMs } from "@gnu-taler/web-util/browser";
const useSWR = _useSWR as unknown as SWRHook;
export function revalidateInstanceDetails() {
@@ -88,7 +89,7 @@ export function useInstanceKYCDetails() {
}
// FIXME: move to web-utils and reuse
-function useAsyncWithRetry<Res>(
+export function useAsyncWithRetry<Res>(
fetcher: (() => Promise<Res>) | undefined,
retry?: (res: Res | undefined, err?: TalerHttpError | undefined) => boolean,
): { result: Res | undefined; error: TalerHttpError | undefined } {
@@ -96,8 +97,8 @@ function useAsyncWithRetry<Res>(
const [error, setError] = useState<TalerHttpError>();
const [retryCounter, setRetryCounter] = useState(0);
+ let unloaded = false;
useEffect(() => {
- let unloaded = false;
if (fetcher) {
fetcher()
.then((resp) => {
@@ -116,23 +117,19 @@ function useAsyncWithRetry<Res>(
}, [fetcher, retryCounter]);
// retry on result or error
+ // FIXME: why we need a secund useEffect? this should be merged with the one above
useEffect(() => {
if (retry && retry(result, error)) {
setRetryCounter((c) => c + 1);
}
+ return () => {
+ unloaded = true;
+ };
}, [result, error]);
return { result, error };
}
-async function delay(t_ms: number) {
- return new Promise((res) => {
- setTimeout(() => {
- res(0);
- }, t_ms);
- });
-}
-
let latestRequestTime = 0;
/**
* FIXME: this will not wait for the fist KYC event to be triggered
@@ -160,7 +157,7 @@ export function useInstanceKYCDetailsLongPolling() {
const diff = now - latestRequestTime;
if (diff < DEFAULT_WAIT) {
console.log("PREVENT REQUEST BURST");
- await delay(DEFAULT_WAIT);
+ await delayMs(DEFAULT_WAIT);
}
}
latestRequestTime = new Date().getTime();
diff --git a/packages/merchant-backoffice-ui/src/hooks/order.ts b/packages/merchant-backoffice-ui/src/hooks/order.ts
@@ -19,12 +19,19 @@ import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
import {
AbsoluteTime,
AccessToken,
+ HttpStatusCode,
+ MerchantOrderStatusResponse,
+ OperationFail,
+ OperationOk,
+ TalerErrorCode,
TalerHttpError,
TalerMerchantManagementResultByMethod,
} from "@gnu-taler/taler-util";
import _useSWR, { SWRHook, mutate } from "swr";
import { useSessionContext } from "../context/session.js";
-import { buildPaginatedResult } from "@gnu-taler/web-util/browser";
+import { buildPaginatedResult, delayMs } from "@gnu-taler/web-util/browser";
+import { useMemo } from "preact/hooks";
+import { useAsyncWithRetry } from "./instance.js";
const useSWR = _useSWR as unknown as SWRHook;
export function revalidateOrderDetails() {
@@ -51,6 +58,54 @@ export function useOrderDetails(oderId: string) {
return undefined;
}
+let latestRequestTime = 0;
+export function useWaitForOrderPayment(
+ orderId: string,
+ current?: MerchantOrderStatusResponse,
+) {
+ const { state, lib } = useSessionContext();
+
+ const token = state.status === "loggedIn" ? state.token : undefined;
+ const evictWhenPaid = useMemo(() => {
+ return async () => {
+ const DEFAULT_WAIT = 5000;
+ const now = new Date().getTime();
+ const diff = now - latestRequestTime;
+ if (diff < DEFAULT_WAIT) {
+ console.log("PREVENT REQUEST BURST");
+ await delayMs(DEFAULT_WAIT);
+ }
+ latestRequestTime = new Date().getTime();
+ const result = await lib.instance.getOrderDetails(token, orderId, {
+ timeout: DEFAULT_WAIT,
+ });
+ if (result.type === "ok" && result.body.order_status === "paid") {
+ await revalidateOrderDetails();
+ }
+ return result;
+ };
+ }, [token]);
+
+ const fetcher = !current || current.order_status === "paid" ? undefined : evictWhenPaid;
+
+ useAsyncWithRetry(fetcher, (r, err) => {
+ // halt if error
+ if (err !== undefined) return false;
+ // loading, just wait
+ if (r == undefined) return false;
+ // error, we don't know the kyc status
+ if (r.type === "fail") {
+ return false;
+ }
+ // order already paid, no need to retry
+ if (r.body.order_status === "paid") {
+ return false;
+ }
+
+ return true;
+ });
+}
+
export interface InstanceOrderFilter {
paid?: boolean;
refunded?: boolean;
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
@@ -50,6 +50,7 @@ import {
import { mergeRefunds } from "../../../../utils/amount.js";
import { RefundModal } from "../list/Table.js";
import { Event, Timeline } from "./Timeline.js";
+import { useWaitForOrderPayment } from "../../../../hooks/order.js";
const TALER_SCREEN_ID = 44;
@@ -264,6 +265,7 @@ function ClaimedPage({
id: string;
order: TalerMerchantApi.CheckPaymentClaimedResponse;
}) {
+ useWaitForOrderPayment(id, order);
const now = new Date();
const refundable =
order.contract_terms.refund_deadline.t_s !== "never" &&
@@ -769,6 +771,8 @@ function UnpaidPage({
const [value, valueHandler] = useState<Partial<Unpaid>>(order);
const { i18n } = useTranslationContext();
const [settings] = usePreference();
+ useWaitForOrderPayment(id, order);
+
return (
<div>
<section class="hero is-hero-bar">
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx
@@ -25,7 +25,7 @@ import {
TalerError,
TalerErrorCode,
TalerMerchantApi,
- assertUnreachable
+ assertUnreachable,
} from "@gnu-taler/taler-util";
import {
LocalNotificationBannerBulma,
@@ -41,14 +41,13 @@ import { useSessionContext } from "../../../../context/session.js";
import {
InstanceOrderFilter,
useInstanceOrders,
- useOrderDetails,
+ useOrderDetails
} from "../../../../hooks/order.js";
import { LoginPage } from "../../../login/index.js";
import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
import { ListPage } from "./ListPage.js";
import { RefundModal } from "./Table.js";
-
const TALER_SCREEN_ID = 46;
interface Props {
@@ -100,18 +99,20 @@ export default function OrderList({ onCreate, onSelect }: Props): VNode {
const isWiredActive = filter.wired === true ? "is-active" : "";
const isAllActive =
filter.paid === undefined &&
- filter.refunded === undefined &&
- filter.wired === undefined
+ filter.refunded === undefined &&
+ filter.wired === undefined
? "is-active"
: "";
- const data = {} as TalerMerchantApi.RefundRequest
+ const data = {} as TalerMerchantApi.RefundRequest;
const refund = safeFunctionHandler(
lib.instance.addRefund.bind(lib.instance),
- !session.token || !orderToBeRefunded ? undefined : [session.token, orderToBeRefunded.order_id, data],
+ !session.token || !orderToBeRefunded
+ ? undefined
+ : [session.token, orderToBeRefunded.order_id, data],
);
refund.onSuccess = () => {
- setOrderToBeRefunded(undefined)
+ setOrderToBeRefunded(undefined);
};
refund.onFail = (fail) => {
switch (fail.case) {
@@ -179,10 +180,13 @@ interface RefundProps {
onConfirmed: () => void;
}
-function RefundModalForTable({ id, onConfirmed, onCancel }: RefundProps): VNode {
+function RefundModalForTable({
+ id,
+ onConfirmed,
+ onCancel,
+}: RefundProps): VNode {
const { i18n } = useTranslationContext();
const result = useOrderDetails(id);
-
if (!result) return <Loading />;
if (result instanceof TalerError) {
return <ErrorLoadingMerchant error={result} />;