commit 6778769ddb0d12a40edb5e668071879d664ddff8
parent 725c4f06b5cec6e96eb07fd85c84fd99183095c6
Author: Antoine A <>
Date: Fri, 18 Jul 2025 12:16:29 +0200
WIP UI for conversion rate classes
Diffstat:
10 files changed, 248 insertions(+), 271 deletions(-)
diff --git a/packages/bank-ui/src/components/Cashouts/state.ts b/packages/bank-ui/src/components/Cashouts/state.ts
@@ -46,6 +46,6 @@ export function useComponentState({
status: "ready",
error: undefined,
cashouts: result.body.cashouts,
- routeCashoutDetails,
+ routeCashoutDetails
};
}
diff --git a/packages/bank-ui/src/components/Cashouts/views.tsx b/packages/bank-ui/src/components/Cashouts/views.tsx
@@ -18,20 +18,17 @@ import {
AbsoluteTime,
Amounts,
HttpStatusCode,
- TalerError,
assertUnreachable,
} from "@gnu-taler/taler-util";
import {
Attention,
- Loading,
Time,
+ useBankCoreApiContext,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { format } from "date-fns";
import { Fragment, VNode, h } from "preact";
-import { useConversionInfo } from "../../hooks/regional.js";
import { RenderAmount } from "../../pages/PaytoWireTransferForm.js";
-import { ErrorLoadingWithDebug } from "../ErrorLoadingWithDebug.js";
import { State } from "./index.js";
const TALER_SCREEN_ID = 3;
@@ -59,29 +56,7 @@ export function ReadyView({
routeCashoutDetails,
}: State.Ready): VNode {
const { i18n, dateLocale } = useTranslationContext();
- const resp = useConversionInfo();
- if (!resp) {
- return <Loading />;
- }
- if (resp instanceof TalerError) {
- return <ErrorLoadingWithDebug error={resp} />;
- }
- if (resp.type === "fail") {
- switch (resp.case) {
- case HttpStatusCode.NotImplemented: {
- return (
- <Attention type="danger" title={i18n.str`Cashout is disabled`}>
- <i18n.Translate>
- Cashout should be enabled in the configuration, the conversion
- rate should be initialized with fee(s), rates and a rounding mode.
- </i18n.Translate>
- </Attention>
- );
- }
- default:
- assertUnreachable(resp);
- }
- }
+ const { config } = useBankCoreApiContext();
if (!cashouts.length) return <div />;
const txByDate = cashouts.reduce(
@@ -192,13 +167,13 @@ export function ReadyView({
<td class="hidden sm:table-cell px-3 py-3.5 text-sm text-red-600 cursor-pointer">
<RenderAmount
value={Amounts.parseOrThrow(item.amount_debit)}
- spec={resp.body.regional_currency_specification}
+ spec={config.currency_specification}
/>
</td>
<td class="hidden sm:table-cell px-3 py-3.5 text-sm text-green-600 cursor-pointer">
<RenderAmount
value={Amounts.parseOrThrow(item.amount_credit)}
- spec={resp.body.fiat_currency_specification}
+ spec={config.fiat_currency_specification!}
/>
</td>
diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts
@@ -44,10 +44,10 @@ const useSWR = _useSWR as unknown as SWRHook;
export type TransferCalculation =
| {
- debit: AmountJson;
- credit: AmountJson;
- beforeFee: AmountJson;
- }
+ debit: AmountJson;
+ credit: AmountJson;
+ beforeFee: AmountJson;
+ }
| "amount-is-too-small";
type EstimatorFunction = (
amount: AmountJson,
@@ -94,17 +94,20 @@ export function useConversionInfo() {
return undefined;
}
-export function useConversionInfoForUser(username: string) {
+export function useConversionRateForUser(
+ username: string,
+ token: AccessToken | undefined,
+) {
const {
lib: { conversionForUser },
config,
} = useBankCoreApiContext();
async function fetcher() {
- return await conversionForUser(username).getConfig();
+ return await conversionForUser(username).getRate(token);
}
const { data, error } = useSWR<
- TalerBankConversionResultByMethod<"getConfig">,
+ TalerBankConversionResultByMethod<"getRate">,
TalerHttpError
>(
!config.allow_conversion ? undefined : ["useConversionInfoForUser"],
@@ -229,42 +232,40 @@ function buildEstimatorWithTheBackend(
};
}
-export function useCashinEstimator(): ConversionEstimators {
- const {
- lib: { conversion },
- } = useBankCoreApiContext();
+function buildConversionEstimatorsWithTheBackend(
+ conversion: TalerBankConversionHttpClient,
+ direction: "cashin" | "cashout"
+): ConversionEstimators {
+ const { state } = useSessionState();
+ const token = state.status === "loggedIn" ? state.token : undefined;
return {
estimateByCredit: buildEstimatorWithTheBackend(
conversion,
- undefined,
- "cashin-rate-from-credit",
+ token,
+ direction == "cashin" ? "cashin-rate-from-credit" : "cashout-rate-from-credit",
),
estimateByDebit: buildEstimatorWithTheBackend(
conversion,
- undefined,
- "cashin-rate-from-debit",
+ token,
+ direction == "cashin" ? "cashin-rate-from-debit" : "cashout-rate-from-debit",
),
};
}
-export function useCashoutEstimator(): ConversionEstimators {
+export function useCashinEstimator(): ConversionEstimators {
const {
lib: { conversion },
} = useBankCoreApiContext();
- return {
- estimateByCredit: buildEstimatorWithTheBackend(
- conversion,
- undefined,
- "cashout-rate-from-credit",
- ),
- estimateByDebit: buildEstimatorWithTheBackend(
- conversion,
- undefined,
- "cashout-rate-from-debit",
- ),
- };
+ return buildConversionEstimatorsWithTheBackend(conversion, "cashin")
+}
+
+export function useCashoutEstimator(): ConversionEstimators {
+ const {
+ lib: { conversion },
+ } = useBankCoreApiContext();
+ return buildConversionEstimatorsWithTheBackend(conversion, "cashout")
}
export function useCashinEstimatorForClass(
@@ -273,21 +274,7 @@ export function useCashinEstimatorForClass(
const {
lib: { conversionForClass },
} = useBankCoreApiContext();
- const { state } = useSessionState();
- const token = state.status === "loggedIn" ? state.token : undefined;
-
- return {
- estimateByCredit: buildEstimatorWithTheBackend(
- conversionForClass(classId),
- token,
- "cashin-rate-from-credit",
- ),
- estimateByDebit: buildEstimatorWithTheBackend(
- conversionForClass(classId),
- token,
- "cashin-rate-from-debit",
- ),
- };
+ return buildConversionEstimatorsWithTheBackend(conversionForClass(classId), "cashin")
}
export function useCashoutEstimatorForClass(
@@ -296,20 +283,7 @@ export function useCashoutEstimatorForClass(
const {
lib: { conversionForClass },
} = useBankCoreApiContext();
- const { state } = useSessionState();
- const token = state.status === "loggedIn" ? state.token : undefined;
- return {
- estimateByCredit: buildEstimatorWithTheBackend(
- conversionForClass(classId),
- token,
- "cashout-rate-from-credit",
- ),
- estimateByDebit: buildEstimatorWithTheBackend(
- conversionForClass(classId),
- token,
- "cashout-rate-from-debit",
- ),
- };
+ return buildConversionEstimatorsWithTheBackend(conversionForClass(classId), "cashout")
}
export function useCashinEstimatorByUser(
@@ -318,21 +292,7 @@ export function useCashinEstimatorByUser(
const {
lib: { conversionForUser },
} = useBankCoreApiContext();
- const { state } = useSessionState();
- const token = state.status === "loggedIn" ? state.token : undefined;
-
- return {
- estimateByCredit: buildEstimatorWithTheBackend(
- conversionForUser(username),
- token,
- "cashin-rate-from-credit",
- ),
- estimateByDebit: buildEstimatorWithTheBackend(
- conversionForUser(username),
- token,
- "cashin-rate-from-debit",
- ),
- };
+ return buildConversionEstimatorsWithTheBackend(conversionForUser(username), "cashin")
}
export function useCashoutEstimatorByUser(
@@ -341,20 +301,7 @@ export function useCashoutEstimatorByUser(
const {
lib: { conversionForUser },
} = useBankCoreApiContext();
- const { state } = useSessionState();
- const token = state.status === "loggedIn" ? state.token : undefined;
- return {
- estimateByCredit: buildEstimatorWithTheBackend(
- conversionForUser(username),
- token,
- "cashout-rate-from-credit",
- ),
- estimateByDebit: buildEstimatorWithTheBackend(
- conversionForUser(username),
- token,
- "cashout-rate-from-debit",
- ),
- };
+ return buildConversionEstimatorsWithTheBackend(conversionForUser(username), "cashout")
}
export async function revalidateBusinessAccounts() {
diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx
@@ -48,6 +48,7 @@ import { RecursivePartial, undefinedIfEmpty } from "../utils.js";
import { doAutoFocus, InputAmount } from "./PaytoWireTransferForm.js";
import { ConversionForm } from "./regional/ConversionConfig.js";
import { AmountJson } from "@gnu-taler/taler-util";
+import { DescribeConversion } from "./admin/ConversionClassList.js";
interface Props {
classId: number;
@@ -67,7 +68,7 @@ type FormType = {
export function ConversionRateClassDetails({
routeCancel,
classId,
- onClassDeleted
+ onClassDeleted,
}: Props): VNode {
const { i18n } = useTranslationContext();
@@ -139,7 +140,7 @@ function Form({
const [section, setSection] = useState<
"detail" | "cashout" | "cashin" | "users" | "test" | "delete"
- >("delete");
+ >("detail");
const initalState: FormValues<FormType> = {
name: detailsResult.name,
@@ -460,16 +461,6 @@ function Form({
<div class="px-6 pt-6">
<div class="justify-between items-center flex ">
<dt class="text-sm text-gray-600">
- <i18n.Translate>Users</i18n.Translate>
- </dt>
- <dd class="text-sm text-gray-900">
- {detailsResult.num_users}
- </dd>
- </div>
- </div>
- <div class="px-6 pt-6">
- <div class="justify-between items-center flex ">
- <dt class="text-sm text-gray-600">
<i18n.Translate>Name</i18n.Translate>
</dt>
<dd class="text-sm text-gray-900">
@@ -525,37 +516,19 @@ function Form({
</dd>
</div>
</div>
-
<div class="px-6 pt-6">
<div class="justify-between items-center flex ">
<dt class="text-sm text-gray-600">
- <i18n.Translate>Cashin minimum</i18n.Translate>
+ <i18n.Translate>Cashin</i18n.Translate>
</dt>
<dd class="text-sm text-gray-900">
- <RenderAmount
- value={Amounts.parseOrThrow(final_cashin_min)}
- spec={conversionInfo.fiat_currency_specification}
- />
- </dd>
- </div>
- </div>
- <div class="px-6 pt-6">
- <div class="justify-between items-center flex ">
- <dt class="text-sm text-gray-600">
- <i18n.Translate>Cashin ratio</i18n.Translate>
- </dt>
- <dd class="text-sm text-gray-900">{final_cashin_ratio}</dd>
- </div>
- </div>
- <div class="px-6 pt-6">
- <div class="justify-between items-center flex ">
- <dt class="text-sm text-gray-600">
- <i18n.Translate>Cashin fee</i18n.Translate>
- </dt>
- <dd class="text-sm text-gray-900">
- <RenderAmount
- value={Amounts.parseOrThrow(final_cashin_fee)}
- spec={conversionInfo.regional_currency_specification}
+ <DescribeConversion
+ ratio={final_cashin_ratio}
+ fee={final_cashin_fee}
+ min={final_cashin_min}
+ rounding={final_cashin_rounding}
+ minSpec={conversionInfo.fiat_currency_specification}
+ feeSpec={conversionInfo.regional_currency_specification}
/>
</dd>
</div>
@@ -564,37 +537,28 @@ function Form({
<div class="px-6 pt-6">
<div class="justify-between items-center flex ">
<dt class="text-sm text-gray-600">
- <i18n.Translate>Cashout minimum</i18n.Translate>
+ <i18n.Translate>Cashout</i18n.Translate>
</dt>
<dd class="text-sm text-gray-900">
- <RenderAmount
- value={Amounts.parseOrThrow(final_cashout_min)}
- spec={conversionInfo.regional_currency_specification}
+ <DescribeConversion
+ ratio={final_cashout_ratio}
+ fee={final_cashout_fee}
+ min={final_cashout_min}
+ rounding={final_cashout_rounding}
+ minSpec={conversionInfo.regional_currency_specification}
+ feeSpec={conversionInfo.fiat_currency_specification}
/>
</dd>
</div>
</div>
- <div class="px-6 pt-6">
- <div class="justify-between items-center flex ">
- <dt class="text-sm text-gray-600">
- <i18n.Translate>Cashout ratio</i18n.Translate>
- </dt>
- <dd class="text-sm text-gray-900">
- {conversionInfo.conversion_rate.cashout_ratio}
- </dd>
- </div>
- </div>
<div class="px-6 pt-6">
<div class="justify-between items-center flex ">
<dt class="text-sm text-gray-600">
- <i18n.Translate>Cashout fee</i18n.Translate>
+ <i18n.Translate>Users</i18n.Translate>
</dt>
<dd class="text-sm text-gray-900">
- <RenderAmount
- value={Amounts.parseOrThrow(final_cashout_fee)}
- spec={conversionInfo.fiat_currency_specification}
- />
+ {detailsResult.num_users}
</dd>
</div>
</div>
@@ -1040,6 +1004,11 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode {
config,
} = useBankCoreApiContext();
const { state } = useSessionState();
+ const resultInfo = useConversionInfo();
+ const convInfo =
+ !resultInfo || resultInfo instanceof Error || resultInfo.type === "fail"
+ ? undefined
+ : resultInfo.body;
const token = state.status === "loggedIn" ? state.token : undefined;
const [filter, setFilter] = useState<{
@@ -1097,6 +1066,8 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode {
filter.showAll = !!v;
if (!v) {
filter.classId = classId;
+ } else {
+ filter.classId = undefined;
}
setFilter(structuredClone(filter));
},
@@ -1151,7 +1122,15 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode {
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
- >{i18n.str`Conversion rate`}</th>
+ >{i18n.str`Class`}</th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Cashin`}</th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Cashout`}</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
@@ -1170,10 +1149,34 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode {
{item.name}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
- {"<pending>"}
+ {item.conversion_rate_class_id}
+ </td>
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
+ <DescribeConversion
+ ratio={item.conversion_rate!.cashin_ratio}
+ fee={item.conversion_rate!.cashin_fee}
+ min={item.conversion_rate!.cashin_min_amount}
+ rounding={
+ item.conversion_rate!.cashin_rounding_mode
+ }
+ minSpec={convInfo!.fiat_currency_specification}
+ feeSpec={convInfo!.regional_currency_specification}
+ />
+ </td>
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
+ <DescribeConversion
+ ratio={item.conversion_rate!.cashout_ratio}
+ fee={item.conversion_rate!.cashout_fee}
+ min={item.conversion_rate!.cashout_min_amount}
+ rounding={
+ item.conversion_rate!.cashout_rounding_mode
+ }
+ minSpec={convInfo!.fiat_currency_specification}
+ feeSpec={convInfo!.regional_currency_specification}
+ />
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
- {classId === filter.classId ? (
+ {classId === item.conversion_rate_class_id ? (
<button
class="disabled:opacity-50 disabled:bg-gray-600 disabled:hover:bg-gray-600 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
onClick={async () => {
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
@@ -739,12 +739,14 @@ export function InputAmount(
name,
value,
left,
+ placeholder,
onChange,
}: {
currency: string;
name: string;
left?: boolean | undefined;
value: string | undefined;
+ placeholder?: string | undefined;
onChange?: (s: string) => void;
},
ref: Ref<HTMLInputElement>,
@@ -760,7 +762,7 @@ export function InputAmount(
type="number"
data-left={left}
class="disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"
- placeholder="0.00"
+ placeholder={placeholder ?? "0.00"}
aria-describedby="price-currency"
ref={ref}
name={name}
diff --git a/packages/bank-ui/src/pages/admin/ConversionClassList.tsx b/packages/bank-ui/src/pages/admin/ConversionClassList.tsx
@@ -182,7 +182,7 @@ export function ConversionClassList({
classId: String(row.conversion_rate_class_id),
})}
>
- <DescribeRatio
+ <DescribeConversion
ratio={
row.cashin_ratio ??
convInfo.conversion_rate.cashin_ratio
@@ -212,7 +212,7 @@ export function ConversionClassList({
classId: String(row.conversion_rate_class_id),
})}
>
- <DescribeRatio
+ <DescribeConversion
ratio={
row.cashout_ratio ??
convInfo.conversion_rate.cashout_ratio
@@ -273,7 +273,7 @@ export function ConversionClassList({
);
}
-function DescribeRatio({
+export function DescribeConversion({
fee,
min,
ratio,
diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx
@@ -27,6 +27,7 @@ import {
import {
Attention,
InternationalizationAPI,
+ Loading,
LocalNotificationBanner,
RouteDefinition,
ShowInputErrorLabel,
@@ -55,6 +56,8 @@ import { useSessionState } from "../../hooks/session.js";
import { undefinedIfEmpty } from "../../utils.js";
import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js";
import { ProfileNavigation } from "../ProfileNavigation.js";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { DescribeConversion } from "../admin/ConversionClassList.js";
const TALER_SCREEN_ID = 126;
@@ -83,29 +86,43 @@ function useComponentState({
}: Props): utils.RecursiveState<VNode> {
const { i18n } = useTranslationContext();
- const result = useConversionInfo();
- const info =
- result && !(result instanceof TalerError) && result.type === "ok"
- ? result.body
- : undefined;
-
const { state: credentials } = useSessionState();
const creds =
credentials.status !== "loggedIn" || !credentials.isUserAdministrator
? undefined
: credentials;
- if (!info) {
- return <i18n.Translate>loading...</i18n.Translate>;
- }
-
if (!creds) {
return <i18n.Translate>only admin can setup conversion</i18n.Translate>;
}
- return function afterComponentLoads() {
- const { i18n } = useTranslationContext();
+ const resp = useConversionInfo();
+ if (!resp) {
+ return <Loading />;
+ }
+ if (resp instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resp} />;
+ }
+
+ if (resp.type !== "ok") {
+ switch (resp.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout is disabled`}>
+ <i18n.Translate>
+ Cashout should be enabled in the configuration, the conversion
+ rate should be initialized with fee(s), rates and a rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ const info = resp.body;
+ return function afterComponentLoads() {
const {
lib: { conversion },
} = useBankCoreApiContext();
@@ -119,9 +136,8 @@ function useComponentState({
cashin_fee: info.conversion_rate.cashin_fee.split(":")[1],
cashin_ratio: info.conversion_rate.cashin_ratio,
cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode,
- cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1],
-
-
+ cashin_tiny_amount:
+ info.conversion_rate.cashin_tiny_amount.split(":")[1],
cashout_min_amount:
info.conversion_rate.cashout_min_amount.split(":")[1],
cashout_fee: info.conversion_rate.cashout_fee.split(":")[1],
@@ -365,10 +381,17 @@ function useComponentState({
<div class="px-6 pt-6">
<div class="justify-between items-center flex ">
<dt class="text-sm text-gray-600">
- <i18n.Translate>Cashin ratio</i18n.Translate>
+ <i18n.Translate>Cashin</i18n.Translate>
</dt>
<dd class="text-sm text-gray-900">
- {info.conversion_rate.cashin_ratio}
+ <DescribeConversion
+ ratio={info.conversion_rate.cashin_ratio}
+ fee={info.conversion_rate.cashin_fee}
+ min={info.conversion_rate.cashin_min_amount}
+ rounding={info.conversion_rate.cashin_rounding_mode}
+ minSpec={info.fiat_currency_specification}
+ feeSpec={info.regional_currency_specification}
+ />
</dd>
</div>
</div>
@@ -376,10 +399,17 @@ function useComponentState({
<div class="px-6 pt-6">
<div class="justify-between items-center flex ">
<dt class="text-sm text-gray-600">
- <i18n.Translate>Cashout ratio</i18n.Translate>
+ <i18n.Translate>Cashout</i18n.Translate>
</dt>
<dd class="text-sm text-gray-900">
- {info.conversion_rate.cashout_ratio}
+ <DescribeConversion
+ ratio={info.conversion_rate.cashout_ratio}
+ fee={info.conversion_rate.cashout_fee}
+ min={info.conversion_rate.cashout_min_amount}
+ rounding={info.conversion_rate.cashout_rounding_mode}
+ minSpec={info.regional_currency_specification}
+ feeSpec={info.fiat_currency_specification}
+ />
</dd>
</div>
</div>
@@ -592,14 +622,17 @@ function createFormValidator(
const cashin_min_amount = Amounts.parse(
`${fiat}:${state.conv.cashin_min_amount}`,
);
- // const cashin_tiny_amount = Amounts.parse(
- // `${regional}:${state.conv.cashin_tiny_amount}`,
- // );
+ const cashin_tiny_amount = Amounts.parse(
+ `${regional}:${state.conv.cashin_tiny_amount}`,
+ );
const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`);
const cashout_min_amount = Amounts.parse(
`${regional}:${state.conv.cashout_min_amount}`,
);
+ const cashout_tiny_amount = Amounts.parse(
+ `${fiat}:${state.conv.cashout_tiny_amount}`,
+ );
const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`);
const am = Amounts.parse(`${fiat}:${state.amount}`);
@@ -666,6 +699,9 @@ function createFormValidator(
cashin_min_amount: !errors?.conv?.cashin_min_amount
? Amounts.stringify(cashin_min_amount!)
: undefined,
+ cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount
+ ? Amounts.stringify(cashin_tiny_amount!)
+ : undefined,
cashin_ratio: !errors?.conv?.cashin_ratio
? String(cashin_ratio!)
: undefined,
@@ -678,6 +714,9 @@ function createFormValidator(
cashout_min_amount: !errors?.conv?.cashout_min_amount
? Amounts.stringify(cashout_min_amount!)
: undefined,
+ cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount
+ ? Amounts.stringify(cashout_tiny_amount!)
+ : undefined,
cashout_ratio: !errors?.conv?.cashout_ratio
? String(cashout_ratio!)
: undefined,
@@ -733,6 +772,7 @@ export function ConversionForm({
currency={inputCurrency}
value={minimum?.value ?? ""}
onChange={minimum?.onUpdate}
+ placeholder={fallback_minimum}
/>
<ShowInputErrorLabel
message={minimum?.error}
@@ -744,13 +784,6 @@ export function ConversionForm({
</i18n.Translate>
</p>
- {!fallback_minimum ? undefined : (
- <p class="mt-2 text-sm text-gray-500">
- <i18n.Translate>
- If none specified the fallback value is {fallback_minimum}.
- </i18n.Translate>
- </p>
- )}
</div>
</div>
</div>
@@ -774,6 +807,7 @@ export function ConversionForm({
ratio?.onUpdate(e.currentTarget.value);
}}
autocomplete="off"
+ placeholder={fallback_ratio ?? "1.0"}
/>
<ShowInputErrorLabel
message={ratio?.error}
@@ -783,13 +817,6 @@ export function ConversionForm({
<p class="mt-2 text-sm text-gray-500">
<i18n.Translate>Conversion ratio between currencies</i18n.Translate>
</p>
- {!fallback_ratio ? undefined : (
- <p class="mt-2 text-sm text-gray-500">
- <i18n.Translate>
- If none specified the fallback value is {fallback_ratio}.
- </i18n.Translate>
- </p>
- )}
</div>
<div class="px-6 pt-4">
@@ -1115,7 +1142,7 @@ export function ConversionForm({
<p class="text-gray-900 my-4">
<i18n.Translate>
With the "up" mode the value will be rounded to 1.3
- </i18n.Translate>
+ </i18n.Translate>.0
</p>
</details>
</section>
@@ -1135,6 +1162,7 @@ export function ConversionForm({
currency={outputCurrency}
value={fee?.value ?? ""}
onChange={fee?.onUpdate}
+ placeholder={fallback_fee}
/>
<ShowInputErrorLabel
message={fee?.error}
@@ -1147,14 +1175,6 @@ export function ConversionForm({
</p>
</div>
</div>
- {!fallback_fee ? undefined : (
- <p class="mt-2 text-sm text-gray-500">
- <i18n.Translate>
- If none specified the fallback value is "{fallback_fee}
- ".
- </i18n.Translate>
- </p>
- )}
</div>
</Fragment>
);
diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx
@@ -43,9 +43,8 @@ import { useAccountDetails } from "../../hooks/account.js";
import { useBankState } from "../../hooks/bank-state.js";
import {
TransferCalculation,
- useCashoutEstimator,
- useConversionInfo,
- useConversionInfoForUser,
+ useCashoutEstimatorByUser,
+ useConversionRateForUser,
} from "../../hooks/regional.js";
import { useSessionState } from "../../hooks/session.js";
import { TanChannel, undefinedIfEmpty } from "../../utils.js";
@@ -90,7 +89,7 @@ export function CreateCashout({
const {
estimateByCredit: calculateFromCredit,
estimateByDebit: calculateFromDebit,
- } = useCashoutEstimator();
+ } = useCashoutEstimatorByUser(accountName);
const { state: credentials } = useSessionState();
const creds = credentials.status !== "loggedIn" ? undefined : credentials;
const [, updateBankState] = useBankState();
@@ -101,7 +100,7 @@ export function CreateCashout({
} = useBankCoreApiContext();
const [form, setForm] = useState<Partial<FormType>>({ isDebit: true });
const [notification, notify, handleError] = useLocalNotification();
- const info = useConversionInfoForUser(accountName);
+ const resp = useConversionRateForUser(accountName, creds?.token);
if (!config.allow_conversion) {
return (
@@ -150,15 +149,15 @@ export function CreateCashout({
assertUnreachable(resultAccount);
}
}
- if (!info) {
+ if (!resp) {
return <Loading />;
}
- if (info instanceof TalerError) {
- return <ErrorLoadingWithDebug error={info} />;
+ if (resp instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resp} />;
}
- if (info.type === "fail") {
- switch (info.case) {
+ if (resp.type === "fail") {
+ switch (resp.case) {
case HttpStatusCode.NotImplemented: {
return (
<Attention type="danger" title={i18n.str`Cashout is disabled`}>
@@ -170,12 +169,12 @@ export function CreateCashout({
);
}
default:
- assertUnreachable(info);
+ assertUnreachable(resp);
}
}
+ const rate = resp.body;
- const conversionInfo = info.body.conversion_rate;
- if (!conversionInfo) {
+ if (!rate) {
return (
<div>conversion enabled but server replied without conversion_rate</div>
);
@@ -183,22 +182,18 @@ export function CreateCashout({
const {
fiat_currency,
- regional_currency,
+ currency: regional_currency,
fiat_currency_specification,
- regional_currency_specification,
- } = info.body;
+ currency_specification: regional_currency_specification,
+ } = config;
const regionalZero = Amounts.zeroOfCurrency(regional_currency);
- const fiatZero = Amounts.zeroOfCurrency(fiat_currency);
+ const fiatZero = Amounts.zeroOfCurrency(fiat_currency!);
const account = {
balance: Amounts.parseOrThrow(resultAccount.body.balance.amount),
balanceIsDebit:
resultAccount.body.balance.credit_debit_indicator == "debit",
debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold),
- minCashout:
- conversionInfo.cashin_min_amount === undefined
- ? regionalZero
- : Amounts.parseOrThrow(conversionInfo.cashin_min_amount),
};
const limit = account.balanceIsDebit
@@ -212,8 +207,8 @@ export function CreateCashout({
};
const [calculationResult, setCalculation] =
useState<TransferCalculation>(zeroCalc);
- const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee);
- const sellRate = conversionInfo.cashout_ratio;
+ const sellFee = Amounts.parseOrThrow(rate.cashout_fee);
+ const sellRate = rate.cashout_ratio;
/**
* can be in regional currency or fiat currency
* depending on the isDebit flag
@@ -228,7 +223,7 @@ export function CreateCashout({
async function doAsync() {
await handleError(async () => {
const higerThanMin = form.isDebit
- ? Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1
+ ? Amounts.cmp(inputAmount, rate.cashout_min_amount) === 1
: true;
const notZero = Amounts.isNonZero(inputAmount);
if (notZero && higerThanMin) {
@@ -262,23 +257,16 @@ export function CreateCashout({
? i18n.str`Balance is not enough`
: calculationResult === "amount-is-too-small"
? i18n.str`Amount needs to be higher`
- : Amounts.cmp(calc.debit, conversionInfo.cashout_min_amount) < 0
+ : Amounts.cmp(calc.debit, rate.cashout_min_amount) < 0
? i18n.str`It is not possible to cashout less than ${
Amounts.stringifyValueWithSpec(
- Amounts.parseOrThrow(conversionInfo.cashout_min_amount),
+ Amounts.parseOrThrow(rate.cashout_min_amount),
regional_currency_specification,
).normal
}`
- : Amounts.cmp(calc.debit, account.minCashout) < 0
- ? i18n.str`Your account have a cashout limit. Minimum account cashout is ${
- Amounts.stringifyValueWithSpec(
- Amounts.parseOrThrow(account.minCashout),
- regional_currency_specification,
- ).normal
- }`
- : Amounts.isZero(calc.credit)
- ? i18n.str`The total transfer to the destination will be zero`
- : undefined,
+ : Amounts.isZero(calc.credit)
+ ? i18n.str`The total transfer to the destination will be zero`
+ : undefined,
});
const trimmedAmountStr = form.amount?.trim();
@@ -441,7 +429,7 @@ export function CreateCashout({
<dd class="text-sm text-gray-900">
<RenderAmount
value={sellFee}
- spec={fiat_currency_specification}
+ spec={fiat_currency_specification!}
/>
</dd>
</div>
@@ -645,7 +633,7 @@ export function CreateCashout({
<InputAmount
name="amount"
left
- currency={form.isDebit ? regional_currency : fiat_currency}
+ currency={form.isDebit ? regional_currency : fiat_currency!}
value={trimmedAmountStr}
onChange={
cashoutDisabled
@@ -704,7 +692,7 @@ export function CreateCashout({
<dd class="text-sm text-gray-900">
<RenderAmount
value={calc.beforeFee}
- spec={fiat_currency_specification}
+ spec={fiat_currency_specification!}
/>
</dd>
</div>
@@ -717,7 +705,7 @@ export function CreateCashout({
<RenderAmount
value={calc.credit}
withColor
- spec={fiat_currency_specification}
+ spec={fiat_currency_specification!}
/>
</dd>
</div>
diff --git a/packages/taler-util/src/http-client/bank-conversion.ts b/packages/taler-util/src/http-client/bank-conversion.ts
@@ -37,6 +37,7 @@ import {
codecForCashinConversionResponse,
codecForCashoutConversionResponse,
codecForConversionBankConfig,
+ codecForConversionRate,
} from "../types-taler-bank-conversion.js";
import { AccessToken } from "../types-taler-common.js";
import { codecForTalerErrorDetail } from "../types-taler-wallet.js";
@@ -100,6 +101,31 @@ export class TalerBankConversionHttpClient {
}
/**
+ * https://docs.taler.net/core/api-bank-conversion-info.html#get--rate
+ *
+ */
+ async getRate(auth: AccessToken | undefined) {
+ const url = new URL(`rate`, this.baseUrl);
+ const headers: Record<string, string> = {};
+ if (auth) {
+ headers.Authorization = makeBearerTokenAuthHeader(auth);
+ }
+ console.log(auth)
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForConversionRate());
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
+
+ /**
* https://docs.taler.net/core/api-bank-conversion-info.html#get--cashin-rate
*
*/
diff --git a/packages/taler-util/src/types-taler-corebank.ts b/packages/taler-util/src/types-taler-corebank.ts
@@ -118,6 +118,14 @@ export interface TalerCorebankConfigResponse {
// How the bank SPA should render this currency.
currency_specification: CurrencySpecification;
+ // External currency used during conversion.
+ // None if conversion is disabled
+ fiat_currency?: string;
+
+ // How the bank SPA should render this currency.
+ // None if conversion is disabled
+ fiat_currency_specification?: CurrencySpecification;
+
// TAN channels supported by the server
supported_tan_channels?: TanChannel[];
@@ -451,6 +459,12 @@ export interface AccountMinimalData {
// Defaults to 'active' is missing
// @since **v4**, will become mandatory in the next version.
status?: AccountStatus;
+
+ // Conversion rate class of the user
+ conversion_rate_class_id?: Integer;
+
+ // Applied conversion rate
+ conversion_rate?: ConversionRate;
}
export type AccountStatus = "active" | "locked" | "deleted";
@@ -468,9 +482,6 @@ export interface ConversionRateClass {
// Number of users affected to this class
num_users: Integer;
- // Applied conversion rate
- conversion_rate?: ConversionRate;
-
// Minimum fiat amount authorised for cashin before conversion
cashin_min_amount?: AmountString;
@@ -497,7 +508,6 @@ export interface ConversionRateClass {
}
export interface ConversionRateClasses {
- default: ConversionRate;
classes: ConversionRateClass[];
}
@@ -626,6 +636,9 @@ export interface AccountData {
// @since **v4**, will become mandatory in the next version.
status?: AccountStatus;
+ // Conversion rate class of the user
+ conversion_rate_class_id?: Integer;
+
// Conversion rate available to the user
// Only present if conversion is activated on the server
// @since **v9**
@@ -849,6 +862,8 @@ export const codecForCoreBankConfig = (): Codec<TalerCorebankConfigResponse> =>
.property("default_debit_threshold", codecOptional(codecForAmountString()))
.property("currency", codecForString())
.property("currency_specification", codecForCurrencySpecificiation())
+ .property("fiat_currency", codecOptional(codecForString()))
+ .property("fiat_currency_specification", codecOptional(codecForCurrencySpecificiation()))
.property(
"supported_tan_channels",
codecOptional(
@@ -913,6 +928,8 @@ export const codecForAccountMinimalData = (): Codec<AccountMinimalData> =>
),
),
)
+ .property("conversion_rate_class_id", codecOptional(codecForNumber()))
+ .property("conversion_rate", codecOptional(codecForConversionRate()))
.build("TalerCorebankApi.AccountMinimalData");
export const codecForListBankAccountsResponse =
@@ -931,6 +948,7 @@ export const codecForAccountData = (): Codec<AccountData> =>
.property("cashout_payto_uri", codecOptional(codecForPaytoString()))
.property("is_public", codecForBoolean())
.property("is_taler_exchange", codecForBoolean())
+ .property("conversion_rate_class_id", codecOptional(codecForNumber()))
.property("conversion_rate", codecOptional(codecForConversionRate()))
.property(
"tan_channel",
@@ -985,7 +1003,6 @@ export const codecForConversionRateClass = (): Codec<ConversionRateClass> =>
codecForConstString("nearest"),
)),
)
- .property("conversion_rate", codecOptional(codecForConversionRate()))
.property("conversion_rate_class_id", codecForNumber())
.property("description", codecOptional(codecForString()))
.property("name", codecForString())
@@ -995,7 +1012,6 @@ export const codecForConversionRateClass = (): Codec<ConversionRateClass> =>
export const codecForConversionRateClasses = (): Codec<ConversionRateClasses> =>
buildCodecForObject<ConversionRateClasses>()
.property("classes", codecForList(codecForConversionRateClass()))
- .property("default", codecForConversionRate())
.build("TalerCorebankApi.ConversionRateClasses");
export const codecForChallengeContactData = (): Codec<ChallengeContactData> =>