taler-typescript-core

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

commit 70bfb888b2406851a88f211698a038d0410a221e
parent 97442be74c9f108d61401384691f5485f0a85457
Author: Florian Dold <dold@taler.net>
Date:   Sat, 29 Aug 2026 00:00:06 +0200

payments: retain failed claims to avoice races in the UI

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-payment-claim.ts | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/taler-wallet-core/src/db/records.ts | 5+++++
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/requests.ts | 2++
Mpackages/wallet-webui/src/routes/App.tsx | 11+++++++----
Mpackages/wallet-webui/src/routes/transaction-model.ts | 16+++++++++-------
Mpackages/wallet-webui/test/transaction-model.test.ts | 29+++++++++++++++++++++++++++++
8 files changed, 519 insertions(+), 59 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-payment-claim.ts b/packages/taler-harness/src/integrationtests/test-payment-claim.ts @@ -133,17 +133,18 @@ export async function runPaymentClaimTest(t: GlobalTestState) { }); /** - * Wait for a claim to be given up on because the order is already claimed. + * Wait for a claim to fail because the order is already claimed. * * Must be set up before the scan that triggers it: the waiter only sees * notifications that arrive after the condition is registered. */ - function claimDiscardedCond( + function claimFailedCond( walletClient: WalletClient, ): Promise<TransactionStateTransitionNotification> { return walletClient.waitForNotificationCond((n) => n.type === NotificationType.TransactionStateTransition && - n.newTxState.major === TransactionMajorState.Deleted && + n.newTxState.major === TransactionMajorState.Failed && + n.newTxState.minor === TransactionMinorState.ClaimProposal && n.errorInfo?.code === TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED ? n : false, @@ -151,20 +152,33 @@ export async function runPaymentClaimTest(t: GlobalTestState) { } await t.runSpanAsync("claim-conflict-before-payment", async () => { - const claimOneDiscarded = claimDiscardedCond(w2.walletClient); + const claimOneFailed = claimFailedCond(w2.walletClient); const claimOne = await w2.walletClient.call( WalletApiOperation.PreparePayForUriV2, { talerPayUri }, ); - const claimOneNotif = await claimOneDiscarded; + const claimOneNotif = await claimOneFailed; t.assertDeepEqual(claimOneNotif.transactionId, claimOne.transactionId); t.assertDeepEqual( claimOneNotif.oldTxState.minor, TransactionMinorState.ClaimProposal, ); - t.assertTrue( - !(await hasTransaction(w2.walletClient, claimOne.transactionId)), + const failedClaimOne = await w2.walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: claimOne.transactionId }, + ); + t.assertDeepEqual( + failedClaimOne.txState.major, + TransactionMajorState.Failed, + ); + t.assertDeepEqual( + failedClaimOne.txState.minor, + TransactionMinorState.ClaimProposal, + ); + t.assertDeepEqual( + failedClaimOne.failReason?.code, + TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, ); const txs = await w2.walletClient.call( @@ -175,21 +189,38 @@ export async function runPaymentClaimTest(t: GlobalTestState) { !txs.transactions.some((x) => x.transactionId === claimOne.transactionId), ); + const retryFailed = claimFailedCond(w2.walletClient); + const retry = await w2.walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { talerPayUri }, + ); + t.assertDeepEqual(retry.transactionId, claimOne.transactionId); + const retryNotif = await retryFailed; + t.assertDeepEqual(retryNotif.transactionId, claimOne.transactionId); + const failedRetry = await w2.walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: claimOne.transactionId }, + ); + t.assertDeepEqual( + failedRetry.failReason?.code, + TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + ); + await restartWallet(); t.assertTrue( !(await hasTransaction(w2.walletClient, claimOne.transactionId)), ); - const claimAgainDiscarded = claimDiscardedCond(w2.walletClient); + const claimAgainFailed = claimFailedCond(w2.walletClient); const claimAgain = await w2.walletClient.call( WalletApiOperation.PreparePayForUriV2, { talerPayUri }, ); t.assertTrue(claimAgain.transactionId !== claimOne.transactionId); - const claimAgainNotif = await claimAgainDiscarded; + const claimAgainNotif = await claimAgainFailed; t.assertDeepEqual(claimAgainNotif.transactionId, claimAgain.transactionId); t.assertTrue( - !(await hasTransaction(w2.walletClient, claimAgain.transactionId)), + await hasTransaction(w2.walletClient, claimAgain.transactionId), ); const holderTx = await walletClient.call( @@ -217,7 +248,7 @@ export async function runPaymentClaimTest(t: GlobalTestState) { await w2.walletClient.call(WalletApiOperation.ClearDb, {}); - const claimTwoDiscarded = claimDiscardedCond(w2.walletClient); + const claimTwoFailed = claimFailedCond(w2.walletClient); const claimTwo = await w2.walletClient.call( WalletApiOperation.PreparePayForUriV2, { @@ -225,14 +256,23 @@ export async function runPaymentClaimTest(t: GlobalTestState) { }, ); - const claimTwoNotif = await claimTwoDiscarded; + const claimTwoNotif = await claimTwoFailed; t.assertDeepEqual(claimTwoNotif.transactionId, claimTwo.transactionId); t.assertDeepEqual( claimTwoNotif.errorInfo?.code, TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, ); - // Neither failed claim left anything behind. + const failedClaimTwo = await w2.walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: claimTwo.transactionId }, + ); + t.assertDeepEqual( + failedClaimTwo.failReason?.code, + TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + ); + + // Contract-less failed claims are inspectable by ID but hidden from history. const txn = await w2.walletClient.call(WalletApiOperation.GetTransactionsV2, { includeAll: true, }); diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -2003,6 +2003,11 @@ export enum PurchaseStatus { /** * Downloading or processing the proposal has failed permanently. + * + * Contract-less claim failures are kept only briefly so that a concurrent + * prepare request can return an inspectable transaction and error. They are + * hidden from transaction history, may be retried by scanning the payment URI + * again, and are removed on expiry or wallet restart. */ FailedClaim = 0x0501_0003, diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -24,6 +24,9 @@ import { TransactionIdStr, TalerPreciseTimestamp, TalerErrorCode, + TimerAPI, + TimerGroup, + TimerHandle, TransactionMajorState, TransactionMinorState, } from "@gnu-taler/taler-util"; @@ -41,8 +44,11 @@ import { import { WalletDbTransaction } from "./db/transaction.js"; import { applyFirstPaySuccessState, + cleanupFailedClaimsOnStartup, computePayMerchantTransactionActions, computePayMerchantTransactionState, + failProposalClaimPermanently, + FAILED_CLAIM_RETENTION_MS, preparePayForUriV2, getCoinsToSpendForMerchantRepair, getAlreadyPaidRefundRequests, @@ -65,6 +71,81 @@ import { import { makeIdbRunner } from "./db/testing/runners.js"; import type { WalletExecutionContext } from "./wallet.js"; +interface ScheduledTimer { + due: number; + callback: () => void; + active: boolean; +} + +class ManualTimer implements TimerAPI { + nowMs = 0; + private readonly scheduled: ScheduledTimer[] = []; + + after(delayMs: number, callback: () => void): TimerHandle { + const scheduled = { + due: this.nowMs + delayMs, + callback, + active: true, + }; + this.scheduled.push(scheduled); + return { + clear: () => { + scheduled.active = false; + }, + unref: () => {}, + }; + } + + every(): TimerHandle { + throw Error("not used by failed payment claim cleanup"); + } + + advance(ms: number): void { + this.nowMs += ms; + while (true) { + const next = this.scheduled + .filter((x) => x.active && x.due <= this.nowMs) + .sort((a, b) => a.due - b.due)[0]; + if (!next) return; + next.active = false; + next.callback(); + } + } +} + +function claimPurchase( + proposalId: string, + status = PurchaseStatus.PendingDownloadingProposal, +): WalletPurchase { + return { + download: undefined, + noncePriv: "nonce-private", + noncePub: "nonce-public", + claimToken: "claim-token", + timestamp: 1 as WalletPurchase["timestamp"], + merchantBaseUrl: "https://merchant.example/", + orderId: "order", + proposalId, + purchaseStatus: status, + repurchaseProposalId: undefined, + downloadSessionId: "session", + autoRefundDeadline: undefined, + lastSessionId: undefined, + merchantPaySig: undefined, + secretSeed: "secret-seed", + payInfo: undefined, + refundAmountAwaiting: undefined, + timestampAccept: undefined, + timestampFirstSuccessfulPay: undefined, + timestampLastRefundStatus: undefined, + pendingRemovedCoinPubs: undefined, + posConfirmation: undefined, + shared: false, + createdFromShared: false, + talerUri: "taler://pay/merchant.example/order/session", + }; +} + test("payment-share flags use the shared transaction path", () => { assert.strictEqual( isSharedPurchase({ shared: true, createdFromShared: false }), @@ -209,6 +290,122 @@ test("preparing a reused shared payment only wakes its transaction task", async assert.strictEqual(taskResets, 1); }); +test("failed claims stay inspectable and a repeat scan renews their cleanup", async () => { + const runner = await makeIdbRunner(); + const timer = new ManualTimer(); + let taskResets = 0; + const wex = { + ws: { timerGroup: new TimerGroup(timer) }, + async runWalletDbTx<T>( + f: (tx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return await runner.runReadWriteTx(f); + }, + taskScheduler: { + async resetTask(): Promise<void> { + taskResets++; + }, + }, + } as unknown as WalletExecutionContext; + const proposalId = "retained-failed-claim"; + const original = claimPurchase(proposalId); + try { + await runner.runReadWriteTx((tx) => tx.upsertPurchase(original)); + + await failProposalClaimPermanently(wex, proposalId, { + code: TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + hint: "the order was already claimed", + }); + + const failed = await runner.runReadWriteTx((tx) => + tx.getPurchase(proposalId), + ); + assert.strictEqual(failed?.purchaseStatus, PurchaseStatus.FailedClaim); + assert.strictEqual( + failed?.failReason?.code, + TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + ); + assert.notStrictEqual(failed?.failReason?.when, undefined); + assert.strictEqual( + await runner.runReadWriteTx((tx) => + tx.getTransactionMeta(`txn:payment:${proposalId}`), + ), + undefined, + "contract-less failures must stay out of transaction history", + ); + + timer.advance(FAILED_CLAIM_RETENTION_MS / 2); + const retried = await preparePayForUriV2( + wex, + "taler://pay/merchant.example/order/new-session", + ); + assert.strictEqual(retried.transactionId, `txn:payment:${proposalId}`); + assert.strictEqual(taskResets, 1); + const pending = await runner.runReadWriteTx((tx) => + tx.getPurchase(proposalId), + ); + assert.strictEqual( + pending?.purchaseStatus, + PurchaseStatus.PendingDownloadingProposal, + ); + assert.strictEqual(pending?.failReason, undefined); + assert.strictEqual(pending?.noncePriv, original.noncePriv); + assert.strictEqual(pending?.claimToken, original.claimToken); + assert.strictEqual(pending?.downloadSessionId, "new-session"); + + await failProposalClaimPermanently(wex, proposalId, { + code: TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + hint: "the retry was also rejected", + }); + timer.advance(FAILED_CLAIM_RETENTION_MS / 2); + assert.notStrictEqual( + await runner.runReadWriteTx((tx) => tx.getPurchase(proposalId)), + undefined, + "the cancelled first timer must not delete the retried claim", + ); + + timer.advance(FAILED_CLAIM_RETENTION_MS / 2 - 1); + assert.notStrictEqual( + await runner.runReadWriteTx((tx) => tx.getPurchase(proposalId)), + undefined, + ); + timer.advance(1); + assert.strictEqual( + await runner.runReadWriteTx((tx) => tx.getPurchase(proposalId)), + undefined, + ); + } finally { + await runner.close(); + } +}); + +test("wallet startup removes failed claims retained by the previous process", async () => { + const runner = await makeIdbRunner(); + const proposalId = "failed-claim-from-previous-process"; + const purchase = claimPurchase(proposalId, PurchaseStatus.FailedClaim); + purchase.failReason = { + code: TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + hint: "the order was already claimed", + }; + const wex = { + async runWalletDbTx<T>( + f: (tx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return await runner.runReadWriteTx(f); + }, + } as unknown as WalletExecutionContext; + try { + await runner.runReadWriteTx((tx) => tx.upsertPurchase(purchase)); + await cleanupFailedClaimsOnStartup(wex); + assert.strictEqual( + await runner.runReadWriteTx((tx) => tx.getPurchase(proposalId)), + undefined, + ); + } finally { + await runner.close(); + } +}); + function makeSelectedCoin( coinPub: string, contribution: AmountString, diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -105,6 +105,7 @@ import { TransactionMinorState, TransactionState, TransactionType, + TimerHandle, UnclaimPaymentResult, URL, WalletNotification, @@ -204,6 +205,80 @@ import { const logger = new Logger("pay-merchant.ts"); /** + * How long a permanent, contract-less payment claim failure remains available. + * + * Preparing a payment creates the purchase record and wakes its background + * task before the prepare request returns. The task can therefore receive a + * permanent merchant error while that request is still in flight. Deleting + * the purchase immediately would let prepare return a transaction ID that + * already fails GetTransactionById, losing the only durable copy of the + * merchant error before the caller can present it. + * + * FailedClaim records close that race: they retain the transaction ID and + * failReason for exact lookup, but have no downloaded contract and therefore + * remain absent from transaction history. They are deliberately temporary, + * since a contract-less claim is not useful as long-term wallet history. The + * latest failure starts a fresh retention interval; wallet restart removes the + * record even sooner. + */ +export const FAILED_CLAIM_RETENTION_MS = 10 * 60 * 1000; + +const failedClaimCleanupTimers = new WeakMap< + WalletExecutionContext["ws"], + Map<string, TimerHandle> +>(); + +function cancelFailedClaimCleanup( + wex: WalletExecutionContext, + proposalId: string, +): void { + const timers = failedClaimCleanupTimers.get(wex.ws); + timers?.get(proposalId)?.clear(); + timers?.delete(proposalId); +} + +function scheduleFailedClaimCleanup( + wex: WalletExecutionContext, + proposalId: string, +): void { + // A retry can fail again before the old deadline. Replacing the handle is + // essential: an older timer must not shorten the interval measured from the + // latest failure. + cancelFailedClaimCleanup(wex, proposalId); + let timers = failedClaimCleanupTimers.get(wex.ws); + if (!timers) { + timers = new Map(); + failedClaimCleanupTimers.set(wex.ws, timers); + } + const handle = wex.ws.timerGroup.after(FAILED_CLAIM_RETENTION_MS, () => { + if (timers?.get(proposalId) === handle) { + timers.delete(proposalId); + } + const ctx = new PayMerchantTransactionContext(wex, proposalId); + wex + .runWalletDbTx(async (tx) => { + const purchase = await tx.getPurchase(proposalId); + // Retry and cleanup are allowed to race. Delete only if this is still + // the same kind of terminal, contract-less record; a retry that already + // moved it back to PendingDownloadingProposal wins. + if (purchase?.purchaseStatus !== PurchaseStatus.FailedClaim) { + return; + } + await ctx.deleteTransactionInTx(tx, { + causeHint: "claim-failure-expired", + }); + }) + .catch((e) => { + logger.warn( + `failed to clean up payment claim ${ctx.transactionId}: ${safeStringifyException(e)}`, + ); + }); + }); + handle.unref(); + timers.set(proposalId, handle); +} + +/** * Context for a merchant pay transaction. * * Used in every task and request handlers. @@ -238,7 +313,10 @@ export class PayMerchantTransactionContext implements TransactionContext { return; } if (!purchaseRec.download) { - // Not ready yet. + // Pending claims and briefly retained FailedClaim records have no + // contract from which to construct useful history metadata. In + // particular, keep FailedClaim addressable by its exact transaction ID + // without showing an UNKNOWN:0 entry in normal transaction history. await tx.deleteTransactionMeta(this.transactionId); return; } @@ -430,6 +508,7 @@ export class PayMerchantTransactionContext implements TransactionContext { await this.wex.runWalletDbTx(async (tx) => { return this.deleteTransactionInTx(tx); }); + cancelFailedClaimCleanup(this.wex, this.proposalId); } async deleteTransactionInTx( @@ -992,14 +1071,39 @@ export async function collectLeftoverClaims( } /** - * Claim a proposal left over from a previous run of the wallet, discarding - * the transaction if the attempt does not succeed. + * Remove hidden claim failures retained by the previous wallet process. + * + * Retention is intentionally bounded by the earlier of ten minutes and a + * wallet restart. We do not persist cleanup timers or turn these incomplete + * claims into durable history; a new scan after restart can safely create a + * fresh claim attempt. + */ +export async function cleanupFailedClaimsOnStartup( + wex: WalletExecutionContext, +): Promise<void> { + await wex.runWalletDbTx(async (tx) => { + const failedClaims = await tx.getPurchasesByStatus( + PurchaseStatus.FailedClaim, + ); + for (const purchase of failedClaims) { + const ctx = new PayMerchantTransactionContext(wex, purchase.proposalId); + await ctx.deleteTransactionInTx(tx, { + causeHint: "claim-failure-restart-cleanup", + }); + } + }); +} + +/** + * Claim a proposal left over from a previous run of the wallet. * * The user never got to see the proposal for such a payment, so keeping it * around only produces background requests that they have no way to explain. - * That is why even a transient error discards it here: the payment URI can - * simply be scanned again. Claiming is idempotent for our nonce, so this one - * retry also recovers a claim whose response we failed to store earlier. + * A transient attempt that got nowhere is discarded so that it does not keep + * producing unexplained background requests. A permanent result is retained + * briefly, as it can explain the result of a concurrently returning prepare + * request. Claiming is idempotent for our nonce, so this one retry also + * recovers a claim whose response we failed to store earlier. */ async function retryLeftoverClaim( wex: WalletExecutionContext, @@ -1016,15 +1120,14 @@ async function retryLeftoverClaim( await discardClaim(wex, proposalId); return TaskRunResult.finished(); } - // A claim that fails permanently discards the purchase itself, but one that - // merely did not get anywhere leaves it behind for us to clean up. The - // failed state only occurs for records written before claims were discarded. + // A permanent failure is retained briefly so that a prepare request can + // return a transaction whose failure is still inspectable. A transient + // leftover claim that did not get anywhere is still discarded. const purchase = await wex.runWalletDbTx(async (tx) => { return tx.getPurchase(proposalId); }); switch (purchase?.purchaseStatus) { case PurchaseStatus.PendingDownloadingProposal: - case PurchaseStatus.FailedClaim: logger.info(`discarding leftover claim ${ctx.transactionId}`); await discardClaim(wex, proposalId); return TaskRunResult.finished(); @@ -1033,32 +1136,56 @@ async function retryLeftoverClaim( } /** - * Discard a purchase whose claim did not produce a contract. - * - * Such a purchase has no contract terms, so there is nothing to show the user - * and nothing for them to act on. Keeping it would leave a transaction that - * the list cannot render and that every later scan of the same order would - * reuse instead of claiming again. - * - * When a reason is given, it travels with the deletion notification, since - * that transition is the only thing a client still sees of the transaction. + * Discard a transient leftover claim that did not reach a permanent result. */ async function discardClaim( wex: WalletExecutionContext, proposalId: string, - err?: TalerErrorDetail, ): Promise<void> { const ctx = new PayMerchantTransactionContext(wex, proposalId); await wex.runWalletDbTx(async (tx) => { - await ctx.deleteTransactionInTx(tx, { - causeHint: err ? "claim-failed" : undefined, - errorInfo: err ? { code: err.code as number, hint: err.hint } : undefined, - }); + await ctx.deleteTransactionInTx(tx); }); + cancelFailedClaimCleanup(wex, proposalId); wex.taskScheduler.stopShepherdTask(ctx.taskId); } /** + * Retain a permanent claim failure long enough for the prepare caller to + * inspect it, instead of deleting the transaction out from under that caller. + * A later failure replaces failReason and renews the retention deadline. + */ +export async function failProposalClaimPermanently( + wex: WalletExecutionContext, + proposalId: string, + err: TalerErrorDetail, +): Promise<void> { + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const storedError: TalerErrorDetail = { + ...err, + when: err.when ?? AbsoluteTime.now(), + }; + const changed = await wex.runWalletDbTx(async (tx) => { + const [purchase, h] = await ctx.getRecordHandle(tx); + if ( + purchase?.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal + ) { + return false; + } + purchase.purchaseStatus = PurchaseStatus.FailedClaim; + purchase.failReason = storedError; + await h.update(purchase, "claim-failed", BalanceEffect.None, { + code: storedError.code as number, + hint: storedError.hint, + }); + return true; + }); + if (changed) { + scheduleFailedClaimCleanup(wex, proposalId); + } +} + +/** * Long-poll timeout (in milliseconds) for merchant order-status requests. */ const MERCHANT_ORDER_STATUS_LONGPOLL_MS = 30_000; @@ -1372,7 +1499,7 @@ async function processDownloadProposal( case "ok": break; case HttpStatusCode.Conflict: - await discardClaim( + await failProposalClaimPermanently( wex, proposalId, makeTalerErrorDetail( @@ -1386,7 +1513,7 @@ async function processDownloadProposal( ); return TaskRunResult.finished(); case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND: { - await discardClaim( + await failProposalClaimPermanently( wex, proposalId, makeTalerErrorDetail( @@ -1425,7 +1552,7 @@ async function processDownloadProposal( {}, "validation for well-formedness failed", ); - await discardClaim(wex, proposalId, err); + await failProposalClaimPermanently(wex, proposalId, err); return TaskRunResult.finished(); } @@ -1445,7 +1572,7 @@ async function processDownloadProposal( {}, `schema validation failed: ${e}`, ); - await discardClaim(wex, proposalId, err); + await failProposalClaimPermanently(wex, proposalId, err); return TaskRunResult.finished(); } @@ -1460,7 +1587,7 @@ async function processDownloadProposal( {}, "validation for well-formedness failed (validateParsed)", ); - await discardClaim(wex, proposalId, err); + await failProposalClaimPermanently(wex, proposalId, err); return TaskRunResult.finished(); } @@ -1475,7 +1602,7 @@ async function processDownloadProposal( {}, bindingError, ); - await discardClaim(wex, proposalId, err); + await failProposalClaimPermanently(wex, proposalId, err); return TaskRunResult.finished(); } @@ -1494,7 +1621,7 @@ async function processDownloadProposal( }, "merchant's signature on contract terms is invalid", ); - await discardClaim(wex, proposalId, err); + await failProposalClaimPermanently(wex, proposalId, err); return TaskRunResult.finished(); } @@ -1762,6 +1889,49 @@ export function isSharedPurchase( return purchase.shared || purchase.createdFromShared === true; } +/** + * Treat another scan of the same payment URI as an explicit retry gesture. + * + * Reusing the proposal preserves the nonce and claim token. This matters + * because merchant claiming is idempotent for those credentials: a retry can + * recover a response the wallet failed to store, whereas replacing the + * transaction would issue a competing claim and lose continuity with the + * transaction ID already shown to the caller. A fresh transaction is only + * created when expiry or restart cleanup has actually removed this record. + * + * FailedClaim intentionally does not advertise the generic RetryTransaction + * action. Re-scanning supplies the current URI/session context and is the + * user-visible action that asks the wallet to try claiming again. + */ +async function retryFailedClaim( + wex: WalletExecutionContext, + proposalId: string, + sessionId: string | undefined, + talerUri: string | undefined, +): Promise<"retried" | "changed" | "gone"> { + const ctx = new PayMerchantTransactionContext(wex, proposalId); + const result = await wex.runWalletDbTx(async (tx) => { + const [purchase, h] = await ctx.getRecordHandle(tx); + if (!purchase) { + return "gone" as const; + } + if (purchase.purchaseStatus !== PurchaseStatus.FailedClaim) { + return "changed" as const; + } + purchase.purchaseStatus = PurchaseStatus.PendingDownloadingProposal; + purchase.failReason = undefined; + purchase.downloadSessionId = sessionId; + purchase.talerUri = talerUri; + await h.update(purchase, "retry-claim", BalanceEffect.None); + return "retried" as const; + }); + if (result === "retried") { + cancelFailedClaimCleanup(wex, proposalId); + await wex.taskScheduler.resetTask(ctx.taskId); + } + return result; +} + export async function createOrReusePurchase( wex: WalletExecutionContext, merchantBaseUrl: string, @@ -1811,10 +1981,22 @@ export async function createOrReusePurchase( oldProposal = oldProposals[0]; } if (oldProposal?.purchaseStatus === PurchaseStatus.FailedClaim) { - // Written by an older version of the wallet, which kept a record for a - // claim that never produced a contract. Reusing it would hand back a - // transaction the user cannot see instead of claiming again. - await discardClaim(wex, oldProposal.proposalId); + const retryResult = await retryFailedClaim( + wex, + oldProposal.proposalId, + sessionId, + talerUri, + ); + if (retryResult !== "gone") { + const oldCtx = new PayMerchantTransactionContext( + wex, + oldProposal.proposalId, + ); + return { + proposalId: oldProposal.proposalId, + transactionId: oldCtx.transactionId, + }; + } oldProposal = undefined; } // If we have already claimed this proposal with the same diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -344,6 +344,7 @@ import { sendTalerUriMessage, } from "./mailbox.js"; import { + cleanupFailedClaimsOnStartup, collectLeftoverClaims, confirmPay, getChoicesForPayment, @@ -1009,6 +1010,7 @@ async function handleSetWalletRunConfig( await migrateCoinAvailability(wex); if (!wex.ws.initCalled) { + await cleanupFailedClaimsOnStartup(wex); await collectLeftoverClaims(wex); await deleteEphemeralExchanges(wex); } diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -3835,13 +3835,15 @@ function PaymentRoute() { ); const transaction = query.data; const terms = transaction.contractTerms; - if (!terms) + if (!terms) { + const claimError = transactionErrorPresentation(transaction); return ( <PaymentScreen state="error" - error={localError( - i18n.str`The payment contract details are unavailable.`, - )} + error={ + claimError ?? + localError(i18n.str`The payment contract details are unavailable.`) + } onSelectChoice={() => {}} onConfirm={() => {}} onWithdraw={() => navigate("/withdraw")} @@ -3849,6 +3851,7 @@ function PaymentRoute() { onOpenFulfillment={() => {}} /> ); + } if ( transaction.txState.major === TransactionMajorState.Dialog && transaction.txState.minor === TransactionMinorState.WaitingForOtherWallet && diff --git a/packages/wallet-webui/src/routes/transaction-model.ts b/packages/wallet-webui/src/routes/transaction-model.ts @@ -372,13 +372,15 @@ function fieldsFor(transaction: Transaction): TransactionDetailField[] { const fields: TransactionDetailField[] = []; switch (transaction.type) { case TransactionType.Payment: - fields.push( - { label: i18n.str`Purchase price`, value: transaction.amountRaw }, - { - label: i18n.str`Total wallet debit`, - value: transaction.amountEffective, - }, - ); + if (transaction.info) { + fields.push( + { label: i18n.str`Purchase price`, value: transaction.amountRaw }, + { + label: i18n.str`Total wallet debit`, + value: transaction.amountEffective, + }, + ); + } if (transaction.info?.orderId) fields.push({ label: i18n.str`Order ID`, diff --git a/packages/wallet-webui/test/transaction-model.test.ts b/packages/wallet-webui/test/transaction-model.test.ts @@ -265,6 +265,35 @@ test("transaction errors preserve every wallet-core terminal reason", () => { assert.equal(transactionErrorPresentation(payment()), undefined); }); +test("contract-less payment failures omit unknown price fields", () => { + const failReason = { + code: TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + hint: "the order was already claimed", + }; + const view = transactionDetailView( + payment({ + txState: { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.ClaimProposal, + working: false, + }, + info: undefined, + amountRaw: "UNKNOWN:0", + amountEffective: "UNKNOWN:0", + failReason, + }), + ); + + assert.strictEqual(view.error?.detail, failReason); + assert( + !view.fields.some( + (field) => + field.label === "Purchase price" || + field.label === "Total wallet debit", + ), + ); +}); + test("payment details include the Penpot order summary when contract terms are requested", () => { const view = transactionDetailView( payment({