taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 36++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
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 {