commit c261fc1e0d3906912c65677d900e1ab866f2b1ce
parent 41abea61fdd060024e7871bc4a851062b6791554
Author: Sebastian <sebasjm@gmail.com>
Date: Tue, 22 Apr 2025 12:03:49 -0300
fix #9487
Diffstat:
7 files changed, 206 insertions(+), 6 deletions(-)
diff --git a/packages/bank-ui/src/hooks/account.ts b/packages/bank-ui/src/hooks/account.ts
@@ -76,6 +76,7 @@ export function revalidateWithdrawalDetails() {
);
}
+// FIXME: move to web-utils and reuse
function useAsyncWithRetry<Res>(
fetcher: (() => Promise<Res>) | undefined,
retry?: (res: Res | undefined, err?: TalerHttpError | undefined) => boolean,
diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx
@@ -38,7 +38,7 @@ import {
} from "./components/menu/index.js";
import { useSessionContext } from "./context/session.js";
import { useInstanceBankAccounts } from "./hooks/bank.js";
-import { useInstanceKYCDetails } from "./hooks/instance.js";
+import { useInstanceKYCDetails, useInstanceKYCDetailsLongPolling } from "./hooks/instance.js";
import { usePreference } from "./hooks/preference.js";
import InstanceCreatePage from "./paths/admin/create/index.js";
import InstanceListPage from "./paths/admin/list/index.js";
@@ -699,7 +699,7 @@ function BankAccountBanner(): VNode {
}
function KycBanner(): VNode {
- const kycStatus = useInstanceKYCDetails();
+ const kycStatus = useInstanceKYCDetailsLongPolling();
const { i18n } = useTranslationContext();
// const today = format(new Date(), dateFormatForSettings(settings));
const [prefs, updatePref] = usePreference();
diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx
@@ -28,7 +28,7 @@ import {
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useSessionContext } from "../../context/session.js";
-import { useInstanceKYCDetails } from "../../hooks/instance.js";
+import { useInstanceKYCDetails, useInstanceKYCDetailsLongPolling } from "../../hooks/instance.js";
import { LangSelector } from "./LangSelector.js";
import { usePreference } from "../../hooks/preference.js";
@@ -42,7 +42,7 @@ interface Props {
export function Sidebar({ mobile }: Props): VNode {
const { i18n } = useTranslationContext();
const { state, logOut, config } = useSessionContext();
- const kycStatus = useInstanceKYCDetails();
+ const kycStatus = useInstanceKYCDetailsLongPolling();
const [pref] = usePreference();
const allKycData =
diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts
@@ -17,11 +17,18 @@
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
import {
AccessToken,
+ assertUnreachable,
+ HttpStatusCode,
+ KycStatusLongPollingReason,
+ MerchantAccountKycRedirect,
+ MerchantAccountKycStatus,
TalerHttpError,
TalerMerchantManagementResultByMethod,
} from "@gnu-taler/taler-util";
import _useSWR, { SWRHook, mutate } from "swr";
import { useSessionContext } from "../context/session.js";
+import { useState, useMemo } from "preact/hooks";
+import { useEffect } from "preact/compat";
const useSWR = _useSWR as unknown as SWRHook;
export function revalidateInstanceDetails() {
@@ -58,6 +65,11 @@ export function revalidateInstanceKYCDetails() {
{ revalidate: true },
);
}
+
+/**
+ * @deprecated use useInstanceKYCDetailsLongPolling
+ * @returns
+ */
export function useInstanceKYCDetails() {
const { state, lib } = useSessionContext();
@@ -75,6 +87,174 @@ export function useInstanceKYCDetails() {
return undefined;
}
+// FIXME: move to web-utils and reuse
+function useAsyncWithRetry<Res>(
+ fetcher: (() => Promise<Res>) | undefined,
+ retry?: (res: Res | undefined, err?: TalerHttpError | undefined) => boolean,
+): { result: Res | undefined; error: TalerHttpError | undefined } {
+ const [result, setResult] = useState<Res>();
+ const [error, setError] = useState<TalerHttpError>();
+ const [retryCounter, setRetryCounter] = useState(0);
+
+ useEffect(() => {
+ let unloaded = false;
+ if (fetcher) {
+ fetcher()
+ .then((resp) => {
+ if (unloaded) return;
+ setResult(resp);
+ })
+ .catch((error: TalerHttpError) => {
+ if (unloaded) return;
+ setError(error);
+ });
+ }
+
+ return () => {
+ unloaded = true;
+ };
+ }, [fetcher, retryCounter]);
+
+ // retry on result or error
+ useEffect(() => {
+ if (retry && retry(result, error)) {
+ setRetryCounter((c) => c + 1);
+ }
+ }, [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
+ * if we wait for kyc transfer event for account 1 then we may miss
+ * aml investigation event for account 2
+ *
+ * @returns
+ */
+export function useInstanceKYCDetailsLongPolling() {
+ const { state, lib } = useSessionContext();
+
+ // we may want to convert this into a set of reason of why to do a long poll
+ // but that will be a multiple request
+ // first, consider adding support on the server side for multi-reason
+ // long polling
+ const [latestReason, setLatestReason] =
+ useState<KycStatusLongPollingReason>();
+
+ const token = state.token;
+ const fetcher = useMemo(() => {
+ return async () => {
+ const DEFAULT_WAIT = 5000;
+ const now = new Date().getTime();
+ if (latestReason) {
+ const diff = now - latestRequestTime;
+ if (diff < DEFAULT_WAIT) {
+ console.log("PREVENT REQUEST BURST");
+ await delay(DEFAULT_WAIT);
+ }
+ }
+ latestRequestTime = new Date().getTime();
+ return lib.instance.getCurrentInstanceKycStatus(state.token, {
+ timeout: DEFAULT_WAIT,
+ reason: latestReason,
+ });
+ };
+ }, [token, latestReason]);
+
+ const { result: data, error } = 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;
+ }
+ // kyc ok, no need to retry
+ if (!r.body) {
+ return false;
+ }
+ // search for a non-ready kyc
+ const acc = getLongPollingReasonSet(r.body.kyc_data);
+
+ // retry if there is an account with long polling reason
+ return acc !== undefined;
+ });
+
+ const currentStatus =
+ data !== undefined && data.type === "ok" && data.body
+ ? getLongPollingReasonSet(data.body.kyc_data)
+ : undefined;
+
+ useEffect(() => {
+ if (currentStatus !== undefined && currentStatus !== latestReason) {
+ // withdrawal has a new state, save the current
+ // and make the query again
+ setLatestReason(currentStatus);
+ }
+ }, [currentStatus]);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+/**
+ * given a response of the kyc endpoint, why should we keep long polling?
+ *
+ * @param data
+ * @returns
+ */
+function getLongPollingReasonSet(
+ data: MerchantAccountKycRedirect[],
+): KycStatusLongPollingReason | undefined {
+ const acc = data.find((k) => getLongPollingReason(k));
+ if (!acc) return undefined;
+ console.log("found reason", acc);
+ return getLongPollingReason(acc);
+}
+
+/**
+ * do we have a reason to wait for this account?
+ *
+ * @param acc
+ * @returns
+ */
+function getLongPollingReason(
+ acc: MerchantAccountKycRedirect,
+): KycStatusLongPollingReason | undefined {
+ switch (acc.status) {
+ case MerchantAccountKycStatus.READY:
+ case MerchantAccountKycStatus.KYC_WIRE_IMPOSSIBLE:
+ case MerchantAccountKycStatus.LOGIC_BUG:
+ case MerchantAccountKycStatus.NO_EXCHANGE_KEY:
+ case MerchantAccountKycStatus.EXCHANGE_INTERNAL_ERROR:
+ case MerchantAccountKycStatus.EXCHANGE_GATEWAY_TIMEOUT:
+ case MerchantAccountKycStatus.EXCHANGE_UNREACHABLE:
+ case MerchantAccountKycStatus.EXCHANGE_STATUS_INVALID:
+ return undefined;
+ case MerchantAccountKycStatus.KYC_WIRE_REQUIRED:
+ return KycStatusLongPollingReason.AUTH_TRANSFER;
+ case MerchantAccountKycStatus.KYC_REQUIRED:
+ return KycStatusLongPollingReason.TO_BE_OK;
+ case MerchantAccountKycStatus.AWAITING_AML_REVIEW:
+ return KycStatusLongPollingReason.AML_INVESTIGATION;
+ default: {
+ assertUnreachable(acc.status);
+ }
+ }
+}
+
export function revalidateManagedInstanceDetails() {
return mutate(
(key) => Array.isArray(key) && key[key.length - 1] === "getInstanceDetails",
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx
@@ -35,7 +35,7 @@ import {
ConfirmModal,
ValidBankAccount,
} from "../../../../components/modal/index.js";
-import { useInstanceKYCDetails } from "../../../../hooks/instance.js";
+import { useInstanceKYCDetailsLongPolling } from "../../../../hooks/instance.js";
import { ListPage } from "./ListPage.js";
interface Props {
@@ -44,7 +44,7 @@ interface Props {
}
export default function ListKYC(_p: Props): VNode {
- const result = useInstanceKYCDetails();
+ const result = useInstanceKYCDetailsLongPolling();
const [showingInstructions, setShowingInstructions] = useState<
TalerMerchantApi.MerchantAccountKycRedirect | undefined
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -666,6 +666,9 @@ export class TalerMerchantInstanceHttpClient {
if (params.timeout) {
url.searchParams.set("timeout_ms", String(params.timeout));
}
+ if (params.reason) {
+ url.searchParams.set("lpt", String(params.reason));
+ }
const headers: Record<string, string> = {};
if (token) {
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -1257,6 +1257,12 @@ export interface PaymentStatusRequestParams {
// explicitly to “YES”.
allowRefundedForRepurchase?: boolean;
}
+
+export enum KycStatusLongPollingReason {
+ AUTH_TRANSFER = 1,
+ AML_INVESTIGATION = 2,
+ TO_BE_OK = 3,
+}
export interface GetKycStatusRequestParams {
// If specified, the KYC check should return
// the KYC status only for this wire account.
@@ -1270,6 +1276,16 @@ export interface GetKycStatusRequestParams {
// timeout_ms milliseconds for the exchanges to
// confirm completion of the KYC process(es).
timeout?: number;
+
+ /**
+ * Specifies what status change we are long-polling for.
+ * Use 1 to wait for the KYC auth transfer (access token available),
+ * 2 to wait for an AML investigation to be done,
+ * and 3 to wait for the KYC status to be OK. If multiple accounts
+ * or exchanges match the query, any account reaching the TARGET
+ * state will cause the response to be returned.
+ */
+ reason?: KycStatusLongPollingReason,
}
export interface GetOtpDeviceRequestParams {
// Timestamp in seconds to use when calculating