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:
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 });
+}