commit 46ecefec98f13a5ad00607f60a4e51130ee4d84a
parent b629ad6d12703d26a50af867810da91491cc4380
Author: Florian Dold <dold@taler.net>
Date: Thu, 27 Aug 2026 10:50:04 +0200
wallet-webui: confirm proposed payments from transaction details
Diffstat:
7 files changed, 561 insertions(+), 178 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts
@@ -122,9 +122,17 @@ export async function runWalletWebUiDemoTest(t: GlobalTestState) {
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("heading", { name: "Review payment" }).waitFor();
await page.getByText("Museum admission", { exact: true }).waitFor();
+ await page.goto(`${demoUrl}#/transactions`);
+ await page.getByRole("heading", { name: "History" }).waitFor();
+ await page
+ .getByRole("button", { name: /City Museum/ })
+ .first()
+ .click();
+ await page.getByRole("heading", { name: "City Museum" }).waitFor();
+ await page.getByText("Payment options", { exact: true }).waitFor();
await page.getByRole("button", { name: "Pay CHF:5.10" }).click();
- await page.getByText("Payment complete", { exact: true }).waitFor();
- await page.getByRole("button", { name: "Back to wallet" }).click();
+ await page.getByText("Completed", { exact: true }).waitFor();
+ await page.goto(demoUrl);
await page
.locator("strong:visible", { hasText: "37.40" })
.first()
diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx
@@ -114,7 +114,8 @@ import {
} from "../ui/error.js";
import { connectionStatus } from "../stores/ui.js";
import {
- asynchronousConfirmPayRequest,
+ activePaymentChoiceIndex,
+ paymentConfirmationRequest,
paymentRepurchaseTarget,
paymentResultState,
paymentReviewView,
@@ -3954,10 +3955,7 @@ function PaymentDialog(props: {
setCancelling(false);
automaticStarted.current = false;
}, [props.actionId, props.transaction.transactionId]);
- const activeChoice =
- selectedChoice ??
- review?.defaultChoiceIndex ??
- review?.choices.find((choice) => choice.payable)?.index;
+ const activeChoice = activePaymentChoiceIndex(review, selectedChoice);
const confirm = useCallback(
async (choiceIndex = activeChoice, collectDonationReceipt = useDonau) => {
if (choiceIndex === undefined) return;
@@ -3965,22 +3963,17 @@ function PaymentDialog(props: {
setState("paying");
setError(undefined);
try {
- const forceTokenSelection = review?.choices.find(
+ const choice = review?.choices.find(
(choice) => choice.index === choiceIndex,
- )?.forceTokenSelection;
- const donationAvailable =
- review?.choices.find((choice) => choice.index === choiceIndex)
- ?.donationReceipt?.status === "available";
+ );
const result = await callMutation(
WalletApiOperation.ConfirmPay,
- asynchronousConfirmPayRequest({
- transactionId: props.transaction.transactionId,
+ paymentConfirmationRequest(
+ props.transaction.transactionId,
choiceIndex,
- ...(forceTokenSelection ? { forcedTokenSel: true } : {}),
- ...(collectDonationReceipt && donationAvailable
- ? { useDonau: true }
- : {}),
- }),
+ choice,
+ collectDonationReceipt,
+ ),
["balances", "transactions"],
);
if (Result.isError(result)) {
@@ -4170,21 +4163,109 @@ function TransactionDetailRoute() {
WalletApiOperation.GetTransactionById,
{ transactionId, includeContractTerms: true },
);
+ const paymentDialog =
+ query.data?.type === TransactionType.Payment &&
+ query.data.txState.major === TransactionMajorState.Dialog
+ ? query.data
+ : undefined;
+ const paymentChoicesQuery = useWalletQuery(
+ connection,
+ WalletApiOperation.GetChoicesForPayment,
+ { transactionId },
+ { enabled: paymentDialog !== undefined },
+ );
+ const paymentDonauQuery = useWalletQuery(
+ connection,
+ WalletApiOperation.GetDonau,
+ {},
+ { enabled: paymentDialog !== undefined },
+ );
const [workingAction, setWorkingAction] = useState<TransactionUiAction>();
const [error, setError] = useState<ErrorPresentation>();
const [message, setMessage] = useState<string>();
const [continuingTransfer, setContinuingTransfer] = useState(false);
+ const [selectedPaymentChoice, setSelectedPaymentChoice] = useState<number>();
+ const [useDonau, setUseDonau] = useState(false);
+ const [confirmingPayment, setConfirmingPayment] = useState(false);
useEffect(() => {
setWorkingAction(undefined);
setError(undefined);
setMessage(undefined);
setContinuingTransfer(false);
+ setSelectedPaymentChoice(undefined);
+ setUseDonau(false);
+ setConfirmingPayment(false);
}, [transactionId]);
const transaction = useMemo(
() =>
query.data ? transactionDetailView(query.data, language) : undefined,
[language, query.data],
);
+ const paymentReview = useMemo(
+ () =>
+ paymentDialog && paymentChoicesQuery.data
+ ? paymentReviewView(
+ paymentDialog,
+ paymentChoicesQuery.data,
+ paymentDonauQuery.data?.currentDonauInfo,
+ language,
+ )
+ : undefined,
+ [
+ language,
+ paymentChoicesQuery.data,
+ paymentDialog,
+ paymentDonauQuery.data?.currentDonauInfo,
+ ],
+ );
+ const activePaymentChoice = activePaymentChoiceIndex(
+ paymentReview,
+ selectedPaymentChoice,
+ );
+ useEffect(() => setUseDonau(false), [activePaymentChoice]);
+ const confirmPayment = async (collectDonationReceipt = useDonau) => {
+ if (!paymentDialog || !paymentReview || activePaymentChoice === undefined)
+ return;
+ if (!(await durability.guardValueOperation())) return;
+ setConfirmingPayment(true);
+ setError(undefined);
+ setMessage(undefined);
+ try {
+ const choice = paymentReview.choices.find(
+ (candidate) => candidate.index === activePaymentChoice,
+ );
+ const result = await callMutation(
+ WalletApiOperation.ConfirmPay,
+ paymentConfirmationRequest(
+ transactionId,
+ activePaymentChoice,
+ choice,
+ collectDonationReceipt,
+ ),
+ ["balances", "transactions"],
+ );
+ if (Result.isError(result)) {
+ setError(
+ walletCoreError(
+ result.detail,
+ i18n.str`Wallet-core could not confirm this payment.`,
+ ),
+ );
+ return;
+ }
+ setMessage(
+ result.value.type === ConfirmPayResultType.Done
+ ? i18n.str`Payment complete`
+ : i18n.str`Payment submitted`,
+ );
+ } catch (cause) {
+ setError(
+ errorFromException(cause, i18n.str`Payment confirmation failed`),
+ );
+ } finally {
+ setConfirmingPayment(false);
+ }
+ };
const runAction = async (action: TransactionUiAction) => {
if (
(action === "retry" || action === "resume" || action === "refund") &&
@@ -4305,6 +4386,42 @@ function TransactionDetailRoute() {
onAction={(action) => void runAction(action)}
onOpenExternal={(url) => void platform.openExternal(url)}
onContinue={() => setContinuingTransfer(true)}
+ paymentReview={
+ paymentDialog
+ ? paymentChoicesQuery.error
+ ? {
+ state: "error",
+ error: errorFromException(
+ paymentChoicesQuery.error,
+ i18n.str`Payment options could not be loaded.`,
+ ),
+ }
+ : !paymentDialog.contractTerms
+ ? {
+ state: "error",
+ error: localError(
+ i18n.str`The payment contract details are unavailable.`,
+ ),
+ }
+ : !paymentReview
+ ? { state: "loading" }
+ : {
+ state: confirmingPayment ? "paying" : "ready",
+ choices: paymentReview.choices,
+ selectedChoice: activePaymentChoice,
+ useDonau,
+ }
+ : undefined
+ }
+ onSelectPaymentChoice={setSelectedPaymentChoice}
+ onConfirmPayment={(collect) => void confirmPayment(collect)}
+ onToggleDonau={setUseDonau}
+ onConfigureDonau={(baseUrl) =>
+ navigate(
+ `/donations?suggested=${encodeURIComponent(baseUrl)}&returnTo=${encodeURIComponent(`/transaction/${transactionId}`)}`,
+ )
+ }
+ onWithdraw={() => navigate("/withdraw")}
/>
);
}
diff --git a/packages/wallet-webui/src/routes/payment-model.ts b/packages/wallet-webui/src/routes/payment-model.ts
@@ -44,6 +44,36 @@ export function asynchronousConfirmPayRequest(
return { ...request, noWait: true };
}
+export function paymentConfirmationRequest(
+ transactionId: TransactionIdStr,
+ choiceIndex: number,
+ choice: PaymentChoiceView | undefined,
+ collectDonationReceipt: boolean,
+): ConfirmPayRequest {
+ return asynchronousConfirmPayRequest({
+ transactionId,
+ choiceIndex,
+ ...(choice?.forceTokenSelection ? { forcedTokenSel: true } : {}),
+ ...(collectDonationReceipt &&
+ choice?.donationReceipt?.status === "available"
+ ? { useDonau: true }
+ : {}),
+ });
+}
+
+/** Choose what the payment review should display and submit. */
+export function activePaymentChoiceIndex(
+ review: PaymentReviewView | undefined,
+ selectedChoice: number | undefined,
+): number | undefined {
+ return (
+ selectedChoice ??
+ review?.defaultChoiceIndex ??
+ review?.choices.find((choice) => choice.payable)?.index ??
+ review?.choices[0]?.index
+ );
+}
+
/** Return the original payment that wallet-core is replaying for a repurchase. */
export function paymentRepurchaseTarget(
transaction: TransactionPayment | undefined,
diff --git a/packages/wallet-webui/src/screens/PaymentScreen.tsx b/packages/wallet-webui/src/screens/PaymentScreen.tsx
@@ -155,6 +155,183 @@ export type PaymentScreenState =
| "done"
| "error";
+export function PaymentOptions(props: {
+ state: "ready" | "paying";
+ choices?: PaymentChoiceView[];
+ selectedChoice?: number;
+ useDonau?: boolean;
+ cancelling?: boolean;
+ onSelectChoice: (index: number) => void;
+ onConfirm: (useDonau?: boolean) => void;
+ onToggleDonau?: (enabled: boolean) => void;
+ onConfigureDonau?: (donauBaseUrl: string) => void;
+ onWithdraw: () => void;
+ onCancel?: () => void;
+}) {
+ const selected = props.choices?.find(
+ (choice) => choice.index === props.selectedChoice,
+ );
+ return (
+ <>
+ {props.choices && props.choices.length > 1 && (
+ <fieldset class="space-y-3">
+ <legend class="mb-2 font-semibold">{i18n.str`Payment options`}</legend>
+ {props.choices.map((choice) => (
+ <label
+ key={choice.index}
+ class={`flex cursor-pointer gap-3 rounded-2xl border bg-surface p-4 ${choice.index === props.selectedChoice ? "border-primary ring-2 ring-primary" : "border-outlineVariant"} ${choice.payable ? "" : "opacity-70"}`}
+ >
+ <input
+ type="radio"
+ name="payment-choice"
+ value={choice.index}
+ checked={choice.index === props.selectedChoice}
+ onChange={() => props.onSelectChoice(choice.index)}
+ class="mt-1 h-5 w-5 accent-primary"
+ />
+ <div class="min-w-0 flex-1">
+ <span class="block font-medium">{choice.description}</span>
+ <span class="mt-1 block text-xl font-semibold">
+ {choice.amountRaw}
+ </span>
+ {choice.amountEffective &&
+ choice.amountEffective !== choice.amountRaw && (
+ <span class="block text-sm text-secondary">
+ {
+ // Placeholder is the fully formatted effective payment amount.
+ i18n.str`Total including fees: ${choice.amountEffective}`
+ }
+ </span>
+ )}
+ {!choice.payable && (
+ <span class="mt-2 block text-sm text-error">
+ {choice.unavailableReason ??
+ (choice.availableBalance
+ ? // Token means a merchant-issued discount or subscription token, not an authentication token.
+ i18n.str`Insufficient balance or required token (${choice.availableBalance} available)`
+ : i18n.str`Insufficient balance or required token`)}
+ </span>
+ )}
+ {choice.tokenWarning && (
+ <span class="mt-2 block text-sm text-onWarningContainer">
+ {choice.tokenWarning}
+ </span>
+ )}
+ <ChoiceDetails choice={choice} />
+ </div>
+ </label>
+ ))}
+ </fieldset>
+ )}
+
+ {selected && (props.choices?.length ?? 0) <= 1 && (
+ <>
+ <h2 class="font-semibold">{i18n.str`Payment options`}</h2>
+ <Card>
+ <p class="text-2xl font-medium">{selected.amountRaw}</p>
+ {selected.amountEffective &&
+ selected.amountEffective !== selected.amountRaw && (
+ <p class="mt-2 text-sm text-secondary">{i18n.str`Total including fees: ${selected.amountEffective}`}</p>
+ )}
+ {!selected.payable && (
+ <p role="alert" class="mt-3 text-error">
+ {selected.unavailableReason ??
+ (selected.availableBalance
+ ? i18n.str`Insufficient balance (${selected.availableBalance} available)`
+ : i18n.str`Insufficient balance`)}
+ </p>
+ )}
+ {selected.tokenWarning && (
+ <p class="mt-3 rounded-xl bg-warningContainer p-3 text-onWarningContainer">
+ {selected.tokenWarning}
+ </p>
+ )}
+ <div class="mt-3 text-left">
+ <ChoiceDetails choice={selected} />
+ </div>
+ </Card>
+ </>
+ )}
+
+ {selected?.donationReceipt && (
+ <Card class="border-primaryContainer bg-primaryContainer text-onPrimaryContainer">
+ <h2 class="font-semibold">{i18n.str`Donation receipt`}</h2>
+ {selected.donationReceipt.status === "available" ? (
+ <label class="mt-3 flex min-h-11 cursor-pointer items-center gap-3">
+ <input
+ type="checkbox"
+ checked={props.useDonau === true}
+ onChange={(event) =>
+ props.onToggleDonau?.(event.currentTarget.checked)
+ }
+ class="h-5 w-5 accent-primary"
+ />
+ <span>
+ {
+ // Opts into collecting a signed Donau receipt for a qualifying charitable payment.
+ i18n.str`Collect a donation receipt for this payment`
+ }
+ </span>
+ </label>
+ ) : (
+ <>
+ <p class="mt-2 text-sm">
+ {selected.donationReceipt.status === "setup"
+ ? i18n.str`This payment can provide a donation receipt. Configure one of the supported donation authorities first.`
+ : i18n.str`The configured donation authority is not supported by this merchant choice.`}
+ </p>
+ {props.onConfigureDonau &&
+ selected.donationReceipt.donauUrls[0] && (
+ <div class="mt-4">
+ <Button
+ tone="secondary"
+ onClick={() =>
+ props.onConfigureDonau!(
+ selected.donationReceipt!.donauUrls[0],
+ )
+ }
+ >{i18n.str`Configure donation receipts`}</Button>
+ </div>
+ )}
+ </>
+ )}
+ </Card>
+ )}
+
+ <div class="flex flex-wrap justify-end gap-3 border-t border-outlineVariant pt-5">
+ {props.onCancel && (
+ <Button
+ tone="secondary"
+ onClick={props.onCancel}
+ disabled={props.cancelling}
+ >
+ {props.cancelling ? i18n.str`Cancelling…` : i18n.str`Cancel`}
+ </Button>
+ )}
+ {selected && !selected.payable && (
+ <Button
+ tone="secondary"
+ onClick={props.onWithdraw}
+ >{i18n.str`Withdraw funds`}</Button>
+ )}
+ <Button
+ onClick={() => props.onConfirm(props.useDonau === true)}
+ disabled={
+ !selected?.payable || props.state === "paying" || props.cancelling
+ }
+ >
+ {props.state === "paying"
+ ? i18n.str`Paying…`
+ : selected
+ ? // Button label; the placeholder is a fully formatted payment amount.
+ i18n.str`Pay ${selected.amountRaw}`
+ : i18n.str`Pay`}
+ </Button>
+ </div>
+ </>
+ );
+}
+
export function PaymentScreen(props: {
state: PaymentScreenState;
merchantName?: string;
@@ -181,9 +358,6 @@ export function PaymentScreen(props: {
onOpenFulfillment: () => void;
onCopyPosConfirmation?: (code: string) => void;
}) {
- const selected = props.choices?.find(
- (choice) => choice.index === props.selectedChoice,
- );
return (
<div class="mx-auto max-w-2xl space-y-5">
<div class="flex items-center gap-4">
@@ -300,162 +474,19 @@ export function PaymentScreen(props: {
}
}
/>
-
- {props.choices && props.choices.length > 1 && (
- <fieldset class="space-y-3">
- <legend class="mb-2 font-semibold">{i18n.str`Payment options`}</legend>
- {props.choices.map((choice) => (
- <label
- key={choice.index}
- class={`flex cursor-pointer gap-3 rounded-2xl border bg-surface p-4 ${choice.index === props.selectedChoice ? "border-primary ring-2 ring-primary" : "border-outlineVariant"} ${choice.payable ? "" : "opacity-70"}`}
- >
- <input
- type="radio"
- name="payment-choice"
- value={choice.index}
- checked={choice.index === props.selectedChoice}
- onChange={() => props.onSelectChoice(choice.index)}
- class="mt-1 h-5 w-5 accent-primary"
- />
- <div class="min-w-0 flex-1">
- <span class="block font-medium">{choice.description}</span>
- <span class="mt-1 block text-xl font-semibold">
- {choice.amountRaw}
- </span>
- {choice.amountEffective &&
- choice.amountEffective !== choice.amountRaw && (
- <span class="block text-sm text-secondary">
- {
- // Placeholder is the fully formatted effective payment amount.
- i18n.str`Total including fees: ${choice.amountEffective}`
- }
- </span>
- )}
- {!choice.payable && (
- <span class="mt-2 block text-sm text-error">
- {choice.unavailableReason ??
- (choice.availableBalance
- ? // Token means a merchant-issued discount or subscription token, not an authentication token.
- i18n.str`Insufficient balance or required token (${choice.availableBalance} available)`
- : i18n.str`Insufficient balance or required token`)}
- </span>
- )}
- {choice.tokenWarning && (
- <span class="mt-2 block text-sm text-onWarningContainer">
- {choice.tokenWarning}
- </span>
- )}
- <ChoiceDetails choice={choice} />
- </div>
- </label>
- ))}
- </fieldset>
- )}
-
- {selected && (props.choices?.length ?? 0) <= 1 && (
- <>
- <h2 class="font-semibold">{i18n.str`Payment options`}</h2>
- <Card>
- <p class="text-2xl font-medium">{selected.amountRaw}</p>
- {selected.amountEffective &&
- selected.amountEffective !== selected.amountRaw && (
- <p class="mt-2 text-sm text-secondary">{i18n.str`Total including fees: ${selected.amountEffective}`}</p>
- )}
- {!selected.payable && (
- <p role="alert" class="mt-3 text-error">
- {selected.unavailableReason ??
- (selected.availableBalance
- ? i18n.str`Insufficient balance (${selected.availableBalance} available)`
- : i18n.str`Insufficient balance`)}
- </p>
- )}
- {selected.tokenWarning && (
- <p class="mt-3 rounded-xl bg-warningContainer p-3 text-onWarningContainer">
- {selected.tokenWarning}
- </p>
- )}
- <div class="mt-3 text-left">
- <ChoiceDetails choice={selected} />
- </div>
- </Card>
- </>
- )}
-
- {selected?.donationReceipt && (
- <Card class="border-primaryContainer bg-primaryContainer text-onPrimaryContainer">
- <h2 class="font-semibold">{i18n.str`Donation receipt`}</h2>
- {selected.donationReceipt.status === "available" ? (
- <label class="mt-3 flex min-h-11 cursor-pointer items-center gap-3">
- <input
- type="checkbox"
- checked={props.useDonau === true}
- onChange={(event) =>
- props.onToggleDonau?.(event.currentTarget.checked)
- }
- class="h-5 w-5 accent-primary"
- />
- <span>
- {
- // Opts into collecting a signed Donau receipt for a qualifying charitable payment.
- i18n.str`Collect a donation receipt for this payment`
- }
- </span>
- </label>
- ) : (
- <>
- <p class="mt-2 text-sm">
- {selected.donationReceipt.status === "setup"
- ? i18n.str`This payment can provide a donation receipt. Configure one of the supported donation authorities first.`
- : i18n.str`The configured donation authority is not supported by this merchant choice.`}
- </p>
- {props.onConfigureDonau &&
- selected.donationReceipt.donauUrls[0] && (
- <div class="mt-4">
- <Button
- tone="secondary"
- onClick={() =>
- props.onConfigureDonau!(
- selected.donationReceipt!.donauUrls[0],
- )
- }
- >{i18n.str`Configure donation receipts`}</Button>
- </div>
- )}
- </>
- )}
- </Card>
- )}
-
- <div class="flex flex-wrap justify-end gap-3 border-t border-outlineVariant pt-5">
- <Button
- tone="secondary"
- onClick={props.onCancel}
- disabled={props.cancelling}
- >
- {props.cancelling ? i18n.str`Cancelling…` : i18n.str`Cancel`}
- </Button>
- {selected && !selected.payable && (
- <Button
- tone="secondary"
- onClick={props.onWithdraw}
- >{i18n.str`Withdraw funds`}</Button>
- )}
- <Button
- onClick={() => props.onConfirm(props.useDonau === true)}
- disabled={
- !selected?.payable ||
- props.state === "paying" ||
- props.cancelling
- }
- >
- {props.state === "paying"
- ? i18n.str`Paying…`
- : selected
- ? // Button label; the placeholder is a fully formatted payment amount.
- i18n.str`Pay ${selected.amountRaw}`
- : i18n.str`Pay`}
- </Button>
- </div>
+ <PaymentOptions
+ state={props.state}
+ choices={props.choices}
+ selectedChoice={props.selectedChoice}
+ useDonau={props.useDonau}
+ cancelling={props.cancelling}
+ onSelectChoice={props.onSelectChoice}
+ onConfirm={props.onConfirm}
+ onToggleDonau={props.onToggleDonau}
+ onConfigureDonau={props.onConfigureDonau}
+ onWithdraw={props.onWithdraw}
+ onCancel={props.onCancel}
+ />
</>
)}
diff --git a/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx b/packages/wallet-webui/src/screens/TransactionDetailScreen.tsx
@@ -12,6 +12,17 @@ import { ConfirmationDialog } from "../ui/ConfirmationDialog.js";
import { OrderSummary } from "../ui/OrderSummary.js";
import { QrFrame } from "../ui/QrFrame.js";
import { i18n } from "../i18n/runtime.js";
+import { PaymentOptions, type PaymentChoiceView } from "./PaymentScreen.js";
+
+export type TransactionPaymentReview =
+ | { state: "loading" }
+ | { state: "error"; error: ErrorPresentation }
+ | {
+ state: "ready" | "paying";
+ choices: PaymentChoiceView[];
+ selectedChoice?: number;
+ useDonau?: boolean;
+ };
function amountLabel(transaction: TransactionDetailView): string {
if (transaction.direction === "credit") return `+${transaction.amount}`;
@@ -29,6 +40,12 @@ export function TransactionDetailScreen(props: {
onAction: (action: TransactionUiAction) => void;
onOpenExternal: (url: string) => void;
onContinue?: () => void;
+ paymentReview?: TransactionPaymentReview;
+ onSelectPaymentChoice?: (index: number) => void;
+ onConfirmPayment?: (useDonau?: boolean) => void;
+ onToggleDonau?: (enabled: boolean) => void;
+ onConfigureDonau?: (donauBaseUrl: string) => void;
+ onWithdraw?: () => void;
}) {
const [confirmation, setConfirmation] = useState<TransactionActionView>();
useEffect(() => setConfirmation(undefined), [props.transaction?.id]);
@@ -202,6 +219,32 @@ export function TransactionDetailScreen(props: {
heading={i18n.str`Order details`}
/>
)}
+ {props.paymentReview?.state === "loading" && (
+ <Card>
+ <p role="status">{i18n.str`Loading payment details…`}</p>
+ </Card>
+ )}
+ {props.paymentReview?.state === "error" && (
+ <ErrorCard
+ title={i18n.str`Payment options could not be loaded.`}
+ error={props.paymentReview.error}
+ />
+ )}
+ {props.paymentReview &&
+ (props.paymentReview.state === "ready" ||
+ props.paymentReview.state === "paying") && (
+ <PaymentOptions
+ state={props.paymentReview.state}
+ choices={props.paymentReview.choices}
+ selectedChoice={props.paymentReview.selectedChoice}
+ useDonau={props.paymentReview.useDonau}
+ onSelectChoice={(index) => props.onSelectPaymentChoice?.(index)}
+ onConfirm={(collect) => props.onConfirmPayment?.(collect)}
+ onToggleDonau={props.onToggleDonau}
+ onConfigureDonau={props.onConfigureDonau}
+ onWithdraw={() => props.onWithdraw?.()}
+ />
+ )}
<Card>
<h2 class="mb-4 font-semibold">{i18n.str`Transaction details`}</h2>
<dl class="grid grid-cols-[minmax(7rem,auto)_1fr] gap-x-4 gap-y-3 text-sm">
diff --git a/packages/wallet-webui/test/payment-model.test.ts b/packages/wallet-webui/test/payment-model.test.ts
@@ -15,13 +15,51 @@ import {
type TransactionPayment,
} from "@gnu-taler/taler-util";
import {
+ activePaymentChoiceIndex,
asynchronousConfirmPayRequest,
+ paymentConfirmationRequest,
paymentRepurchaseTarget,
paymentResultState,
paymentReviewView,
safeWebUrl,
+ type PaymentReviewView,
} from "../src/routes/payment-model.js";
+test("payment review keeps an unavailable choice visible", () => {
+ const review: PaymentReviewView = {
+ merchantName: "Example Merchant",
+ summary: "Unavailable payment",
+ order: { summary: "Unavailable payment", products: [] },
+ choices: [
+ {
+ index: 3,
+ description: "Unavailable",
+ amountRaw: "CHF:10",
+ payable: false,
+ inputs: [],
+ outputs: [],
+ },
+ ],
+ };
+ assert.equal(activePaymentChoiceIndex(review, undefined), 3);
+ assert.equal(activePaymentChoiceIndex(review, 7), 7);
+ const unavailableChoice = review.choices[0];
+ assert.equal(
+ activePaymentChoiceIndex(
+ {
+ ...review,
+ defaultChoiceIndex: 4,
+ choices: [
+ unavailableChoice,
+ { ...unavailableChoice, index: 4, payable: true },
+ ],
+ },
+ undefined,
+ ),
+ 4,
+ );
+});
+
test("payment confirmation always returns immediately", () => {
assert.deepEqual(
asynchronousConfirmPayRequest({
@@ -34,6 +72,33 @@ test("payment confirmation always returns immediately", () => {
noWait: true,
},
);
+ assert.deepEqual(
+ paymentConfirmationRequest(
+ "txn:payment:test" as TransactionIdStr,
+ 2,
+ {
+ index: 2,
+ description: "Donation",
+ amountRaw: "CHF:5",
+ payable: true,
+ forceTokenSelection: true,
+ inputs: [],
+ outputs: [],
+ donationReceipt: {
+ status: "available",
+ donauUrls: ["https://donau.example/"],
+ },
+ },
+ true,
+ ),
+ {
+ transactionId: "txn:payment:test",
+ choiceIndex: 2,
+ forcedTokenSel: true,
+ useDonau: true,
+ noWait: true,
+ },
+ );
});
test("repurchases follow the original payment replay instead of failing", () => {
diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx
@@ -2964,6 +2964,95 @@ test("dangerous transaction actions require confirmation", async () => {
await window.happyDOM.abort();
});
+test("transaction details confirm a payment and expose insufficient balance actions", 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>;
+ };
+ };
+ const axe = (await import("axe-core")).default as unknown as {
+ run(context: Element): Promise<{ violations: unknown[] }>;
+ };
+ let confirmed = false;
+ let withdrew = false;
+ const transaction = {
+ id: "txn:payment:dialog",
+ title: "Example Museum",
+ subtitle: "Museum entry",
+ typeLabel: "Payment",
+ icon: "↗",
+ amount: "CHF:5",
+ direction: "debit" as const,
+ state: "Needs confirmation",
+ tone: "warning" as const,
+ timestamp: "1 Jan 2026, 10:00",
+ pending: true,
+ statusDetail: "This transaction is waiting for your decision.",
+ fields: [],
+ actions: [],
+ };
+ const renderDetail = (payable: boolean) => (
+ <main>
+ <TransactionDetailScreen
+ loading={false}
+ transaction={transaction}
+ paymentReview={{
+ state: "ready",
+ selectedChoice: 0,
+ choices: [
+ {
+ index: 0,
+ description: "Museum entry",
+ amountRaw: "CHF:5",
+ amountEffective: "CHF:5.10",
+ payable,
+ availableBalance: payable ? undefined : "CHF:2",
+ unavailableReason: payable
+ ? undefined
+ : "The wallet has CHF:2 available.",
+ inputs: [],
+ outputs: [],
+ },
+ ],
+ }}
+ onBack={() => {}}
+ onAction={() => {}}
+ onOpenExternal={() => {}}
+ onConfirmPayment={() => {
+ confirmed = true;
+ }}
+ onWithdraw={() => {
+ withdrew = true;
+ }}
+ />
+ </main>
+ );
+ const view = render(renderDetail(true));
+ const user = userEvent.setup({
+ document: window.document as unknown as Document,
+ });
+ await user.click(view.getByRole("button", { name: "Pay CHF:5" }));
+ assert.equal(confirmed, true);
+ assert.equal(view.queryByRole("button", { name: "Cancel" }), null);
+
+ view.rerender(renderDetail(false));
+ assert(view.getByRole("alert").textContent?.includes("CHF:2"));
+ assert(
+ view.getByRole("button", { name: "Pay CHF:5" }).hasAttribute("disabled"),
+ );
+ await user.click(view.getByRole("button", { name: "Withdraw funds" }));
+ assert.equal(withdrew, true);
+ assert.deepEqual(
+ (await axe.run(window.document.body as unknown as Element)).violations,
+ [],
+ );
+ cleanup();
+ await window.happyDOM.abort();
+});
+
test("ready sent transfer details show the peer payment QR code", async () => {
const window = installDom();
const { render, cleanup } = await import("@testing-library/preact");