commit 7ea4ae8015e945c66e82f57d3834196fd2d215b7 parent f2ef2d571bc75485941199fd33293ee53288b3b7 Author: Florian Dold <dold@taler.net> Date: Sun, 20 Sep 2026 00:27:50 +0200 wallet-core: preserve transaction abort and failure reasons Store permanent bank, merchant, peer payment and coin recovery failures on transaction records so history retains their underlying causes. Record explicit user abort and abandon reasons and preserve per-coin refund, refresh and recoup details across database conversion. Leave historical records without reasons unchanged. Diffstat:
16 files changed, 1411 insertions(+), 35 deletions(-)
diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -305,6 +305,9 @@ export type KycAuthTransferOptionRaw = TransferOptionRaw & { * in the same transaction that inserts the WalletRecoupGroup. */ export interface WalletRecoupGroup { + /** Durable reason for a permanent failure. */ + failReason?: TalerErrorDetail; + /** * Unique identifier for the recoup group record. */ @@ -1092,6 +1095,9 @@ export interface WalletPurchase { * Metadata about a group of refunds with the merchant. */ export interface WalletRefundGroup { + /** Durable reason for a permanent failure. */ + failReason?: TalerErrorDetail; + transactionAmounts?: WalletTransactionAmounts; status: RefundGroupStatus; @@ -1119,6 +1125,9 @@ export interface WalletRefundGroup { * Refund for a single coin in a payment with a merchant. */ export interface WalletRefundItem { + /** Durable reason for a permanent failure. */ + failReason?: TalerErrorDetail; + /** * Auto-increment DB record ID. */ diff --git a/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts b/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts @@ -734,3 +734,57 @@ test("coin recovery stores are created when upgrading from master", async () => cleanup(); } }); + +test("failure-reason migration leaves historical reasons unset across reopen", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb( + db, + schemaMigrations.filter((m) => m.version < 24), + ); + await ( + await db.prepare(`INSERT INTO recoup_groups + (recoup_group_id, exchange_base_url, operation_status, timestamp_started, + coin_pubs, recoup_finished_per_coin, schedule_refresh_coins) + VALUES ('old', 'https://exchange.example/', 0, 1, '[]', '[]', '[]')`) + ).run({}); + await db.close(); + db = await openRaw(path); + await initSqliteWalletDb(db); + assert.deepStrictEqual( + await queryAll( + db, + "SELECT fail_reason FROM recoup_groups WHERE recoup_group_id = 'old'", + ), + [{ fail_reason: null }], + ); + for (const table of ["recoup_groups", "refund_groups", "refund_items"]) { + assert.ok( + (await queryAll(db, `PRAGMA table_info(${table})`)).some( + (column) => column.name === "fail_reason", + ), + ); + } + await ( + await db.prepare( + `UPDATE recoup_groups SET fail_reason = '{"code":7072}' WHERE recoup_group_id = 'old'`, + ) + ).run({}); + await db.close(); + db = await openRaw(path); + await initSqliteWalletDb(db); + assert.equal( + ( + await queryAll( + db, + "SELECT fail_reason FROM recoup_groups WHERE recoup_group_id = 'old'", + ) + )[0].fail_reason, + '{"code":7072}', + ); + await db.close(); + } finally { + cleanup(); + } +}); diff --git a/packages/taler-wallet-core/src/db/sqlite/schema.ts b/packages/taler-wallet-core/src/db/sqlite/schema.ts @@ -85,7 +85,7 @@ * * Bump this when adding a migration to {@link schemaMigrations}. */ -export const SQLITE_SCHEMA_VERSION = 23; +export const SQLITE_SCHEMA_VERSION = 24; /** * Tables of the IndexedDB emulation, children before parents. @@ -1510,6 +1510,15 @@ export const schemaMigrations: SchemaMigration[] = [ )`, ], }, + { + version: 24, + name: "transaction-failure-reasons", + statements: [ + "ALTER TABLE recoup_groups ADD COLUMN fail_reason TEXT", + "ALTER TABLE refund_groups ADD COLUMN fail_reason TEXT", + "ALTER TABLE refund_items ADD COLUMN fail_reason TEXT", + ], + }, ]; /** Native tables that contain wallet records (not schema bookkeeping). */ diff --git a/packages/taler-wallet-core/src/db/sqlite/transaction.ts b/packages/taler-wallet-core/src/db/sqlite/transaction.ts @@ -998,8 +998,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { await this.run( `INSERT INTO refund_groups (transaction_amounts, refund_group_id, proposal_id, status, timestamp_created, - amount_raw, amount_effective, refresh_group_id - ) VALUES ($transactionAmounts, $id, $proposal_id, $status, $ts, $raw, $eff, $refresh_group_id) + amount_raw, amount_effective, refresh_group_id, fail_reason + ) VALUES ($transactionAmounts, $id, $proposal_id, $status, $ts, $raw, $eff, $refresh_group_id, $fail_reason) ON CONFLICT(refund_group_id) DO UPDATE SET transaction_amounts = excluded.transaction_amounts, proposal_id = excluded.proposal_id, @@ -1007,12 +1007,14 @@ export class SqliteWalletTransaction implements WalletDbTransaction { timestamp_created = excluded.timestamp_created, amount_raw = excluded.amount_raw, amount_effective = excluded.amount_effective, - refresh_group_id = excluded.refresh_group_id`, + refresh_group_id = excluded.refresh_group_id, + fail_reason = excluded.fail_reason`, { transactionAmounts: rec.transactionAmounts === undefined ? null : jsonToDb(rec.transactionAmounts), + fail_reason: rec.failReason == null ? null : jsonToDb(rec.failReason), id: rec.refundGroupId, proposal_id: rec.proposalId, status: rec.status, @@ -1045,6 +1047,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { // does not exist on WalletRefundGroup survive review. private rowToRefundGroup(row: ResultRow): WalletRefundGroup { return { + ...(row.fail_reason == null + ? {} + : { failReason: dbToJson(row.fail_reason) }), ...(row.transaction_amounts == null ? {} : { transactionAmounts: dbToJson(row.transaction_amounts) }), @@ -1080,8 +1085,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { await this.run( `INSERT INTO refund_items ( id, refund_group_id, status, proposal_id, execution_time, - obtained_time, refund_amount, coin_pub, rtxid - ) VALUES ($id, $gid, $status, $pid, $exec, $obt, $amt, $coin, $rtxid) + obtained_time, refund_amount, coin_pub, rtxid, fail_reason + ) VALUES ($id, $gid, $status, $pid, $exec, $obt, $amt, $coin, $rtxid, $fail_reason) ON CONFLICT(id) DO UPDATE SET refund_group_id = excluded.refund_group_id, status = excluded.status, @@ -1090,7 +1095,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { obtained_time = excluded.obtained_time, refund_amount = excluded.refund_amount, coin_pub = excluded.coin_pub, - rtxid = excluded.rtxid`, + rtxid = excluded.rtxid, + fail_reason = excluded.fail_reason`, this.refundItemParams(rec, rec.id), ); return rec.id; @@ -1098,8 +1104,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { const res = await this.run( `INSERT INTO refund_items ( refund_group_id, status, proposal_id, execution_time, - obtained_time, refund_amount, coin_pub, rtxid - ) VALUES ($gid, $status, $pid, $exec, $obt, $amt, $coin, $rtxid)`, + obtained_time, refund_amount, coin_pub, rtxid, fail_reason + ) VALUES ($gid, $status, $pid, $exec, $obt, $amt, $coin, $rtxid, $fail_reason)`, this.refundItemParams(rec, undefined), ); return Number(res.lastInsertRowid); @@ -1110,6 +1116,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { id: number | undefined, ): Record<string, any> { const p: Record<string, any> = { + fail_reason: rec.failReason == null ? null : jsonToDb(rec.failReason), gid: rec.refundGroupId, status: rec.status, pid: rec.proposalId ?? null, @@ -1142,6 +1149,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToRefundItem(row: ResultRow): WalletRefundItem { return { + ...(row.fail_reason == null + ? {} + : { failReason: dbToJson(row.fail_reason) }), id: num(row.id), refundGroupId: str(row.refund_group_id), status: num(row.status), @@ -5495,6 +5505,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToRecoupGroup(row: ResultRow): WalletRecoupGroup { return { + ...(row.fail_reason == null + ? {} + : { failReason: dbToJson(row.fail_reason) }), recoupGroupId: str(row.recoup_group_id), exchangeBaseUrl: str(row.exchange_base_url), operationStatus: num(row.operation_status), @@ -5538,8 +5551,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { recoup_group_id, exchange_base_url, operation_status, timestamp_started, timestamp_finished, coin_pubs, recoup_finished_per_coin, failed_coin_pubs, successful_coin_pubs, - schedule_refresh_coins - ) VALUES ($id, $url, $status, $started, $finished, $pubs, $fin, $failed, $success, $sched) + schedule_refresh_coins, fail_reason + ) VALUES ($id, $url, $status, $started, $finished, $pubs, $fin, $failed, $success, $sched, $fail_reason) ON CONFLICT(recoup_group_id) DO UPDATE SET exchange_base_url = excluded.exchange_base_url, operation_status = excluded.operation_status, @@ -5549,8 +5562,10 @@ export class SqliteWalletTransaction implements WalletDbTransaction { recoup_finished_per_coin = excluded.recoup_finished_per_coin, failed_coin_pubs = excluded.failed_coin_pubs, successful_coin_pubs = excluded.successful_coin_pubs, - schedule_refresh_coins = excluded.schedule_refresh_coins`, + schedule_refresh_coins = excluded.schedule_refresh_coins, + fail_reason = excluded.fail_reason`, { + fail_reason: rec.failReason == null ? null : jsonToDb(rec.failReason), id: rec.recoupGroupId, url: rec.exchangeBaseUrl, status: rec.operationStatus, diff --git a/packages/taler-wallet-core/src/db/testing/conformance-cases.ts b/packages/taler-wallet-core/src/db/testing/conformance-cases.ts @@ -35,6 +35,7 @@ import { DenomKeyType, ExchangeEntrySource, TalerPreciseTimestamp, + TalerErrorCode, TransactionIdStr, TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; @@ -786,6 +787,64 @@ function makeRefundItem( } export const conformanceCases: ConformanceCase[] = [ + { + name: "permanent recoup and refund reasons survive storage and replacement", + async run(t, runner) { + const reason = { + code: TalerErrorCode.WALLET_RECOUP_GROUP_FAILED, + hint: "permanent rejection", + errorsPerCoin: { + coin: { + code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN, + hint: "unknown coin", + }, + }, + }; + const recoup = makeRecoupGroup( + "reason-recoup", + "https://reason-exchange/", + ); + recoup.failReason = reason; + const group = makeRefundGroup("reason-refund", "reason-purchase"); + group.failReason = reason; + const item = makeRefundItem(group.refundGroupId, "reason-coin", 7); + item.failReason = reason; + await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, group.proposalId); + await tx.upsertRecoupGroup(recoup); + await tx.upsertRefundGroup(group); + item.id = await tx.upsertRefundItem(item); + }); + await runner.runReadWriteTx(async (tx) => { + t.deepEqual( + (await tx.getRecoupGroup(recoup.recoupGroupId))?.failReason, + reason, + ); + t.deepEqual( + (await tx.getRefundGroup(group.refundGroupId))?.failReason, + reason, + ); + t.deepEqual( + (await tx.getRefundItemByCoinAndRtxid(item.coinPub, item.rtxid)) + ?.failReason, + reason, + ); + delete item.failReason; + await tx.upsertRefundItem(item); + }); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getRefundItemByCoinAndRtxid(item.coinPub, item.rtxid), + ) + )?.failReason, + undefined, + ); + // Leave a reason on every record kind for the conversion corpus. + item.failReason = reason; + await runner.runReadWriteTx((tx) => tx.upsertRefundItem(item)); + }, + }, // ---------------------------------------------------------------- basics { diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -576,7 +576,14 @@ export class DepositTransactionContext implements TransactionContext { case DepositOperationStatus.PendingDeposit: case DepositOperationStatus.SuspendedDeposit: { dg.operationStatus = DepositOperationStatus.Aborting; - dg.abortReason = reason; + dg.abortReason = + reason ?? + dg.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); await tx.upsertDepositGroup(dg); await this.updateTransactionMeta(tx); applyNotifyTransition(tx.notify, transactionId, { @@ -738,7 +745,14 @@ export class DepositTransactionContext implements TransactionContext { assertUnreachable(dg.operationStatus); } dg.operationStatus = newState; - dg.failReason = reason; + dg.failReason = + reason ?? + dg.failReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, + {}, + "transaction abandoned by the user", + ); await tx.upsertDepositGroup(dg); await this.updateTransactionMeta(tx); applyNotifyTransition(tx.notify, transactionId, { diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -693,8 +693,18 @@ export class PayMerchantTransactionContext implements TransactionContext { if (nextStatus === undefined) { return; } - if (nextStatus === PurchaseStatus.AbortingWithRefund) { - purchase.abortReason = reason; + if ( + nextStatus === PurchaseStatus.AbortingWithRefund || + nextStatus === PurchaseStatus.AbortedProposalRefused + ) { + purchase.abortReason = + reason ?? + purchase.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); } purchase.purchaseStatus = nextStatus; await h.update(purchase, "abort"); @@ -731,7 +741,14 @@ export class PayMerchantTransactionContext implements TransactionContext { switch (purchase.purchaseStatus) { case PurchaseStatus.AbortingWithRefund: purchase.purchaseStatus = PurchaseStatus.FailedAbort; - purchase.failReason = reason; + purchase.failReason = + reason ?? + purchase.failReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, + {}, + "transaction abandoned by the user", + ); break; default: return; @@ -971,6 +988,7 @@ export class RefundTransactionContext implements TransactionContext { }), txState, stId: refundRecord.status, + failReason: refundRecord.failReason, txActions: [TransactionAction.Delete], paymentInfo, }; @@ -1781,6 +1799,11 @@ async function processDownloadProposal( if (repurchase) { logger.info("repurchase detected"); p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected; + p.failReason = makeErrorDetail( + TalerErrorCode.WALLET_ORDER_ALREADY_PAID, + { orderId: p.orderId, fulfillmentUrl }, + "this purchase was already paid", + ); p.repurchaseProposalId = repurchase.proposalId; await startPayReplay(wex, tx, repurchase.proposalId, p.downloadSessionId); } else if (isSharedPurchase(p)) { @@ -5261,6 +5284,8 @@ async function processPurchaseDialogShared( return; } p.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; + p.abortReason = + resp.case === HttpStatusCode.NotFound ? resp.detail : undefined; await h.update(p, "shared-order-gone"); }); return TaskRunResult.progress(); @@ -5287,6 +5312,11 @@ async function processPurchaseDialogShared( switch (p?.purchaseStatus) { case PurchaseStatus.DialogShared: p.purchaseStatus = PurchaseStatus.FailedPaidByOther; + p.failReason = makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_COMPLETED_BY_OTHER_WALLET, + {}, + "order paid by another wallet", + ); break; default: return; @@ -5372,6 +5402,8 @@ async function processPurchaseDialogUnclaimed( return; } rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted; + rec.abortReason = + response.case === HttpStatusCode.NotFound ? response.detail : undefined; await handle.update(rec, "unclaimed-order-gone"); }); return TaskRunResult.progress(); @@ -5406,6 +5438,11 @@ async function processPurchaseDialogUnclaimed( return; } rec.purchaseStatus = PurchaseStatus.AbortedClaimedByOther; + rec.abortReason = makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_COMPLETED_BY_OTHER_WALLET, + {}, + "order claimed by another wallet", + ); await handle.update(rec, "unclaimed-order-claimed-by-other"); }); return TaskRunResult.progress(); @@ -6210,6 +6247,62 @@ function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus { } } +/** Retain the exchange reply even when it has no structured Taler error. */ +export function getPermanentRefundReason( + rf: MerchantCoinRefundStatus, +): TalerErrorDetail | undefined { + if (rf.type !== "failure" || isTransientRefundStatus(rf.exchange_status)) + return undefined; + const reply = rf.exchange_reply; + const detail = + typeof reply === "object" && reply !== null && !Array.isArray(reply) + ? reply + : {}; + return { + ...detail, + code: + rf.exchange_code ?? + (typeof detail.code === "number" + ? detail.code + : TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR), + hint: + typeof detail.hint === "string" + ? detail.hint + : "exchange permanently rejected the refund", + when: AbsoluteTime.now(), + httpStatusCode: rf.exchange_status, + exchangeReply: reply, + }; +} + +export function getRefundGroupFailure( + items: WalletRefundItem[], +): TalerErrorDetail | undefined { + const errors = items + .filter( + (item) => + item.status === RefundItemStatus.Failed && item.failReason != null, + ) + .map((item) => ({ + coinPub: item.coinPub, + rtransactionId: item.rtxid, + error: item.failReason!, + })) + .sort((a, b) => + a.coinPub < b.coinPub + ? -1 + : a.coinPub > b.coinPub + ? 1 + : a.rtransactionId - b.rtransactionId, + ); + if (errors.length === 0) return undefined; + return makeErrorDetail( + TalerErrorCode.WALLET_REFUND_GROUP_FAILED, + { errors }, + "one or more refunds were permanently rejected", + ); +} + export function isTransientRefundStatus(status: number): boolean { return ( status === HttpStatusCode.RequestTimeout || @@ -6255,7 +6348,7 @@ export function setRefundGroupEffectiveAmount( /** * Store refunds, possibly creating a new refund group. */ -async function storeRefunds( +export async function storeRefunds( wex: WalletExecutionContext, purchase: WalletPurchase, refunds: MerchantCoinRefundStatus[], @@ -6347,6 +6440,7 @@ async function storeRefunds( } if (rf.type === "success") { oldItem.status = RefundItemStatus.Done; + delete oldItem.failReason; oldItem.refundAmount = rf.refund_amount; oldItem.executionTime = timestampProtocolToDb(rf.execution_time); oldItem.obtainedTime = timestampPreciseToDb(now); @@ -6354,8 +6448,10 @@ async function storeRefunds( } else { if (isTransientRefundStatus(rf.exchange_status)) { oldItem.status = RefundItemStatus.Pending; + delete oldItem.failReason; } else { oldItem.status = RefundItemStatus.Failed; + oldItem.failReason = getPermanentRefundReason(rf); } } await tx.upsertRefundItem(oldItem); @@ -6382,6 +6478,7 @@ async function storeRefunds( refundGroupId: newGroup.refundGroupId, rtxid: rf.rtransaction_id, status, + failReason: getPermanentRefundReason(rf), }; newGroupRefunds.push(newItem); if (status === RefundItemStatus.Done) { @@ -6487,6 +6584,7 @@ async function storeRefunds( numFailed++; } } + refundGroup.failReason = getRefundGroupFailure(items); numPendingItemsTotal += numPending; const oldTxState: TransactionState = computeRefundTransactionState(refundGroup); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -36,6 +36,7 @@ import { ScopeType, TalerError, TalerErrorCode, + makeErrorDetail, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolTimestamp, @@ -490,7 +491,14 @@ export class PeerPullCreditTransactionContext implements TransactionContext { case PeerPullPaymentCreditStatus.AbortingDeletePurse: case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse: rec.status = PeerPullPaymentCreditStatus.Failed; - rec.failReason = reason; + rec.failReason = + reason ?? + rec.failReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, + {}, + "transaction abandoned by the user", + ); break; default: assertUnreachable(rec.status); @@ -568,19 +576,40 @@ export class PeerPullCreditTransactionContext implements TransactionContext { case PeerPullPaymentCreditStatus.PendingCreatePurse: case PeerPullPaymentCreditStatus.PendingMergeKycRequired: rec.status = PeerPullPaymentCreditStatus.AbortingDeletePurse; - rec.abortReason = reason; + rec.abortReason = + reason ?? + rec.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); break; case PeerPullPaymentCreditStatus.PendingWithdrawing: throw Error("can't abort anymore"); case PeerPullPaymentCreditStatus.PendingReady: - rec.abortReason = reason; + rec.abortReason = + reason ?? + rec.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); rec.status = PeerPullPaymentCreditStatus.AbortingDeletePurse; break; case PeerPullPaymentCreditStatus.SuspendedCreatePurse: case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired: case PeerPullPaymentCreditStatus.SuspendedReady: rec.status = PeerPullPaymentCreditStatus.AbortingDeletePurse; - rec.abortReason = reason; + rec.abortReason = + reason ?? + rec.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); break; case PeerPullPaymentCreditStatus.Done: case PeerPullPaymentCreditStatus.SuspendedWithdrawing: diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -771,7 +771,14 @@ export class PeerPullDebitTransactionContext implements TransactionContext { case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: // FIXME: Should we also abort the corresponding refresh session?! rec.status = PeerPullDebitRecordStatus.Failed; - rec.failReason = reason; + rec.failReason = + reason ?? + rec.failReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, + {}, + "transaction abandoned by the user", + ); break; default: return; @@ -799,14 +806,28 @@ export class PeerPullDebitTransactionContext implements TransactionContext { // Can happen for DBs that still have a prospective // coin selection. pi.status = PeerPullDebitRecordStatus.Aborted; - pi.abortReason = reason; + pi.abortReason = + reason ?? + pi.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); } else { // A deposit request may have reached the exchange even if the wallet // never received its response. Reconcile authenticated coin history // before creating any refresh request. pi.status = PeerPullDebitRecordStatus.AbortingReconcile; pi.cleanupFinalStatus = PeerPullDebitRecordStatus.Aborted; - pi.abortReason = reason; + pi.abortReason = + reason ?? + pi.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); } await h.update(pi, "abort"); }); @@ -1073,6 +1094,11 @@ async function processPeerPullDebitDialogProposed( switch (rec?.status) { case PeerPullDebitRecordStatus.DialogProposed: rec.status = PeerPullDebitRecordStatus.Aborted; + rec.abortReason = makeErrorDetail( + TalerErrorCode.WALLET_PEER_PULL_DEBIT_ALREADY_PAID, + {}, + "invoice paid by another wallet", + ); break; default: return; @@ -1512,6 +1538,19 @@ async function processPeerPullDebitAbortingRefresh( return; } } + if ( + refreshGroup == null || + refreshGroup.operationStatus === RefreshOperationStatus.Failed + ) { + rec.failReason = makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_RECOVERY_FAILED, + { + transactionId: `txn:refresh:${abortRefreshGroupId}`, + innerError: refreshGroup?.failReason, + }, + "invoice payment recovery refresh failed or disappeared", + ); + } await h.update(rec, "aborting-refresh-failed"); }); return terminal ? TaskRunResult.finished() : TaskRunResult.backoff(); diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -375,7 +375,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { this.wex.taskScheduler.stopShepherdTask(this.taskId); } - async userAbortTransaction(): Promise<void> { + async userAbortTransaction(reason?: TalerErrorDetail): Promise<void> { const shouldReconcile = await this.wex.runWalletDbTx(async (tx) => { const [rec, h] = await this.getRecordHandle(tx); if (!rec) { @@ -410,6 +410,14 @@ export class PeerPushCreditTransactionContext implements TransactionContext { default: assertUnreachable(rec.status); } + rec.abortReason = + reason ?? + rec.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); await h.update(rec, "abort"); return rec.status === PeerPushCreditStatus.AbortingMerge; }); @@ -491,7 +499,14 @@ export class PeerPushCreditTransactionContext implements TransactionContext { case PeerPushCreditStatus.PendingWithdrawing: case PeerPushCreditStatus.SuspendedWithdrawing: rec.status = PeerPushCreditStatus.Failed; - rec.failReason = reason; + rec.failReason = + reason ?? + rec.failReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, + {}, + "transaction abandoned by the user", + ); break; case PeerPushCreditStatus.Done: case PeerPushCreditStatus.Aborted: @@ -1426,7 +1441,11 @@ async function processPendingMerge( switch (rec.status) { case PeerPushCreditStatus.PendingMergeKycRequired: case PeerPushCreditStatus.PendingMerge: { - // FIXME: reason / minor state "completed by other"? + rec.failReason = makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_COMPLETED_BY_OTHER_WALLET, + {}, + "payment merged into another wallet reserve", + ); rec.status = PeerPushCreditStatus.Failed; break; } @@ -1523,6 +1542,11 @@ async function processPendingWithdrawing( const wg = await tx.getWithdrawalGroup(wgId); if (!wg) { ppi.status = PeerPushCreditStatus.Failed; + ppi.failReason = makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_DEPENDENCY_FAILED, + { transactionId: `txn:withdrawal:${wgId}` }, + "payment withdrawal is missing", + ); await h.update(ppi, "withdrawal-missing"); return TaskRunResult.finished(); } @@ -1544,6 +1568,14 @@ async function processPendingWithdrawing( ppi.failReason = wg.failReason; } else { ppi.status = PeerPushCreditStatus.Failed; + ppi.failReason = makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_DEPENDENCY_FAILED, + { + transactionId: `txn:withdrawal:${wgId}`, + innerError: wg.failReason ?? wg.abortReason, + }, + "payment withdrawal failed", + ); } await h.update( ppi, @@ -1656,6 +1688,11 @@ async function processPeerPushDebitDialogProposed( switch (rec.status) { case PeerPushCreditStatus.DialogProposed: { rec.status = PeerPushCreditStatus.Aborted; + rec.abortReason = makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_COMPLETED_BY_OTHER_WALLET, + {}, + "payment received by another wallet", + ); break; } default: diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -38,6 +38,7 @@ import { SelectedProspectiveCoin, TalerError, TalerErrorCode, + makeErrorDetail, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolDuration, @@ -304,13 +305,27 @@ export class PeerPushDebitTransactionContext implements TransactionContext { switch (rec.status) { case PeerPushDebitStatus.PendingReady: case PeerPushDebitStatus.SuspendedReady: - rec.abortReason = reason; + rec.abortReason = + reason ?? + rec.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); rec.status = PeerPushDebitStatus.AbortingDeletePurse; break; case PeerPushDebitStatus.SuspendedCreatePurse: case PeerPushDebitStatus.PendingCreatePurse: // Network request might already be in-flight! - rec.abortReason = reason; + rec.abortReason = + reason ?? + rec.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); rec.status = PeerPushDebitStatus.AbortingDeletePurse; break; case PeerPushDebitStatus.SuspendedAbortingDeletePurse: @@ -424,7 +439,14 @@ export class PeerPushDebitTransactionContext implements TransactionContext { case PeerPushDebitStatus.ExpiredDeletePurse: case PeerPushDebitStatus.SuspendedExpiredDeletePurse: rec.status = PeerPushDebitStatus.Failed; - rec.failReason = reason; + rec.failReason = + reason ?? + rec.failReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, + {}, + "transaction abandoned by the user", + ); break; case PeerPushDebitStatus.Done: case PeerPushDebitStatus.Aborted: diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts @@ -24,6 +24,8 @@ /** * Imports. */ +import { getHttpResponseErrorDetails } from "@gnu-taler/taler-util/http"; +import { addPermanentCoinFailure } from "./transaction-reasons.js"; import { AgeRestriction, AmountJson, @@ -35,6 +37,9 @@ import { Logger, RefreshReason, TalerPreciseTimestamp, + TalerErrorDetail, + TalerErrorCode, + makeErrorDetail, Transaction, TransactionAction, TransactionIdStr, @@ -140,17 +145,24 @@ async function recoupRewardCoin( }); } -async function markRecoupCoinPermanentlyFailed( +export async function markRecoupCoinPermanentlyFailed( wex: WalletExecutionContext, recoupGroupId: string, coinIdx: number, coinPub: string, + reason: TalerErrorDetail, ): Promise<void> { await wex.runWalletDbTx(async (tx) => { const group = await tx.getRecoupGroup(recoupGroupId); if (group?.operationStatus !== RecoupOperationStatus.Pending) { return; } + group.failReason = addPermanentCoinFailure( + group.failReason, + TalerErrorCode.WALLET_RECOUP_GROUP_FAILED, + coinPub, + reason, + ); group.failedCoinPubs ??= []; if (!group.failedCoinPubs.includes(coinPub)) { group.failedCoinPubs.push(coinPub); @@ -229,6 +241,12 @@ async function recoupRefreshCoin( recoupGroupId, coinIdx, coin.coinPub, + recoupResp.detail ?? + makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(recoupResp.response), + "exchange permanently rejected recoup", + ), ); return; } @@ -385,6 +403,12 @@ export async function recoupWithdrawCoin( recoupGroupId, coinIdx, coin.coinPub, + recoupResp.detail ?? + makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(recoupResp.response), + "exchange permanently rejected recoup", + ), ); return; } @@ -680,6 +704,7 @@ export class RecoupTransactionContext implements TransactionContext { timestamp: timestampPreciseFromDb(rec.timestampStarted), txState: computeRecoupTransactionState(rec), stId: rec.operationStatus, + failReason: rec.failReason, txActions: computeRecoupTransactionActions(rec), scopes: await getScopeForAllExchanges(tx, [rec.exchangeBaseUrl]), amountRaw: summary.amount, diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -22,6 +22,7 @@ /** * Imports. */ +import { addPermanentCoinFailure } from "./transaction-reasons.js"; import { AutoRefreshOutputPlan, evaluateAutoRefresh } from "./autoRefresh.js"; import { AbsoluteTime, @@ -1677,6 +1678,12 @@ async function handleRefreshMeltConflict( await h.update(rg, "melt-conflict-abort-pay-spent"); } else { rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; + rg.failReason = addPermanentCoinFailure( + rg.failReason, + TalerErrorCode.WALLET_REFRESH_GROUP_FAILED, + rg.oldCoinPubs[coinIndex], + errDetails, + ); refreshSession.lastError = errDetails; await destroyRefreshSession(tx, rg, refreshSession); await tx.upsertRefreshSession(refreshSession); @@ -1927,6 +1934,12 @@ async function handleRefreshMeltNotFound( return; } rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; + rg.failReason = addPermanentCoinFailure( + rg.failReason, + TalerErrorCode.WALLET_REFRESH_GROUP_FAILED, + rg.oldCoinPubs[coinIndex], + errDetails, + ); const refreshSession = await tx.getRefreshSession( ctx.refreshGroupId, coinIndex, @@ -2192,7 +2205,7 @@ async function refreshReveal( logger.trace("refresh finished (end of reveal)"); } -async function handleRefreshRevealError( +export async function handleRefreshRevealError( ctx: RefreshTransactionContext, coinIndex: number, errDetails: TalerErrorDetail, @@ -2209,6 +2222,12 @@ async function handleRefreshRevealError( return; } rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed; + rg.failReason = addPermanentCoinFailure( + rg.failReason, + TalerErrorCode.WALLET_REFRESH_GROUP_FAILED, + rg.oldCoinPubs[coinIndex], + errDetails, + ); const refreshSession = await tx.getRefreshSession( ctx.refreshGroupId, coinIndex, diff --git a/packages/taler-wallet-core/src/transaction-reasons.test.ts b/packages/taler-wallet-core/src/transaction-reasons.test.ts @@ -0,0 +1,813 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + Amounts, + CancellationToken, + ContractTermsUtil, + encodeCrock, + HttpStatusCode, + MerchantCoinRefundFailureStatus, + TalerErrorCode, + TalerErrorDetail, + RefreshReason, + TransactionMajorState, + TransactionType, + TimerGroup, + SetTimeoutTimerAPI, +} from "@gnu-taler/taler-util"; +import { dummyHttpResponse, HeadersImpl } from "@gnu-taler/taler-util/http"; +import { + handleRefreshRevealError, + RefreshTransactionContext, + processRefreshGroup, +} from "./refresh.js"; +import { + markRecoupCoinPermanentlyFailed, + processRecoupGroup, +} from "./recoup.js"; +import { DbRetryInfo } from "./common.js"; +import { + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + WalletExchangeEntry, + WalletExchangeDetails, + WalletRefreshGroup, + WalletRecoupGroup, + RefreshOperationStatus, + RefreshCoinStatus, + RecoupOperationStatus, + RefundReason, + PurchaseStatus, + RefundItemStatus, + WalletPurchase, + WalletRefundItem, + WalletWithdrawalGroup, + WithdrawalGroupStatus, + WithdrawalRecordType, + WalletPeerPullDebit, + PeerPullDebitRecordStatus, + WalletPeerPushCredit, + PeerPushCreditStatus, +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; +import { runnerFactories } from "./db/testing/runners.js"; +import { + PayMerchantTransactionContext, + processPurchase, + getPermanentRefundReason, + getRefundGroupFailure, + storeRefunds, +} from "./pay-merchant.js"; +import { + PeerPushCreditTransactionContext, + processPeerPushCredit, +} from "./pay-peer-push-credit.js"; +import { processPeerPullDebit } from "./pay-peer-pull-debit.js"; +import { + WithdrawTransactionContext, + processWithdrawalGroup, +} from "./withdraw.js"; +import { + getTransactionById, + getTransactions, + getTransactionsV2, +} from "./transactions.js"; +import { WalletExecutionContext } from "./wallet.js"; +import { addPermanentCoinFailure } from "./transaction-reasons.js"; + +const key = encodeCrock(new Uint8Array(32).fill(1)); +const merchant = "https://merchant.example/"; +const exchange = "https://exchange.example/"; +const reason: TalerErrorDetail = { + code: TalerErrorCode.MERCHANT_GENERIC_ORDER_UNKNOWN, + hint: "deleted order", + detail: "retained context", +}; +const contract = { + amount: "TESTKUDOS:1", + max_fee: "TESTKUDOS:0", + nonce: key, + h_wire: key, + exchanges: [], + fulfillment_url: `${merchant}article`, + merchant_pub: key, + merchant: { name: "Shop" }, + order_id: "order", + pay_deadline: { t_s: 4000000000 }, + wire_transfer_deadline: { t_s: 4000000000 }, + merchant_base_url: merchant, + refund_deadline: { t_s: 4000000000 }, + summary: "Article", + timestamp: { t_s: 1 }, + wire_method: "iban", +}; +const contractHash = ContractTermsUtil.hashContractTerms(contract); + +function purchase(status: PurchaseStatus): WalletPurchase { + return { + proposalId: "reason-payment", + orderId: "order", + merchantBaseUrl: merchant, + purchaseStatus: status, + noncePriv: key, + noncePub: key, + timestamp: 1, + shared: false, + claimToken: "token", + download: { + contractTermsHash: contractHash, + currency: "TESTKUDOS", + contractTermsMerchantSig: key, + }, + } as WalletPurchase; +} +function withdrawal( + status: WithdrawalGroupStatus, + manual = false, +): WalletWithdrawalGroup { + return { + withdrawalGroupId: "reason-withdrawal", + reservePub: key, + reservePriv: key, + secretSeed: key, + status, + exchangeBaseUrl: exchange, + timestampStart: 1, + instructedAmount: "TESTKUDOS:1", + rawWithdrawalAmount: "TESTKUDOS:1", + effectiveWithdrawalAmount: "TESTKUDOS:1", + denomsSel: { + selectedDenoms: [], + totalCoinValue: "TESTKUDOS:1", + totalWithdrawCost: "TESTKUDOS:1", + }, + wgInfo: manual + ? { withdrawalType: WithdrawalRecordType.BankManual } + : { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { + talerWithdrawUri: "taler://withdraw/bank.example/operation", + exchangePaytoUri: "payto://iban/DE123", + }, + }, + } as unknown as WalletWithdrawalGroup; +} + +for (const makeRunner of runnerFactories) { + async function fixture(filename?: string) { + const db = await makeRunner(filename); + let status = 404; + let body: unknown = reason; + const headers = new HeadersImpl(); + headers.set("content-type", "application/json"); + const wex = { + ws: { + networkAvailable: true, + leftoverClaims: new Set(), + timerGroup: new TimerGroup(new SetTimeoutTimerAPI()), + addNotificationListener: () => () => {}, + devExperimentState: {}, + config: { testing: {} }, + }, + cancellationToken: CancellationToken.create().token, + runWalletDbTx: <T>(f: (tx: WalletDbTransaction) => Promise<T>) => + db.runReadWriteTx(f), + taskScheduler: { + resetTask: (id: string) => + db.runReadWriteTx((tx) => tx.deleteOperationRetry(id)), + stopShepherdTask() {}, + startShepherdTask() {}, + async ensureRunning() {}, + }, + http: { + fetch: async (url: string) => ({ + ...dummyHttpResponse, + headers, + requestUrl: url, + requestMethod: "GET", + status, + json: async () => structuredClone(body), + text: async () => JSON.stringify(body), + }), + }, + } as unknown as WalletExecutionContext; + await db.runReadWriteTx((tx) => + tx.upsertContractTerms({ h: contractHash, contractTermsRaw: contract }), + ); + await db.runReadWriteTx(async (tx) => { + await tx.upsertExchangeDetails({ + rowId: 1, + exchangeBaseUrl: exchange, + masterPublicKey: key, + currency: "TESTKUDOS", + auditors: [], + protocolVersionRange: "18:0:1", + tinyAmount: "TESTKUDOS:0.01", + bankComplianceLanguage: undefined, + defaultPeerPushExpiration: undefined, + reserveClosingDelay: { d_us: 1000 }, + globalFees: [], + wireInfo: { accounts: [], feesForType: {} }, + } as WalletExchangeDetails); + await tx.upsertExchange({ + baseUrl: exchange, + entryStatus: ExchangeEntryDbRecordStatus.Used, + updateStatus: ExchangeEntryDbUpdateStatus.Ready, + detailsPointer: { + currency: "TESTKUDOS", + masterPublicKey: key, + updateClock: 1, + }, + nextUpdateStamp: 1, + nextRefreshCheckStamp: 1, + } as unknown as WalletExchangeEntry); + }); + return { + db, + wex, + reply: (s: number, b: unknown) => { + status = s; + body = b; + }, + }; + } + + test(`${makeRunner.name}: user reasons survive retry reset, list lookup and reopen`, async () => { + const dir = mkdtempSync(join(tmpdir(), "wallet-reasons-")); + const filename = join(dir, "wallet.sqlite3"); + let f = await fixture(filename); + try { + await f.db.runReadWriteTx(async (tx) => { + await tx.upsertPurchase(purchase(PurchaseStatus.DialogProposed)); + await tx.upsertOperationRetry({ + id: "pay:reason-payment", + retryInfo: DbRetryInfo.reset(), + lastError: reason, + }); + }); + const ctx = new PayMerchantTransactionContext(f.wex, "reason-payment"); + await ctx.userAbortTransaction(); + // A repeated action on the terminal record must preserve the first reason. + await ctx.userAbortTransaction(reason); + await f.db.close(); + f = await fixture(filename); + const id = "txn:payment:reason-payment"; + const detail = await getTransactionById(f.wex, { transactionId: id }); + assert.equal(detail.txState.major, TransactionMajorState.Aborted); + assert.equal( + detail.abortReason?.code, + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + ); + assert.equal(detail.error, undefined); + for (const list of [ + await getTransactions(f.wex), + await getTransactionsV2(f.wex), + ]) { + assert.deepEqual( + list.transactions.find((tx) => String(tx.transactionId) === id) + ?.abortReason, + detail.abortReason, + ); + } + await f.db.runReadWriteTx((tx) => + tx.upsertPurchase(purchase(PurchaseStatus.AbortingWithRefund)), + ); + const resumedCtx = new PayMerchantTransactionContext( + f.wex, + "reason-payment", + ); + await resumedCtx.userFailTransaction(); + assert.equal( + (await getTransactionById(f.wex, { transactionId: id })).failReason + ?.code, + TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, + ); + const existing = { + ...purchase(PurchaseStatus.AbortingWithRefund), + failReason: reason, + }; + await f.db.runReadWriteTx((tx) => tx.upsertPurchase(existing)); + await resumedCtx.userFailTransaction(); + assert.deepEqual( + (await getTransactionById(f.wex, { transactionId: id })).failReason, + reason, + ); + } finally { + await f.db.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + test(`${makeRunner.name}: deleted merchant orders retain backend reasons`, async () => { + const f = await fixture(); + try { + for (const status of [ + PurchaseStatus.DialogShared, + PurchaseStatus.DialogUnclaimed, + ]) { + await f.db.runReadWriteTx((tx) => tx.upsertPurchase(purchase(status))); + await processPurchase(f.wex, "reason-payment"); + const detail = await getTransactionById(f.wex, { + transactionId: "txn:payment:reason-payment", + }); + assert.equal(detail.txState.major, TransactionMajorState.Aborted); + assert.equal(detail.abortReason?.code, reason.code); + assert.equal(detail.abortReason?.hint, reason.hint); + } + } finally { + await f.db.close(); + } + }); + + test(`${makeRunner.name}: bank rejection details and manual withdrawal failures reach history`, async () => { + const f = await fixture(); + try { + const bankReason = { + code: TalerErrorCode.BANK_TRANSACTION_NOT_FOUND, + hint: "operation removed", + }; + f.reply(404, bankReason); + for (const status of [ + WithdrawalGroupStatus.DialogProposed, + WithdrawalGroupStatus.PendingRegisteringBank, + WithdrawalGroupStatus.AbortingBank, + ]) { + await f.db.runReadWriteTx((tx) => + tx.upsertWithdrawalGroup(withdrawal(status)), + ); + await processWithdrawalGroup(f.wex, "reason-withdrawal"); + const detail = await getTransactionById(f.wex, { + transactionId: "txn:withdrawal:reason-withdrawal", + }); + assert.equal( + (detail.failReason ?? detail.abortReason)?.code, + bankReason.code, + ); + } + for (const [status, body, expectedCode] of [ + [ + WithdrawalGroupStatus.PendingRegisteringBank, + { status: "aborted", wire_types: [], amount: "TESTKUDOS:1" }, + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + ], + [ + WithdrawalGroupStatus.DialogProposed, + { status: "confirmed", wire_types: [], amount: "TESTKUDOS:1" }, + TalerErrorCode.WALLET_TRANSACTION_COMPLETED_BY_OTHER_WALLET, + ], + ] as const) { + f.reply(200, body); + await f.db.runReadWriteTx((tx) => + tx.upsertWithdrawalGroup(withdrawal(status)), + ); + await processWithdrawalGroup(f.wex, "reason-withdrawal"); + const detail = await getTransactionById(f.wex, { + transactionId: "txn:withdrawal:reason-withdrawal", + }); + assert.equal( + (detail.failReason ?? detail.abortReason)?.code, + expectedCode, + ); + } + const conflict = { + code: TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT, + hint: "another reserve selected", + }; + f.reply(409, conflict); + await f.db.runReadWriteTx((tx) => + tx.upsertWithdrawalGroup( + withdrawal(WithdrawalGroupStatus.PendingRegisteringBank), + ), + ); + await processWithdrawalGroup(f.wex, "reason-withdrawal"); + assert.equal( + ( + await getTransactionById(f.wex, { + transactionId: "txn:withdrawal:reason-withdrawal", + }) + ).abortReason?.code, + conflict.code, + ); + // Malformed replies remain retryable exceptions, not invented permanent failures. + f.reply(404, {}); + await f.db.runReadWriteTx((tx) => + tx.upsertWithdrawalGroup( + withdrawal(WithdrawalGroupStatus.AbortingBank), + ), + ); + await assert.rejects(processWithdrawalGroup(f.wex, "reason-withdrawal")); + assert.equal( + ( + await getTransactionById(f.wex, { + transactionId: "txn:withdrawal:reason-withdrawal", + }) + ).failReason, + undefined, + ); + const manual = withdrawal(WithdrawalGroupStatus.FailedKycHardLimit, true); + manual.failReason = { + code: TalerErrorCode.WALLET_KYC_LIMIT_EXCEEDED, + hint: "hard limit", + requestedAmount: "TESTKUDOS:1", + }; + await f.db.runReadWriteTx(async (tx) => { + await tx.upsertWithdrawalGroup(manual); + await new WithdrawTransactionContext( + f.wex, + manual.withdrawalGroupId, + ).updateTransactionMeta(tx); + }); + const list = await getTransactionsV2(f.wex); + assert.deepEqual( + list.transactions.find((tx) => tx.type === TransactionType.Withdrawal) + ?.failReason, + manual.failReason, + ); + } finally { + await f.db.close(); + } + }); + + test(`${makeRunner.name}: permanent refresh and recoup failures survive finalization`, async () => { + const f = await fixture(); + try { + const coinPubs = [key, encodeCrock(new Uint8Array(32).fill(2))]; + const errors = [ + reason, + { + code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN, + hint: "unknown coin", + }, + ]; + const rg = { + refreshGroupId: "failed-refresh", + operationStatus: RefreshOperationStatus.Pending, + currency: "TESTKUDOS", + reason: RefreshReason.Manual, + oldCoinPubs: coinPubs, + inputPerCoin: ["TESTKUDOS:1", "TESTKUDOS:1"], + expectedOutputPerCoin: ["TESTKUDOS:1", "TESTKUDOS:1"], + statusPerCoin: [RefreshCoinStatus.Pending, RefreshCoinStatus.Pending], + refundRequests: {}, + timestampCreated: 1, + } as WalletRefreshGroup; + const rc = { + recoupGroupId: "failed-recoup", + exchangeBaseUrl: exchange, + operationStatus: RecoupOperationStatus.Pending, + timestampStarted: 1, + timestampFinished: undefined, + coinPubs, + recoupFinishedPerCoin: [false, false], + scheduleRefreshCoins: [], + } as unknown as WalletRecoupGroup; + await f.db.runReadWriteTx(async (tx) => { + await tx.upsertRefreshGroup(rg); + await tx.upsertRecoupGroup(rc); + for (let coinIndex = 0; coinIndex < 2; coinIndex++) { + await tx.upsertRefreshSession({ + refreshGroupId: rg.refreshGroupId, + coinIndex, + amountRefreshOutput: "TESTKUDOS:1", + newDenoms: [], + }); + } + }); + for (let i = 0; i < 2; i++) { + await handleRefreshRevealError( + new RefreshTransactionContext(f.wex, rg.refreshGroupId), + i, + errors[i], + ); + await markRecoupCoinPermanentlyFailed( + f.wex, + rc.recoupGroupId, + i, + coinPubs[i], + errors[i], + ); + } + await processRefreshGroup(f.wex, rg.refreshGroupId); + await processRecoupGroup(f.wex, rc.recoupGroupId); + const list = await getTransactionsV2(f.wex, { includeRefreshes: true }); + for (const id of [ + "txn:refresh:failed-refresh", + "txn:recoup:failed-recoup", + ]) { + const detail = await getTransactionById(f.wex, { transactionId: id }); + assert.equal(detail.txState.major, TransactionMajorState.Failed); + assert.deepEqual( + detail.failReason?.errorsPerCoin, + Object.fromEntries( + coinPubs.map((coinPub, i) => [coinPub, errors[i]]), + ), + ); + assert.deepEqual( + list.transactions.find((tx) => String(tx.transactionId) === id) + ?.failReason, + detail.failReason, + ); + } + } finally { + await f.db.close(); + } + }); + + test(`${makeRunner.name}: refund rejection details reach group history`, async () => { + const f = await fixture(); + try { + const p = purchase(PurchaseStatus.PendingAcceptRefund); + await f.db.runReadWriteTx((tx) => tx.upsertPurchase(p)); + const failures: MerchantCoinRefundFailureStatus[] = [1, 2].map((i) => ({ + type: "failure", + exchange_status: 404, + exchange_code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN, + exchange_reply: { hint: `rejected refund ${i}`, evidence: i }, + coin_pub: key, + rtransaction_id: i, + refund_amount: "TESTKUDOS:0.1", + execution_time: { t_s: 1 }, + })); + await storeRefunds(f.wex, p, failures, RefundReason.NormalRefund); + const groups = await f.db.runReadWriteTx((tx) => + tx.getRefundGroupsByProposal(p.proposalId), + ); + assert.equal(groups.length, 1); + const detail = await getTransactionById(f.wex, { + transactionId: `txn:refund:${groups[0].refundGroupId}`, + }); + assert.equal(detail.txState.major, TransactionMajorState.Failed); + assert.equal( + detail.failReason?.code, + TalerErrorCode.WALLET_REFUND_GROUP_FAILED, + ); + const errors = detail.failReason?.errors as { + coinPub: string; + rtransactionId: number; + error: TalerErrorDetail; + }[]; + assert.deepEqual( + errors.map((e) => [ + e.coinPub, + e.rtransactionId, + e.error.hint, + e.error.evidence, + ]), + failures.map((rf) => [ + rf.coin_pub, + rf.rtransaction_id, + rf.exchange_reply.hint, + rf.exchange_reply.evidence, + ]), + ); + const list = await getTransactionsV2(f.wex); + assert.deepEqual( + list.transactions.find((tx) => tx.type === TransactionType.Refund) + ?.failReason, + detail.failReason, + ); + // A later retryable response clears only that item's obsolete permanent reason. + await f.db.runReadWriteTx((tx) => tx.upsertPurchase(p)); + await storeRefunds( + f.wex, + p, + [{ ...failures[0], exchange_status: 503 }], + RefundReason.NormalRefund, + ); + const updated = await f.db.runReadWriteTx((tx) => + tx.getRefundGroup(groups[0].refundGroupId), + ); + assert.deepEqual( + (updated?.failReason?.errors as { rtransactionId: number }[]).map( + (e) => e.rtransactionId, + ), + [2], + ); + const item = await f.db.runReadWriteTx((tx) => + tx.getRefundItemByCoinAndRtxid(key, 1), + ); + assert.equal(item?.failReason, undefined); + } finally { + await f.db.close(); + } + }); + + test(`${makeRunner.name}: peer payment recovery retains missing and failed refresh causes`, async () => { + const f = await fixture(); + try { + const peerContract = { + amount: "TESTKUDOS:1", + summary: "Peer payment", + purse_expiration: { t_s: 4000000000 }, + }; + const peerHash = ContractTermsUtil.hashContractTerms(peerContract); + const peer = { + peerPullDebitId: "pull", + pursePub: key, + contractPriv: key, + exchangeBaseUrl: exchange, + amount: "TESTKUDOS:1", + totalCostEstimated: "TESTKUDOS:1", + contractTermsHash: peerHash, + timestampCreated: 1, + abortRefreshGroupId: "recovery", + status: PeerPullDebitRecordStatus.AbortingRefresh, + } as unknown as WalletPeerPullDebit; + await f.db.runReadWriteTx(async (tx) => { + await tx.upsertContractTerms({ + h: peerHash, + contractTermsRaw: peerContract, + }); + await tx.upsertPeerPullDebit(peer); + }); + for (const withChild of [false, true]) { + await f.db.runReadWriteTx(async (tx) => { + await tx.upsertPeerPullDebit(peer); + if (withChild) + await tx.upsertRefreshGroup({ + refreshGroupId: "recovery", + operationStatus: RefreshOperationStatus.Failed, + currency: "TESTKUDOS", + reason: RefreshReason.AbortPeerPullDebit, + oldCoinPubs: [], + inputPerCoin: [], + expectedOutputPerCoin: [], + statusPerCoin: [], + refundRequests: {}, + timestampCreated: 1, + failReason: reason, + } as unknown as WalletRefreshGroup); + }); + await processPeerPullDebit(f.wex, "pull"); + const stored = await f.db.runReadWriteTx((tx) => + tx.getPeerPullDebit("pull"), + ); + assert.equal(stored?.status, PeerPullDebitRecordStatus.Failed); + assert.equal( + stored?.failReason?.code, + TalerErrorCode.WALLET_TRANSACTION_RECOVERY_FAILED, + ); + assert.equal(stored?.failReason?.transactionId, "txn:refresh:recovery"); + assert.deepEqual( + stored?.failReason?.innerError, + withChild ? reason : undefined, + ); + } + } finally { + await f.db.close(); + } + }); + + test(`${makeRunner.name}: incoming peer payment preserves missing and failed withdrawal causes`, async () => { + const f = await fixture(); + try { + const peerContract = { + amount: "TESTKUDOS:1", + summary: "Peer payment", + purse_expiration: { t_s: 4000000000 }, + }; + const peerHash = ContractTermsUtil.hashContractTerms(peerContract); + const peer = { + peerPushCreditId: "peer", + pursePub: key, + mergePriv: key, + contractPriv: key, + exchangeBaseUrl: exchange, + currency: "TESTKUDOS", + timestamp: 1, + estimatedAmountEffective: "TESTKUDOS:1", + contractTermsHash: peerHash, + withdrawalGroupId: "reason-withdrawal", + status: PeerPushCreditStatus.PendingWithdrawing, + } as WalletPeerPushCredit; + await f.db.runReadWriteTx(async (tx) => { + await tx.upsertContractTerms({ + h: peerHash, + contractTermsRaw: peerContract, + }); + await tx.upsertPeerPushCredit(peer); + }); + await processPeerPushCredit(f.wex, "peer"); + let stored = await f.db.runReadWriteTx((tx) => + tx.getPeerPushCredit("peer"), + ); + assert.equal( + stored?.failReason?.code, + TalerErrorCode.WALLET_TRANSACTION_DEPENDENCY_FAILED, + ); + assert.equal( + stored?.failReason?.transactionId, + "txn:withdrawal:reason-withdrawal", + ); + await f.db.runReadWriteTx(async (tx) => { + const child = withdrawal(WithdrawalGroupStatus.FailedBankAborted); + child.failReason = reason; + await tx.upsertWithdrawalGroup(child); + await tx.upsertPeerPushCredit(peer); + }); + await processPeerPushCredit(f.wex, "peer"); + stored = await f.db.runReadWriteTx((tx) => tx.getPeerPushCredit("peer")); + assert.deepEqual(stored?.failReason?.innerError, reason); + // A user refusal before merge also records its reason. + peer.status = PeerPushCreditStatus.DialogProposed; + await f.db.runReadWriteTx((tx) => tx.upsertPeerPushCredit(peer)); + await new PeerPushCreditTransactionContext( + f.wex, + "peer", + ).userAbortTransaction(); + assert.equal( + (await f.db.runReadWriteTx((tx) => tx.getPeerPushCredit("peer"))) + ?.abortReason?.code, + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + ); + } finally { + await f.db.close(); + } + }); +} + +test("permanent coin failures retain siblings and replace only the retried coin", () => { + for (const code of [ + TalerErrorCode.WALLET_REFRESH_GROUP_FAILED, + TalerErrorCode.WALLET_RECOUP_GROUP_FAILED, + ] as const) { + let failure = addPermanentCoinFailure(undefined, code, "b", reason); + failure = addPermanentCoinFailure(failure, code, "a", { + code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN, + }); + failure = addPermanentCoinFailure(failure, code, "a", { + code: TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN, + }); + assert.deepEqual(failure.errorsPerCoin, { + a: { code: TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN }, + b: reason, + }); + } +}); + +test("refund failure aggregation preserves identities, HTTP details and successful siblings", () => { + const refund = { + type: "failure", + exchange_status: 404, + exchange_code: TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN, + exchange_reply: { hint: "coin unknown", evidence: "kept" }, + } as MerchantCoinRefundFailureStatus; + const error = getPermanentRefundReason(refund)!; + assert.equal(error.code, TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN); + assert.equal(error.httpStatusCode, 404); + assert.equal(error.hint, "coin unknown"); + assert.equal(error.evidence, "kept"); + assert.equal( + getPermanentRefundReason({ ...refund, exchange_status: 503 }), + undefined, + ); + assert.equal( + getPermanentRefundReason({ + ...refund, + exchange_code: undefined, + exchange_reply: "missing", + })?.code, + TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR, + ); + const items = [ + { status: RefundItemStatus.Done, coinPub: "a", rtxid: 1 }, + { + status: RefundItemStatus.Failed, + coinPub: "b", + rtxid: 2, + failReason: error, + }, + { + status: RefundItemStatus.Failed, + coinPub: "b", + rtxid: 3, + failReason: reason, + }, + ] as WalletRefundItem[]; + const group = getRefundGroupFailure(items)!; + assert.equal(group.code, TalerErrorCode.WALLET_REFUND_GROUP_FAILED); + assert.deepEqual(group.errors, [ + { coinPub: "b", rtransactionId: 2, error }, + { coinPub: "b", rtransactionId: 3, error: reason }, + ]); + for (const item of items) item.status = RefundItemStatus.Done; + assert.equal(getRefundGroupFailure(items), undefined); +}); diff --git a/packages/taler-wallet-core/src/transaction-reasons.ts b/packages/taler-wallet-core/src/transaction-reasons.ts @@ -0,0 +1,50 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + makeErrorDetail, + TalerErrorCode, + TalerErrorDetail, +} from "@gnu-taler/taler-util"; + +/** Add a permanent coin failure without replacing failures of sibling coins. */ +export function addPermanentCoinFailure( + previous: TalerErrorDetail | undefined, + code: + | TalerErrorCode.WALLET_REFRESH_GROUP_FAILED + | TalerErrorCode.WALLET_RECOUP_GROUP_FAILED, + coinPub: string, + error: TalerErrorDetail, +): TalerErrorDetail { + const errorsPerCoin = { + ...(previous?.code === code + ? (previous.errorsPerCoin as Record<string, TalerErrorDetail>) + : {}), + [coinPub]: error, + }; + return makeErrorDetail( + code, + { + errorsPerCoin: Object.fromEntries( + Object.entries(errorsPerCoin).sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + ), + ), + }, + code === TalerErrorCode.WALLET_REFRESH_GROUP_FAILED + ? "one or more coins could not be refreshed" + : "one or more revoked coins could not be recouped", + ); +} diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -19,6 +19,7 @@ * bank-integrated and manual. */ +import { getHttpResponseErrorDetails } from "@gnu-taler/taler-util/http"; import { updateTransactionAmounts } from "./transaction-amounts.js"; import { AbsoluteTime, @@ -368,6 +369,7 @@ function buildTransactionForManualWithdraw( withdrawalGroupId: wg.withdrawalGroupId, }), abortReason: wg.abortReason, + failReason: wg.failReason, ...(ort?.lastError ? { error: ort.lastError } : {}), }; if (ort?.lastError) { @@ -714,7 +716,14 @@ export class WithdrawTransactionContext implements TransactionContext { default: assertUnreachable(wg.status); } - wg.abortReason = reason; + wg.abortReason = + reason ?? + wg.abortReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, + {}, + "transaction aborted by the user", + ); wg.status = newStatus; await h.update(wg, "abort"); }); @@ -792,7 +801,14 @@ export class WithdrawTransactionContext implements TransactionContext { return; } wg.status = newStatus; - wg.failReason = reason; + wg.failReason = + reason ?? + wg.failReason ?? + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, + {}, + "transaction abandoned by the user", + ); await h.update(wg, "user-fail"); }); } @@ -1233,12 +1249,21 @@ async function transitionSimple( from: WithdrawalGroupStatus, to: WithdrawalGroupStatus, causeHint: string, + reason: TalerErrorDetail, ): Promise<void> { await ctx.wex.runWalletDbTx(async (tx) => { const [rec, h] = await ctx.getRecordHandle(tx); switch (rec?.status) { case from: { rec.status = to; + if ( + computeWithdrawalTransactionStatus(rec).major === + TransactionMajorState.Failed + ) { + rec.failReason = reason; + } else { + rec.abortReason = reason; + } await h.update(rec, causeHint); } } @@ -1296,6 +1321,12 @@ async function processWithdrawalGroupDialogProposed( WithdrawalGroupStatus.DialogProposed, WithdrawalGroupStatus.AbortedBank, "wop-not-found", + resp.detail ?? + makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(resp.response), + "bank rejected the withdrawal operation", + ), ); break; } @@ -1309,6 +1340,11 @@ async function processWithdrawalGroupDialogProposed( WithdrawalGroupStatus.DialogProposed, WithdrawalGroupStatus.AbortedOtherWallet, "wop-not-pending", + makeErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_COMPLETED_BY_OTHER_WALLET, + {}, + "bank withdrawal was taken over by another wallet", + ), ); } break; @@ -2655,6 +2691,15 @@ async function processWithdrawalGroupAbortingBank( return; } wg.status = newStatus; + if (abortResp.case === HttpStatusCode.NotFound) { + wg.failReason = + abortResp.detail ?? + makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(abortResp.response), + "bank withdrawal operation not found while aborting", + ); + } if (newStatus !== WithdrawalGroupStatus.SuspendedQueryingStatus) { wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now()); } @@ -3735,6 +3780,11 @@ async function registerReserveWithBank( WithdrawalGroupStatus.PendingRegisteringBank, WithdrawalGroupStatus.FailedBankAborted, "devexp-post-wop-failed", + makeErrorDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + "simulated bank withdrawal rejection", + ), ); return TaskRunResult.progress(); } @@ -3761,6 +3811,12 @@ async function registerReserveWithBank( WithdrawalGroupStatus.PendingRegisteringBank, WithdrawalGroupStatus.FailedBankAborted, "register-wop-not-found", + completeResp.detail ?? + makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(completeResp.response), + "bank rejected the withdrawal operation", + ), ); return TaskRunResult.progress(); default: @@ -3771,6 +3827,12 @@ async function registerReserveWithBank( WithdrawalGroupStatus.PendingRegisteringBank, WithdrawalGroupStatus.FailedBankAborted, "register-wop-conflict", + completeResp.detail ?? + makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(completeResp.response), + "bank rejected the withdrawal operation", + ), ); return TaskRunResult.progress(); } @@ -3802,6 +3864,11 @@ async function registerReserveWithBank( break; case "aborted": r.status = WithdrawalGroupStatus.FailedBankAborted; + r.failReason = makeErrorDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + "bank aborted the withdrawal", + ); r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); break; default: @@ -3836,6 +3903,11 @@ async function transitionBankAborted( const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()); r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now); r.status = WithdrawalGroupStatus.FailedBankAborted; + r.failReason = makeErrorDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + "bank aborted the withdrawal", + ); await h.update(r, "bank-aborted"); return TaskRunResult.progress(); }); @@ -3892,6 +3964,12 @@ async function processBankRegisterReserve( WithdrawalGroupStatus.PendingRegisteringBank, WithdrawalGroupStatus.FailedBankAborted, "wop-not-found", + statusResp.detail ?? + makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(statusResp.response), + "bank rejected the withdrawal operation", + ), ); return TaskRunResult.progress(); case HttpStatusCode.Conflict: @@ -3900,6 +3978,12 @@ async function processBankRegisterReserve( WithdrawalGroupStatus.PendingRegisteringBank, WithdrawalGroupStatus.AbortedOtherWallet, "wop-conflict", + statusResp.detail ?? + makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(statusResp.response), + "bank rejected the withdrawal operation", + ), ); return TaskRunResult.progress(); }