taler-typescript-core

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

commit 8bcb68fd7ade1bb17a4b46b06ed9f7846512ff4b
parent 37019e79814239d3468bf6c928a6a25fd4d831ff
Author: Florian Dold <dold@taler.net>
Date:   Fri, 11 Sep 2026 20:27:59 +0200

wallet-webui: show cumulative withdrawal limit warnings

Keep the prepared bank transaction ID when recalculating an amount or age
restriction so wallet-core can check the sender account.

Show cumulative KYC warnings, disable confirmation for known hard limits,
and keep unavailable checks informational. Preserve existing balance
warnings and compatibility with older wallet-core responses.

Issue: https://bugs.taler.net/n/10489

Diffstat:
Mpackages/wallet-webui/src/routes/App.tsx | 18+++++++++---------
Mpackages/wallet-webui/src/routes/withdrawal-review-model.ts | 37++++++++++++++++++++++++++++++++++---
Mpackages/wallet-webui/src/screens/WithdrawalReviewScreen.tsx | 6++++++
Mpackages/wallet-webui/test/routing.test.ts | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/wallet-webui/test/screens.test.tsx | 42++++++++++++++++++++++++++++++++++++++++++
5 files changed, 176 insertions(+), 13 deletions(-)

diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -169,6 +169,7 @@ import { } from "./exchange-key-recovery-model.js"; import { withdrawalReviewModel, + withdrawalPreviewRequest, type WithdrawalPreparation, } from "./withdrawal-review-model.js"; import { @@ -2714,11 +2715,7 @@ function WithdrawalRoute( const amount = `${provider.currency}:${value}` as AmountString; const result = await progressRequest.callAndInvalidate( WalletApiOperation.GetWithdrawalDetailsForAmount, - { - exchangeBaseUrl: provider.exchangeBaseUrl, - amount, - ...(restrictAge === undefined ? {} : { restrictAge }), - }, + withdrawalPreviewRequest(provider.exchangeBaseUrl, amount, restrictAge), ["exchanges"], ); if (result.tag === "error") { @@ -2870,6 +2867,7 @@ function WithdrawalRoute( blockedReason={keyRecovery?.message ?? review.blockedReason} blockedActionLabel={keyRecovery?.actionLabel ?? review.gate?.label} warning={review.warning} + limitInfo={review.limitInfo} balanceKyc={review.balanceKyc} coinCountWarning={review.coinCountWarning} working={accepting || preparing || review.trustLoading} @@ -3602,11 +3600,12 @@ function IntegratedWithdrawalRoute() { const amount = `${provider.currency}:${value}` as AmountString; const result = await progressRequest.callAndInvalidate( WalletApiOperation.GetWithdrawalDetailsForAmount, - { - exchangeBaseUrl: provider.exchangeBaseUrl, + withdrawalPreviewRequest( + provider.exchangeBaseUrl, amount, - ...(restrictAge === undefined ? {} : { restrictAge }), - }, + restrictAge, + preparation?.transactionId, + ), ["exchanges"], ); if (Result.isError(result)) @@ -3791,6 +3790,7 @@ function IntegratedWithdrawalRoute() { blockedReason={keyRecovery?.message ?? review.blockedReason} blockedActionLabel={keyRecovery?.actionLabel ?? review.gate?.label} warning={review.warning} + limitInfo={review.limitInfo} balanceKyc={review.balanceKyc} coinCountWarning={review.coinCountWarning} working={accepting || preparing || review.trustLoading} diff --git a/packages/wallet-webui/src/routes/withdrawal-review-model.ts b/packages/wallet-webui/src/routes/withdrawal-review-model.ts @@ -2,6 +2,8 @@ import { Amounts, type AmountString, type ExchangeListItem, + type GetWithdrawalDetailsForAmountRequest, + type TransactionIdStr, type WithdrawalDetailsForAmount, } from "@gnu-taler/taler-util"; import { i18n } from "../i18n/runtime.js"; @@ -16,6 +18,21 @@ export interface WithdrawalPreparation { ageRestrictionOptions: number[]; } +/** Keep the bank transaction context when amount or age choices are recalculated. */ +export function withdrawalPreviewRequest( + exchangeBaseUrl: string, + amount: AmountString, + restrictAge?: number, + transactionId?: TransactionIdStr, +): GetWithdrawalDetailsForAmountRequest { + return { + exchangeBaseUrl, + amount, + ...(restrictAge === undefined ? {} : { restrictAge }), + ...(transactionId === undefined ? {} : { transactionId }), + }; +} + export function withdrawalReviewModel( prepared: WithdrawalPreparation, exchanges: ExchangeListItem[] | undefined, @@ -51,11 +68,15 @@ export function withdrawalReviewModel( (!trustLoading && !managedExchange ? i18n.str`The wallet could not verify the selected exchange. Go back and select it again.` : undefined); + const volumeStatus = prepared.details.withdrawalKycStatus; const blockedReason = (trustLoading ? i18n.str`Checking the exchange's keys and terms…` : trustError) ?? gate?.reason ?? + (volumeStatus === "hard-limit" + ? i18n.str`This withdrawal would exceed the exchange’s withdrawal limit. Choose a smaller amount or try again later.` + : undefined) ?? (accountCount === 0 ? i18n.str`The exchange did not provide a usable bank account for this withdrawal.` : undefined); @@ -66,9 +87,18 @@ export function withdrawalReviewModel( Amounts.cmp(prepared.details.amountRaw, prepared.details.kycSoftLimit) >= 0, ); - const warning = overSoftKyc - ? i18n.str`This amount may require identity verification at the exchange.` - : undefined; + const warning = + volumeStatus === "hard-limit" + ? undefined + : volumeStatus === "kyc-required" + ? i18n.str`This withdrawal, together with your recent withdrawals, may require identity verification at the exchange.` + : overSoftKyc + ? i18n.str`This amount may require identity verification at the exchange.` + : undefined; + const limitInfo = + !warning && volumeStatus === "unknown" + ? i18n.str`Withdrawal limits could not be checked. Identity verification may be required.` + : undefined; const usage = prepared.details.balanceKyc; const balanceKyc = overSoftKyc && @@ -85,6 +115,7 @@ export function withdrawalReviewModel( gate, blockedReason, warning, + limitInfo, balanceKyc, coinCountWarning, trustLoading, diff --git a/packages/wallet-webui/src/screens/WithdrawalReviewScreen.tsx b/packages/wallet-webui/src/screens/WithdrawalReviewScreen.tsx @@ -14,6 +14,7 @@ export function WithdrawalReviewScreen(props: { blockedReason?: string; blockedActionLabel?: string; warning?: string; + limitInfo?: string; balanceKyc?: BalanceKycUsage; coinCountWarning?: string; ageRestrictionOptions?: number[]; @@ -108,6 +109,11 @@ export function WithdrawalReviewScreen(props: { )} </Card> )} + {props.limitInfo && ( + <Card> + <p class="text-secondary">{props.limitInfo}</p> + </Card> + )} {props.coinCountWarning && ( <Card class="border-warning bg-warningContainer"> <p class="text-onWarningContainer">{props.coinCountWarning}</p> diff --git a/packages/wallet-webui/test/routing.test.ts b/packages/wallet-webui/test/routing.test.ts @@ -7,7 +7,10 @@ import { peerRequestExchangeGate, withdrawalExchangeGate, } from "../src/routes/exchange-gate-model.js"; -import { withdrawalReviewModel } from "../src/routes/withdrawal-review-model.js"; +import { + withdrawalReviewModel, + withdrawalPreviewRequest, +} from "../src/routes/withdrawal-review-model.js"; import { safeWalletReturnTo } from "../src/routes/ManagementRoutes.js"; test("hash route matching ignores flow query parameters", () => { @@ -245,3 +248,84 @@ test("withdrawal review uses the explicit KYC decision and balance figures", () ); assert.equal(exact.warning, undefined); }); + +test("bank withdrawal preview recalculation retains account context without leaking it to manual flows", () => { + for (const age of [undefined, 18]) { + for (const amount of ["KUDOS:40", "KUDOS:100"] as const) { + const bank = withdrawalPreviewRequest( + "https://exchange.example/", + amount, + age, + "txn:withdrawal:prepared" as never, + ); + assert.equal(bank.transactionId, "txn:withdrawal:prepared"); + assert.equal(bank.amount, amount); + assert.equal(bank.restrictAge, age); + } + } + const manual = withdrawalPreviewRequest( + "https://exchange.example/", + "KUDOS:100" as never, + ); + assert.equal(Object.hasOwn(manual, "transactionId"), false); +}); + +test("withdrawal review distinguishes volume warnings, hard limits, and unavailable checks", () => { + const prepared = { + provider: { + exchangeBaseUrl: "https://exchange.example/", + currency: "KUDOS", + commonAmounts: [], + }, + amount: "KUDOS:100", + ageRestrictionOptions: [], + details: { + amountRaw: "KUDOS:100", + amountEffective: "KUDOS:99.8", + numCoins: 19, + withdrawalAccountsList: [{ status: "ok" }], + kycRequired: false, + }, + }; + const exchanges = [ + { + exchangeBaseUrl: prepared.provider.exchangeBaseUrl, + tosStatus: ExchangeTosStatus.Accepted, + }, + ] as never; + const model = (status: string | undefined, kycRequired = false) => + withdrawalReviewModel( + { + ...prepared, + details: { + ...prepared.details, + withdrawalKycStatus: status, + kycRequired, + }, + } as never, + exchanges, + false, + ); + const volume = model("kyc-required", true); + assert.match(volume.warning ?? "", /together with your recent withdrawals/); + assert.equal(volume.blockedReason, undefined); + const hard = model("hard-limit", true); + assert.match( + hard.blockedReason ?? "", + /exceed the exchange’s withdrawal limit/, + ); + assert.equal(hard.warning, undefined); + const unknown = model("unknown"); + assert.match(unknown.limitInfo ?? "", /could not be checked/); + assert.equal(unknown.warning, undefined); + assert.equal(unknown.blockedReason, undefined); + const balance = model("unknown", true); + assert.match(balance.warning ?? "", /identity verification/); + assert.equal(balance.limitInfo, undefined); + for (const status of ["ok", undefined]) { + const clear = model(status); + assert.equal(clear.warning, undefined); + assert.equal(clear.limitInfo, undefined); + assert.equal(clear.blockedReason, undefined); + } +}); diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx @@ -4483,3 +4483,45 @@ test("withdrawal review shows balance usage alongside both warnings", async () = cleanup(); await window.happyDOM.abort(); }); + +test("withdrawal review keeps unknown limits informational and blocks known hard limits", async () => { + const window = installDom(); + const { render, cleanup, act } = await import("@testing-library/preact"); + let confirmed = 0; + const props = { + exchange: "https://exchange.example/", + amountRaw: "KUDOS:100", + amountEffective: "KUDOS:99.8", + working: false, + onConfirm: () => { + confirmed++; + }, + onBack: () => {}, + onCancel: () => {}, + }; + const view = render( + <WithdrawalReviewScreen + {...props} + limitInfo="Withdrawal limits could not be checked. Identity verification may be required." + />, + ); + assert(view.getByText(/Withdrawal limits could not be checked/)); + const button = view.getByRole("button", { + name: "Withdraw", + }) as HTMLButtonElement; + assert.equal(button.disabled, false); + await act(() => button.click()); + assert.equal(confirmed, 1); + view.rerender( + <WithdrawalReviewScreen + {...props} + blockedReason="This withdrawal would exceed the exchange’s withdrawal limit. Choose a smaller amount or try again later." + />, + ); + assert.match(view.getByRole("alert").textContent!, /exceed the exchange/); + assert.equal(button.disabled, true); + await act(() => button.click()); + assert.equal(confirmed, 1); + cleanup(); + await window.happyDOM.abort(); +});