taler-typescript-core

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

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:
Mpackages/taler-harness/src/integrationtests/test-payment-unclaim.ts | 31++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/types-taler-wallet.ts | 12++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 26+++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 14++++++++++++++
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;