commit 1a3f894084bde73ca0663b4fce449648ee483ea9
parent b3ad27f5a2537cb5f97e1b6ce272bd180e7ac532
Author: Sebastian <sebasjm@gmail.com>
Date: Thu, 25 Sep 2025 15:53:57 -0300
fix #10460
Diffstat:
13 files changed, 159 insertions(+), 55 deletions(-)
diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts
@@ -179,7 +179,7 @@ function buildEstimatorWithTheBackend(
}
const credit = Amounts.parseOrThrow(resp.body.amount_credit);
const debit = Amounts.parseOrThrow(resp.body.amount_debit);
- const beforeFee = Amounts.sub(credit, fee).amount;
+ const beforeFee = Amounts.add(credit, fee).amount;
return opFixedSuccess({
debit,
diff --git a/packages/bank-ui/src/pages/AccountPage/index.ts b/packages/bank-ui/src/pages/AccountPage/index.ts
@@ -26,6 +26,7 @@ import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js
import { LoginForm } from "../LoginForm.js";
import { useComponentState } from "./state.js";
import { InvalidIbanView, ReadyView } from "./views.js";
+import { IntAmountJson, IntAmounts } from "../regional/CreateCashout.js";
export interface Props {
account: string;
@@ -77,7 +78,7 @@ export namespace State {
error: undefined;
account: string;
tab: "charge-wallet" | "wire-transfer" | undefined;
- limit: AmountJson;
+ limit: IntAmountJson;
balance: AmountJson;
onOperationCreated: (wopid: string) => void;
diff --git a/packages/bank-ui/src/pages/AccountPage/state.ts b/packages/bank-ui/src/pages/AccountPage/state.ts
@@ -23,6 +23,7 @@ import {
} from "@gnu-taler/taler-util";
import { useAccountDetails } from "../../hooks/account.js";
import { Props, State } from "./index.js";
+import { IntAmounts } from "../regional/CreateCashout.js";
export function useComponentState({
account,
@@ -36,7 +37,6 @@ export function useComponentState({
onOperationCreated,
onClose,
routeClose,
-
}: Props): State {
const result = useAccountDetails(account);
@@ -59,13 +59,13 @@ export function useComponentState({
case HttpStatusCode.Unauthorized:
return {
status: "login",
-
+
reason: "forbidden",
};
case HttpStatusCode.NotFound:
return {
status: "login",
-
+
reason: "not-found",
};
default: {
@@ -93,9 +93,8 @@ export function useComponentState({
}
const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
- const limit = balanceIsDebit
- ? Amounts.sub(debitThreshold, balance).amount
- : Amounts.add(balance, debitThreshold).amount;
+ const limit = IntAmounts.toIntAmount(balance, balanceIsDebit)
+ .increment(debitThreshold).result;
const positiveBalance = balanceIsDebit
? Amounts.zeroOfAmount(balance)
@@ -110,7 +109,7 @@ export function useComponentState({
routeOperationDetails,
routeCreateWireTransfer,
routePublicAccounts,
-
+
onClose,
routeClose,
routeChargeWallet,
diff --git a/packages/bank-ui/src/pages/OperationState/index.ts b/packages/bank-ui/src/pages/OperationState/index.ts
@@ -39,8 +39,6 @@ import {
} from "./views.js";
export interface Props {
- currency: string;
-
routeClose: RouteDefinition;
onAbort: () => void;
focus?: boolean;
diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts
@@ -35,7 +35,6 @@ import { useSessionState } from "../../hooks/session.js";
import { Props, State } from "./index.js";
export function useComponentState({
- currency,
routeClose,
onAbort,
focus,
@@ -46,6 +45,7 @@ export function useComponentState({
const { state: credentials } = useSessionState();
const creds = credentials.status !== "loggedIn" ? undefined : credentials;
const {
+ config,
lib: { bank },
} = useBankCoreApiContext();
@@ -56,7 +56,7 @@ export function useComponentState({
async function doSilentStart() {
// FIXME: if amount is not enough use balance
- const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`);
+ const parsedAmount = Amounts.parseOrThrow(`${config.currency}:${amount}`);
if (!creds) return;
const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest =
preference.fastWithdrawalForm
diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx
@@ -147,6 +147,7 @@ export function NeedConfirmationView({
<SolveMFAChallenges
currentChallenge={mfa.pendingChallenge}
description={i18n.str`Confirm withdrawal.`}
+ username={details.username}
onCancel={mfa.doCancelChallenge}
onCompleted={repeatConfirm}
/>
diff --git a/packages/bank-ui/src/pages/PaymentOptions.stories.tsx b/packages/bank-ui/src/pages/PaymentOptions.stories.tsx
@@ -31,5 +31,7 @@ export const USD = tests.createExample(PaymentOptions, {
currency: "USD",
fraction: 0,
value: 1,
+ negative: false,
+ saturated: false,
},
});
diff --git a/packages/bank-ui/src/pages/PaymentOptions.tsx b/packages/bank-ui/src/pages/PaymentOptions.tsx
@@ -23,6 +23,7 @@ import { Fragment, VNode, h } from "preact";
import { useBankState } from "../hooks/bank-state.js";
import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
+import { IntAmountJson, IntAmounts } from "./regional/CreateCashout.js";
const TALER_SCREEN_ID = 105;
@@ -66,7 +67,7 @@ const TALER_SCREEN_ID = 105;
// }
export interface PaymentOptionProps {
- limit: AmountJson;
+ limit: IntAmountJson;
balance: AmountJson;
tab: "charge-wallet" | "wire-transfer" | undefined;
@@ -203,7 +204,6 @@ export function PaymentOptions({
</div>
{tab === "charge-wallet" && (
<WalletWithdrawForm
- routeOperationDetails={routeOperationDetails}
focus
limit={limit}
balance={balance}
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx
@@ -31,5 +31,7 @@ export const USD = tests.createExample(PaytoWireTransferForm, {
currency: "USD",
fraction: 0,
value: 1,
+ negative: false,
+ saturated: false,
},
});
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
@@ -49,6 +49,7 @@ import { useState } from "preact/hooks";
import { LoggedIn, useSessionState } from "../hooks/session.js";
import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js";
import { SolveMFAChallenges } from "./SolveMFA.js";
+import { IntAmountJson, IntAmounts } from "./regional/CreateCashout.js";
const TALER_SCREEN_ID = 106;
@@ -60,7 +61,7 @@ export interface Props {
onSuccess: () => void;
routeCancel?: RouteDefinition;
routeCashout?: RouteDefinition;
- limit: AmountJson;
+ limit: IntAmountJson;
balance: AmountJson;
}
@@ -95,9 +96,16 @@ export function PaytoWireTransferForm({
undefined,
);
const { i18n } = useTranslationContext();
+ const wireFee =
+ config.wire_transfer_fees === undefined
+ ? Amounts.zeroOfCurrency(config.currency)
+ : Amounts.parseOrThrow(config.wire_transfer_fees);
+
+
const trimmedAmountStr = amount?.trim();
- const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`);
+ const limitWithFee = IntAmounts.from(limit).deduce(wireFee).getResultZeroIfNegative()
+ const parsedAmount = Amounts.parse(`${limitWithFee.currency}:${trimmedAmountStr}`);
const [notification, notifyOnError] = useLocalNotificationBetter();
const mfa = useChallengeHandler();
@@ -106,16 +114,6 @@ export function PaytoWireTransferForm({
? ("x-taler-bank" as const)
: ("iban" as const);
- const wireFee =
- config.wire_transfer_fees === undefined
- ? Amounts.zeroOfCurrency(config.currency)
- : Amounts.parseOrThrow(config.wire_transfer_fees);
-
- const limitWithFee =
- Amounts.cmp(limit, wireFee) === 1
- ? Amounts.sub(limit, wireFee).amount
- : Amounts.zeroOfAmount(limit);
-
const errorsWire = undefinedIfEmpty({
account: !account
? i18n.str`Required`
@@ -190,7 +188,7 @@ export function PaytoWireTransferForm({
payto.params.message = encodeURIComponent(subject);
payto_uri = stringifyPaytoUri(payto);
- sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString;
+ sendingAmount = `${limitWithFee.currency}:${trimmedAmountStr}` as AmountString;
}
const puri = payto_uri;
const sAmount = sendingAmount;
@@ -554,7 +552,7 @@ export function PaytoWireTransferForm({
<InputAmount
name="amount"
left
- currency={limit.currency}
+ currency={limitWithFee.currency}
value={trimmedAmountStr}
onChange={(d) => {
setAmount(d);
@@ -593,9 +591,9 @@ export function PaytoWireTransferForm({
placeholder={((): TranslatedString => {
switch (paytoType) {
case "x-taler-bank":
- return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limit.currency}:X.Y]`;
+ return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limitWithFee.currency}:X.Y]`;
case "iban":
- return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`;
+ return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limitWithFee.currency}:X.Y]`;
}
})()}
onInput={(e): void => {
@@ -610,7 +608,7 @@ export function PaytoWireTransferForm({
</div>
</div>
)}
- {Amounts.cmp(limitWithFee, balance) > 0 ? (
+ {Amounts.isNonZero(limitWithFee) ? (
<p class="mt-2 text-sm text-gray-900">
<i18n.Translate>
The maximum amount for a wire transfer is{" "}
diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx
@@ -50,6 +50,7 @@ import {
RenderAmount,
doAutoFocus,
} from "./PaytoWireTransferForm.js";
+import { IntAmountJson, IntAmounts } from "./regional/CreateCashout.js";
const TALER_SCREEN_ID = 112;
@@ -100,12 +101,10 @@ function OldWithdrawalForm({
balance,
routeCancel,
focus,
- routeOperationDetails,
}: {
- limit: AmountJson;
+ limit: IntAmountJson;
balance: AmountJson;
focus?: boolean;
- routeOperationDetails: RouteDefinition<{ wopid: string }>;
onOperationCreated: (wopid: string) => void;
routeCancel: RouteDefinition;
}): VNode {
@@ -370,12 +369,10 @@ export function WalletWithdrawForm({
routeCancel,
onOperationCreated,
onOperationAborted,
- routeOperationDetails,
}: {
- limit: AmountJson;
+ limit: IntAmountJson;
balance: AmountJson;
focus?: boolean;
- routeOperationDetails: RouteDefinition<{ wopid: string }>;
onOperationCreated: (wopid: string) => void;
onOperationAborted: () => void;
@@ -424,7 +421,6 @@ export function WalletWithdrawForm({
{!pref.fastWithdrawalForm ? (
<OldWithdrawalForm
focus={focus}
- routeOperationDetails={routeOperationDetails}
limit={limit}
balance={balance}
routeCancel={routeCancel}
@@ -433,7 +429,6 @@ export function WalletWithdrawForm({
) : (
<OperationState
focus={focus}
- currency={limit.currency}
routeClose={routeCancel}
onAbort={onOperationAborted}
/>
diff --git a/packages/bank-ui/src/pages/WireTransfer.tsx b/packages/bank-ui/src/pages/WireTransfer.tsx
@@ -31,6 +31,7 @@ import { useSessionState } from "../hooks/session.js";
import { LoginForm } from "./LoginForm.js";
import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { IntAmounts } from "./regional/CreateCashout.js";
const TALER_SCREEN_ID = 113;
@@ -76,20 +77,15 @@ export function WireTransfer({
}
const { body: data } = result;
- const balance = Amounts.parseOrThrow(data.balance.amount);
- const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
-
+ const balanceAbs = Amounts.parseOrThrow(data.balance.amount);
+ const isBalanceNegative = data.balance.credit_debit_indicator == "debit";
const debitThreshold = Amounts.parseOrThrow(data.debit_threshold);
- if (!balance) return <Fragment />;
+ const balance = IntAmounts.toIntAmount(balanceAbs, isBalanceNegative);
+ const limit = balance.increment(debitThreshold).result;
- const limit = balanceIsDebit
- ? Amounts.sub(debitThreshold, balance).amount
- : Amounts.add(balance, debitThreshold).amount;
+ const positiveBalance = balance.getResultZeroIfNegative();
- const positiveBalance = balanceIsDebit
- ? Amounts.zeroOfAmount(balance)
- : balance;
return (
<div class="px-4 mt-8">
<div class="sm:flex sm:items-center mb-4">
diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx
@@ -208,9 +208,10 @@ export function CreateCashout({
debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold),
};
- const limit = account.balanceIsDebit
- ? Amounts.sub(account.debitThreshold, account.balance).amount
- : Amounts.add(account.balance, account.debitThreshold).amount;
+ const balanceLimit = IntAmounts.toIntAmount(
+ account.balance,
+ account.balanceIsDebit,
+ ).increment(account.debitThreshold);
const zeroCalc = {
debit: regionalZero,
@@ -271,7 +272,10 @@ export function CreateCashout({
const calc =
calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult;
- const balanceAfter = Amounts.sub(account.balance, calc.debit).amount;
+ const balanceAfter = IntAmounts.toIntAmount(
+ account.balance,
+ account.balanceIsDebit,
+ ).deduce(calc.debit).result;
function updateForm(newForm: typeof form): void {
setForm(newForm);
@@ -282,7 +286,9 @@ export function CreateCashout({
? i18n.str`Required`
: !inputAmount
? i18n.str`Invalid`
- : Amounts.cmp(limit, calc.debit) === -1
+ : Amounts.isZero(
+ balanceLimit.deduce(calc.debit).getResultZeroIfNegative(),
+ )
? i18n.str`Balance is not enough`
: calculationResult === "amount-is-too-small"
? i18n.str`Amount needs to be higher`
@@ -412,6 +418,8 @@ export function CreateCashout({
<dd class="text-sm text-gray-900">
<RenderAmount
value={account.balance}
+ negative={account.balanceIsDebit}
+ withSign
spec={regional_currency_specification}
/>
</dd>
@@ -675,6 +683,8 @@ export function CreateCashout({
<dd class="text-sm text-gray-900">
<RenderAmount
value={balanceAfter}
+ negative={balanceAfter.negative}
+ withSign
spec={regional_currency_specification}
/>
</dd>
@@ -736,3 +746,105 @@ export function CreateCashout({
</div>
);
}
+export interface IntAmountJson extends AmountJson {
+ negative: boolean;
+ saturated: boolean;
+}
+
+/**
+ * Helper to manipulate amounts that can be negative.
+ *
+ */
+export class IntAmounts {
+ readonly result: IntAmountJson;
+ private constructor(
+ value: AmountJson,
+ negative: boolean = false,
+ saturated: boolean = false,
+ ) {
+ this.result = {
+ ...value,
+ negative,
+ saturated,
+ };
+ }
+
+ static from(value: IntAmountJson): IntAmounts {
+ return new IntAmounts(value, value.negative, value.saturated);
+ }
+
+ static toIntAmount(value: AmountJson, negative: boolean = false): IntAmounts {
+ return new IntAmounts(value, negative);
+ }
+
+ getResultZeroIfNegative(): IntAmountJson {
+ return this.result.negative
+ ? IntAmounts.toIntAmount(Amounts.zeroOfCurrency(this.result.currency)).result
+ : this.result;
+ }
+ /**
+ * Sum this value to a positive or negative amount.
+ *
+ * @param d
+ * @returns
+ */
+ merge(d: IntAmountJson): IntAmounts {
+ if (d.negative) {
+ return this.deduce(d);
+ } else {
+ return this.increment(d);
+ }
+ }
+ /**
+ * Deduce the absolute value by the amount
+ *
+ * @param am
+ * @returns
+ */
+ deduce(am: AmountJson): IntAmounts {
+ if (this.result.negative) {
+ const { amount, saturated } = Amounts.add(this.result, am);
+ return IntAmounts.from({
+ ...amount,
+ saturated,
+ negative: true,
+ });
+ } else {
+ const negative = Amounts.cmp(this.result, am) < 0;
+ const { amount, saturated } = negative
+ ? Amounts.sub(am, this.result)
+ : Amounts.sub(this.result, am);
+ return IntAmounts.from({
+ ...amount,
+ negative,
+ saturated,
+ });
+ }
+ }
+ /**
+ * Increment the value by the amount
+ *
+ * @param am
+ * @returns
+ */
+ increment(am: AmountJson): IntAmounts {
+ if (this.result.negative) {
+ const negative = Amounts.cmp(this.result, am) > 0;
+ const { amount, saturated } = negative
+ ? Amounts.sub(this.result, am)
+ : Amounts.sub(am, this.result);
+ return IntAmounts.from({
+ ...amount,
+ negative,
+ saturated,
+ });
+ } else {
+ const { amount, saturated } = Amounts.add(this.result, am);
+ return IntAmounts.from({
+ ...amount,
+ saturated,
+ negative: false,
+ });
+ }
+ }
+}