taler-typescript-core

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

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:
Mpackages/wallet-webui/src/routes/App.tsx | 39++++++++++++++++++++-------------------
Mpackages/wallet-webui/src/routes/action-model.ts | 10+++++-----
Mpackages/wallet-webui/src/routes/transaction-model.ts | 19++++++++++++++++---
Mpackages/wallet-webui/src/screens/PaymentScreen.tsx | 14+++++++-------
Mpackages/wallet-webui/src/screens/StorybookScreen.tsx | 3+++
Mpackages/wallet-webui/src/screens/TransactionDetailScreen.tsx | 2+-
Mpackages/wallet-webui/test/action-model.test.ts | 13++++++++-----
Mpackages/wallet-webui/test/screens.test.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/test/transaction-model.test.ts | 28++++++++++++++++++++++++++++
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,