taler-typescript-core

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

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:
Mpackages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts | 12++++++++++--
Mpackages/wallet-webui/src/routes/App.tsx | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/wallet-webui/src/routes/payment-model.ts | 30++++++++++++++++++++++++++++++
Mpackages/wallet-webui/src/screens/PaymentScreen.tsx | 349+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/wallet-webui/src/screens/TransactionDetailScreen.tsx | 43+++++++++++++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/test/payment-model.test.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/wallet-webui/test/screens.test.tsx | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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");