commit 444785e37db60943b0634e532aa9e0758167a1e7
parent ed8cd5e545ec8a035c309aad8fedf9343c6ff362
Author: Florian Dold <florian@dold.me>
Date: Fri, 20 Mar 2026 14:56:07 +0100
wallet-core: fix some transitions to failed
Diffstat:
7 files changed, 128 insertions(+), 42 deletions(-)
diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts
@@ -3254,6 +3254,7 @@ export type PurseConflict =
| PurseCreateConflict
| PurseDepositConflict
| PurseContractConflict;
+
export type PurseConflictPartial =
| PurseCreateConflict
| PurseDepositConflict
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -1443,9 +1443,9 @@ export enum PurchaseStatus {
DialogShared = 0x0101_0001,
/**
- * Downloading or processing the proposal has failed permanently.
+ * Generic failure, check error code.
*/
- FailedClaim = 0x0501_0000,
+ Failed = 0x0501_0000,
/**
* Tried to abort, but aborting failed or was cancelled.
@@ -1455,6 +1455,11 @@ export enum PurchaseStatus {
FailedPaidByOther = 0x0501_0002,
/**
+ * Downloading or processing the proposal has failed permanently.
+ */
+ FailedClaim = 0x0501_0003,
+
+ /**
* Payment was successful.
*/
Done = 0x0500_0000,
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -435,6 +435,22 @@ export class PayMerchantTransactionContext implements TransactionContext {
});
}
+ async failTransaction(
+ fromSt: PurchaseStatus,
+ reason?: TalerErrorDetail,
+ ): Promise<void> {
+ const { wex } = this;
+ await wex.runLegacyWalletDbTx(async (tx) => {
+ const [purchase, h] = await this.getRecordHandle(tx);
+ if (purchase?.purchaseStatus != fromSt) {
+ return;
+ }
+ purchase.purchaseStatus = PurchaseStatus.Failed;
+ purchase.failReason = reason;
+ await h.update(purchase);
+ });
+ }
+
async userAbortTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex } = this;
await wex.runLegacyWalletDbTx(async (tx) => {
@@ -1668,7 +1684,7 @@ async function handleInsufficientFunds(
logger.trace("got exchange error reply (see below)");
logger.trace(j2s(exchangeReply));
}
- await ctx.userAbortTransaction({
+ await ctx.failTransaction(proposal.purchaseStatus, {
code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
message: `unable to handle /pay exchange error response (${exchangeReply.code})`,
exchangeReply,
@@ -1680,7 +1696,7 @@ async function handleInsufficientFunds(
logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
if (!brokenCoinPub) {
- await ctx.userAbortTransaction({
+ await ctx.failTransaction(proposal.purchaseStatus, {
code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
message: "Exchange claimed bad coin, but coin was not used.",
brokenCoinPub,
@@ -1692,7 +1708,7 @@ async function handleInsufficientFunds(
TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND
) {
// We might support this in the future.
- await ctx.userAbortTransaction({
+ await ctx.failTransaction(proposal.purchaseStatus, {
code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
message: "Denomination used in payment became invalid.",
errorDetails: err,
@@ -2016,6 +2032,7 @@ async function checkPaymentByProposalId(
case PurchaseStatus.AbortingWithRefund:
case PurchaseStatus.FailedAbort:
case PurchaseStatus.FailedClaim:
+ case PurchaseStatus.Failed:
case PurchaseStatus.FinalizingQueryingAutoRefund:
case PurchaseStatus.PendingAcceptRefund:
case PurchaseStatus.PendingPaying:
@@ -3014,6 +3031,7 @@ export async function processPurchase(
case PurchaseStatus.SuspendedQueryingAutoRefund:
case PurchaseStatus.SuspendedQueryingRefund:
case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund:
+ case PurchaseStatus.Failed:
case PurchaseStatus.FailedAbort:
case PurchaseStatus.FailedPaidByOther:
case PurchaseStatus.Expired:
@@ -3317,7 +3335,7 @@ async function processPurchasePay(
case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN:
// We might want to handle this in the future by re-denomination,
// for now we just abort.
- await ctx.userAbortTransaction({
+ await ctx.failTransaction(purchase.purchaseStatus, {
code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
message: "Denomination used in payment became invalid.",
errorDetails: err,
@@ -3333,7 +3351,8 @@ async function processPurchasePay(
if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
logger.warn(`pay transaction aborted, merchant has KYC problems`);
- await ctx.userAbortTransaction(
+ await ctx.failTransaction(
+ purchase.purchaseStatus,
makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING, {
exchangeResponse: await resp.json(),
}),
@@ -3343,7 +3362,8 @@ async function processPurchasePay(
if (resp.status === HttpStatusCode.Gone) {
logger.warn(`pay transaction aborted, order expired`);
- await ctx.userAbortTransaction(
+ await ctx.failTransaction(
+ purchase.purchaseStatus,
makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_ORDER_GONE, {}),
);
return TaskRunResult.progress();
@@ -3789,6 +3809,10 @@ export function computePayMerchantTransactionState(
return {
major: TransactionMajorState.Expired,
};
+ case PurchaseStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
case PurchaseStatus.FailedClaim:
return {
major: TransactionMajorState.Failed,
@@ -3909,6 +3933,8 @@ export function computePayMerchantTransactionActions(
return [TransactionAction.Delete];
case PurchaseStatus.AbortedIncompletePayment:
return [TransactionAction.Delete];
+ case PurchaseStatus.Failed:
+ return [TransactionAction.Delete];
case PurchaseStatus.FailedClaim:
return [TransactionAction.Delete];
case PurchaseStatus.FailedAbort:
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -357,6 +357,22 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
this.wex.taskScheduler.stopShepherdTask(this.taskId);
}
+ async failTransaction(
+ fromSt: PeerPullPaymentCreditStatus,
+ reason?: TalerErrorDetail,
+ ): Promise<void> {
+ const { wex } = this;
+ await wex.runLegacyWalletDbTx(async (tx) => {
+ const [rec, h] = await this.getRecordHandle(tx);
+ if (rec?.status != fromSt) {
+ return;
+ }
+ rec.status = PeerPullPaymentCreditStatus.Failed;
+ rec.failReason = reason;
+ await h.update(rec);
+ });
+ }
+
async userFailTransaction(reason?: TalerErrorDetail): Promise<void> {
await this.wex.runLegacyWalletDbTx(async (tx) => {
const [rec, h] = await this.getRecordHandle(tx);
@@ -517,7 +533,7 @@ async function queryPurseForPeerPullCredit(
});
return TaskRunResult.finished();
case HttpStatusCode.NotFound:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(pullIni.status, resp.detail);
return TaskRunResult.finished();
default:
assertUnreachable(resp);
@@ -663,7 +679,7 @@ async function processPeerPullCreditAbortingDeletePurse(
});
return TaskRunResult.finished();
case HttpStatusCode.Forbidden:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(peerPullIni.status, resp.detail);
return TaskRunResult.finished();
case HttpStatusCode.Conflict:
// FIXME check if done ?
@@ -848,15 +864,16 @@ async function processPeerPullCreditCreatePurse(
}
case HttpStatusCode.Forbidden:
case HttpStatusCode.NotFound:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(pullIni.status, resp.detail);
return TaskRunResult.finished();
case HttpStatusCode.Conflict:
- await ctx.userFailTransaction({ code: resp.body.code });
+ await ctx.failTransaction(pullIni.status, { code: resp.body.code });
return TaskRunResult.finished();
case HttpStatusCode.PaymentRequired:
throw Error(`unexpected reserve merge response ${resp.case}`);
case TalerErrorCode.EXCHANGE_RESERVES_PURSE_EXPIRATION_BEFORE_NOW:
- await ctx.userFailTransaction(
+ await ctx.failTransaction(
+ pullIni.status,
resp.detail ? { code: resp.detail?.code } : undefined,
);
return TaskRunResult.finished();
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -118,9 +118,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
});
}
- async updateTransactionMeta(
- tx: WalletIndexedDbTransaction,
- ): Promise<void> {
+ async updateTransactionMeta(tx: WalletIndexedDbTransaction): Promise<void> {
const rec = await tx.peerPullDebit.get(this.peerPullDebitId);
if (rec == null) {
await tx.transactionsMeta.delete(this.peerPullDebitId);
@@ -201,15 +199,29 @@ export class PeerPullDebitTransactionContext implements TransactionContext {
);
}
+ async failTransaction(
+ fromSt: PeerPullDebitRecordStatus,
+ reason?: TalerErrorDetail,
+ ): Promise<void> {
+ const { wex } = this;
+ await wex.runLegacyWalletDbTx(async (tx) => {
+ const [rec, h] = await this.getRecordHandle(tx);
+ if (rec?.status != fromSt) {
+ return;
+ }
+ rec.status = PeerPullDebitRecordStatus.Failed;
+ rec.failReason = reason;
+ await h.update(rec);
+ });
+ }
+
async userDeleteTransaction(): Promise<void> {
await this.wex.runLegacyWalletDbTx(async (tx) => {
await this.deleteTransactionInTx(tx);
});
}
- async deleteTransactionInTx(
- tx: WalletIndexedDbTransaction,
- ): Promise<void> {
+ async deleteTransactionInTx(tx: WalletIndexedDbTransaction): Promise<void> {
const [rec, h] = await this.getRecordHandle(tx);
if (!rec) {
return;
@@ -356,7 +368,7 @@ async function handlePurseCreationConflict(
conflict: PurseConflict,
): Promise<TaskRunResult> {
if (conflict.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
- await ctx.userFailTransaction();
+ await ctx.failTransaction(peerPullInc.status, { ...conflict });
return TaskRunResult.finished();
}
@@ -470,7 +482,7 @@ async function processPeerPullDebitDialogProposed(
});
return TaskRunResult.finished();
case HttpStatusCode.NotFound:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(pullIni.status, resp.detail);
return TaskRunResult.finished();
default:
assertUnreachable(resp);
@@ -612,7 +624,8 @@ async function processPeerPullDebitPendingDeposit(
case "ok":
continue;
case HttpStatusCode.Gone: {
- await ctx.userAbortTransaction(
+ await ctx.failTransaction(
+ peerPullInc.status,
makeTalerErrorDetail(
TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE,
{},
@@ -624,7 +637,7 @@ async function processPeerPullDebitPendingDeposit(
return handlePurseCreationConflict(ctx, peerPullInc, resp.body);
case HttpStatusCode.Forbidden:
case HttpStatusCode.NotFound:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(peerPullInc.status, resp.detail);
return TaskRunResult.finished();
default:
assertUnreachable(resp);
diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -126,9 +126,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
});
}
- async updateTransactionMeta(
- tx: WalletIndexedDbTransaction,
- ): Promise<void> {
+ async updateTransactionMeta(tx: WalletIndexedDbTransaction): Promise<void> {
const rec = await tx.peerPushCredit.get(this.peerPushCreditId);
if (rec == null) {
await tx.transactionsMeta.delete(this.peerPushCreditId);
@@ -283,9 +281,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
);
}
- async deleteTransactionInTx(
- tx: WalletIndexedDbTransaction,
- ): Promise<void> {
+ async deleteTransactionInTx(tx: WalletIndexedDbTransaction): Promise<void> {
const [rec, h] = await this.getRecordHandle(tx);
if (!rec) {
return;
@@ -418,6 +414,22 @@ export class PeerPushCreditTransactionContext implements TransactionContext {
this.wex.taskScheduler.startShepherdTask(this.taskId);
}
+ async failTransaction(
+ fromSt: PeerPushCreditStatus,
+ reason?: TalerErrorDetail,
+ ): Promise<void> {
+ const { wex } = this;
+ await wex.runLegacyWalletDbTx(async (tx) => {
+ const [rec, h] = await this.getRecordHandle(tx);
+ if (rec?.status != fromSt) {
+ return;
+ }
+ rec.status = PeerPushCreditStatus.Failed;
+ rec.failReason = reason;
+ await h.update(rec);
+ });
+ }
+
async userFailTransaction(reason?: TalerErrorDetail): Promise<void> {
await this.wex.runLegacyWalletDbTx(async (tx) => {
const [rec, h] = await this.getRecordHandle(tx);
@@ -888,7 +900,7 @@ async function processPendingMerge(
return TaskRunResult.finished();
case HttpStatusCode.Forbidden:
case HttpStatusCode.NotFound:
- await ctx.userFailTransaction(mergeResp.detail);
+ await ctx.failTransaction(peerInc.status, mergeResp.detail);
return TaskRunResult.finished();
default:
assertUnreachable(mergeResp);
@@ -1039,7 +1051,7 @@ async function processPeerPushDebitDialogProposed(
});
return TaskRunResult.finished();
case HttpStatusCode.NotFound:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(pullIni.status, resp.detail);
return TaskRunResult.finished();
default:
assertUnreachable(resp);
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -116,9 +116,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
});
}
- async updateTransactionMeta(
- tx: WalletIndexedDbTransaction,
- ): Promise<void> {
+ async updateTransactionMeta(tx: WalletIndexedDbTransaction): Promise<void> {
const rec = await tx.peerPushDebit.get(this.pursePub);
if (rec == null) {
await tx.transactionsMeta.delete(this.pursePub);
@@ -219,9 +217,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
});
}
- async deleteTransactionInTx(
- tx: WalletIndexedDbTransaction,
- ): Promise<void> {
+ async deleteTransactionInTx(tx: WalletIndexedDbTransaction): Promise<void> {
const [rec, h] = await this.getRecordHandle(tx);
if (!rec) {
return;
@@ -330,6 +326,22 @@ export class PeerPushDebitTransactionContext implements TransactionContext {
this.wex.taskScheduler.startShepherdTask(this.taskId);
}
+ async failTransaction(
+ fromSt: PeerPushDebitStatus,
+ reason?: TalerErrorDetail,
+ ): Promise<void> {
+ const { wex } = this;
+ await wex.runLegacyWalletDbTx(async (tx) => {
+ const [rec, h] = await this.getRecordHandle(tx);
+ if (rec?.status != fromSt) {
+ return;
+ }
+ rec.status = PeerPushDebitStatus.Failed;
+ rec.failReason = reason;
+ await h.update(rec);
+ });
+ }
+
async userFailTransaction(reason?: TalerErrorDetail): Promise<void> {
await this.wex.runLegacyWalletDbTx(async (tx) => {
const [rec, h] = await this.getRecordHandle(tx);
@@ -477,7 +489,7 @@ async function handlePurseCreationConflict(
const pursePub = peerPushInitiation.pursePub;
const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
if (conflict.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
- await ctx.userFailTransaction();
+ await ctx.failTransaction(peerPushInitiation.status, { ...conflict });
return TaskRunResult.finished();
}
@@ -700,7 +712,7 @@ async function processPeerPushDebitCreateReserve(
continue;
case HttpStatusCode.Forbidden:
case HttpStatusCode.NotFound:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(peerPushInitiation.status, resp.detail);
return TaskRunResult.finished();
case HttpStatusCode.Conflict:
return handlePurseCreationConflict(
@@ -752,7 +764,7 @@ async function processPeerPushDebitCreateReserve(
);
case HttpStatusCode.Forbidden:
case HttpStatusCode.NotFound:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(peerPushInitiation.status, resp.detail);
return TaskRunResult.finished();
default:
assertUnreachable(resp);
@@ -799,7 +811,7 @@ async function processPeerPushDebitCreateReserve(
});
return TaskRunResult.progress();
case HttpStatusCode.NotFound:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(peerPushInitiation.status, resp.detail);
return TaskRunResult.finished();
default:
assertUnreachable(resp);
@@ -825,7 +837,7 @@ async function processPeerPushDebitAbortingDeletePurse(
case HttpStatusCode.Conflict:
throw Error("purse deletion conflict");
case HttpStatusCode.Forbidden:
- await ctx.userFailTransaction(resp.detail);
+ await ctx.failTransaction(peerPushInitiation.status, resp.detail);
return TaskRunResult.finished();
}