commit 71998cc739d15cb44f7d87c1d673f2d18f3cbfe7
parent 070db02d43068eb75ff39fad81de715ced293e36
Author: Florian Dold <dold@taler.net>
Date: Sat, 5 Sep 2026 23:14:00 +0200
wallet web UI: show cumulative balance KYC warnings before withdrawal
Prefer the explicit preview decision and display current and projected
balances, the threshold, and remaining allowance when the balance limit
is crossed. Fall back to numeric limits for older wallet-core versions.
Show KYC and coin-count warnings independently in both withdrawal flows.
Issue: https://bugs.taler.net/n/10410
Diffstat:
5 files changed, 176 insertions(+), 7 deletions(-)
diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx
@@ -2870,6 +2870,8 @@ function WithdrawalRoute(
blockedReason={keyRecovery?.message ?? review.blockedReason}
blockedActionLabel={keyRecovery?.actionLabel ?? review.gate?.label}
warning={review.warning}
+ balanceKyc={review.balanceKyc}
+ coinCountWarning={review.coinCountWarning}
working={accepting || preparing || review.trustLoading}
error={error}
onBlockedAction={resolveKeyRecovery ?? resolveGate}
@@ -3789,6 +3791,8 @@ function IntegratedWithdrawalRoute() {
blockedReason={keyRecovery?.message ?? review.blockedReason}
blockedActionLabel={keyRecovery?.actionLabel ?? review.gate?.label}
warning={review.warning}
+ balanceKyc={review.balanceKyc}
+ coinCountWarning={review.coinCountWarning}
working={accepting || preparing || review.trustLoading}
cancelling={cancelling}
error={error}
diff --git a/packages/wallet-webui/src/routes/withdrawal-review-model.ts b/packages/wallet-webui/src/routes/withdrawal-review-model.ts
@@ -60,19 +60,33 @@ export function withdrawalReviewModel(
? i18n.str`The exchange did not provide a usable bank account for this withdrawal.`
: undefined);
const overSoftKyc =
- prepared.details.kycSoftLimit &&
- Amounts.cmp(prepared.details.amountRaw, prepared.details.kycSoftLimit) >= 0;
- const warning =
+ prepared.details.kycRequired ??
+ Boolean(
+ prepared.details.kycSoftLimit &&
+ 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 usage = prepared.details.balanceKyc;
+ const balanceKyc =
+ overSoftKyc &&
+ usage?.threshold != null &&
+ Amounts.cmp(usage.projectedBalance, usage.threshold) > 0
+ ? usage
+ : undefined;
+ const coinCountWarning =
prepared.details.numCoins > 100
? i18n.str`This withdrawal creates more than 100 digital coins and may take longer than usual.`
- : overSoftKyc
- ? i18n.str`This amount may require identity verification at the exchange.`
- : undefined;
+ : undefined;
return {
fee,
gate,
blockedReason,
warning,
+ balanceKyc,
+ coinCountWarning,
trustLoading,
};
}
diff --git a/packages/wallet-webui/src/screens/WithdrawalReviewScreen.tsx b/packages/wallet-webui/src/screens/WithdrawalReviewScreen.tsx
@@ -1,3 +1,4 @@
+import type { BalanceKycUsage } from "@gnu-taler/taler-util";
import { Button } from "../ui/Button.js";
import { Card } from "../ui/Card.js";
import { ErrorCard } from "../ui/ErrorCard.js";
@@ -13,6 +14,8 @@ export function WithdrawalReviewScreen(props: {
blockedReason?: string;
blockedActionLabel?: string;
warning?: string;
+ balanceKyc?: BalanceKycUsage;
+ coinCountWarning?: string;
ageRestrictionOptions?: number[];
restrictAge?: number;
working: boolean;
@@ -91,6 +94,23 @@ export function WithdrawalReviewScreen(props: {
{props.warning && (
<Card class="border-warning bg-warningContainer">
<p class="text-onWarningContainer">{props.warning}</p>
+ {props.balanceKyc && (
+ <dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-1 text-onWarningContainer sm:grid-cols-2">
+ <dt>{i18n.str`Current balance`}</dt>
+ <dd>{props.balanceKyc.currentBalance}</dd>
+ <dt>{i18n.str`Balance after withdrawal`}</dt>
+ <dd>{props.balanceKyc.projectedBalance}</dd>
+ <dt>{i18n.str`Balance limit`}</dt>
+ <dd>{props.balanceKyc.threshold}</dd>
+ <dt>{i18n.str`Remaining balance allowance`}</dt>
+ <dd>{props.balanceKyc.remaining}</dd>
+ </dl>
+ )}
+ </Card>
+ )}
+ {props.coinCountWarning && (
+ <Card class="border-warning bg-warningContainer">
+ <p class="text-onWarningContainer">{props.coinCountWarning}</p>
</Card>
)}
{props.blockedReason && (
diff --git a/packages/wallet-webui/test/routing.test.ts b/packages/wallet-webui/test/routing.test.ts
@@ -134,7 +134,8 @@ test("withdrawal review blocks unknown trust and keeps KYC warnings aligned", ()
acceptedExchanges,
false,
);
- assert.match(manyCoins.warning ?? "", /more than 100 digital coins/);
+ assert.match(manyCoins.coinCountWarning ?? "", /more than 100 digital coins/);
+ assert.match(manyCoins.warning ?? "", /identity verification/);
});
test("exchange management only resumes safe internal wallet routes", () => {
@@ -157,3 +158,90 @@ test("exchange management only resumes safe internal wallet routes", () => {
undefined,
);
});
+
+test("withdrawal review uses the explicit KYC decision and balance figures", () => {
+ const prepared = {
+ provider: {
+ exchangeBaseUrl: "https://exchange.example/",
+ currency: "KUDOS",
+ commonAmounts: [],
+ },
+ amount: "KUDOS:8",
+ details: {
+ amountRaw: "KUDOS:8",
+ amountEffective: "KUDOS:7.84",
+ numCoins: 15,
+ withdrawalAccountsList: [{ status: "ok" }],
+ kycSoftLimit: "KUDOS:10",
+ kycRequired: true,
+ balanceKyc: {
+ currentBalance: "KUDOS:7.84",
+ projectedBalance: "KUDOS:15.68",
+ threshold: "KUDOS:10",
+ remaining: "KUDOS:2.16",
+ },
+ },
+ ageRestrictionOptions: [],
+ };
+ const result = withdrawalReviewModel(prepared as never, [], false);
+ assert.match(result.warning ?? "", /identity verification/);
+ assert.deepEqual(result.balanceKyc, prepared.details.balanceKyc);
+
+ const cleared = withdrawalReviewModel(
+ {
+ ...prepared,
+ details: {
+ ...prepared.details,
+ kycRequired: false,
+ kycSoftLimit: "KUDOS:0",
+ },
+ } as never,
+ [],
+ false,
+ );
+ assert.equal(
+ cleared.warning,
+ undefined,
+ "explicit false overrides the legacy soft limit",
+ );
+ assert.equal(cleared.balanceKyc, undefined);
+
+ const zeroLimit = withdrawalReviewModel(
+ {
+ ...prepared,
+ details: {
+ ...prepared.details,
+ kycSoftLimit: "KUDOS:0",
+ balanceKyc: {
+ currentBalance: "KUDOS:0",
+ projectedBalance: "KUDOS:7.84",
+ },
+ },
+ } as never,
+ [],
+ false,
+ );
+ assert.match(zeroLimit.warning ?? "", /identity verification/);
+ assert.equal(
+ zeroLimit.balanceKyc,
+ undefined,
+ "zero-limit warnings need no balance threshold",
+ );
+
+ const exact = withdrawalReviewModel(
+ {
+ ...prepared,
+ details: {
+ ...prepared.details,
+ kycRequired: false,
+ balanceKyc: {
+ ...prepared.details.balanceKyc,
+ projectedBalance: "KUDOS:10",
+ },
+ },
+ } as never,
+ [],
+ false,
+ );
+ assert.equal(exact.warning, undefined);
+});
diff --git a/packages/wallet-webui/test/screens.test.tsx b/packages/wallet-webui/test/screens.test.tsx
@@ -4440,3 +4440,46 @@ test("DD71 details show independent risk, recovery and conditional costs", async
cleanup();
await window.happyDOM.close();
});
+
+test("withdrawal review shows balance usage alongside both warnings", async () => {
+ const window = installDom();
+ const { render, cleanup } = await import("@testing-library/preact");
+ const view = render(
+ <main>
+ <WithdrawalReviewScreen
+ exchange="https://exchange.example/"
+ amountRaw="KUDOS:8"
+ amountEffective="KUDOS:7.84"
+ warning="This amount may require identity verification at the exchange."
+ coinCountWarning="This withdrawal creates more than 100 digital coins and may take longer than usual."
+ balanceKyc={{
+ currentBalance: "KUDOS:7.84",
+ projectedBalance: "KUDOS:15.68",
+ threshold: "KUDOS:10",
+ remaining: "KUDOS:2.16",
+ }}
+ working={false}
+ onConfirm={() => {}}
+ onBack={() => {}}
+ onCancel={() => {}}
+ />
+ </main>,
+ );
+ assert(view.getByText(/identity verification/));
+ assert(view.getByText(/more than 100 digital coins/));
+ for (const [label, value] of [
+ ["Current balance", "KUDOS:7.84"],
+ ["Balance after withdrawal", "KUDOS:15.68"],
+ ["Balance limit", "KUDOS:10"],
+ ["Remaining balance allowance", "KUDOS:2.16"],
+ ]) {
+ assert.equal(view.getByText(label).nextElementSibling?.textContent, value);
+ }
+ assert.equal(
+ (view.getByRole("button", { name: "Withdraw" }) as HTMLButtonElement)
+ .disabled,
+ false,
+ );
+ cleanup();
+ await window.happyDOM.abort();
+});