taler-typescript-core

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

commit c261fc1e0d3906912c65677d900e1ab866f2b1ce
parent 41abea61fdd060024e7871bc4a851062b6791554
Author: Sebastian <sebasjm@gmail.com>
Date:   Tue, 22 Apr 2025 12:03:49 -0300

fix #9487

Diffstat:
Mpackages/bank-ui/src/hooks/account.ts | 1+
Mpackages/merchant-backoffice-ui/src/Routing.tsx | 4++--
Mpackages/merchant-backoffice-ui/src/components/menu/SideBar.tsx | 4++--
Mpackages/merchant-backoffice-ui/src/hooks/instance.ts | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx | 4++--
Mpackages/taler-util/src/http-client/merchant.ts | 3+++
Mpackages/taler-util/src/types-taler-merchant.ts | 16++++++++++++++++
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