taler-typescript-core

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

commit db7b7beea74e50463a0e5ff67db3b304047f2649
parent def199019fab39b492b668c668a80877f672196d
Author: Florian Dold <dold@taler.net>
Date:   Thu, 23 Jul 2026 02:55:40 +0200

wallet: use typed merchant client in pay-merchant

Diffstat:
Mpackages/taler-util/src/http-client/merchant.ts | 19+++++++++++++++----
Mpackages/taler-util/src/types-taler-merchant.ts | 14++++++++------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 393+++++++++++++++++++++++++++++++++++++------------------------------------------
3 files changed, 209 insertions(+), 217 deletions(-)

diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -66,7 +66,7 @@ import { codecForOutOfStockResponse, codecForPaidRefundStatusResponse, codecForPaymentDeniedLegallyResponse, - codecForPaymentResponse, + codecForMerchantPayResponse, codecForPostOrderResponse, codecForPotAddedResponse, codecForPotDetailResponse, @@ -465,7 +465,7 @@ export class TalerMerchantInstanceHttpClient { this.cacheEvictor.notifySuccess( TalerMerchantInstanceCacheEviction.UPDATE_ORDER, ); - return opSuccessFromHttp(resp, codecForPaymentResponse()); + return opSuccessFromHttp(resp, codecForMerchantPayResponse()); } case HttpStatusCode.BadRequest: return opKnownHttpFailure(resp.status, resp); @@ -542,14 +542,25 @@ export class TalerMerchantInstanceHttpClient { timeout: this.timeout, }); + // The order status is discriminated by the HTTP status code: 200 => paid, + // 202 => go to reorder URL, 402 => unpaid. We surface each as a distinct + // result case so callers can narrow the body type. switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForStatusPaid()); case HttpStatusCode.Accepted: - return opSuccessFromHttp(resp, codecForStatusGoto()); + return opKnownAlternativeHttpFailure( + resp, + HttpStatusCode.Accepted, + codecForStatusGoto(), + ); // case HttpStatusCode.Found: not possible since content is not HTML case HttpStatusCode.PaymentRequired: - return opSuccessFromHttp(resp, codecForStatusStatusUnpaid()); + return opKnownAlternativeHttpFailure( + resp, + HttpStatusCode.PaymentRequired, + codecForStatusStatusUnpaid(), + ); case HttpStatusCode.Forbidden: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -1693,7 +1693,7 @@ export interface CoinPaySig { } export interface StatusPaid { - type: "paid"; + type?: "paid"; // Was the payment refunded (even partially, via refund or abort)? refunded: boolean; @@ -1708,7 +1708,7 @@ export interface StatusPaid { refund_taken: AmountString; } export interface StatusGotoResponse { - type: "goto"; + type?: "goto"; // The client should go to the reorder URL, there a fresh // order might be created as this one is taken by another // customer or wallet (or repurchase detection logic may @@ -1716,7 +1716,7 @@ export interface StatusGotoResponse { public_reorder_url: string; } export interface StatusUnpaidResponse { - type: "unpaid"; + type?: "unpaid"; // URI that the wallet must process to complete the payment. taler_pay_uri: TalerUriString; @@ -4629,18 +4629,20 @@ export const codecForStatusPaid = (): Codec<StatusPaid> => .property("refund_pending", codecForBoolean()) .property("refund_taken", codecForAmountString()) .property("refunded", codecForBoolean()) - .property("type", codecForConstString("paid")) + // The order-status response is discriminated by HTTP status, not by a + // body field; some merchants don't send `type` at all. + .property("type", codecOptional(codecForConstString("paid"))) .build("TalerMerchantApi.StatusPaid"); export const codecForStatusGoto = (): Codec<StatusGotoResponse> => buildCodecForObject<StatusGotoResponse>() .property("public_reorder_url", codecForURLString()) - .property("type", codecForConstString("goto")) + .property("type", codecOptional(codecForConstString("goto"))) .build("TalerMerchantApi.StatusGotoResponse"); export const codecForStatusStatusUnpaid = (): Codec<StatusUnpaidResponse> => buildCodecForObject<StatusUnpaidResponse>() - .property("type", codecForConstString("unpaid")) + .property("type", codecOptional(codecForConstString("unpaid"))) .property("already_paid_order_id", codecOptional(codecForString())) .property("fulfillment_url", codecOptional(codecForString())) .property("taler_pay_uri", codecForTalerUriString()) diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -37,12 +37,7 @@ import { checkLogicInvariant, ChoiceSelectionDetail, ChoiceSelectionDetailType, - codecForAbortResponse, codecForMerchantContractTerms, - codecForMerchantOrderStatusPaid, - codecForMerchantOrderStatusUnpaid, - codecForMerchantPayResponse, - codecForWalletRefundResponse, CoinDepositPermission, CoinRefreshRequest, ConfirmPayResult, @@ -111,10 +106,6 @@ import { } from "@gnu-taler/taler-util"; import { getHttpResponseErrorDetails, - HttpResponse, - readResponseJsonOrThrow, - readSuccessResponseJsonOrThrow, - readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { @@ -123,8 +114,6 @@ import { selectPayCoinsInTx, } from "./coinSelection.js"; import { - cancelableFetch, - cancelableLongPoll, constructTaskIdentifier, genericWaitForState, genericWaitForStateVal, @@ -886,6 +875,11 @@ async function failProposalClaimPermanently( }); } +/** + * Long-poll timeout (in milliseconds) for merchant order-status requests. + */ +const MERCHANT_ORDER_STATUS_LONGPOLL_MS = 30_000; + function getPayRequestTimeout(purchase: WalletPurchase): Duration { return Duration.multiply( { d_ms: 15000 }, @@ -3308,11 +3302,6 @@ async function processPurchasePay( } if (!purchase.merchantPaySig) { - const payUrl = new URL( - `orders/${download.contractTerms.order_id}/pay`, - download.contractTerms.merchant_base_url, - ); - let slates: WalletSlate[] | undefined = undefined; let donauPlanchets: WalletDonationPlanchet[] | undefined = undefined; let wallet_data: PayWalletData | undefined = undefined; @@ -3406,92 +3395,90 @@ async function processPurchasePay( logger.trace(`making pay request ... ${j2s(reqBody)}`); } + const merchantClient = walletMerchantClient( + download.contractTerms.merchant_base_url, + wex, + getPayRequestTimeout(purchase), + ); const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () => - cancelableFetch(wex, payUrl, { - method: "POST", - body: reqBody, - timeout: getPayRequestTimeout(purchase), - }), + merchantClient.makePayment(download.contractTerms.order_id, reqBody), ); logger.trace(`got resp ${JSON.stringify(resp)}`); - if (resp.status === HttpStatusCode.Conflict) { - const err = await readTalerErrorResponse(resp); - if ( - err.code === - TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS - ) { - return handleInsufficientFunds(wex, proposalId, err); + switch (resp.case) { + case "ok": + break; + case HttpStatusCode.Conflict: { + const err = resp.detail!; + if ( + err.code === + TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS + ) { + return handleInsufficientFunds(wex, proposalId, err); + } + return throwUnexpectedRequestError(resp.response, err); } - } - - if (resp.status === HttpStatusCode.BadRequest) { - const err = await readTalerErrorResponse(resp); - if ( - err.code === - TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND - ) { - return handleInsufficientFunds(wex, proposalId, err); + case HttpStatusCode.BadRequest: { + const err = resp.detail!; + if ( + err.code === + TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND + ) { + return handleInsufficientFunds(wex, proposalId, err); + } + return throwUnexpectedRequestError(resp.response, err); } - } - - if (resp.status === HttpStatusCode.BadGateway) { - const err = await readTalerErrorResponse(resp); - switch (err.code) { - case TalerErrorCode.MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS: { - const exchangeEc = (err.exchange_reply as any)?.code; - switch (exchangeEc) { - case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN: - // We might want to handle this in the future by re-denomination, - // for now we just abort. - await ctx.failTransaction(purchase.purchaseStatus, { - code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, - message: "Denomination used in payment became invalid.", - errorDetails: err, - }); - return TaskRunResult.progress(); + case HttpStatusCode.BadGateway: { + const err = resp.detail!; + switch (err.code) { + case TalerErrorCode.MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS: { + const exchangeEc = (err.exchange_reply as any)?.code; + switch (exchangeEc) { + case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN: + // We might want to handle this in the future by re-denomination, + // for now we just abort. + await ctx.failTransaction(purchase.purchaseStatus, { + code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION, + message: "Denomination used in payment became invalid.", + errorDetails: err, + }); + return TaskRunResult.progress(); + } + break; } - break; } + // We don't know the specific error, so it's safer to retry. + return throwUnexpectedRequestError(resp.response, err); } - // We don't know the specific error, so it's safer to retry. - return throwUnexpectedRequestError(resp, err); - } - - if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { - logger.warn(`pay transaction failed, merchant has KYC problems`); - await ctx.userAbortTransaction( - makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING, { - exchangeResponse: await resp.json(), - }), - ); - return TaskRunResult.progress(); - } - - if (resp.status === HttpStatusCode.Gone) { - logger.warn(`pay transaction aborted, order expired`); - await ctx.userAbortTransaction( - makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_ORDER_GONE, {}), - ); - return TaskRunResult.progress(); - } - - if (resp.status != 200) { - logger.info( - `got error response (http status ${resp.status}) from merchant`, - ); - const err = await readTalerErrorResponse(resp); - if (logger.shouldLogTrace()) { - logger.trace(`error body: ${j2s(err)}`); - } - return throwUnexpectedRequestError(resp, err); + case HttpStatusCode.UnavailableForLegalReasons: + logger.warn(`pay transaction failed, merchant has KYC problems`); + await ctx.userAbortTransaction( + makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING, { + exchangeResponse: resp.body, + }), + ); + return TaskRunResult.progress(); + case HttpStatusCode.Gone: + logger.warn(`pay transaction aborted, order expired`); + await ctx.userAbortTransaction( + makeTalerErrorDetail( + TalerErrorCode.WALLET_PAY_MERCHANT_ORDER_GONE, + {}, + ), + ); + return TaskRunResult.progress(); + default: + logger.info( + `got error response (http status ${resp.response.status}) from merchant`, + ); + if (logger.shouldLogTrace()) { + logger.trace(`error body: ${j2s(resp.detail)}`); + } + return throwUnexpectedRequestError(resp.response, resp.detail!); } - const merchantResp = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantPayResponse(), - ); + const merchantResp = resp.body; logger.trace("got success from pay URL", merchantResp); @@ -3589,30 +3576,24 @@ async function processPurchasePay( await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp); } else { - const payAgainUrl = new URL( - `orders/${download.contractTerms.order_id}/paid`, - download.contractTerms.merchant_base_url, - ); const reqBody = { sig: purchase.merchantPaySig, h_contract: download.contractTermsHash, session_id: sessionId ?? "", }; logger.trace(`/paid request body: ${j2s(reqBody)}`); + const merchantClient = walletMerchantClient( + download.contractTerms.merchant_base_url, + wex, + ); const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () => - cancelableFetch(wex, payAgainUrl, { - method: "POST", - body: reqBody, - }), + merchantClient.demostratePayment(download.contractTerms.order_id, reqBody), ); - logger.trace(`/paid response status: ${resp.status}`); - if ( - resp.status !== HttpStatusCode.NoContent && - resp.status != HttpStatusCode.Ok - ) { + logger.trace(`/paid response status: ${resp.response.status}`); + if (resp.case !== "ok") { throw TalerError.fromDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, - getHttpResponseErrorDetails(resp), + getHttpResponseErrorDetails(resp.response), "/paid failed", ); } @@ -4137,31 +4118,28 @@ async function checkIfOrderIsAlreadyPaid( contract: DownloadedContractData, doLongPolling: boolean, ) { - const requestUrl = new URL( - `orders/${contract.contractTerms.order_id}`, + const merchantClient = walletMerchantClient( contract.contractTerms.merchant_base_url, + wex, + ); + const resp = await merchantClient.getPaymentStatus( + contract.contractTerms.order_id, + { + contractTermHash: contract.contractTermsHash, + timeout: doLongPolling ? MERCHANT_ORDER_STATUS_LONGPOLL_MS : undefined, + }, ); - requestUrl.searchParams.set("h_contract", contract.contractTermsHash); - - let resp: HttpResponse; - - if (doLongPolling) { - resp = await cancelableLongPoll(wex, requestUrl); - } else { - resp = await cancelableFetch(wex, requestUrl); - } - if ( - resp.status === HttpStatusCode.Ok || - resp.status === HttpStatusCode.Accepted || - resp.status === HttpStatusCode.Found - ) { - return true; - } else if (resp.status === HttpStatusCode.PaymentRequired) { - return false; + switch (resp.case) { + case "ok": // 200 paid + case HttpStatusCode.Accepted: // 202 goto + return true; + case HttpStatusCode.PaymentRequired: // 402 unpaid + return false; + default: + // forbidden, not found, not acceptable + throw Error(`this order cant be paid: ${resp.response.status}`); } - // forbidden, not found, not acceptable - throw Error(`this order cant be paid: ${resp.status}`); } async function processPurchaseDialogProposed( @@ -4239,31 +4217,23 @@ async function processPurchaseDialogShared( let paidByOther = false; - const requestUrl = new URL( - `orders/${contractTerms.order_id}`, + const merchantClient = walletMerchantClient( contractTerms.merchant_base_url, + wex, ); - requestUrl.searchParams.set("h_contract", download.contractTermsHash); - if (rec.lastSessionId != null) { - requestUrl.searchParams.set("session_id", rec.lastSessionId); - } - - let httpResp: HttpResponse; - - httpResp = await cancelableLongPoll(wex, requestUrl); + const resp = await merchantClient.getPaymentStatus(contractTerms.order_id, { + contractTermHash: download.contractTermsHash, + sessionId: rec.lastSessionId ?? undefined, + timeout: MERCHANT_ORDER_STATUS_LONGPOLL_MS, + }); - switch (httpResp.status) { - case HttpStatusCode.Ok: - case HttpStatusCode.Accepted: - case HttpStatusCode.Found: + switch (resp.case) { + case "ok": // 200 paid + case HttpStatusCode.Accepted: // 202 goto paidByOther = true; break; - case HttpStatusCode.PaymentRequired: - const resp = await readResponseJsonOrThrow( - httpResp, - codecForMerchantOrderStatusUnpaid(), - ); - if (resp.already_paid_order_id != null) { + case HttpStatusCode.PaymentRequired: // 402 unpaid + if (resp.body.already_paid_order_id != null) { paidByOther = true; } break; @@ -4349,22 +4319,24 @@ async function processPurchaseAutoRefund( return TaskRunResult.progress(); } - const requestUrl = new URL( - `orders/${download.contractTerms.order_id}`, + const merchantClient = walletMerchantClient( download.contractTerms.merchant_base_url, + wex, + ); + const resp = await merchantClient.getPaymentStatus( + download.contractTerms.order_id, + { + contractTermHash: download.contractTermsHash, + refund: Amounts.stringify(totalKnownRefund), + timeout: MERCHANT_ORDER_STATUS_LONGPOLL_MS, + }, ); - requestUrl.searchParams.set("h_contract", download.contractTermsHash); - - requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund)); - - const resp = await cancelableLongPoll(wex, requestUrl); // FIXME: Check other status codes! - - const orderStatus = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantOrderStatusPaid(), - ); + if (resp.case !== "ok") { + throw Error(`expected paid order status, got HTTP ${resp.response.status}`); + } + const orderStatus = resp.body; if (!orderStatus.refund_pending) { return TaskRunResult.longpollReturnedPending(); @@ -4393,10 +4365,6 @@ async function processPurchaseAbortingRefund( const ctx = new PayMerchantTransactionContext(wex, proposalId); const download = await expectProposalDownload(wex, purchase); logger.trace(`processing aborting-refund for proposal ${proposalId}`); - const requestUrl = new URL( - `orders/${download.contractTerms.order_id}/abort`, - download.contractTerms.merchant_base_url, - ); const abortingCoins: AbortingCoin[] = []; @@ -4459,37 +4427,43 @@ async function processPurchaseAbortingRefund( coins: abortingCoins, }; - logger.trace(`making order abort request to ${requestUrl.href}`); + logger.trace(`making order abort request for ${download.contractTerms.order_id}`); - const abortHttpResp = await cancelableFetch(wex, requestUrl, { - method: "POST", - body: abortReq, - }); + const merchantClient = walletMerchantClient( + download.contractTerms.merchant_base_url, + wex, + ); + const abortHttpResp = await merchantClient.abortIncompletePayment( + download.contractTerms.order_id, + abortReq, + ); - logger.trace(`abort response status: ${j2s(abortHttpResp.status)}`); + logger.trace(`abort response status: ${j2s(abortHttpResp.response.status)}`); - if (abortHttpResp.status === HttpStatusCode.NotFound) { - const err = await readTalerErrorResponse(abortHttpResp); - if ( - err.code === + if ( + abortHttpResp.case === HttpStatusCode.NotFound && + abortHttpResp.detail?.code === TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND - ) { - await wex.runWalletDbTx(async (tx) => { - const [rec, h] = await ctx.getRecordHandle(tx); - if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) { - return; - } - rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; - await h.update(rec); - }); - return TaskRunResult.progress(); - } + ) { + await wex.runWalletDbTx(async (tx) => { + const [rec, h] = await ctx.getRecordHandle(tx); + if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) { + return; + } + rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; + await h.update(rec); + }); + return TaskRunResult.progress(); } - const abortResp = await readSuccessResponseJsonOrThrow( - abortHttpResp, - codecForAbortResponse(), - ); + if (abortHttpResp.case !== "ok") { + return throwUnexpectedRequestError( + abortHttpResp.response, + abortHttpResp.detail!, + ); + } + + const abortResp = abortHttpResp.body; if (logger.shouldLogTrace()) { logger.trace(`abort response: ${j2s(abortResp)}`); @@ -4532,17 +4506,20 @@ async function processPurchaseQueryRefund( const download = await expectProposalDownload(wex, purchase); - const requestUrl = new URL( - `orders/${download.contractTerms.order_id}`, + const merchantClient = walletMerchantClient( download.contractTerms.merchant_base_url, + wex, ); - requestUrl.searchParams.set("h_contract", download.contractTermsHash); - - const resp = await cancelableFetch(wex, requestUrl); - const orderStatus = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantOrderStatusPaid(), + const resp = await merchantClient.getPaymentStatus( + download.contractTerms.order_id, + { + contractTermHash: download.contractTermsHash, + }, ); + if (resp.case !== "ok") { + throw Error(`expected paid order status, got HTTP ${resp.response.status}`); + } + const orderStatus = resp.body; const ctx = new PayMerchantTransactionContext(wex, proposalId); @@ -4581,24 +4558,26 @@ async function processPurchaseAcceptRefund( ): Promise<TaskRunResult> { const download = await expectProposalDownload(wex, purchase); - const requestUrl = new URL( - `orders/${download.contractTerms.order_id}/refund`, + logger.trace(`making refund request for ${download.contractTerms.order_id}`); + + const merchantClient = walletMerchantClient( download.contractTerms.merchant_base_url, + wex, ); - - logger.trace(`making refund request to ${requestUrl.href}`); - - const request = await cancelableFetch(wex, requestUrl, { - method: "POST", - body: { + const request = await merchantClient.obtainRefund( + download.contractTerms.order_id, + { h_contract: download.contractTermsHash, }, - }); - - const refundResponse = await readSuccessResponseJsonOrThrow( - request, - codecForWalletRefundResponse(), ); + if (request.case !== "ok") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(request.response), + "refund request failed", + ); + } + const refundResponse = request.body; return await storeRefunds( wex, purchase,