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:
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,
);
}