taler-typescript-core

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

commit 4becda01297301f3063669dc687e22fcc5f98126
parent d3e65709f8318d9bb4f2341342d00795aecec08c
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Thu, 29 Jan 2026 16:24:03 -0300

fix #9955

Diffstat:
Mpackages/bank-ui/src/hooks/account.ts | 2++
Mpackages/merchant-backoffice-ui/src/Routing.tsx | 4++--
Dpackages/merchant-backoffice-ui/src/hooks/async.ts | 78------------------------------------------------------------------------------
Mpackages/merchant-backoffice-ui/src/hooks/instance.ts | 231++++++++++++++++++++++++-------------------------------------------------------
Mpackages/merchant-backoffice-ui/src/hooks/order.ts | 98++++++++++++++++++++++++++++++-------------------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx | 21+++------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx | 11+++++------
Mpackages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx | 6++----
Mpackages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx | 4++--
Mpackages/taler-util/src/http-client/merchant.ts | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/taler-util/src/types-taler-merchant.ts | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/web-util/src/hooks/useAsync.ts | 57+++++++++++++++++++++++++++++++++++++--------------------
Mpackages/web-util/src/hooks/useAsyncAsHook.ts | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++++-
13 files changed, 429 insertions(+), 388 deletions(-)

diff --git a/packages/bank-ui/src/hooks/account.ts b/packages/bank-ui/src/hooks/account.ts @@ -85,6 +85,8 @@ export function useWithdrawalDetails(wid: string | undefined) { lib: { bank: api }, } = useBankCoreApiContext(); + + //FIXME: use swr const prev = useAsync( wid === undefined ? undefined 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 { useInstanceKYCDetailsLongPolling } from "./hooks/instance.js"; +import { useInstanceKYCDetails } 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"; @@ -925,7 +925,7 @@ function BankAccountBanner(): VNode { } function KycBanner(): VNode { - const kycStatus = useInstanceKYCDetailsLongPolling(); + const kycStatus = useInstanceKYCDetails(); const { i18n } = useTranslationContext(); // const today = format(new Date(), dateFormatForSettings(settings)); const [prefs, updatePref] = usePreference(); diff --git a/packages/merchant-backoffice-ui/src/hooks/async.ts b/packages/merchant-backoffice-ui/src/hooks/async.ts @@ -1,78 +0,0 @@ -/* - 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 { useState } from "preact/hooks"; - -export interface Options { - slowTolerance: number; -} - -export interface AsyncOperationApi<T> { - request: (...a: unknown[]) => void; - cancel: () => void; - data: T | undefined; - isSlow: boolean; - isLoading: boolean; - error: string | undefined; -} - -export function useAsync<T>( - fn?: (...args: unknown[]) => 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<string>(); - const [isSlow, setSlow] = useState(false); - - const request = async (...args: unknown[]) => { - if (!fn) return; - setLoading(true); - - const handler = setTimeout(() => { - setSlow(true); - }, tooLong); - - try { - const result = await fn(...args); - setData(result); - } catch (error) { - console.error(JSON.stringify(error, undefined, 2)) - setError(error instanceof Error ? error.message : String(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/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts @@ -17,20 +17,12 @@ // FIX default import https://github.com/microsoft/TypeScript/issues/49189 import { AccessToken, - assertUnreachable, - HttpStatusCode, - KycStatusLongPollingReason, - MerchantAccountKycRedirect, - MerchantAccountKycStatus, - opEmptySuccess, TalerHttpError, - TalerMerchantManagementResultByMethod, + TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util"; -import _useSWR, { SWRHook, mutate } from "swr"; +import { delayMs, LONG_POLL_DELAY, useLongPolling } from "@gnu-taler/web-util/browser"; +import _useSWR, { mutate, SWRHook } 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() { @@ -69,173 +61,90 @@ export function revalidateInstanceKYCDetails() { } -// FIXME: move to web-utils and reuse -export 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); - - let unloaded = false; - useEffect(() => { - 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 - // FIXME: why we need a second 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 }; -} 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 + * return a handler that calls f() but it will wait + * if it is called faster that $delay. + * + * this is useful for lonpoll against server since + * server may return earlier and we should not + * request againt fast + * + * @param f + * @param delay */ +async function preventRequestBurst<T>(waitBeforeCall: number, f: () => Promise<T>) { + const now = new Date().getTime(); + const diff = now - latestRequestTime; + if (diff < waitBeforeCall) { + console.log("PREVENT REQUEST BURST"); + await delayMs(diff + 10); + } + latestRequestTime = new Date().getTime(); + return f() +} + export function useInstanceKYCDetailsLongPolling() { const { state, lib } = useSessionContext(); + const token = state.token + + async function fetcher([token]: [AccessToken]) { + return await lib.instance.getCurrentInstanceKycStatus(token); + } - // 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(() => { - if (!token) return async () => undefined - 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 delayMs(DEFAULT_WAIT); + const { data, error, mutate } = useSWR< + TalerMerchantManagementResultByMethod<"getCurrentInstanceKycStatus">, + TalerHttpError + >([token, "getCurrentInstanceKycStatus"], fetcher); + + const result = useLongPolling( + data, + (result) => { + if (!result || result.type === "fail") return undefined; + if (!result.body) return undefined; + if (!result.body.etag) return undefined; + return result.body + }, + async (latestStatus) => { + const r = await lib.instance.getCurrentInstanceKycStatus(token!, { + longpoll: { + type: "state-change", + etag: latestStatus.etag!, + timeout: LONG_POLL_DELAY } - } - latestRequestTime = new Date().getTime(); - return lib.instance.getCurrentInstanceKycStatus(token, { - timeout: DEFAULT_WAIT, - reason: latestReason, }); - }; - }, [token, latestReason]); - - const { result: data, error } = useAsyncWithRetry(!token ? undefined : 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]); + mutate(r, { revalidate: false}) + return r + }, + [], + { minTime: LONG_POLL_DELAY } + ); - if (data) return data; if (error) return error; - return undefined; -} + return result; -/** - * 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.UNSUPPORTED_ACCOUNT: - case MerchantAccountKycStatus.EXCHANGE_STATUS_INVALID: - case MerchantAccountKycStatus.MERCHANT_INTERNAL_ERROR: - 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 useInstanceKYCDetails() { + const { state, lib } = useSessionContext(); + const token = state.token + + async function fetcher([token]: [AccessToken]) { + return await lib.instance.getCurrentInstanceKycStatus(token); } + + const { data, error } = useSWR< + TalerMerchantManagementResultByMethod<"getCurrentInstanceKycStatus">, + TalerHttpError + >([token, "getCurrentInstanceKycStatus"], fetcher); + + if (error) return error; + return data; + } + export function revalidateManagedInstanceDetails() { return mutate( (key) => Array.isArray(key) && key[key.length - 1] === "getInstanceDetails", diff --git a/packages/merchant-backoffice-ui/src/hooks/order.ts b/packages/merchant-backoffice-ui/src/hooks/order.ts @@ -19,20 +19,13 @@ import { PAGINATED_LIST_REQUEST } from "../utils/constants.js"; import { AbsoluteTime, AccessToken, - HttpStatusCode, - MerchantOrderStatusResponse, - opEmptySuccess, - OperationFail, - OperationOk, - TalerErrorCode, TalerHttpError, - TalerMerchantManagementResultByMethod, + TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util"; -import _useSWR, { SWRHook, mutate } from "swr"; +import { buildPaginatedResult, LONG_POLL_DELAY, useLongPolling } from "@gnu-taler/web-util/browser"; +import _useSWR, { mutate, SWRHook } from "swr"; import { useSessionContext } from "../context/session.js"; -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() { @@ -59,56 +52,41 @@ export function useOrderDetails(oderId: string) { return undefined; } -let latestRequestTime = 0; -export function useWaitForOrderPayment( - orderId: string, - current?: MerchantOrderStatusResponse, -) { +export function useOrderDetailsWithLongPoll(orderId: string) { const { state, lib } = useSessionContext(); + const token = state.token + + async function fetcher([dId, token]: [string, AccessToken]) { + return await lib.instance.getOrderDetails(token, dId); + } - const token = state.status === "loggedIn" ? state.token : undefined; - const evictWhenPaid = useMemo(() => { - if (!token) return async () => undefined - 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, + const { data, error, mutate } = useSWR< + TalerMerchantManagementResultByMethod<"getOrderDetails">, + TalerHttpError + >([orderId, token, "getOrderDetails"], fetcher); + + const result = useLongPolling( + data, + (result) => { + if (!result || result.type === "fail") return undefined; + return result.body + }, + async (latestStatus) => { + const r = await lib.instance.getOrderDetails(token!, orderId, { + longpoll: { + etag: latestStatus.etag!, + timeout: LONG_POLL_DELAY + } }); - if (result.type === "ok" && result.body.order_status === "paid") { - await revalidateOrderDetails(); - } - return result; - }; - }, [token]); - - const fetcher = - !token || !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; - }); + mutate(r, { revalidate: false }) + return r + }, + [orderId], + { minTime: LONG_POLL_DELAY } + ); + + if (error) return error; + return result; } export interface InstanceOrderFilter { @@ -128,12 +106,10 @@ export function revalidateInstanceOrders() { } export function useInstanceOrders( args?: InstanceOrderFilter, - updatePosition: (d: string | undefined) => void = () => {}, + updatePosition: (d: string | undefined) => void = () => { }, ) { const { state, lib } = useSessionContext(); - // const [offset, setOffset] = useState<string | undefined>(args?.position); - async function fetcher([token, o, p, r, w, d]: [ AccessToken, string, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx @@ -23,32 +23,17 @@ import { AccessToken, BasicOrTokenAuth, FacadeCredentials, - HttpStatusCode, - OperationFail, - OperationOk, - PaytoString, Paytos, - TalerError, TalerMerchantApi, TalerRevenueHttpClient, - opFixedSuccess, + opFixedSuccess } from "@gnu-taler/taler-util"; -import { readUnexpectedResponseDetails, type HttpRequestLibrary } from "@gnu-taler/taler-util/http"; +import { type HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import { - BrowserFetchHttpLib, - LocalNotificationBannerBulma, - useChallengeHandler, - useLocalNotificationBetter, - useTranslationContext, + BrowserFetchHttpLib } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { NotificationCard } from "../../../../components/menu/index.js"; -import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; -import { useSessionContext } from "../../../../context/session.js"; -import { Notification } from "../../../../utils/types.js"; import { CreatePage } from "./CreatePage.js"; -import { Session } from "inspector"; export type Entity = TalerMerchantApi.AccountAddDetails; interface Props { 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 @@ -24,8 +24,7 @@ import { Paytos, TalerError, TalerMerchantApi, - assertUnreachable, - parsePaytoUri, + assertUnreachable } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -36,8 +35,8 @@ import { ConfirmModal, ValidBankAccount, } from "../../../../components/modal/index.js"; -import { useInstanceKYCDetailsLongPolling } from "../../../../hooks/instance.js"; import { ListPage } from "./ListPage.js"; +import { useInstanceKYCDetails } from "../../../../hooks/instance.js"; const TALER_SCREEN_ID = 40; @@ -47,7 +46,7 @@ interface Props { } export default function ListKYC(_p: Props): VNode { - const result = useInstanceKYCDetailsLongPolling(); + const result = useInstanceKYCDetails(); const [showingInstructions, setShowingInstructions] = useState< TalerMerchantApi.MerchantAccountKycRedirect | undefined @@ -227,8 +226,8 @@ function ShowInstructionForKycRedirect({ > <p style={{ paddingTop: 0 }}> <i18n.Translate> - This account cannot be used. The payment service provider must - verify the account information manually. + This account cannot be used. The payment service provider must + verify the account information manually. </i18n.Translate> </p> </ConfirmModal> 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 @@ -41,9 +41,9 @@ import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputGroup } from "../../../../components/form/InputGroup.js"; import { InputLocation } from "../../../../components/form/InputLocation.js"; import { TextField } from "../../../../components/form/TextField.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; import { ProductList } from "../../../../components/product/ProductList.js"; import { useSessionContext } from "../../../../context/session.js"; -import { useWaitForOrderPayment } from "../../../../hooks/order.js"; import { datetimeFormatForPreferences, usePreference, @@ -51,7 +51,6 @@ import { import { mergeRefunds } from "../../../../utils/amount.js"; import { RefundModal } from "../list/Table.js"; import { Event, Timeline } from "./Timeline.js"; -import { NotificationCard } from "../../../../components/menu/index.js"; const TALER_SCREEN_ID = 44; @@ -269,7 +268,7 @@ function ClaimedPage({ const { i18n } = useTranslationContext(); const [value, valueHandler] = useState<Partial<Claimed>>(order); const [preferences] = usePreference(); - useWaitForOrderPayment(id, order); + // useWaitForOrderPayment(id, order); const now = new Date(); const refundable = order.contract_terms.refund_deadline.t_s !== "never" && @@ -817,7 +816,6 @@ function UnpaidPage({ const [value, valueHandler] = useState<Partial<Unpaid>>(order); const { i18n } = useTranslationContext(); const [preferences] = usePreference(); - useWaitForOrderPayment(id, order); return ( <div> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx @@ -25,7 +25,7 @@ import { import { Fragment, VNode, h } from "preact"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; import { Loading } from "../../../../components/exception/loading.js"; -import { useOrderDetails } from "../../../../hooks/order.js"; +import { useOrderDetails, useOrderDetailsWithLongPoll } from "../../../../hooks/order.js"; import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { DetailPage } from "./DetailPage.js"; @@ -38,7 +38,7 @@ export interface Props { } export default function Update({ oid, onBack }: Props): VNode { - const result = useOrderDetails(oid); + const result = useOrderDetailsWithLongPoll(oid); const { i18n } = useTranslationContext(); diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -33,6 +33,7 @@ import { ResultByMethod, TalerErrorCode, TalerMerchantApi, + assertUnreachable, carefullyParseConfig, codecForAbortResponse, codecForAccountAddResponse, @@ -44,14 +45,17 @@ import { codecForChallengeRequestResponse, codecForChallengeResponse, codecForClaimResponse, + codecForExpectedTansferList, codecForFullInventoryDetailsResponse, - codecForExpectedTansferList as codecForExpectedTansferList, + codecForGetSessionStatusPaidResponse, + codecForGroupAddedResponse, + codecForGroupsSummaryResponse, codecForInstancesResponse, codecForInventorySummaryResponse, codecForLoginTokenSuccessResponse, codecForMerchantOrderPrivateStatusResponse, - codecForMerchantPosProductDetail, codecForMerchantRefundResponse, + codecForMerchantStatisticsReportResponse, codecForOrderHistory, codecForOtpDeviceDetails, codecForOtpDeviceSummaryResponse, @@ -60,8 +64,14 @@ import { codecForPaymentDeniedLegallyResponse, codecForPaymentResponse, codecForPostOrderResponse, + codecForPotAddedResponse, + codecForPotDetailResponse, + codecForPotsSummaryResponse, codecForProductDetailResponse, codecForQueryInstancesResponse, + codecForReportAddedResponse, + codecForReportDetailResponse, + codecForReportsSummaryResponse, codecForStatisticsAmountResponse, codecForStatisticsCounterResponse, codecForStatusGoto, @@ -84,16 +94,7 @@ import { opKnownAlternativeHttpFailure, opKnownHttpFailure, opKnownTalerFailure, - opUnknownHttpFailure, - codecForMerchantStatisticsReportResponse, - codecForReportAddedResponse, - codecForReportDetailResponse, - codecForReportsSummaryResponse, - codecForGroupsSummaryResponse, - codecForGroupAddedResponse, - codecForPotAddedResponse, - codecForPotsSummaryResponse, - codecForPotDetailResponse, + opUnknownHttpFailure } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, @@ -246,9 +247,9 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationOk<TalerMerchantApi.LoginTokenSuccessResponse> | OperationAlternative< - HttpStatusCode.Accepted, - TalerMerchantApi.ChallengeResponse - > + HttpStatusCode.Accepted, + TalerMerchantApi.ChallengeResponse + > | OperationFail<HttpStatusCode.Unauthorized> > { const url = new URL(`private/token`, this.baseUrl); @@ -506,6 +507,55 @@ export class TalerMerchantInstanceHttpClient { } /** + * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-sessions-$SESSION_ID?fulfillment_url=$URL + */ + async getOrderIdForSessionAndUrl( + sessionId: string, + fulfillmentUrl: string, + params: { + timeout?: number; + } = {} + ) { + const url = new URL(`sessions/${sessionId}`, this.baseUrl); + + if (fulfillmentUrl !== undefined) { + url.searchParams.set( + "fulfillment_url", + fulfillmentUrl, + ); + } + if (params.timeout) { + url.searchParams.set("timeout_ms", String(params.timeout)); + } + + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + }); + + switch (resp.status) { + case HttpStatusCode.Ok: { + const final = await opSuccessFromHttp( + resp, + codecForGetSessionStatusPaidResponse(), + ); + return { paid: true, ...final } + } + case HttpStatusCode.Accepted: { + const final = opSuccessFromHttp( + resp, + codecForGetSessionStatusPaidResponse(), + ); + return { paid: false, ...final } + } + case HttpStatusCode.NotFound: { + return opKnownHttpFailure(resp.status, resp); + } + default: + return opUnknownHttpFailure(resp); + } + } + + /** * https://docs.taler.net/core/api-merchant.html#demonstrating-payment */ async demostratePayment(orderId: string, body: TalerMerchantApi.PaidRequest) { @@ -792,14 +842,33 @@ export class TalerMerchantInstanceHttpClient { if (params.exchangeURL) { url.searchParams.set("exchange_url", params.exchangeURL); } - 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 (params.longpoll) { + switch (params.longpoll.type) { + case "state-enter": + url.searchParams.set("lp_status", (params.longpoll.status)); + break; + case "state-exit": + url.searchParams.set("lp_not_status", params.longpoll.status); + break; + case "state-change": + url.searchParams.set("lp_not_etag", (params.longpoll.etag)); + headers["If-none-match"] = params.longpoll.etag + break; + default: assertUnreachable(params.longpoll) + } + url.searchParams.set("timeout_ms", String(params.longpoll.timeout)); + } else { + // backward compat, prefer longpoll + if (params.timeout) { + url.searchParams.set("timeout_ms", String(params.timeout)); + } + if (params.reason) { + url.searchParams.set("lpt", String(params.reason)); + } + } + if (token) { headers.Authorization = makeBearerTokenAuthHeader(token); } @@ -807,12 +876,17 @@ export class TalerMerchantInstanceHttpClient { method: "GET", headers, }); + const etag = resp.headers.get("etag")?.replace(/"/g, ""); switch (resp.status) { - case HttpStatusCode.Ok: - return opSuccessFromHttp(resp, codecForAccountKycRedirects()); + case HttpStatusCode.Ok: { + const f = await opSuccessFromHttp(resp, codecForAccountKycRedirects()); + return opFixedSuccess({ etag, ...f.body }) + } case HttpStatusCode.NoContent: return opEmptySuccess(); + case HttpStatusCode.NotModified: + return opEmptySuccess(); case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: // FIXME: missing in docs @@ -1573,6 +1647,17 @@ export class TalerMerchantInstanceHttpClient { } const headers: Record<string, string> = {}; + if (params.longpoll) { + url.searchParams.set("lp_not_etag", (params.longpoll.etag)); + headers["If-none-match"] = params.longpoll.etag + url.searchParams.set("timeout_ms", String(params.longpoll.timeout)); + } else { + // backward compat, prefer longpoll + if (params.timeout) { + url.searchParams.set("timeout_ms", String(params.timeout)); + } + } + if (token) { headers.Authorization = makeBearerTokenAuthHeader(token); } @@ -1580,13 +1665,16 @@ export class TalerMerchantInstanceHttpClient { method: "GET", headers, }); + const etag = resp.headers.get("etag")?.replace(/"/g, ""); switch (resp.status) { - case HttpStatusCode.Ok: - return opSuccessFromHttp( + case HttpStatusCode.Ok: { + const f = await opSuccessFromHttp( resp, codecForMerchantOrderPrivateStatusResponse(), ); + return opFixedSuccess({ etag, ...f.body }) + } case HttpStatusCode.NotFound: { const details = await readTalerErrorResponse(resp); switch (details.code) { diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -1410,6 +1410,9 @@ export interface PaymentStatusRequestParams { allowRefundedForRepurchase?: boolean; } +/** + * @deprecated use KycLongPollingReason + */ export enum KycStatusLongPollingReason { /** * Waiting for an account to receive the wire transfer @@ -1424,6 +1427,31 @@ export enum KycStatusLongPollingReason { */ TO_BE_OK = 3, } + +export type KycLongPollingReason = + KycLongPollingReasonWaitForStateEnter + | KycLongPollingReasonWaitForStateExit + | KycLongPollingReasonWaitForStateChange; + +export type KycEtag = string; + +export type KycLongPollingReasonWaitForStateEnter = { + type: "state-enter", + status: MerchantAccountKycStatus; + timeout: number, +} +export type KycLongPollingReasonWaitForStateExit = { + type: "state-exit", + status: MerchantAccountKycStatus; + timeout: number, +} +export type KycLongPollingReasonWaitForStateChange = { + type: "state-change", + etag: KycEtag; + timeout: number, +} + + export interface GetKycStatusRequestParams { // If specified, the KYC check should return // the KYC status only for this wire account. @@ -1433,12 +1461,16 @@ export interface GetKycStatusRequestParams { // the KYC status only for the given exchange. // Otherwise, for all exchanges we interacted with. exchangeURL?: string; - // If specified, the merchant will wait up to - // timeout_ms milliseconds for the exchanges to - // confirm completion of the KYC process(es). + /** + * @deprecated use longpoll + * If specified, the merchant will wait up to + * timeout_ms milliseconds for the exchanges to + * confirm completion of the KYC process(es). + */ timeout?: number; /** + * @deprecated use longpoll * 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, @@ -1447,6 +1479,15 @@ export interface GetKycStatusRequestParams { * state will cause the response to be returned. */ reason?: KycStatusLongPollingReason; + + /** + * + */ + longpoll?: KycLongPollingReason; +} +export type OrderDetailLongPollingReason = { + etag: string; + timeout: number; } export interface GetOtpDeviceRequestParams { // Timestamp in seconds to use when calculating @@ -1460,9 +1501,18 @@ export interface GetOrderRequestParams { // Session ID that the payment must be bound to. // If not specified, the payment is not session-bound. sessionId?: string; - // Timeout in milliseconds to wait for a payment if - // the answer would otherwise be negative (long polling). + /** + * @deprecated use longpoll + * Timeout in milliseconds to wait for a payment if + * the answer would otherwise be negative (long polling). + */ timeout?: number; + + /** + * + */ + longpoll?: OrderDetailLongPollingReason; + // Since protocol v9 refunded orders are only returned // under “already_paid_order_id” if this flag is set // explicitly to “YES”. @@ -2172,6 +2222,10 @@ export interface MerchantAccountKycRedirect { // Base URL of the exchange this is about. exchange_url: string; + // Currency used by the exchange. + // @since protocol **v25**. + exchange_currency?: string; + // HTTP status code returned by the exchange when we asked for // information about the KYC status. // Since protocol **v17**. @@ -2257,6 +2311,12 @@ export interface AccountAddDetails { credit_facade_credentials?: FacadeCredentials; } +// Fixed-point decimal string in the form "<integer>[.<fraction>]". +// Fractional part has up to six digits. +// "-1" is only valid for fields that explicitly allow "infinity". +// Since protocol **v25**; used in template selection since **v25**. +export type DecimalQuantity = string; + export type FacadeCredentials = | NoFacadeCredentials | BasicAuthFacadeCredentials @@ -2418,11 +2478,9 @@ export interface ProductAddDetailRequest { // Unit in which the product is measured (liters, kilograms, packages, etc.). unit: string; - // The price for one unit of the product. Zero is used - // to imply that this product is not sold separately, or - // that the price is not fixed, and must be supplied by the - // front-end. If non-zero, this price MUST include applicable - // taxes. + // Legacy price field. + // Deprecated since **v25**; + // when present it must match the first element of unit_price. price: AmountString; // An optional base64-encoded product image. @@ -2437,6 +2495,18 @@ export interface ProductAddDetailRequest { // A value of -1 indicates "infinite" (i.e. for "electronic" books). total_stock: Integer; + // Preferred way to express the per-unit price of the product. Supply at least one entry; + // the first entry must match price. + // The price given MUST include applicable taxes if price_is_net + // is false, and MUST exclude applicable taxes if price_is_net + // is true. + // Zero implies that the product is not sold separately or that the price must be supplied + // by the frontend. + // Each entry must use a distinct currency. + // Since API version **v25**. + // Currency uniqueness enforced since protocol **v25**. + unit_price?: AmountString[]; + // Identifies where the product is in stock. address?: Location; @@ -4312,6 +4382,7 @@ export const codecForMerchantAccountKycRedirect = .property("h_wire", codecForString()) .property("payto_uri", codecForPaytoString()) .property("exchange_url", codecForURLString()) + .property("exchange_currency", codecOptional(codecForString())) .property("exchange_http_status", codecForNumber()) .property("no_keys", codecForBoolean()) .property("auth_conflict", codecForBoolean()) @@ -4692,6 +4763,26 @@ export const codecForMerchantOrderPrivateStatusResponse = .alternative("claimed", codecForCheckPaymentClaimedResponse()) .build("TalerMerchantApi.MerchantOrderStatusResponse"); +export interface GetSessionStatusPaidResponse { + // Order ID of the paid order. + order_id: string; +} +export const codecForGetSessionStatusPaidResponse = + (): Codec<GetSessionStatusPaidResponse> => + buildCodecForObject<GetSessionStatusPaidResponse>() + .property("order_id", codecForString()) + .build("TalerMerchantApi.GetSessionStatusPaidResponse"); + +export interface GetSessionStatusUnpaidResponse { + // Order ID of the unpaid order. + order_id: string; +} +export const codecForGetSessionStatusUnpaidResponse = + (): Codec<GetSessionStatusUnpaidResponse> => + buildCodecForObject<GetSessionStatusUnpaidResponse>() + .property("order_id", codecForString()) + .build("TalerMerchantApi.GetSessionStatusUnpaidResponse"); + export const codecForRefundDetails = (): Codec<RefundDetails> => buildCodecForObject<RefundDetails>() .property("reason", codecForString()) diff --git a/packages/web-util/src/hooks/useAsync.ts b/packages/web-util/src/hooks/useAsync.ts @@ -53,49 +53,62 @@ export function useAsync<Res>( return data; } +export const LONG_POLL_DELAY = 15000; + + +// FIXME: the problem with this compared with useSWR is that +// if the hook is called from more than one place then +// you will have multiple request. This needs to be merged, maybe based on a +// key /** - * First start with `first` value - * Check if the it should do long-polling with `shouldRetryFn` - * Call `retryFn` if is required, long poll is expected for this function - * - * If `retryFn` returns faster than `minTime` (by error or because server - * returned a response faster) then wait until `minTime` + * First start with `initial` value, if initial is undefined then finish. + * Otherwise: + * Based on `initial` check if it should do long-polling with `shouldRetryFn` + * If the result is undefined then finish. + * Otherwise: + * Verify if the call is going to fast, if so slow down. + * + * Call `retryFn` as the long poll function. + * The result will be the next `initial` value. * + * + * * @param fetcher fetcher should be a memoized function * @param retry * @param deps * @returns */ export function useLongPolling<Res, Rt>( - first: Res, + initial: Res, shouldRetryFn: (res: Res, count: number) => Rt | undefined, retryFn: (last: Rt) => Promise<Res>, deps: Array<any> = [], opts: { minTime?: number } = {}, ) { - const mt = opts?.minTime ?? 1000; // do not try faster than this + const minTime = opts?.minTime ?? 1000; const [retry, setRetry] = useState<{ count: number; fn: (() => Promise<Res>) | undefined; - startMs: number; + startMs: number | undefined; }>({ count: 0, fn: undefined, - startMs: new Date().getTime(), + startMs: undefined, }); const result = useAsync(retry.fn, [retry.count, ...deps]); - const body = result ?? first; + const body = result ?? initial; useEffect(() => { - if (!body) return; + if (body === undefined) return; const _body = body; - function doChceck() { - const rt = shouldRetryFn(_body, retry.count); - if (!rt) return; + const rt = shouldRetryFn(_body, retry.count); + if (rt === undefined) return; + + function doRetry(rt: Rt) { // call again setRetry((lt) => ({ count: lt.count + 1, @@ -104,12 +117,16 @@ export function useLongPolling<Res, Rt>( })); } - const diff = new Date().getTime() - retry.startMs; - if (diff < mt) { - // calling too fast, wait - delayMs(mt - diff).then(doChceck); + if (retry.startMs === undefined) { + doRetry(rt); } else { - doChceck(); + const diff = new Date().getTime() - retry.startMs; + if (diff < minTime) { + // calling too fast, wait whats left to reach minTime + delayMs(minTime - diff).then(() => doRetry(rt)); + } else { + doRetry(rt); + } } }, [body]); diff --git a/packages/web-util/src/hooks/useAsyncAsHook.ts b/packages/web-util/src/hooks/useAsyncAsHook.ts @@ -13,7 +13,7 @@ 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 { TalerErrorDetail } from "@gnu-taler/taler-util"; +import { TalerErrorDetail, TalerHttpError } from "@gnu-taler/taler-util"; // import { TalerError } from "@gnu-taler/taler-wallet-core"; import { useEffect, useMemo, useState } from "preact/hooks"; @@ -92,3 +92,57 @@ export function useAsyncAsHook<T>( if (!result) return undefined; return { ...result, retry: doAsync }; } + +/** + * @deprecated + * + * Convert an async function named $fetcher into a hook behavior + * with a retry function condition. + * + * The $retry function is called every time $fetcher finalize + * and if $retry returns true the $fetcher is called again + * + * @param fetcher + * @param retry + * @returns + */ +export 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); + + let unloaded = false; + useEffect(() => { + 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 + // FIXME: why we need a second 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 }; +}