taler-typescript-core

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

commit b7b77db25f33bd675f3ee721376052dd1710b509
parent e540862ac5ecf5b55ae9129f1b61e7ee01894f2f
Author: Florian Dold <florian@dold.me>
Date:   Thu, 19 Mar 2026 23:53:10 +0100

wallet-core: automatically delete expired pay-merchant transactions after 24h

Diffstat:
Mpackages/taler-wallet-core/src/common.ts | 11+++++++++--
Mpackages/taler-wallet-core/src/db.ts | 6++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 1+
Mpackages/taler-wallet-core/src/shepherd.ts | 35+++++++++++++++++++++++++++++++++++
4 files changed, 51 insertions(+), 2 deletions(-)

diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -658,6 +658,7 @@ export enum PendingTaskType { PeerPushCredit = "peer-push-credit", PeerPullDebit = "peer-pull-debit", ValidateDenoms = "validate-denoms", + CleanupExpiredTransactions = "cleanup-expired-transactions", } /** @@ -679,7 +680,8 @@ export type ParsedTaskIdentifier = | { tag: PendingTaskType.Purchase; proposalId: string } | { tag: PendingTaskType.Recoup; recoupGroupId: string } | { tag: PendingTaskType.Refresh; refreshGroupId: string } - | { tag: PendingTaskType.ValidateDenoms }; + | { tag: PendingTaskType.ValidateDenoms } + | { tag: PendingTaskType.CleanupExpiredTransactions }; export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { const task = x.split(":"); @@ -716,6 +718,8 @@ export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { return { tag: type, withdrawalGroupId: rest[0] }; case PendingTaskType.ValidateDenoms: return { tag: type }; + case PendingTaskType.CleanupExpiredTransactions: + return { tag: type }; default: throw Error("invalid task identifier"); } @@ -749,6 +753,8 @@ export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr { return `${p.tag}:${p.withdrawalGroupId}` as TaskIdStr; case PendingTaskType.ValidateDenoms: return `${p.tag}:` as TaskIdStr; + case PendingTaskType.CleanupExpiredTransactions: + return `${p.tag}:` as TaskIdStr; default: assertUnreachable(p); } @@ -1094,4 +1100,4 @@ export async function getGenericRecordHandle<T>( }); }; return [rec, { rec, update }]; -} +} +\ No newline at end of file diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -1672,6 +1672,12 @@ export interface PurchaseRecord { timestampLastRefundStatus: DbPreciseTimestamp | undefined; /** + * Timestamp when the wallet noticed that the transaction expired. + * May be later than the pay deadline. + */ + timestampExpired?: DbPreciseTimestamp; + + /** * Last session signature that we submitted to /pay (if any). */ lastSessionId: string | undefined; diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -4057,6 +4057,7 @@ async function processPurchaseDialogProposed( if (r2?.purchaseStatus !== PurchaseStatus.DialogProposed) { return; } + r2.timestampExpired = timestampPreciseToDb(TalerPreciseTimestamp.now()); r2.purchaseStatus = PurchaseStatus.Expired; await h.update(r2); }); diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -53,6 +53,7 @@ import { OPERATION_STATUS_NONFINAL_FIRST, OPERATION_STATUS_NONFINAL_LAST, OperationRetryRecord, + PurchaseStatus, ReserveRecordStatus, WalletIndexedDbTransaction, timestampAbsoluteFromDb, @@ -70,6 +71,7 @@ import { updateExchangeFromUrlHandler, } from "./exchanges.js"; import { + PayMerchantTransactionContext, computePayMerchantTransactionState, computeRefundTransactionState, processPurchase, @@ -132,6 +134,7 @@ function taskGivesLiveness(taskId: string): boolean { case PendingTaskType.ExchangeAutoRefresh: case PendingTaskType.ExchangeWalletKyc: case PendingTaskType.ValidateDenoms: + case PendingTaskType.CleanupExpiredTransactions: return false; case PendingTaskType.Deposit: case PendingTaskType.PeerPullCredit: @@ -670,6 +673,8 @@ async function callOperationHandlerForTaskId( return await processExchangeKyc(wex, pending.exchangeBaseUrl); case PendingTaskType.ValidateDenoms: return await processValidateDenoms(wex); + case PendingTaskType.CleanupExpiredTransactions: + return await processCleanupExpiredTransactions(wex); default: return assertUnreachable(pending); } @@ -708,7 +713,10 @@ async function taskToRetryNotification( return makeTransactionRetryNotification(ws, tx, pendingTaskId, e); case PendingTaskType.Recoup: case PendingTaskType.ValidateDenoms: + case PendingTaskType.CleanupExpiredTransactions: return undefined; + default: + assertUnreachable(parsedTaskId); } } @@ -1136,3 +1144,30 @@ export async function getActiveTaskIds( return res; } + +export async function processCleanupExpiredTransactions( + wex: WalletExecutionContext, +): Promise<TaskRunResult> { + await wex.runLegacyWalletDbTx(async (tx) => { + const expired = await tx.purchases.indexes.byStatus.getAll( + PurchaseStatus.Expired, + ); + for (const exp of expired) { + if (!exp.timestampExpired) { + continue; + } + if ( + AbsoluteTime.isExpired( + AbsoluteTime.addDuration( + timestampAbsoluteFromDb(exp.timestampExpired), + Duration.fromSpec({ hours: 24 }), + ), + ) + ) { + const ctx = new PayMerchantTransactionContext(wex, exp.proposalId); + await ctx.deleteTransaction(); + } + } + }); + return TaskRunResult.runAgainAfter({ hours: 24 }); +}