commit 5c4f580867b6c6852d291a5e52949cedf71e995e
parent 2dc0f4f8c9104cc296613cf3abba876f35055f7e
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:47 +0200
wallet-core: align merchant actions with state transitions
Diffstat:
2 files changed, 110 insertions(+), 31 deletions(-)
diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts
@@ -18,6 +18,7 @@ import {
AmountString,
SelectedCoin,
SignedTokenEnvelope,
+ TransactionAction,
TransactionIdStr,
} from "@gnu-taler/taler-util";
import assert from "node:assert";
@@ -31,7 +32,11 @@ import {
} from "./db-common.js";
import { WalletDbTransaction } from "./dbtx.js";
import {
+ computePayMerchantTransactionActions,
getCoinsToSpendForMerchantRepair,
+ getPayMerchantAbortTransition,
+ getPayMerchantResumeTransition,
+ getPayMerchantSuspendTransition,
getRefundTotals,
isPaymentSessionComplete,
releasePaymentTokensInTx,
@@ -227,3 +232,34 @@ test("payment replay waits for the requested session to finish", () => {
assert.strictEqual(isPaymentSessionComplete(purchase, "new-session"), true);
assert.strictEqual(isPaymentSessionComplete(purchase, "other-session"), false);
});
+
+test("every advertised merchant action has a state transition", () => {
+ const statuses = Object.values(PurchaseStatus).filter(
+ (value): value is PurchaseStatus => typeof value === "number",
+ );
+ for (const status of statuses) {
+ const purchase = { purchaseStatus: status } as WalletPurchase;
+ const actions = computePayMerchantTransactionActions(purchase);
+ if (actions.includes(TransactionAction.Suspend)) {
+ assert.notStrictEqual(
+ getPayMerchantSuspendTransition(status),
+ undefined,
+ `missing suspend transition for ${PurchaseStatus[status]}`,
+ );
+ }
+ if (actions.includes(TransactionAction.Resume)) {
+ assert.notStrictEqual(
+ getPayMerchantResumeTransition(status),
+ undefined,
+ `missing resume transition for ${PurchaseStatus[status]}`,
+ );
+ }
+ if (actions.includes(TransactionAction.Abort)) {
+ assert.notStrictEqual(
+ getPayMerchantAbortTransition(status, true),
+ undefined,
+ `missing abort transition for ${PurchaseStatus[status]}`,
+ );
+ }
+ }
+});
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -487,11 +487,13 @@ export class PayMerchantTransactionContext implements TransactionContext {
if (!purchase) {
throw Error("purchase not found");
}
- let newStatus = transitionSuspend[purchase.purchaseStatus];
- if (!newStatus?.next) {
+ const nextStatus = getPayMerchantSuspendTransition(
+ purchase.purchaseStatus,
+ );
+ if (nextStatus === undefined) {
return;
}
- purchase.purchaseStatus = newStatus.next;
+ purchase.purchaseStatus = nextStatus;
await h.update(purchase, "suspend");
});
}
@@ -519,32 +521,17 @@ export class PayMerchantTransactionContext implements TransactionContext {
if (!purchase) {
throw Error("purchase not found");
}
- switch (purchase.purchaseStatus) {
- case PurchaseStatus.Done:
- return;
- case PurchaseStatus.PendingPaying:
- case PurchaseStatus.SuspendedPaying: {
- purchase.abortReason = reason;
- purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
- break;
- }
- case PurchaseStatus.PendingQueryingAutoRefund:
- case PurchaseStatus.SuspendedQueryingAutoRefund:
- case PurchaseStatus.PendingAcceptRefund:
- case PurchaseStatus.SuspendedPendingAcceptRefund:
- case PurchaseStatus.PendingQueryingRefund:
- case PurchaseStatus.SuspendedQueryingRefund:
- if (!purchase.timestampFirstSuccessfulPay) {
- throw Error("invalid state");
- }
- purchase.purchaseStatus = PurchaseStatus.Done;
- break;
- case PurchaseStatus.DialogProposed:
- purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
- break;
- default:
- return;
+ const nextStatus = getPayMerchantAbortTransition(
+ purchase.purchaseStatus,
+ purchase.timestampFirstSuccessfulPay !== undefined,
+ );
+ if (nextStatus === undefined) {
+ return;
+ }
+ if (nextStatus === PurchaseStatus.AbortingWithRefund) {
+ purchase.abortReason = reason;
}
+ purchase.purchaseStatus = nextStatus;
await h.update(purchase, "abort");
});
await wex.taskScheduler.resetTask(this.taskId);
@@ -557,11 +544,13 @@ export class PayMerchantTransactionContext implements TransactionContext {
if (!purchase) {
throw Error("purchase not found");
}
- let newStatus = transitionResume[purchase.purchaseStatus];
- if (!newStatus?.next) {
+ const nextStatus = getPayMerchantResumeTransition(
+ purchase.purchaseStatus,
+ );
+ if (nextStatus === undefined) {
return;
}
- purchase.purchaseStatus = newStatus.next;
+ purchase.purchaseStatus = nextStatus;
await h.update(purchase, "resume", BalanceEffect.Any);
});
await wex.taskScheduler.resetTask(this.taskId);
@@ -3722,6 +3711,12 @@ const transitionSuspend: {
[PurchaseStatus.FinalizingQueryingAutoRefund]: {
next: PurchaseStatus.SuspendedFinalizingQueryingAutoRefund,
},
+ [PurchaseStatus.PendingQueryingRefund]: {
+ next: PurchaseStatus.SuspendedQueryingRefund,
+ },
+ [PurchaseStatus.PendingAcceptRefund]: {
+ next: PurchaseStatus.SuspendedPendingAcceptRefund,
+ },
};
const transitionResume: {
@@ -3747,8 +3742,56 @@ const transitionResume: {
[PurchaseStatus.SuspendedFinalizingQueryingAutoRefund]: {
next: PurchaseStatus.FinalizingQueryingAutoRefund,
},
+ [PurchaseStatus.SuspendedQueryingRefund]: {
+ next: PurchaseStatus.PendingQueryingRefund,
+ },
+ [PurchaseStatus.SuspendedPendingAcceptRefund]: {
+ next: PurchaseStatus.PendingAcceptRefund,
+ },
};
+export function getPayMerchantSuspendTransition(
+ status: PurchaseStatus,
+): PurchaseStatus | undefined {
+ return transitionSuspend[status]?.next;
+}
+
+export function getPayMerchantResumeTransition(
+ status: PurchaseStatus,
+): PurchaseStatus | undefined {
+ return transitionResume[status]?.next;
+}
+
+export function getPayMerchantAbortTransition(
+ status: PurchaseStatus,
+ hasSuccessfulPay: boolean,
+): PurchaseStatus | undefined {
+ switch (status) {
+ case PurchaseStatus.PendingDownloadingProposal:
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ case PurchaseStatus.DialogProposed:
+ return PurchaseStatus.AbortedProposalRefused;
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.SuspendedPaying:
+ return PurchaseStatus.AbortingWithRefund;
+ case PurchaseStatus.PendingPayingReplay:
+ case PurchaseStatus.SuspendedPayingReplay:
+ return PurchaseStatus.Done;
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ case PurchaseStatus.PendingAcceptRefund:
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ case PurchaseStatus.PendingQueryingRefund:
+ case PurchaseStatus.SuspendedQueryingRefund:
+ if (!hasSuccessfulPay) {
+ throw Error("invalid payment state for refund processing");
+ }
+ return PurchaseStatus.Done;
+ default:
+ return undefined;
+ }
+}
+
export function computePayMerchantTransactionState(
purchaseRecord: WalletPurchase,
): TransactionState {