commit 4ee1438b4ee7a382b729313891c65acc600ebc6b
parent b1151201ed7e4f5d8912bfc8dd3d18cf27d8cc74
Author: Florian Dold <dold@taler.net>
Date: Fri, 4 Sep 2026 01:04:05 +0200
wallet web UI: preserve claimed payments when going back
Diffstat:
9 files changed, 145 insertions(+), 40 deletions(-)
diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx
@@ -187,7 +187,7 @@ import {
scopeRouteQuery,
} from "./scope-model.js";
import {
- abortTransactionBeforeCompleting,
+ cancelTransactionBeforeCompleting,
restoreActionGateReason,
runGuardedValueOperation,
withdrawalTransferResultHint,
@@ -2056,7 +2056,7 @@ function PeerReceiveRoute() {
setCancelling(true);
setError(undefined);
try {
- const outcome = await abortTransactionBeforeCompleting(
+ const outcome = await cancelTransactionBeforeCompleting(
() =>
callMutation(
WalletApiOperation.AbortTransaction,
@@ -3328,7 +3328,7 @@ function IntegratedWithdrawalRoute() {
setCancelling(true);
setError(undefined);
try {
- const outcome = await abortTransactionBeforeCompleting(
+ const outcome = await cancelTransactionBeforeCompleting(
() =>
callMutation(
WalletApiOperation.AbortTransaction,
@@ -3941,7 +3941,7 @@ function PaymentRoute() {
setCancelling(true);
setActionError(undefined);
try {
- const outcome = await abortTransactionBeforeCompleting(
+ const outcome = await cancelTransactionBeforeCompleting(
() =>
callMutation(
WalletApiOperation.AbortTransaction,
@@ -3976,6 +3976,7 @@ function PaymentRoute() {
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => navigate("/withdraw")}
+ onBack={completeAndReturn}
onCancel={completeAndReturn}
onOpenFulfillment={() => {}}
/>
@@ -3991,6 +3992,7 @@ function PaymentRoute() {
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => navigate("/withdraw")}
+ onBack={completeAndReturn}
onCancel={completeAndReturn}
onOpenFulfillment={() => {}}
/>
@@ -4009,6 +4011,7 @@ function PaymentRoute() {
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => navigate("/withdraw")}
+ onBack={completeAndReturn}
onCancel={completeAndReturn}
onOpenFulfillment={() => {}}
/>
@@ -4029,6 +4032,7 @@ function PaymentRoute() {
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => navigate("/withdraw")}
+ onBack={completeAndReturn}
onCancel={completeAndReturn}
onOpenFulfillment={() => {}}
onReclaim={() => void reclaim()}
@@ -4086,6 +4090,7 @@ function PaymentRoute() {
onConfirm={() => {}}
onWithdraw={() => navigate("/withdraw")}
onResume={() => void resumePayment()}
+ onBack={completeAndReturn}
onCancel={
resultState === "paused"
? () => void cancelPausedPayment()
@@ -4253,10 +4258,10 @@ function PaymentDialog(props: {
setCancelling(true);
setError(undefined);
try {
- const outcome = await abortTransactionBeforeCompleting(
+ const outcome = await cancelTransactionBeforeCompleting(
() =>
callMutation(
- WalletApiOperation.AbortTransaction,
+ WalletApiOperation.DeleteTransaction,
{ transactionId: props.transaction.transactionId },
["balances", "transactions"],
),
@@ -4416,6 +4421,10 @@ function PaymentDialog(props: {
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => navigate("/withdraw")}
+ onBack={() => {
+ void completeActionBestEffort(platform.actionInbox, props.actionId);
+ navigate("/");
+ }}
onCancel={() => void cancel()}
onOpenFulfillment={() => {}}
cancelling={cancelling}
@@ -4458,19 +4467,11 @@ function PaymentDialog(props: {
onKeepHere={keepPaymentHere}
onReclaim={() => void reclaim()}
onWithdraw={() => navigate("/withdraw")}
- onCancel={
- state === "handoff"
- ? () => {
- void completeActionBestEffort(
- platform.actionInbox,
- props.actionId,
- );
- navigate("/");
- }
- : state === "pending" || state === "done"
- ? () => navigate("/")
- : () => void cancel()
- }
+ onBack={() => {
+ void completeActionBestEffort(platform.actionInbox, props.actionId);
+ navigate("/");
+ }}
+ onCancel={() => void cancel()}
onOpenFulfillment={() => {
if (fulfillmentUrl)
void platform.openExternal(fulfillmentUrl, props.fulfillmentTarget);
diff --git a/packages/wallet-webui/src/routes/action-model.ts b/packages/wallet-webui/src/routes/action-model.ts
@@ -1,17 +1,17 @@
import { Result, type Result as ResultType } from "@gnu-taler/taler-util";
import { i18n } from "../i18n/runtime.js";
-export type AbortTransactionOutcome =
+export type TransactionCancellationOutcome =
| { cancelled: true }
| { cancelled: false; kind: "wallet"; detail: unknown }
| { cancelled: false; kind: "exception"; cause: unknown };
-export async function abortTransactionBeforeCompleting<T, E>(
- abort: () => Promise<ResultType<T, E>>,
+export async function cancelTransactionBeforeCompleting<T, E>(
+ cancel: () => Promise<ResultType<T, E>>,
complete: () => void,
-): Promise<AbortTransactionOutcome> {
+): Promise<TransactionCancellationOutcome> {
try {
- const result = await abort();
+ const result = await cancel();
if (Result.isError(result)) {
return { cancelled: false, kind: "wallet", detail: result.detail };
}
diff --git a/packages/wallet-webui/src/routes/transaction-model.ts b/packages/wallet-webui/src/routes/transaction-model.ts
@@ -653,9 +653,22 @@ export function transactionDetailView(
): TransactionDetailView {
const base = transactionHistoryView(transaction, language);
const state = transactionStateView(transaction);
- const actions = transaction.txActions.map(
- (action) => actionPresentations()[action],
- );
+ const presentations = actionPresentations();
+ const actions = transaction.txActions.map((action) => {
+ if (
+ action === TransactionAction.Delete &&
+ transaction.type === TransactionType.Payment &&
+ transaction.txState.major === TransactionMajorState.Dialog
+ ) {
+ return {
+ id: "delete" as const,
+ label: i18n.str`Cancel payment`,
+ dangerous: true,
+ confirmation: i18n.str`Cancel this payment? The wallet will remove it from transaction history. The merchant may keep the order claimed until it expires.`,
+ };
+ }
+ return presentations[action];
+ });
if (
transaction.type === TransactionType.Payment &&
transaction.refundPending
diff --git a/packages/wallet-webui/src/screens/PaymentScreen.tsx b/packages/wallet-webui/src/screens/PaymentScreen.tsx
@@ -447,6 +447,7 @@ export function PaymentScreen(props: {
onToggleDonau?: (enabled: boolean) => void;
onConfigureDonau?: (donauBaseUrl: string) => void;
onWithdraw: () => void;
+ onBack: () => void;
onCancel: () => void;
onResume?: () => void;
onUnclaim?: () => void;
@@ -465,9 +466,8 @@ export function PaymentScreen(props: {
{(props.state === "ready" || props.state === "error") && (
<button
type="button"
- onClick={props.onCancel}
- disabled={props.cancelling}
- aria-label={i18n.str`Cancel payment`}
+ onClick={props.onBack}
+ aria-label={i18n.str`Back to wallet`}
class="grid h-11 w-11 place-items-center rounded-full text-2xl hover:bg-secondaryContainer"
>
←
@@ -595,7 +595,7 @@ export function PaymentScreen(props: {
{props.state === "handoff" && props.talerPayUri && (
<PaymentHandoff
talerPayUri={props.talerPayUri}
- onDone={props.onCancel}
+ onDone={props.onBack}
onReclaim={props.onReclaim}
reclaiming={props.reclaiming}
/>
@@ -616,7 +616,7 @@ export function PaymentScreen(props: {
class="mt-2"
>{i18n.str`The other wallet claimed this payment. You can safely close this screen.`}</p>
<div class="mt-5">
- <Button onClick={props.onCancel}>{i18n.str`Back to wallet`}</Button>
+ <Button onClick={props.onBack}>{i18n.str`Back to wallet`}</Button>
</div>
</Card>
)}
@@ -634,7 +634,7 @@ export function PaymentScreen(props: {
class="mt-2"
>{i18n.str`The wallet is completing the payment. Its progress is available in transaction history.`}</p>
<div class="mt-5">
- <Button onClick={props.onCancel}>{i18n.str`Back to wallet`}</Button>
+ <Button onClick={props.onBack}>{i18n.str`Back to wallet`}</Button>
</div>
</Card>
)}
@@ -684,7 +684,7 @@ export function PaymentScreen(props: {
)}
<Button
tone="secondary"
- onClick={props.onCancel}
+ onClick={props.onBack}
>{i18n.str`Back to wallet`}</Button>
</div>
</Card>
diff --git a/packages/wallet-webui/src/screens/StorybookScreen.tsx b/packages/wallet-webui/src/screens/StorybookScreen.tsx
@@ -639,6 +639,7 @@ export function StorybookScreen(props: {
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
/>
@@ -656,6 +657,7 @@ export function StorybookScreen(props: {
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
onCopyPosConfirmation={() => {}}
@@ -682,6 +684,7 @@ export function StorybookScreen(props: {
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
/>
diff --git a/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx b/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx
@@ -368,7 +368,7 @@ export function TransactionDetailScreen(props: {
{confirmUnclaim && props.onUnclaimPayment && (
<ConfirmationDialog
title={i18n.str`Continue with another wallet?`}
- description={i18n.str`This wallet will release the payment so another wallet can claim it. You will not be able to pay this order here afterward.`}
+ description={i18n.str`This wallet will release the payment so another wallet can claim it. You can continue with this wallet again until another wallet claims the order.`}
cancelLabel={i18n.str`Keep payment here`}
confirmLabel={i18n.str`Continue with another wallet`}
working={
diff --git a/packages/wallet-webui/test/action-model.test.ts b/packages/wallet-webui/test/action-model.test.ts
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { Result } from "@gnu-taler/taler-util";
import {
- abortTransactionBeforeCompleting,
+ cancelTransactionBeforeCompleting,
restoreActionGateReason,
runGuardedValueOperation,
withdrawalTransferResultHint,
@@ -27,18 +27,21 @@ test("restore actions name the backup-sync gate without suggesting import", () =
assert.match(reason, /database import is not a safe substitute/i);
});
-test("transaction cancellation completes its action only after a successful abort", async () => {
+test("transaction cancellation completes its action only after success", async () => {
let completions = 0;
const complete = () => completions++;
assert.deepEqual(
- await abortTransactionBeforeCompleting(async () => Result.of({}), complete),
+ await cancelTransactionBeforeCompleting(
+ async () => Result.of({}),
+ complete,
+ ),
{ cancelled: true },
);
assert.equal(completions, 1);
const detail = { code: 42 };
assert.deepEqual(
- await abortTransactionBeforeCompleting(
+ await cancelTransactionBeforeCompleting(
async () => Result.errorWithDetail("failed", detail),
complete,
),
@@ -48,7 +51,7 @@ test("transaction cancellation completes its action only after a successful abor
const failure = Error("transport unavailable");
assert.deepEqual(
- await abortTransactionBeforeCompleting(async () => {
+ await cancelTransactionBeforeCompleting(async () => {
throw failure;
}, complete),
{ cancelled: false, kind: "exception", cause: failure },
diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx
@@ -1450,6 +1450,7 @@ test("payment choices are keyboard-operable and accessible", async () => {
confirmed = true;
}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
/>
@@ -1507,6 +1508,7 @@ test("payment handoff is confirmed even when the payment choice is unavailable",
unclaimed = true;
}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
/>
@@ -1529,6 +1531,55 @@ test("payment handoff is confirmed even when the payment choice is unavailable",
await window.happyDOM.abort();
});
+test("payment back navigation is separate from cancellation", async () => {
+ const window = installDom();
+ const { render, cleanup } = await import("@testing-library/preact");
+ const userEvent = (await import("@testing-library/user-event"))
+ .default as unknown as {
+ setup(options: { document: Document }): {
+ click(element: Element): Promise<void>;
+ };
+ };
+ let backed = 0;
+ let cancelled = 0;
+ const view = render(
+ <main>
+ <PaymentScreen
+ state="ready"
+ merchantName="Example Merchant"
+ choices={[
+ {
+ index: 0,
+ description: "Museum entry",
+ amountRaw: "CHF:5",
+ payable: true,
+ inputs: [],
+ outputs: [],
+ },
+ ]}
+ selectedChoice={0}
+ onSelectChoice={() => {}}
+ onConfirm={() => {}}
+ onWithdraw={() => {}}
+ onBack={() => backed++}
+ onCancel={() => cancelled++}
+ onOpenFulfillment={() => {}}
+ />
+ </main>,
+ );
+ const user = userEvent.setup({
+ document: window.document as unknown as Document,
+ });
+ await user.click(view.getByRole("button", { name: "Back to wallet" }));
+ assert.equal(backed, 1);
+ assert.equal(cancelled, 0);
+ await user.click(view.getByRole("button", { name: "Cancel" }));
+ assert.equal(backed, 1);
+ assert.equal(cancelled, 1);
+ cleanup();
+ await window.happyDOM.abort();
+});
+
test("payment handoff shows a QR code and has a neutral completion screen", async () => {
const window = installDom();
const { render, cleanup } = await import("@testing-library/preact");
@@ -1551,6 +1602,7 @@ test("payment handoff shows a QR code and has a neutral completion screen", asyn
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
/>
@@ -1572,6 +1624,7 @@ test("payment handoff shows a QR code and has a neutral completion screen", asyn
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
/>
@@ -1622,6 +1675,7 @@ test("payment handoff can be reversed while it is being prepared", async () => {
keptHere = true;
}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
/>
@@ -1702,6 +1756,7 @@ test("a legally paused payment can be resumed or cancelled", async () => {
resumed = true;
}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {
cancelled = true;
}}
@@ -1761,6 +1816,7 @@ test("payment donation receipt collection is an explicit opt-in", async () => {
}}
onConfirm={() => {}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
/>
@@ -1800,6 +1856,7 @@ test("completed POS payments display and copy the merchant confirmation code", a
onSelectChoice={() => {}}
onConfirm={() => {}}
onWithdraw={() => {}}
+ onBack={() => {}}
onCancel={() => {}}
onOpenFulfillment={() => {}}
onCopyPosConfirmation={(code) => {
diff --git a/packages/wallet-webui/test/transaction-model.test.ts b/packages/wallet-webui/test/transaction-model.test.ts
@@ -221,6 +221,34 @@ test("details expose only wallet-core actions plus a known available refund", ()
);
});
+test("claimed payment deletion is presented as payment cancellation", () => {
+ const view = transactionDetailView(
+ payment({
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Ready,
+ working: false,
+ },
+ txActions: [TransactionAction.Delete],
+ }),
+ );
+ assert.equal(view.actions[0]?.id, "delete");
+ assert.equal(view.actions[0]?.label, "Cancel payment");
+ assert.match(
+ view.actions[0]?.confirmation ?? "",
+ /remove it from transaction history/i,
+ );
+ assert.match(
+ view.actions[0]?.confirmation ?? "",
+ /claimed until it expires/i,
+ );
+
+ const ordinaryDelete = transactionDetailView(
+ payment({ txActions: [TransactionAction.Delete] }),
+ );
+ assert.equal(ordinaryDelete.actions[0]?.label, "Delete record");
+});
+
test("transaction details preserve complete wallet-core errors", () => {
const detail = {
code: TalerErrorCode.WALLET_NETWORK_ERROR,