This commit is contained in:
Sebastian 2023-03-11 18:19:38 -03:00
parent b72729f065
commit c67d94c56e
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
10 changed files with 147 additions and 88 deletions

View File

@ -169,6 +169,8 @@ namespace SandboxBackend {
balance: Balance; balance: Balance;
// payto://-URI of the account. (New) // payto://-URI of the account. (New)
paytoUri: string; paytoUri: string;
// Number indicating the max debit allowed for the requesting user.
debitThreshold: Amount;
} }
interface BankAccountCreateWithdrawalRequest { interface BankAccountCreateWithdrawalRequest {
// Amount to withdraw. // Amount to withdraw.
@ -369,6 +371,9 @@ namespace SandboxBackend {
// Contains ratios and fees related to buying // Contains ratios and fees related to buying
// and selling the circuit currency. // and selling the circuit currency.
ratios_and_fees: RatiosAndFees; ratios_and_fees: RatiosAndFees;
// Fiat currency. That is the currency in which
// cash-out operations ultimately wire money.
fiat_currency: string;
} }
interface RatiosAndFees { interface RatiosAndFees {
// Exchange rate to buy the circuit currency from fiat. // Exchange rate to buy the circuit currency from fiat.
@ -379,9 +384,6 @@ namespace SandboxBackend {
buy_in_fee: float; buy_in_fee: float;
// Fee to subtract after applying the sell ratio. // Fee to subtract after applying the sell ratio.
sell_out_fee: float; sell_out_fee: float;
// Fiat currency. That is the currency in which
// cash-out operations ultimately wire money.
fiat_currency: string;
} }
interface Cashouts { interface Cashouts {
// Every string represents a cash-out operation UUID. // Every string represents a cash-out operation UUID.

View File

@ -31,6 +31,7 @@ import {
// FIX default import https://github.com/microsoft/TypeScript/issues/49189 // FIX default import https://github.com/microsoft/TypeScript/issues/49189
import _useSWR, { SWRHook } from "swr"; import _useSWR, { SWRHook } from "swr";
import { Amounts } from "@gnu-taler/taler-util";
const useSWR = _useSWR as unknown as SWRHook; const useSWR = _useSWR as unknown as SWRHook;
export function useAccessAPI(): AccessAPI { export function useAccessAPI(): AccessAPI {
@ -180,7 +181,21 @@ export function useAccountDetails(
keepPreviousData: true, 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; if (error) return error.info;
return { loading: true }; return { loading: true };
} }

View File

@ -299,12 +299,6 @@ export function useRatiosAndFeeConfig(): HttpResponse<
keepPreviousData: true, 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 (data) return data;
if (error) return error.info; if (error) return error.info;
return { loading: true }; return { loading: true };

View File

@ -15,7 +15,6 @@
*/ */
import App from "./components/app.js"; import App from "./components/app.js";
import { h, render } from "preact"; import { h, render } from "preact";
import "./scss/main.scss"; import "./scss/main.scss";

View File

@ -20,8 +20,6 @@ import {
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser"; } from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact"; 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 { Transactions } from "../components/Transactions/index.js";
import { useAccountDetails } from "../hooks/access.js"; import { useAccountDetails } from "../hooks/access.js";
import { PaymentOptions } from "./PaymentOptions.js"; import { PaymentOptions } from "./PaymentOptions.js";
@ -44,8 +42,8 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
} }
const { data } = result; const { data } = result;
const balance = Amounts.parse(data.balance.amount); const balance = Amounts.parseOrThrow(data.balance.amount);
const errorParsingBalance = !balance; const debitThreshold = Amounts.parseOrThrow(data.debitThreshold);
const payto = parsePaytoUri(data.paytoUri); const payto = parsePaytoUri(data.paytoUri);
if (!payto || !payto.isKnown || payto.targetType !== "iban") { if (!payto || !payto.isKnown || payto.targetType !== "iban") {
return ( return (
@ -54,7 +52,9 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
} }
const accountNumber = payto.iban; const accountNumber = payto.iban;
const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount
: Amounts.add(balance, debitThreshold).amount;
return ( return (
<Fragment> <Fragment>
<div> <div>
@ -66,17 +66,6 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
</h1> </h1>
</div> </div>
{errorParsingBalance ? (
<div class="informational informational-fail" style={{ marginTop: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<p>
<b>Server Error: invalid balance</b>
</p>
</div>
<p>Your account is in an invalid state.</p>
</div>
) : (
<Fragment>
<section id="assets"> <section id="assets">
<div class="asset-summary"> <div class="asset-summary">
<h2>{i18n.str`Bank account balance`}</h2> <h2>{i18n.str`Bank account balance`}</h2>
@ -87,9 +76,7 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
) : ( ) : (
<div class="large-amount amount"> <div class="large-amount amount">
{balanceIsDebit ? <b>-</b> : null} {balanceIsDebit ? <b>-</b> : null}
<span class="value">{`${Amounts.stringifyValue( <span class="value">{`${Amounts.stringifyValue(balance)}`}</span>
balance,
)}`}</span>
&nbsp; &nbsp;
<span class="currency">{`${balance.currency}`}</span> <span class="currency">{`${balance.currency}`}</span>
</div> </div>
@ -99,11 +86,9 @@ export function AccountPage({ account, onLoadNotOk }: Props): VNode {
<section id="payments"> <section id="payments">
<div class="payments"> <div class="payments">
<h2>{i18n.str`Payments`}</h2> <h2>{i18n.str`Payments`}</h2>
<PaymentOptions currency={balance.currency} /> <PaymentOptions limit={limit} />
</div> </div>
</section> </section>
</Fragment>
)}
<section style={{ marginTop: "2em" }}> <section style={{ marginTop: "2em" }}>
<div class="active"> <div class="active">

View File

@ -212,8 +212,7 @@ function useRatiosAndFeeConfigWithChangeDetection(): HttpResponse<
oldResult.ratios_and_fees.sell_at_ratio || oldResult.ratios_and_fees.sell_at_ratio ||
result.data.ratios_and_fees.sell_out_fee !== result.data.ratios_and_fees.sell_out_fee !==
oldResult.ratios_and_fees.sell_out_fee || oldResult.ratios_and_fees.sell_out_fee ||
result.data.ratios_and_fees.fiat_currency !== result.data.fiat_currency !== oldResult.fiat_currency);
oldResult.ratios_and_fees.fiat_currency);
return { return {
...result, ...result,
@ -238,16 +237,19 @@ function CreateCashout({
if (!result.ok) return onLoadNotOk(result); if (!result.ok) return onLoadNotOk(result);
if (!ratiosResult.ok) return onLoadNotOk(ratiosResult); if (!ratiosResult.ok) return onLoadNotOk(ratiosResult);
const config = ratiosResult.data; const config = ratiosResult.data;
const maybeBalance = Amounts.parse(result.data.balance.amount); const balance = Amounts.parseOrThrow(result.data.balance.amount);
if (!maybeBalance) return <div>error</div>; const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
const balance = maybeBalance;
const zero = Amounts.zeroOfCurrency(balance.currency); 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 sellRate = config.ratios_and_fees.sell_at_ratio;
const sellFee = !config.ratios_and_fees.sell_out_fee const sellFee = !config.ratios_and_fees.sell_out_fee
? zero ? zero
: Amounts.fromFloat(config.ratios_and_fees.sell_out_fee, balance.currency); : 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 <div>error rate</div>; if (!sellRate || sellRate < 0) return <div>error rate</div>;
@ -278,12 +280,12 @@ function CreateCashout({
? i18n.str`required` ? i18n.str`required`
: !amount : !amount
? i18n.str`could not be parsed` ? i18n.str`could not be parsed`
: Amounts.cmp(balance, amount_debit) === -1 : Amounts.cmp(limit, amount_debit) === -1
? i18n.str`balance is not enough` ? i18n.str`balance is not enough`
: Amounts.cmp(credit_before_fee, sellFee) === -1 : 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) : Amounts.isZero(amount_credit)
? i18n.str`amount is not enough` ? i18n.str`the total transfer at destination will be zero`
: undefined, : undefined,
channel: !form.channel ? i18n.str`required` : undefined, channel: !form.channel ? i18n.str`required` : undefined,
}); });

View File

@ -14,6 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { AmountJson } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
@ -25,7 +26,7 @@ import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
* Let the user choose a payment option, * Let the user choose a payment option,
* then specify the details trigger the action. * 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 { i18n } = useTranslationContext();
const { pageStateSetter } = usePageContext(); const { pageStateSetter } = usePageContext();
@ -62,7 +63,7 @@ export function PaymentOptions({ currency }: { currency: string }): VNode {
<h3>{i18n.str`Obtain digital cash`}</h3> <h3>{i18n.str`Obtain digital cash`}</h3>
<WalletWithdrawForm <WalletWithdrawForm
focus focus
currency={currency} limit={limit}
onSuccess={(data) => { onSuccess={(data) => {
pageStateSetter((prevState: PageStateType) => ({ pageStateSetter((prevState: PageStateType) => ({
...prevState, ...prevState,
@ -80,7 +81,7 @@ export function PaymentOptions({ currency }: { currency: string }): VNode {
<h3>{i18n.str`Transfer to bank account`}</h3> <h3>{i18n.str`Transfer to bank account`}</h3>
<PaytoWireTransferForm <PaytoWireTransferForm
focus focus
currency={currency} limit={limit}
onSuccess={() => { onSuccess={() => {
pageStateSetter((prevState: PageStateType) => ({ pageStateSetter((prevState: PageStateType) => ({
...prevState, ...prevState,

View File

@ -15,6 +15,7 @@
*/ */
import { import {
AmountJson,
Amounts, Amounts,
buildPayto, buildPayto,
HttpStatusCode, HttpStatusCode,
@ -30,7 +31,11 @@ import { h, VNode } from "preact";
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import { PageStateType } from "../context/pageState.js"; import { PageStateType } from "../context/pageState.js";
import { useAccessAPI } from "../hooks/access.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"; import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
const logger = new Logger("PaytoWireTransferForm"); const logger = new Logger("PaytoWireTransferForm");
@ -39,12 +44,12 @@ export function PaytoWireTransferForm({
focus, focus,
onError, onError,
onSuccess, onSuccess,
currency, limit,
}: { }: {
focus?: boolean; focus?: boolean;
onError: (e: PageStateType["error"]) => void; onError: (e: PageStateType["error"]) => void;
onSuccess: () => void; onSuccess: () => void;
currency: string; limit: AmountJson;
}): VNode { }): VNode {
// const backend = useBackendContext(); // const backend = useBackendContext();
// const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button? // const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button?
@ -65,7 +70,8 @@ export function PaytoWireTransferForm({
if (focus) ref.current?.focus(); if (focus) ref.current?.focus();
}, [focus, isRawPayto]); }, [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 IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
const errorsWire = undefinedIfEmpty({ const errorsWire = undefinedIfEmpty({
@ -73,14 +79,16 @@ export function PaytoWireTransferForm({
? i18n.str`Missing IBAN` ? i18n.str`Missing IBAN`
: !IBAN_REGEX.test(iban) : !IBAN_REGEX.test(iban)
? i18n.str`IBAN should have just uppercased letters and numbers` ? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined, : validateIBAN(iban, i18n),
subject: !subject ? i18n.str`Missing subject` : undefined, subject: !subject ? i18n.str`Missing subject` : undefined,
amount: !amount amount: !trimmedAmountStr
? i18n.str`Missing amount` ? i18n.str`Missing amount`
: !(parsedAmount = Amounts.parse(`${currency}:${amount}`)) : !parsedAmount
? i18n.str`Amount is not valid` ? i18n.str`Amount is not valid`
: Amounts.isZero(parsedAmount) : Amounts.isZero(parsedAmount)
? i18n.str`Should be greater than 0` ? i18n.str`Should be greater than 0`
: Amounts.cmp(limit, parsedAmount) === -1
? i18n.str`balance is not enough`
: undefined, : undefined,
}); });
@ -143,10 +151,10 @@ export function PaytoWireTransferForm({
type="text" type="text"
readonly readonly
class="currency-indicator" class="currency-indicator"
size={currency?.length} size={limit.currency.length}
maxLength={currency?.length} maxLength={limit.currency.length}
tabIndex={-1} tabIndex={-1}
value={currency} value={limit.currency}
/> />
&nbsp; &nbsp;
<input <input
@ -185,7 +193,7 @@ export function PaytoWireTransferForm({
try { try {
await createTransaction({ await createTransaction({
paytoUri, paytoUri,
amount: `${currency}:${amount}`, amount: `${limit.currency}:${amount}`,
}); });
onSuccess(); onSuccess();
setAmount(undefined); setAmount(undefined);
@ -257,7 +265,7 @@ export function PaytoWireTransferForm({
? i18n.str`only "IBAN" target are supported` ? i18n.str`only "IBAN" target are supported`
: !IBAN_REGEX.test(parsed.iban) : !IBAN_REGEX.test(parsed.iban)
? i18n.str`IBAN should have just uppercased letters and numbers` ? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined, : validateIBAN(parsed.iban, i18n),
}); });
return ( return (
@ -296,7 +304,8 @@ export function PaytoWireTransferForm({
<div style={{ fontSize: "small", marginTop: 4 }}> <div style={{ fontSize: "small", marginTop: 4 }}>
Hint: Hint:
<code> <code>
payto://iban/[receiver-iban]?message=[subject]&amount=[{currency} payto://iban/[receiver-iban]?message=[subject]&amount=[
{limit.currency}
:X.Y] :X.Y]
</code> </code>
</div> </div>

View File

@ -14,7 +14,12 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { Amounts, HttpStatusCode, Logger } from "@gnu-taler/taler-util"; import {
AmountJson,
Amounts,
HttpStatusCode,
Logger,
} from "@gnu-taler/taler-util";
import { import {
RequestError, RequestError,
useTranslationContext, useTranslationContext,
@ -30,11 +35,11 @@ const logger = new Logger("WalletWithdrawForm");
export function WalletWithdrawForm({ export function WalletWithdrawForm({
focus, focus,
currency, limit,
onError, onError,
onSuccess, onSuccess,
}: { }: {
currency: string; limit: AmountJson;
focus?: boolean; focus?: boolean;
onError: (e: PageStateType["error"]) => void; onError: (e: PageStateType["error"]) => void;
onSuccess: ( onSuccess: (
@ -52,20 +57,20 @@ export function WalletWithdrawForm({
if (focus) ref.current?.focus(); if (focus) ref.current?.focus();
}, [focus]); }, [focus]);
// Beware: We never ever want to treat the amount as a float!
const trimmedAmountStr = amountStr?.trim(); const trimmedAmountStr = amountStr?.trim();
const parsedAmount = trimmedAmountStr const parsedAmount = trimmedAmountStr
? Amounts.parse(`${currency}:${trimmedAmountStr}`) ? Amounts.parse(`${limit.currency}:${trimmedAmountStr}`)
: undefined; : undefined;
const errors = undefinedIfEmpty({ const errors = undefinedIfEmpty({
amount: amount:
trimmedAmountStr == null trimmedAmountStr == null
? i18n.str`required` ? i18n.str`required`
: parsedAmount == null : !parsedAmount
? i18n.str`invalid` ? i18n.str`invalid`
: Amounts.cmp(limit, parsedAmount) === -1
? i18n.str`balance is not enough`
: undefined, : undefined,
}); });
return ( return (
@ -87,10 +92,10 @@ export function WalletWithdrawForm({
type="text" type="text"
readonly readonly
class="currency-indicator" class="currency-indicator"
size={currency.length} size={limit.currency.length}
maxLength={currency.length} maxLength={limit.currency.length}
tabIndex={-1} tabIndex={-1}
value={currency} value={limit.currency}
/> />
&nbsp; &nbsp;
<input <input

View File

@ -34,3 +34,50 @@ export function compose<SType extends { status: string }, PType>(
return h(); return h();
}; };
} }
/**
*
* @param obj VNode
* @returns
*/
export function saveVNodeForInspection<T>(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<any>;
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);
}
}