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:
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({