taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-merchant.ts | 53+++++++++++++++++++++++++++--------------------------
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(); }