commit 8b1b040ce159c371a6b9ffe57f9b85b4e7deb987
parent e09adaa044f648e5d0e5b7a74756b5944f2dd6e2
Author: Florian Dold <dold@taler.net>
Date: Wed, 26 Aug 2026 01:56:25 +0200
wallet-webui: follow repurchase payment replays
Diffstat:
3 files changed, 134 insertions(+), 19 deletions(-)
diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx
@@ -110,6 +110,8 @@ import {
import { connectionStatus } from "../stores/ui.js";
import {
asynchronousConfirmPayRequest,
+ paymentRepurchaseTarget,
+ paymentResultState,
paymentReviewView,
safeWebUrl,
} from "./payment-model.js";
@@ -3677,11 +3679,33 @@ function PaymentRoute() {
WalletApiOperation.GetTransactionById,
{ transactionId, includeContractTerms: true },
);
+ const payment =
+ query.data?.type === TransactionType.Payment ? query.data : undefined;
+ const repurchaseTransactionId = paymentRepurchaseTarget(payment);
+ const repurchaseQuery = useWalletQuery(
+ connection,
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: repurchaseTransactionId ?? "",
+ includeContractTerms: false,
+ },
+ { enabled: repurchaseTransactionId !== undefined },
+ );
+ const replayPayment =
+ repurchaseQuery.data?.type === TransactionType.Payment
+ ? repurchaseQuery.data
+ : undefined;
+ const replayDone =
+ repurchaseTransactionId !== undefined &&
+ replayPayment?.txState.major === TransactionMajorState.Done;
+ useEffect(() => {
+ if (!replayDone) return;
+ void completeActionBestEffort(platform.actionInbox, actionId);
+ }, [actionId, platform, replayDone]);
const waitingForContract =
- query.data?.type === TransactionType.Payment &&
- query.data.txState.minor === TransactionMinorState.ClaimProposal &&
- query.data.txState.working === true &&
- !query.data.contractTerms;
+ payment?.txState.minor === TransactionMinorState.ClaimProposal &&
+ payment.txState.working === true &&
+ !payment.contractTerms;
const refreshPayment = query.mutate;
useEffect(() => {
if (!waitingForContract) return;
@@ -3748,15 +3772,28 @@ function PaymentRoute() {
);
if (transaction.txState.major === TransactionMajorState.Dialog)
return <PaymentDialog transaction={transaction} actionId={actionId} />;
- const done = transaction.txState.major === TransactionMajorState.Done;
- const pending = transaction.txState.major === TransactionMajorState.Pending;
- const terminalError = transactionErrorPresentation(
- transaction,
- i18n.str`Payment is ${transactionStateView(transaction).label}.`,
- );
+ const resultState = paymentResultState(transaction, replayPayment);
+ const statusTransaction = repurchaseTransactionId
+ ? (replayPayment ?? transaction)
+ : transaction;
+ const statusFallback = i18n.str`Payment is ${transactionStateView(statusTransaction).label}.`;
+ const repurchaseError =
+ repurchaseQuery.error && repurchaseTransactionId
+ ? errorFromException(
+ repurchaseQuery.error,
+ i18n.str`The payment transaction is unavailable.`,
+ )
+ : repurchaseTransactionId && repurchaseQuery.data && !replayPayment
+ ? localError(i18n.str`The payment transaction is unavailable.`)
+ : undefined;
+ const terminalError =
+ resultState === "error"
+ ? (transactionErrorPresentation(statusTransaction, statusFallback) ??
+ localError(statusFallback))
+ : undefined;
return (
<PaymentScreen
- state={done ? `done` : pending ? "pending" : "error"}
+ state={repurchaseError ? "error" : resultState}
merchantName={terms.merchant.name}
merchantWebsite={terms.merchant.website}
summary={terms.summary}
@@ -3766,14 +3803,7 @@ function PaymentRoute() {
safeWebUrl(terms.fulfillment_url) ? terms.fulfillment_url : undefined
}
posConfirmation={transaction.posConfirmation}
- error={
- done || pending
- ? undefined
- : (terminalError ??
- localError(
- i18n.str`Payment is ${transactionStateView(transaction).label}.`,
- ))
- }
+ error={repurchaseError ?? terminalError}
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => navigate("/withdraw")}
diff --git a/packages/wallet-webui/src/routes/payment-model.ts b/packages/wallet-webui/src/routes/payment-model.ts
@@ -5,10 +5,13 @@ import {
MerchantContractOutputType,
MerchantContractVersion,
TokenAvailabilityHint,
+ TransactionMajorState,
+ TransactionMinorState,
isSafeExternalUrl,
type ConfirmPayRequest,
type PaymentInsufficientBalanceDetails,
type GetChoicesForPaymentResult,
+ type TransactionIdStr,
type TransactionPayment,
} from "@gnu-taler/taler-util";
import type {
@@ -41,6 +44,37 @@ export function asynchronousConfirmPayRequest(
return { ...request, noWait: true };
}
+/** Return the original payment that wallet-core is replaying for a repurchase. */
+export function paymentRepurchaseTarget(
+ transaction: TransactionPayment | undefined,
+): TransactionIdStr | undefined {
+ if (
+ transaction?.txState.major !== TransactionMajorState.Failed ||
+ transaction.txState.minor !== TransactionMinorState.Repurchase
+ ) {
+ return undefined;
+ }
+ return transaction.repurchaseTransactionId;
+}
+
+/** Map a payment and its optional repurchase replay to the payment screen. */
+export function paymentResultState(
+ transaction: TransactionPayment,
+ replay: TransactionPayment | undefined,
+): "pending" | "done" | "error" {
+ if (paymentRepurchaseTarget(transaction)) {
+ if (!replay) return "pending";
+ if (replay.txState.major === TransactionMajorState.Done) return "done";
+ if (replay.txState.major === TransactionMajorState.Pending)
+ return "pending";
+ return "error";
+ }
+ if (transaction.txState.major === TransactionMajorState.Done) return "done";
+ if (transaction.txState.major === TransactionMajorState.Pending)
+ return "pending";
+ return "error";
+}
+
export function paymentReviewView(
transaction: TransactionPayment,
result: GetChoicesForPaymentResult,
diff --git a/packages/wallet-webui/test/payment-model.test.ts b/packages/wallet-webui/test/payment-model.test.ts
@@ -7,12 +7,17 @@ import {
MerchantContractOutputType,
MerchantContractTokenKind,
TokenAvailabilityHint,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
type GetChoicesForPaymentResult,
type TransactionIdStr,
type TransactionPayment,
} from "@gnu-taler/taler-util";
import {
asynchronousConfirmPayRequest,
+ paymentRepurchaseTarget,
+ paymentResultState,
paymentReviewView,
safeWebUrl,
} from "../src/routes/payment-model.js";
@@ -31,6 +36,52 @@ test("payment confirmation always returns immediately", () => {
);
});
+test("repurchases follow the original payment replay instead of failing", () => {
+ const originalId = "txn:payment:original" as TransactionIdStr;
+ const repurchase = {
+ type: TransactionType.Payment,
+ txState: {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Repurchase,
+ },
+ repurchaseTransactionId: originalId,
+ } as TransactionPayment;
+ const replay = (major: TransactionMajorState) =>
+ ({
+ type: TransactionType.Payment,
+ txState: { major },
+ }) as TransactionPayment;
+
+ assert.equal(paymentRepurchaseTarget(repurchase), originalId);
+ assert.equal(paymentResultState(repurchase, undefined), "pending");
+ assert.equal(
+ paymentResultState(repurchase, replay(TransactionMajorState.Pending)),
+ "pending",
+ );
+ assert.equal(
+ paymentResultState(repurchase, replay(TransactionMajorState.Done)),
+ "done",
+ );
+ assert.equal(
+ paymentResultState(repurchase, replay(TransactionMajorState.Failed)),
+ "error",
+ );
+ assert.equal(
+ paymentRepurchaseTarget({
+ ...repurchase,
+ repurchaseTransactionId: undefined,
+ }),
+ undefined,
+ );
+ assert.equal(
+ paymentResultState(
+ { ...repurchase, repurchaseTransactionId: undefined },
+ undefined,
+ ),
+ "error",
+ );
+});
+
test("payment review preserves core choice availability and rejects unsafe fulfillment URLs", () => {
const transaction = {
contractTerms: {