commit 053bedf6a2460b1a75ecc680a85f6229c128991f
parent 9a5461eff67ed94c1e9beab060d38bc52e6652f6
Author: Florian Dold <dold@taler.net>
Date: Thu, 24 Sep 2026 12:46:34 +0200
wallet-core: check for refunds after the monitoring deadline
Query the merchant after an expired auto-refund window before marking
payment monitoring complete. This lets a resumed wallet discover refunds
granted while it was offline and keeps failed checks retryable.
Limit long polls to the remaining window and avoid a balance invalidation
for the finalizing-to-done transition itself.
Diffstat:
2 files changed, 212 insertions(+), 27 deletions(-)
diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts
@@ -54,6 +54,7 @@ import {
WalletRefreshGroup,
WalletSlate,
WalletToken,
+ timestampProtocolToDb,
} from "./db/records.js";
import { WalletDbTransaction } from "./db/transaction.js";
import {
@@ -89,7 +90,7 @@ import {
validateClaimResponseBindings,
partitionPayCoinSelectionForRepair,
} from "./pay-merchant.js";
-import { makeIdbRunner } from "./db/testing/runners.js";
+import { makeIdbRunner, runnerFactories } from "./db/testing/runners.js";
import type { WalletExecutionContext } from "./wallet.js";
import { nativeCrypto } from "./crypto/cryptoImplementation.js";
import { HeadersImpl } from "@gnu-taler/taler-util/http";
@@ -169,6 +170,189 @@ function claimPurchase(
};
}
+for (const makeRunner of runnerFactories) {
+ for (const scenario of [
+ {
+ name: "keeps monitoring before the deadline",
+ seconds: 300,
+ refund: false,
+ expected: PurchaseStatus.FinalizingQueryingAutoRefund,
+ },
+ {
+ name: "bounds the long poll by the remaining window",
+ seconds: 5,
+ refund: false,
+ expected: PurchaseStatus.FinalizingQueryingAutoRefund,
+ },
+ {
+ name: "checks once after resuming past the deadline",
+ seconds: -60,
+ refund: false,
+ expected: PurchaseStatus.Done,
+ },
+ {
+ name: "collects a refund offered while offline",
+ seconds: -60,
+ refund: true,
+ expected: PurchaseStatus.PendingAcceptRefund,
+ },
+ {
+ name: "finishes when the last long poll crosses the deadline",
+ seconds: 5,
+ refund: false,
+ crossDeadline: true,
+ expected: PurchaseStatus.Done,
+ },
+ {
+ name: "accepts a refund even when the last poll crosses the deadline",
+ seconds: 5,
+ refund: true,
+ crossDeadline: true,
+ expected: PurchaseStatus.PendingAcceptRefund,
+ },
+ {
+ name: "retries a failed overdue check",
+ seconds: -60,
+ refund: true,
+ failFirst: true,
+ expected: PurchaseStatus.PendingAcceptRefund,
+ },
+ {
+ name: "retries an overdue check after a merchant error",
+ seconds: -60,
+ refund: true,
+ failFirst: true,
+ httpError: true,
+ expected: PurchaseStatus.PendingAcceptRefund,
+ },
+ ]) {
+ test(`${makeRunner.name}: auto-refund ${scenario.name}`, async (t) => {
+ const runner = await makeRunner();
+ try {
+ const now = 1_800_000_000;
+ t.mock.timers.enable({ apis: ["Date"], now: now * 1000 });
+ const pub = encodeCrock(new Uint8Array(32));
+ const hash = encodeCrock(new Uint8Array(64));
+ const purchase = claimPurchase(
+ "auto-refund",
+ PurchaseStatus.FinalizingQueryingAutoRefund,
+ );
+ purchase.noncePriv = purchase.noncePub = purchase.secretSeed = pub;
+ purchase.download = {
+ contractTermsHash: hash,
+ contractTermsMerchantSig: hash,
+ currency: "TEST",
+ };
+ purchase.autoRefundDeadline = timestampProtocolToDb({
+ t_s: now + scenario.seconds,
+ });
+ purchase.payInfo = {
+ totalPayCost: "TEST:0.01",
+ payCoinSelection: { coinPubs: [], coinContributions: [] },
+ };
+ const contract = {
+ version: 0,
+ amount: "TEST:0.01",
+ max_fee: "TEST:0",
+ order_id: purchase.orderId,
+ nonce: pub,
+ merchant_base_url: purchase.merchantBaseUrl,
+ h_wire: hash,
+ wire_method: "iban",
+ summary: "snack",
+ fulfillment_message: "Enjoy",
+ auto_refund: { d_us: 300000000 },
+ pay_deadline: { t_s: now + 300 },
+ refund_deadline: { t_s: now + 600 },
+ wire_transfer_deadline: { t_s: now + 86400 },
+ timestamp: { t_s: now },
+ merchant: { name: "test" },
+ merchant_pub: pub,
+ exchanges: [],
+ };
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertContractTerms({ h: hash, contractTermsRaw: contract });
+ await tx.upsertPurchase(purchase);
+ });
+ const urls: URL[] = [];
+ const wex = {
+ ws: { networkAvailable: true },
+ runWalletDbTx: runner.runReadWriteTx.bind(runner),
+ http: {
+ fetch: async (url: string) => {
+ urls.push(new URL(url));
+ if (
+ scenario.failFirst &&
+ !scenario.httpError &&
+ urls.length === 1
+ )
+ throw Error("offline");
+ if (scenario.crossDeadline) {
+ t.mock.timers.setTime((now + 60) * 1000);
+ }
+ const httpError = scenario.httpError && urls.length === 1;
+ const body = httpError
+ ? {
+ code: TalerErrorCode.GENERIC_DB_SOFT_FAILURE,
+ hint: "temporarily unavailable",
+ }
+ : {
+ refund_pending: scenario.refund,
+ refunded: scenario.refund,
+ refund_amount: scenario.refund ? "TEST:0.01" : "TEST:0",
+ refund_taken: "TEST:0",
+ };
+ const headers = new HeadersImpl();
+ headers.set("content-type", "application/json");
+ return {
+ status: httpError ? 503 : 200,
+ requestUrl: url,
+ requestMethod: "GET",
+ headers,
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ };
+ },
+ },
+ } as unknown as WalletExecutionContext;
+ if (scenario.failFirst) {
+ await assert.rejects(
+ processPurchase(wex, purchase.proposalId),
+ (error: unknown) =>
+ scenario.httpError
+ ? error instanceof TalerError &&
+ error.errorDetail.code ===
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
+ error.errorDetail.httpStatusCode === 503
+ : error instanceof Error && error.message === "offline",
+ );
+ const stored = await runner.runReadWriteTx((tx) =>
+ tx.getPurchase(purchase.proposalId),
+ );
+ assert.strictEqual(
+ stored?.purchaseStatus,
+ PurchaseStatus.FinalizingQueryingAutoRefund,
+ );
+ }
+ await processPurchase(wex, purchase.proposalId);
+ assert.strictEqual(urls.length, scenario.failFirst ? 2 : 1);
+ const timeout = Number(urls.at(-1)!.searchParams.get("timeout_ms"));
+ assert.ok(
+ timeout <= Math.max(0, Math.min(30000, scenario.seconds * 1000)),
+ );
+ if (scenario.seconds > 0) assert.ok(timeout > 0);
+ assert.strictEqual(urls.at(-1)!.searchParams.get("refund"), "TEST:0");
+ const stored = await runner.runReadWriteTx((tx) =>
+ tx.getPurchase(purchase.proposalId),
+ );
+ assert.strictEqual(stored?.purchaseStatus, scenario.expected);
+ } finally {
+ await runner.close();
+ }
+ });
+ }
+}
+
test("proposal download accepts only valid merchant contract signatures", async () => {
const runner = await makeIdbRunner();
try {
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -5466,13 +5466,11 @@ async function processPurchaseAutoRefund(
throw Error("choice index not specified for contract v1");
}
- const noAutoRefundOrExpired =
- !purchase.autoRefundDeadline ||
- AbsoluteTime.isExpired(
- AbsoluteTime.fromProtocolTimestamp(
+ const deadline = purchase.autoRefundDeadline
+ ? AbsoluteTime.fromProtocolTimestamp(
timestampProtocolFromDb(purchase.autoRefundDeadline),
- ),
- );
+ )
+ : undefined;
const totalKnownRefund = await wex.runWalletDbTx(async (tx) => {
const refunds = await tx.getRefundGroupsByProposal(purchase.proposalId);
@@ -5490,10 +5488,7 @@ async function processPurchaseAutoRefund(
const fullyRefunded = Amounts.cmp(amountRaw, totalKnownRefund) <= 0;
- // We stop with the auto-refund state when the auto-refund period
- // is over or the product is already fully refunded.
-
- if (noAutoRefundOrExpired || fullyRefunded) {
+ const finish = async (cause: string): Promise<TaskRunResult> => {
await wex.runWalletDbTx(async (tx) => {
const [p, h] = await ctx.getRecordHandle(tx);
switch (p?.purchaseStatus) {
@@ -5503,11 +5498,19 @@ async function processPurchaseAutoRefund(
default:
return;
}
+ const balanceEffect =
+ p.purchaseStatus === PurchaseStatus.FinalizingQueryingAutoRefund
+ ? BalanceEffect.None
+ : BalanceEffect.Any;
p.purchaseStatus = PurchaseStatus.Done;
p.refundAmountAwaiting = undefined;
- await h.update(p, "auto-refund-done");
+ await h.update(p, cause, balanceEffect);
});
return TaskRunResult.progress();
+ };
+
+ if (!deadline || fullyRefunded) {
+ return finish("auto-refund-done");
}
const merchantClient = walletMerchantClient(
@@ -5519,7 +5522,13 @@ async function processPurchaseAutoRefund(
{
contractTermHash: download.contractTermsHash,
refund: Amounts.stringify(totalKnownRefund),
- timeout: MERCHANT_ORDER_STATUS_LONGPOLL_MS,
+ // After resuming past the deadline, still ask the merchant once. A
+ // refund may have been granted while the wallet was offline. Bound
+ // earlier long polls by the deadline so completion is not delayed.
+ timeout: Math.min(
+ MERCHANT_ORDER_STATUS_LONGPOLL_MS,
+ Duration.toMilliseconds(AbsoluteTime.remaining(deadline)),
+ ),
},
);
@@ -5527,20 +5536,7 @@ async function processPurchaseAutoRefund(
// The merchant deleted the order, so no auto-refund can arrive for it
// anymore. That leaves the payment where the auto-refund deadline would
// have left it.
- await wex.runWalletDbTx(async (tx) => {
- const [p, h] = await ctx.getRecordHandle(tx);
- switch (p?.purchaseStatus) {
- case PurchaseStatus.PendingQueryingAutoRefund:
- case PurchaseStatus.FinalizingQueryingAutoRefund:
- break;
- default:
- return;
- }
- p.purchaseStatus = PurchaseStatus.Done;
- p.refundAmountAwaiting = undefined;
- await h.update(p, "auto-refund-order-gone");
- });
- return TaskRunResult.progress();
+ return finish("auto-refund-order-gone");
}
// FIXME: Check other status codes!
@@ -5550,6 +5546,11 @@ async function processPurchaseAutoRefund(
const orderStatus = resp.body;
if (!orderStatus.refund_pending) {
+ // A successful response after the deadline is required before stopping;
+ // network/protocol errors above must leave monitoring retryable.
+ if (AbsoluteTime.isExpired(deadline)) {
+ return finish("auto-refund-done");
+ }
return TaskRunResult.longpollReturnedPending();
}