diff --git a/packages/demobank-ui/src/declaration.d.ts b/packages/demobank-ui/src/declaration.d.ts index 8dc5fd8b2..4d8484a4f 100644 --- a/packages/demobank-ui/src/declaration.d.ts +++ b/packages/demobank-ui/src/declaration.d.ts @@ -169,6 +169,8 @@ namespace SandboxBackend { balance: Balance; // payto://-URI of the account. (New) paytoUri: string; + // Number indicating the max debit allowed for the requesting user. + debitThreshold: Amount; } interface BankAccountCreateWithdrawalRequest { // Amount to withdraw. @@ -369,6 +371,9 @@ namespace SandboxBackend { // Contains ratios and fees related to buying // and selling the circuit currency. ratios_and_fees: RatiosAndFees; + // Fiat currency. That is the currency in which + // cash-out operations ultimately wire money. + fiat_currency: string; } interface RatiosAndFees { // Exchange rate to buy the circuit currency from fiat. @@ -379,9 +384,6 @@ namespace SandboxBackend { buy_in_fee: float; // Fee to subtract after applying the sell ratio. sell_out_fee: float; - // Fiat currency. That is the currency in which - // cash-out operations ultimately wire money. - fiat_currency: string; } interface Cashouts { // Every string represents a cash-out operation UUID. diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts index 8282210d4..750b95fa0 100644 --- a/packages/demobank-ui/src/hooks/access.ts +++ b/packages/demobank-ui/src/hooks/access.ts @@ -31,6 +31,7 @@ import { // FIX default import https://github.com/microsoft/TypeScript/issues/49189 import _useSWR, { SWRHook } from "swr"; +import { Amounts } from "@gnu-taler/taler-util"; const useSWR = _useSWR as unknown as SWRHook; export function useAccessAPI(): AccessAPI { @@ -180,7 +181,21 @@ export function useAccountDetails( keepPreviousData: true, }); - if (data) return data; + //FIXME: remove optional when libeufin sandbox has implemented the feature + if (data && typeof data.data.debitThreshold === "undefined") { + data.data.debitThreshold = "100"; + } + //FIXME: sandbox server should return amount string + if (data) { + const d = structuredClone(data); + const { currency } = Amounts.parseOrThrow(data.data.balance.amount); + d.data.debitThreshold = Amounts.stringify({ + currency, + value: Number.parseInt(d.data.debitThreshold, 10), + fraction: 0, + }); + return d; + } if (error) return error.info; return { loading: true }; } diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index 423ed1a5b..548862d85 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -299,12 +299,6 @@ export function useRatiosAndFeeConfig(): HttpResponse< keepPreviousData: true, }); - if (data) { - // data.data.ratios_and_fees.sell_out_fee = 2 - if (!data.data.ratios_and_fees.fiat_currency) { - data.data.ratios_and_fees.fiat_currency = "FIAT"; - } - } if (data) return data; if (error) return error.info; return { loading: true }; diff --git a/packages/demobank-ui/src/index.tsx b/packages/demobank-ui/src/index.tsx index a0ce8cb59..2e0f740fe 100644 --- a/packages/demobank-ui/src/index.tsx +++ b/packages/demobank-ui/src/index.tsx @@ -15,7 +15,6 @@ */ import App from "./components/app.js"; - import { h, render } from "preact"; import "./scss/main.scss"; diff --git a/packages/demobank-ui/src/pages/AccountPage.tsx b/packages/demobank-ui/src/pages/AccountPage.tsx index bd9a5acd7..c6ec7c88e 100644 --- a/packages/demobank-ui/src/pages/AccountPage.tsx +++ b/packages/demobank-ui/src/pages/AccountPage.tsx @@ -20,8 +20,6 @@ import { useTranslationContext, } from "@gnu-taler/web-util/lib/index.browser"; import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { Cashouts } from "../components/Cashouts/index.js"; import { Transactions } from "../components/Transactions/index.js"; import { useAccountDetails } from "../hooks/access.js"; import { PaymentOptions } from "./PaymentOptions.js"; @@ -44,8 +42,8 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode { } const { data } = result; - const balance = Amounts.parse(data.balance.amount); - const errorParsingBalance = !balance; + const balance = Amounts.parseOrThrow(data.balance.amount); + const debitThreshold = Amounts.parseOrThrow(data.debitThreshold); const payto = parsePaytoUri(data.paytoUri); if (!payto || !payto.isKnown || payto.targetType !== "iban") { return ( @@ -54,7 +52,9 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode { } const accountNumber = payto.iban; const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; - + const limit = balanceIsDebit + ? Amounts.sub(debitThreshold, balance).amount + : Amounts.add(balance, debitThreshold).amount; return (
@@ -66,44 +66,29 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
- {errorParsingBalance ? ( -
-
-

- Server Error: invalid balance -

-
-

Your account is in an invalid state.

+
+
+

{i18n.str`Bank account balance`}

+ {!balance ? ( +
+ Waiting server response... +
+ ) : ( +
+ {balanceIsDebit ? - : null} + {`${Amounts.stringifyValue(balance)}`} +   + {`${balance.currency}`} +
+ )}
- ) : ( - -
-
-

{i18n.str`Bank account balance`}

- {!balance ? ( -
- Waiting server response... -
- ) : ( -
- {balanceIsDebit ? - : null} - {`${Amounts.stringifyValue( - balance, - )}`} -   - {`${balance.currency}`} -
- )} -
-
-
-
-

{i18n.str`Payments`}

- -
-
-
- )} +
+
+
+

{i18n.str`Payments`}

+ +
+
diff --git a/packages/demobank-ui/src/pages/BusinessAccount.tsx b/packages/demobank-ui/src/pages/BusinessAccount.tsx index 9bd799746..128b47114 100644 --- a/packages/demobank-ui/src/pages/BusinessAccount.tsx +++ b/packages/demobank-ui/src/pages/BusinessAccount.tsx @@ -212,8 +212,7 @@ function useRatiosAndFeeConfigWithChangeDetection(): HttpResponse< oldResult.ratios_and_fees.sell_at_ratio || result.data.ratios_and_fees.sell_out_fee !== oldResult.ratios_and_fees.sell_out_fee || - result.data.ratios_and_fees.fiat_currency !== - oldResult.ratios_and_fees.fiat_currency); + result.data.fiat_currency !== oldResult.fiat_currency); return { ...result, @@ -238,16 +237,19 @@ function CreateCashout({ if (!result.ok) return onLoadNotOk(result); if (!ratiosResult.ok) return onLoadNotOk(ratiosResult); const config = ratiosResult.data; - const maybeBalance = Amounts.parse(result.data.balance.amount); - if (!maybeBalance) return
error
; - const balance = maybeBalance; + const balance = Amounts.parseOrThrow(result.data.balance.amount); + const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold); const zero = Amounts.zeroOfCurrency(balance.currency); + const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit"; + const limit = balanceIsDebit + ? Amounts.sub(debitThreshold, balance).amount + : Amounts.add(balance, debitThreshold).amount; const sellRate = config.ratios_and_fees.sell_at_ratio; const sellFee = !config.ratios_and_fees.sell_out_fee ? zero : Amounts.fromFloat(config.ratios_and_fees.sell_out_fee, balance.currency); - const fiatCurrency = config.ratios_and_fees.fiat_currency; + const fiatCurrency = config.fiat_currency; if (!sellRate || sellRate < 0) return
error rate
; @@ -278,12 +280,12 @@ function CreateCashout({ ? i18n.str`required` : !amount ? i18n.str`could not be parsed` - : Amounts.cmp(balance, amount_debit) === -1 + : Amounts.cmp(limit, amount_debit) === -1 ? i18n.str`balance is not enough` : Amounts.cmp(credit_before_fee, sellFee) === -1 - ? i18n.str`amount is not enough` + ? i18n.str`the total amount to transfer does not cover the fees` : Amounts.isZero(amount_credit) - ? i18n.str`amount is not enough` + ? i18n.str`the total transfer at destination will be zero` : undefined, channel: !form.channel ? i18n.str`required` : undefined, }); diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index 610efafc0..291f2aa9e 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see */ +import { AmountJson } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -25,7 +26,7 @@ import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; * Let the user choose a payment option, * then specify the details trigger the action. */ -export function PaymentOptions({ currency }: { currency: string }): VNode { +export function PaymentOptions({ limit }: { limit: AmountJson }): VNode { const { i18n } = useTranslationContext(); const { pageStateSetter } = usePageContext(); @@ -62,7 +63,7 @@ export function PaymentOptions({ currency }: { currency: string }): VNode {

{i18n.str`Obtain digital cash`}

{ pageStateSetter((prevState: PageStateType) => ({ ...prevState, @@ -80,7 +81,7 @@ export function PaymentOptions({ currency }: { currency: string }): VNode {

{i18n.str`Transfer to bank account`}

{ pageStateSetter((prevState: PageStateType) => ({ ...prevState, diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index f25680481..027f8e25a 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -15,6 +15,7 @@ */ import { + AmountJson, Amounts, buildPayto, HttpStatusCode, @@ -30,7 +31,11 @@ import { h, VNode } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { PageStateType } from "../context/pageState.js"; import { useAccessAPI } from "../hooks/access.js"; -import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js"; +import { + buildRequestErrorMessage, + undefinedIfEmpty, + validateIBAN, +} from "../utils.js"; import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; const logger = new Logger("PaytoWireTransferForm"); @@ -39,12 +44,12 @@ export function PaytoWireTransferForm({ focus, onError, onSuccess, - currency, + limit, }: { focus?: boolean; onError: (e: PageStateType["error"]) => void; onSuccess: () => void; - currency: string; + limit: AmountJson; }): VNode { // const backend = useBackendContext(); // const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button? @@ -65,7 +70,8 @@ export function PaytoWireTransferForm({ if (focus) ref.current?.focus(); }, [focus, isRawPayto]); - let parsedAmount = undefined; + const trimmedAmountStr = amount?.trim(); + const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`); const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; const errorsWire = undefinedIfEmpty({ @@ -73,14 +79,16 @@ export function PaytoWireTransferForm({ ? i18n.str`Missing IBAN` : !IBAN_REGEX.test(iban) ? i18n.str`IBAN should have just uppercased letters and numbers` - : undefined, + : validateIBAN(iban, i18n), subject: !subject ? i18n.str`Missing subject` : undefined, - amount: !amount + amount: !trimmedAmountStr ? i18n.str`Missing amount` - : !(parsedAmount = Amounts.parse(`${currency}:${amount}`)) + : !parsedAmount ? i18n.str`Amount is not valid` : Amounts.isZero(parsedAmount) ? i18n.str`Should be greater than 0` + : Amounts.cmp(limit, parsedAmount) === -1 + ? i18n.str`balance is not enough` : undefined, }); @@ -143,10 +151,10 @@ export function PaytoWireTransferForm({ type="text" readonly class="currency-indicator" - size={currency?.length} - maxLength={currency?.length} + size={limit.currency.length} + maxLength={limit.currency.length} tabIndex={-1} - value={currency} + value={limit.currency} />   Hint: - payto://iban/[receiver-iban]?message=[subject]&amount=[{currency} + payto://iban/[receiver-iban]?message=[subject]&amount=[ + {limit.currency} :X.Y]
diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx index c1ad2f0cf..8bbfe0713 100644 --- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx @@ -14,7 +14,12 @@ GNU Taler; see the file COPYING. If not, see */ -import { Amounts, HttpStatusCode, Logger } from "@gnu-taler/taler-util"; +import { + AmountJson, + Amounts, + HttpStatusCode, + Logger, +} from "@gnu-taler/taler-util"; import { RequestError, useTranslationContext, @@ -30,11 +35,11 @@ const logger = new Logger("WalletWithdrawForm"); export function WalletWithdrawForm({ focus, - currency, + limit, onError, onSuccess, }: { - currency: string; + limit: AmountJson; focus?: boolean; onError: (e: PageStateType["error"]) => void; onSuccess: ( @@ -52,20 +57,20 @@ export function WalletWithdrawForm({ if (focus) ref.current?.focus(); }, [focus]); - // Beware: We never ever want to treat the amount as a float! - const trimmedAmountStr = amountStr?.trim(); const parsedAmount = trimmedAmountStr - ? Amounts.parse(`${currency}:${trimmedAmountStr}`) + ? Amounts.parse(`${limit.currency}:${trimmedAmountStr}`) : undefined; const errors = undefinedIfEmpty({ amount: trimmedAmountStr == null ? i18n.str`required` - : parsedAmount == null + : !parsedAmount ? i18n.str`invalid` + : Amounts.cmp(limit, parsedAmount) === -1 + ? i18n.str`balance is not enough` : undefined, }); return ( @@ -87,10 +92,10 @@ export function WalletWithdrawForm({ type="text" readonly class="currency-indicator" - size={currency.length} - maxLength={currency.length} + size={limit.currency.length} + maxLength={limit.currency.length} tabIndex={-1} - value={currency} + value={limit.currency} />   ( return h(); }; } + +/** + * + * @param obj VNode + * @returns + */ +export function saveVNodeForInspection(obj: T): T { + // @ts-ignore + window["showVNodeInfo"] = function showVNodeInfo() { + inspect(obj); + }; + return obj; +} +function inspect(obj: any) { + if (!obj) return; + if (obj.__c && obj.__c.__H) { + const componentName = obj.__c.constructor.name; + const hookState = obj.__c.__H; + const stateList = hookState.__ as Array; + console.log("==============", componentName); + stateList.forEach((hook) => { + const { __: value, c: context, __h: factory, __H: args } = hook; + if (typeof context !== "undefined") { + const { __c: contextId } = context; + console.log("context:", contextId, hook); + } else if (typeof factory === "function") { + console.log("memo:", value, "deps:", args); + } else if (typeof value === "function") { + const effectName = value.name; + console.log("effect:", effectName, "deps:", args); + } else if (typeof value.current !== "undefined") { + const ref = value.current; + console.log("ref:", ref instanceof Element ? ref.outerHTML : ref); + } else if (value instanceof Array) { + console.log("state:", value[0]); + } else { + console.log(hook); + } + }); + } + const children = obj.__k; + if (children instanceof Array) { + children.forEach((e) => inspect(e)); + } else { + inspect(children); + } +}