taler-typescript-core

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

commit 1a3f894084bde73ca0663b4fce449648ee483ea9
parent b3ad27f5a2537cb5f97e1b6ce272bd180e7ac532
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu, 25 Sep 2025 15:53:57 -0300

fix #10460

Diffstat:
Mpackages/bank-ui/src/hooks/regional.ts | 2+-
Mpackages/bank-ui/src/pages/AccountPage/index.ts | 3++-
Mpackages/bank-ui/src/pages/AccountPage/state.ts | 13++++++-------
Mpackages/bank-ui/src/pages/OperationState/index.ts | 2--
Mpackages/bank-ui/src/pages/OperationState/state.ts | 4++--
Mpackages/bank-ui/src/pages/OperationState/views.tsx | 1+
Mpackages/bank-ui/src/pages/PaymentOptions.stories.tsx | 2++
Mpackages/bank-ui/src/pages/PaymentOptions.tsx | 4++--
Mpackages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx | 2++
Mpackages/bank-ui/src/pages/PaytoWireTransferForm.tsx | 32+++++++++++++++-----------------
Mpackages/bank-ui/src/pages/WalletWithdrawForm.tsx | 11+++--------
Mpackages/bank-ui/src/pages/WireTransfer.tsx | 16++++++----------
Mpackages/bank-ui/src/pages/regional/CreateCashout.tsx | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
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, + }); + } + } +}