commit c327f11ada394ca92d2e2f705d884447d055fafe
parent c698c74a021edf75cf51499a6c1c80e42b9f7383
Author: Florian Dold <dold@taler.net>
Date: Tue, 1 Sep 2026 22:53:28 +0200
wallet-core: let wallets reclaim handed-off payments
Diffstat:
5 files changed, 130 insertions(+), 2 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-payment-unclaim.ts b/packages/taler-harness/src/integrationtests/test-payment-unclaim.ts
@@ -76,6 +76,19 @@ export async function runPaymentUnclaimTest(t: GlobalTestState) {
},
});
+ // Reclaiming is also safe when cancelling an unclaim request left its
+ // outcome unknown to the caller.
+ await firstWallet.call(WalletApiOperation.ReclaimPayment, {
+ transactionId: firstPrepared.transactionId,
+ });
+ await firstWallet.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: firstPrepared.transactionId,
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ },
+ });
+
const released = await firstWallet.call(WalletApiOperation.UnclaimPayment, {
transactionId: firstPrepared.transactionId,
});
@@ -99,9 +112,25 @@ export async function runPaymentUnclaimTest(t: GlobalTestState) {
firstWaiting.txState.minor === TransactionMinorState.WaitingForOtherWallet,
);
+ await firstWallet.call(WalletApiOperation.ReclaimPayment, {
+ transactionId: firstPrepared.transactionId,
+ });
+ await firstWallet.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: firstPrepared.transactionId,
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ },
+ });
+ const releasedAgain = await firstWallet.call(
+ WalletApiOperation.UnclaimPayment,
+ { transactionId: firstPrepared.transactionId },
+ );
+ t.assertTrue(releasedAgain.talerPayUri === released.talerPayUri);
+
const secondPrepared = await secondWallet.call(
WalletApiOperation.PreparePayForUriV2,
- { talerPayUri: released.talerPayUri },
+ { talerPayUri: releasedAgain.talerPayUri },
);
await secondWallet.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: secondPrepared.transactionId,
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -2847,16 +2847,28 @@ export type GetChoicesForPaymentResult = {
export interface UnclaimPaymentRequest {
transactionId: TransactionIdStr;
+
+ /** Enables progress correlation and cancellation through cancelProgressToken. */
+ progressToken?: string;
}
export const codecForUnclaimPaymentRequest = (): Codec<UnclaimPaymentRequest> =>
buildCodecForObject<UnclaimPaymentRequest>()
.property("transactionId", codecForTransactionIdStr())
+ .property("progressToken", codecOptional(codecForString()))
.build("UnclaimPaymentRequest");
export interface UnclaimPaymentResult {
talerPayUri: TalerUriString;
}
+export interface ReclaimPaymentRequest {
+ transactionId: TransactionIdStr;
+}
+export const codecForReclaimPaymentRequest = (): Codec<ReclaimPaymentRequest> =>
+ buildCodecForObject<ReclaimPaymentRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("ReclaimPaymentRequest");
+
export interface CheckPayTemplateRequest {
talerPayTemplateUri: string;
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -4929,6 +4929,55 @@ export async function unclaimPayment(
}
/**
+ * Claim an order again for this wallet after it was released for handoff.
+ *
+ * DialogProposed is accepted as well as DialogUnclaimed because cancellation
+ * can race with the merchant processing /unclaim. Claiming again with the
+ * same nonce is idempotent and reconciles that unknown outcome.
+ */
+export async function reclaimPayment(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ await wex.runWalletDbTx(async (tx) => {
+ const [purchase, handle] = await ctx.getRecordHandle(tx);
+ if (!purchase) {
+ throw makeTransactionNotFoundError(ctx.transactionId);
+ }
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.DialogProposed:
+ case PurchaseStatus.DialogUnclaimed:
+ purchase.purchaseStatus = PurchaseStatus.PendingDownloadingProposal;
+ purchase.failReason = undefined;
+ await handle.update(purchase, "reclaim", BalanceEffect.None);
+ tx.scheduleOnCommit(() => {
+ wex.taskScheduler.resetTask(ctx.taskId).catch((e) => {
+ logger.error(safeStringifyException(e));
+ });
+ });
+ return;
+ case PurchaseStatus.PendingDownloadingProposal:
+ tx.scheduleOnCommit(() => {
+ wex.taskScheduler.resetTask(ctx.taskId).catch((e) => {
+ logger.error(safeStringifyException(e));
+ });
+ });
+ return;
+ default:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED,
+ {
+ txState: computePayMerchantTransactionState(purchase),
+ debugStateNum: purchase.purchaseStatus,
+ },
+ "this payment cannot be claimed again in its current state",
+ );
+ }
+ });
+}
+
+/**
* Status of an order from the point of view of a wallet that is not the one
* that (possibly) paid it. "gone" means that the merchant does not have the
* order anymore, and thus nobody can pay it.
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -113,6 +113,7 @@ import {
PurgeExchangeLegacyKeysRequest,
RemoveGlobalCurrencyAuditorRequest,
RemoveGlobalCurrencyExchangeRequest,
+ ReclaimPaymentRequest,
Result,
RunFixupRequest,
ScopeType,
@@ -238,6 +239,7 @@ import {
codecForPurgeExchangeLegacyKeysRequest,
codecForRemoveGlobalCurrencyAuditorRequest,
codecForRemoveGlobalCurrencyExchangeRequest,
+ codecForReclaimPaymentRequest,
codecForResumeTransaction,
codecForRetryProgressTokenNowRequest,
codecForRetryTransactionRequest,
@@ -352,6 +354,7 @@ import {
getChoicesForPayment,
preparePayForTemplateV2,
preparePayForUriV2,
+ reclaimPayment,
startQueryRefund,
startRefundQueryForUri,
unclaimPayment,
@@ -715,7 +718,24 @@ async function handleUnclaimPayment(
if (parsed?.tag !== TransactionType.Payment) {
throw makeInvalidTransactionIdError(req.transactionId);
}
- return unclaimPayment(wex, parsed.proposalId);
+ return runWithMaybeProgressContext(
+ wex,
+ "unclaimPayment",
+ req.progressToken,
+ () => unclaimPayment(wex, parsed.proposalId),
+ );
+}
+
+async function handleReclaimPayment(
+ wex: WalletExecutionContext,
+ req: ReclaimPaymentRequest,
+): Promise<EmptyObject> {
+ const parsed = parseTransactionIdentifier(req.transactionId);
+ if (parsed?.tag !== TransactionType.Payment) {
+ throw makeInvalidTransactionIdError(req.transactionId);
+ }
+ await reclaimPayment(wex, parsed.proposalId);
+ return {};
}
/**
@@ -2719,6 +2739,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForUnclaimPaymentRequest(),
handler: handleUnclaimPayment,
},
+ [WalletApiOperation.ReclaimPayment]: {
+ codec: codecForReclaimPaymentRequest(),
+ handler: handleReclaimPayment,
+ },
[WalletApiOperation.PrepareWithdrawExchange]: {
codec: codecForPrepareWithdrawExchangeRequest(),
handler: handlePrepareWithdrawExchange,
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -177,6 +177,7 @@ import {
PurgeExchangeLegacyKeysRequest,
RemoveGlobalCurrencyAuditorRequest,
RemoveGlobalCurrencyExchangeRequest,
+ ReclaimPaymentRequest,
Result,
RetryProgressTokenNowRequest,
RetryTransactionRequest,
@@ -314,6 +315,7 @@ export enum WalletApiOperation {
PreparePayForPaivana = "preparePayForPaivana",
GetPaivanaCookie = "getPaivanaCookie",
UnclaimPayment = "unclaimPayment",
+ ReclaimPayment = "reclaimPayment",
CheckPayForTemplate = "checkPayForTemplate",
StartRefundQueryForUri = "startRefundQueryForUri",
StartRefundQuery = "startRefundQuery",
@@ -926,6 +928,12 @@ export type UnclaimPaymentOp = {
response: UnclaimPaymentResult;
};
+export type ReclaimPaymentOp = {
+ op: WalletApiOperation.ReclaimPayment;
+ request: ReclaimPaymentRequest;
+ response: EmptyObject;
+};
+
export type CheckPayForTemplateOp = {
op: WalletApiOperation.CheckPayForTemplate;
request: CheckPayTemplateRequest;
@@ -1845,8 +1853,13 @@ export const walletApiExpectedErrors = {
[WalletApiOperation.UnclaimPayment]: [
TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND,
TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED,
+ TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED,
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
],
+ [WalletApiOperation.ReclaimPayment]: [
+ TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND,
+ TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED,
+ ],
[WalletApiOperation.StartRefundQueryForUri]: [
TalerErrorCode.WALLET_TALER_URI_MALFORMED,
TalerErrorCode.WALLET_PURCHASE_NOT_FOUND,
@@ -2057,6 +2070,7 @@ export type WalletOperations = {
[WalletApiOperation.PreparePayForPaivana]: PreparePayForPaivanaOp;
[WalletApiOperation.GetPaivanaCookie]: GetPaivanaCookieOp;
[WalletApiOperation.UnclaimPayment]: UnclaimPaymentOp;
+ [WalletApiOperation.ReclaimPayment]: ReclaimPaymentOp;
[WalletApiOperation.CheckPayForTemplate]: CheckPayForTemplateOp;
[WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp;
[WalletApiOperation.GetChoicesForPayment]: GetChoicesForPaymentOp;