taler-typescript-core

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

commit eefcce641c302de2b8580fdf3fa579d138f1b4a3
parent 208df6ba5543f239b68e0ca1af519dfdc09e0ffb
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:48 +0200

wallet-core: process partial merchant abort results

Diffstat:
Apackages/taler-util/src/http-client/merchant-abort.test.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/merchant.ts | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 39+++++++++++++++++++++++----------------
3 files changed, 220 insertions(+), 48 deletions(-)

diff --git a/packages/taler-util/src/http-client/merchant-abort.test.ts b/packages/taler-util/src/http-client/merchant-abort.test.ts @@ -0,0 +1,57 @@ +/* + This file is part of GNU Taler + (C) 2026 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/>. + */ + +import assert from "node:assert"; +import { test } from "node:test"; +import { FakeHttpLib } from "../http-fake.js"; +import { HttpStatusCode } from "../http-status-codes.js"; +import { TalerMerchantInstanceHttpClient } from "./merchant.js"; + +test("merchant abort preserves per-coin results returned with HTTP 502", async () => { + const body = { + refunds: [ + { type: "undeposited" }, + { + type: "failure", + exchange_status: HttpStatusCode.ServiceUnavailable, + }, + ], + }; + const http = new FakeHttpLib().on("POST", "/orders/order/abort", { + status: HttpStatusCode.BadGateway, + body, + }); + const client = new TalerMerchantInstanceHttpClient( + "https://merchant.example/", + http, + ); + + const result = await client.abortIncompletePayment("order", { + h_contract: "contract-hash", + coins: [], + }); + + assert.strictEqual(result.case, HttpStatusCode.BadGateway); + if (result.case !== HttpStatusCode.BadGateway) return; + assert.strictEqual(result.body.refunds.length, 2); + assert.strictEqual(result.body.refunds[0].type, "undeposited"); + assert.strictEqual(result.body.refunds[1].type, "failure"); + if (result.body.refunds[1].type !== "failure") return; + assert.strictEqual( + result.body.refunds[1].exchange_status, + HttpStatusCode.ServiceUnavailable, + ); +}); diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -381,7 +381,10 @@ export class TalerMerchantInstanceHttpClient { * */ async deleteAccessToken(token: AccessToken, serial: number) { - const url = new URL(`private/tokens/${pathSegment(String(serial))}`, this.baseUrl); + const url = new URL( + `private/tokens/${pathSegment(String(serial))}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { headers.Authorization = makeBearerTokenAuthHeader(token); @@ -705,6 +708,18 @@ export class TalerMerchantInstanceHttpClient { return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.RequestTimeout: + case HttpStatusCode.PreconditionFailed: + case HttpStatusCode.PayloadTooLarge: + case HttpStatusCode.InternalServerError: + case HttpStatusCode.GatewayTimeout: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.BadGateway: + return opKnownAlternativeHttpFailure( + resp, + resp.status, + codecForAbortResponse(), + ); default: return opUnknownHttpFailure(resp); } @@ -1162,7 +1177,10 @@ export class TalerMerchantInstanceHttpClient { wireAccount: string, body: TalerMerchantApi.AccountPatchDetails, ) { - const url = new URL(`private/accounts/${pathSegment(wireAccount)}`, this.baseUrl); + const url = new URL( + `private/accounts/${pathSegment(wireAccount)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -1226,7 +1244,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-accounts-$H_WIRE */ async getBankAccountDetails(token: AccessToken, wireAccount: string) { - const url = new URL(`private/accounts/${pathSegment(wireAccount)}`, this.baseUrl); + const url = new URL( + `private/accounts/${pathSegment(wireAccount)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -1259,7 +1280,10 @@ export class TalerMerchantInstanceHttpClient { challengeIds?: string[]; } = {}, ) { - const url = new URL(`private/accounts/${pathSegment(wireAccount)}`, this.baseUrl); + const url = new URL( + `private/accounts/${pathSegment(wireAccount)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (params.challengeIds && params.challengeIds.length > 0) { @@ -1521,7 +1545,10 @@ export class TalerMerchantInstanceHttpClient { productId: string, body: TalerMerchantApi.ProductPatchDetailRequest, ) { - const url = new URL(`private/products/${pathSegment(productId)}`, this.baseUrl); + const url = new URL( + `private/products/${pathSegment(productId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -1633,7 +1660,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-products-$PRODUCT_ID */ async getProductDetails(token: AccessToken, productId: string) { - const url = new URL(`private/products/${pathSegment(productId)}`, this.baseUrl); + const url = new URL( + `private/products/${pathSegment(productId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -1664,7 +1694,10 @@ export class TalerMerchantInstanceHttpClient { productId: string, body: TalerMerchantApi.LockRequest, ) { - const url = new URL(`private/products/${pathSegment(productId)}/lock`, this.baseUrl); + const url = new URL( + `private/products/${pathSegment(productId)}/lock`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -1704,7 +1737,10 @@ export class TalerMerchantInstanceHttpClient { force?: boolean; } = {}, ) { - const url = new URL(`private/products/${pathSegment(productId)}`, this.baseUrl); + const url = new URL( + `private/products/${pathSegment(productId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2017,7 +2053,10 @@ export class TalerMerchantInstanceHttpClient { orderId: string, body: TalerMerchantApi.ForgetRequest, ) { - const url = new URL(`private/orders/${pathSegment(orderId)}/forget`, this.baseUrl); + const url = new URL( + `private/orders/${pathSegment(orderId)}/forget`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2103,7 +2142,10 @@ export class TalerMerchantInstanceHttpClient { orderId: string, body: TalerMerchantApi.RefundRequest, ) { - const url = new URL(`private/orders/${pathSegment(orderId)}/refund`, this.baseUrl); + const url = new URL( + `private/orders/${pathSegment(orderId)}/refund`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2282,7 +2324,10 @@ export class TalerMerchantInstanceHttpClient { token: AccessToken, serial_wid: number, ) { - const url = new URL(`private/incoming/${pathSegment(String(serial_wid))}`, this.baseUrl); + const url = new URL( + `private/incoming/${pathSegment(String(serial_wid))}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2309,7 +2354,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-transfers-$TID */ async deleteWireTransfer(token: AccessToken, transferId: string | number) { - const url = new URL(`private/transfers/${pathSegment(String(transferId))}`, this.baseUrl); + const url = new URL( + `private/transfers/${pathSegment(String(transferId))}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2385,7 +2433,10 @@ export class TalerMerchantInstanceHttpClient { deviceId: string, body: TalerMerchantApi.OtpDevicePatchDetails, ) { - const url = new URL(`private/otp-devices/${pathSegment(deviceId)}`, this.baseUrl); + const url = new URL( + `private/otp-devices/${pathSegment(deviceId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2450,7 +2501,10 @@ export class TalerMerchantInstanceHttpClient { deviceId: string, params: TalerMerchantApi.GetOtpDeviceRequestParams = {}, ) { - const url = new URL(`private/otp-devices/${pathSegment(deviceId)}`, this.baseUrl); + const url = new URL( + `private/otp-devices/${pathSegment(deviceId)}`, + this.baseUrl, + ); if (params.faketime) { url.searchParams.set("faketime", String(params.faketime)); @@ -2483,7 +2537,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-otp-devices-$DEVICE_ID */ async deleteOtpDevice(token: AccessToken, deviceId: string) { - const url = new URL(`private/otp-devices/${pathSegment(deviceId)}`, this.baseUrl); + const url = new URL( + `private/otp-devices/${pathSegment(deviceId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2561,7 +2618,10 @@ export class TalerMerchantInstanceHttpClient { templateId: string, body: TalerMerchantApi.TemplatePatchDetails, ) { - const url = new URL(`private/templates/${pathSegment(templateId)}`, this.baseUrl); + const url = new URL( + `private/templates/${pathSegment(templateId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2626,7 +2686,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-templates-$TEMPLATE_ID */ async getTemplateDetails(token: AccessToken, templateId: string) { - const url = new URL(`private/templates/${pathSegment(templateId)}`, this.baseUrl); + const url = new URL( + `private/templates/${pathSegment(templateId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2652,7 +2715,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-templates-$TEMPLATE_ID */ async deleteTemplate(token: AccessToken, templateId: string) { - const url = new URL(`private/templates/${pathSegment(templateId)}`, this.baseUrl); + const url = new URL( + `private/templates/${pathSegment(templateId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2763,7 +2829,10 @@ export class TalerMerchantInstanceHttpClient { webhookId: string, body: TalerMerchantApi.WebhookPatchDetails, ) { - const url = new URL(`private/webhooks/${pathSegment(webhookId)}`, this.baseUrl); + const url = new URL( + `private/webhooks/${pathSegment(webhookId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2824,7 +2893,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-webhooks-$WEBHOOK_ID */ async getWebhookDetails(token: AccessToken, webhookId: string) { - const url = new URL(`private/webhooks/${pathSegment(webhookId)}`, this.baseUrl); + const url = new URL( + `private/webhooks/${pathSegment(webhookId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2850,7 +2922,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCES]-private-webhooks-$WEBHOOK_ID */ async deleteWebhook(token: AccessToken, webhookId: string) { - const url = new URL(`private/webhooks/${pathSegment(webhookId)}`, this.baseUrl); + const url = new URL( + `private/webhooks/${pathSegment(webhookId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2930,7 +3005,10 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.Unauthorized> > { - const url = new URL(`private/tokenfamilies/${pathSegment(tokenSlug)}`, this.baseUrl); + const url = new URL( + `private/tokenfamilies/${pathSegment(tokenSlug)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -2994,7 +3072,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-tokenfamilies-$TOKEN_FAMILY_SLUG */ async getTokenFamilyDetails(token: AccessToken, tokenSlug: string) { - const url = new URL(`private/tokenfamilies/${pathSegment(tokenSlug)}`, this.baseUrl); + const url = new URL( + `private/tokenfamilies/${pathSegment(tokenSlug)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -3021,7 +3102,10 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCES]-private-tokenfamilies-$TOKEN_FAMILY_SLUG */ async deleteTokenFamily(token: AccessToken, tokenSlug: string) { - const url = new URL(`private/tokenfamilies/${pathSegment(tokenSlug)}`, this.baseUrl); + const url = new URL( + `private/tokenfamilies/${pathSegment(tokenSlug)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -3705,7 +3789,10 @@ export class TalerMerchantInstanceHttpClient { | OperationOk<TalerMerchantApi.MerchantStatisticsReportResponse> | OperationFail<HttpStatusCode.NotImplemented> > { - const url = new URL(`private/statistics-report/${pathSegment(name)}`, this.baseUrl); + const url = new URL( + `private/statistics-report/${pathSegment(name)}`, + this.baseUrl, + ); if (params.count !== undefined) { url.searchParams.set("count", String(params.count)); @@ -3754,7 +3841,10 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<HttpStatusCode.Unauthorized> | OperationFail<HttpStatusCode.NotImplemented> > { - const url = new URL(`private/statistics-report/${pathSegment(name)}`, this.baseUrl); + const url = new URL( + `private/statistics-report/${pathSegment(name)}`, + this.baseUrl, + ); if (params.count !== undefined) { url.searchParams.set("count", String(params.count)); @@ -4004,7 +4094,10 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp body: TalerMerchantApi.InstanceReconfigurationMessage, params: { challengeIds?: string[] } = {}, ) { - const url = new URL(`management/instances/${pathSegment(instanceId)}`, this.baseUrl); + const url = new URL( + `management/instances/${pathSegment(instanceId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -4072,7 +4165,10 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp * */ async getInstanceDetails(token: AccessToken, instanceId: string) { - const url = new URL(`management/instances/${pathSegment(instanceId)}`, this.baseUrl); + const url = new URL( + `management/instances/${pathSegment(instanceId)}`, + this.baseUrl, + ); const headers: Record<string, string> = {}; if (token) { @@ -4103,7 +4199,10 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp instanceId: string, params: { purge?: boolean; challengeIds?: string[] } = {}, ) { - const url = new URL(`management/instances/${pathSegment(instanceId)}`, this.baseUrl); + const url = new URL( + `management/instances/${pathSegment(instanceId)}`, + this.baseUrl, + ); if (params.purge !== undefined) { url.searchParams.set("purge", params.purge ? "YES" : "NO"); @@ -4153,7 +4252,10 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp instanceId: string, params: TalerMerchantApi.GetKycStatusRequestParams = {}, ) { - const url = new URL(`management/instances/${pathSegment(instanceId)}/kyc`, this.baseUrl); + const url = new URL( + `management/instances/${pathSegment(instanceId)}/kyc`, + this.baseUrl, + ); if (params.wireHash) { url.searchParams.set("h_wire", params.wireHash); @@ -4206,7 +4308,10 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp statSlug: string, params: TalerMerchantApi.GetStatisticsRequestParams = {}, ) { - const url = new URL(`private/statistics-counter/${pathSegment(statSlug)}`, this.baseUrl); + const url = new URL( + `private/statistics-counter/${pathSegment(statSlug)}`, + this.baseUrl, + ); if (params.by) { url.searchParams.set("by", params.by); @@ -4244,7 +4349,10 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp statSlug: string, params: TalerMerchantApi.GetStatisticsRequestParams = {}, ) { - const url = new URL(`private/statistics-amount/${pathSegment(statSlug)}`, this.baseUrl); + const url = new URL( + `private/statistics-amount/${pathSegment(statSlug)}`, + this.baseUrl, + ); if (params.by) { url.searchParams.set("by", params.by); diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -1764,7 +1764,10 @@ async function storeFirstPaySuccess( await tx.deleteToken(tokenPub); } const payInfo = purchase.payInfo; - checkDbInvariant(!!payInfo, `purchase ${purchase.orderId} without payInfo`); + checkDbInvariant( + !!payInfo, + `purchase ${purchase.orderId} without payInfo`, + ); payInfo.slateTokenSigs = tokenFinalization.slateTokenSigs; } @@ -1871,7 +1874,11 @@ export function splitPaymentOutputTokenSignatures( const slateTokenSigs: SignedTokenEnvelope[] = new Array(slateCount); const donauTokenSigs: SignedTokenEnvelope[] = new Array(donauCount); - for (let responseIndex = 0; responseIndex < tokenSigs.length; responseIndex++) { + for ( + let responseIndex = 0; + responseIndex < tokenSigs.length; + responseIndex++ + ) { const position = responsePositions[responseIndex]; if (position.kind === "slate") { slateTokenSigs[position.index] = tokenSigs[responseIndex]; @@ -3516,9 +3523,7 @@ async function processPurchasePay( // state durable. The verified records, consumed inputs and payment // success are committed together below. const outputTokens = await Promise.all( - slateList.map((slate, i) => - validateToken(wex, slate, slateTokenSigs[i]), - ), + slateList.map((slate, i) => validateToken(wex, slate, slateTokenSigs[i])), ); if (donauPlanchetList.length > 0) { @@ -4590,7 +4595,10 @@ async function processPurchaseAbortingRefund( ); } - if (abortHttpResp.case !== "ok") { + if ( + abortHttpResp.case !== "ok" && + abortHttpResp.case !== HttpStatusCode.BadGateway + ) { return throwUnexpectedRequestError( abortHttpResp.response, abortHttpResp.detail!, @@ -4604,6 +4612,9 @@ async function processPurchaseAbortingRefund( } const refunds: MerchantCoinRefundStatus[] = []; + const abortResponseTime = AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.now(), + ); if (abortResp.refunds.length != abortingCoins.length) { throw TalerError.fromDetail( @@ -4623,12 +4634,10 @@ async function processPurchaseAbortingRefund( coin_pub: payCoinSelection.coinPubs[i], refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]), rtransaction_id: 0, - execution_time: AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.addDuration( - AbsoluteTime.fromProtocolTimestamp(download.contractTerms.timestamp), - Duration.fromSpec({ seconds: 1 }), - ), - ), + // The abort API does not carry the exchange's signing time. Use the + // time at which this fresh response was received to select the exchange + // signing key, rather than the potentially much older contract time. + execution_time: abortResponseTime, }); } const storeResult = await storeRefunds( @@ -4997,10 +5006,8 @@ export function setRefundGroupEffectiveAmount( outputAmounts: AmountJson[], ): void { refundGroup.amountEffective = Amounts.stringify( - Amounts.sumOrZero( - Amounts.currencyOf(refundGroup.amountRaw), - outputAmounts, - ).amount, + Amounts.sumOrZero(Amounts.currencyOf(refundGroup.amountRaw), outputAmounts) + .amount, ); }