commit 3922157f6ad0a3a046f4cc1f3f7ed54d83b21094 parent 7aa78726f91a4ff65664e07708f89aaae7d13e19 Author: Antoine A <> Date: Fri, 18 Jul 2025 13:51:25 +0200 Merge branch 'dev/sebasjm/conversion-rate-class' Diffstat:
29 files changed, 3585 insertions(+), 704 deletions(-)
diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -21,7 +21,7 @@ import { useCurrentLocation, useLocalNotification, useNavigationContext, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -33,11 +33,14 @@ import { TokenRequest, TranslatedString, assertUnreachable, - createRFC8959AccessTokenEncoded + createRFC8959AccessTokenEncoded, } from "@gnu-taler/taler-util"; import { useEffect } from "preact/hooks"; import { useBankState } from "./hooks/bank-state.js"; -import { useRefreshSessionBeforeExpires, useSessionState } from "./hooks/session.js"; +import { + useRefreshSessionBeforeExpires, + useSessionState, +} from "./hooks/session.js"; import { AccountPage } from "./pages/AccountPage/index.js"; import { BankFrame } from "./pages/BankFrame.js"; import { LoginForm, SESSION_DURATION } from "./pages/LoginForm.js"; @@ -57,6 +60,8 @@ import { RemoveAccount } from "./pages/admin/RemoveAccount.js"; import { ConversionConfig } from "./pages/regional/ConversionConfig.js"; import { CreateCashout } from "./pages/regional/CreateCashout.js"; import { ShowCashoutDetails } from "./pages/regional/ShowCashoutDetails.js"; +import { NewConversionRateClass } from "./pages/NewConversionRateClass.js"; +import { ConversionRateClassDetails } from "./pages/ConversionRateClassDetails.js"; const TALER_SCREEN_ID = 100; @@ -64,7 +69,7 @@ Routing.SCREEN_ID = TALER_SCREEN_ID; export function Routing(): VNode { const session = useSessionState(); - useRefreshSessionBeforeExpires() + useRefreshSessionBeforeExpires(); if (session.state.status === "loggedIn") { const { isUserAdministrator, username } = session.state; @@ -294,7 +299,7 @@ const privatePages = { myAccountDetails: urlPattern(/\/my-profile/, () => "#/my-profile"), myAccountPassword: urlPattern(/\/my-password/, () => "#/my-password"), myAccountCashouts: urlPattern(/\/my-cashouts/, () => "#/my-cashouts"), - conversionConfig: urlPattern(/\/conversion/, () => "#/conversion"), + conversionConfig: urlPattern(/\/conversion$/, () => "#/conversion"), accountDetails: urlPattern<{ account: string }>( /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/details/, ({ account }) => `#/profile/${account}/details`, @@ -319,6 +324,14 @@ const privatePages = { /\/operation\/(?<wopid>[a-zA-Z0-9-]+)/, ({ wopid }) => `#/operation/${wopid}`, ), + conversionRateClassCreate: urlPattern( + /\/new-conversion-rate-group/, + () => "#/new-conversion-rate-group", + ), + conversionRateClassDetails: urlPattern<{ classId: string }>( + /\/conversion-rate-group\/(?<classId>[0-9]+)\/details/, + ({ classId }) => `#/conversion-rate-group/${classId}/details`, + ), }; function PrivateRouting({ @@ -533,13 +546,19 @@ function PrivateRouting({ onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({})) } - routeCreate={privatePages.accountCreate} + routeCreateAccount={privatePages.accountCreate} routeRemoveAccount={privatePages.accountDelete} routeShowAccount={privatePages.accountDetails} routeShowCashoutsAccount={privatePages.accountCashouts} routeUpdatePasswordAccount={privatePages.accountChangePassword} routeCreateWireTransfer={privatePages.wireTranserCreate} routeDownloadStats={privatePages.statsDownload} + routeCreateConversionRateClass={ + privatePages.conversionRateClassCreate + } + routeShowConversionRateClass={ + privatePages.conversionRateClassDetails + } /> ); } @@ -662,6 +681,35 @@ function PrivateRouting({ /> ); } + case "conversionRateClassCreate": { + return ( + <NewConversionRateClass + onCreated={(id) => + navigateTo( + privatePages.conversionRateClassDetails.url({ + classId: String(id), + }), + ) + } + routeCancel={privatePages.home} + /> + ); + } + case "conversionRateClassDetails": { + const id = Number.parseInt(location.values.classId, 10); + if (Number.isNaN(id)) { + return <div>class id is not a number "{location.values.classId}"</div>; + } + return ( + <ConversionRateClassDetails + classId={id} + routeCancel={privatePages.home} + onClassDeleted={() => { + navigateTo(privatePages.home.url({})); + }} + /> + ); + } case "notifications": { return <ShowNotifications />; } diff --git a/packages/bank-ui/src/app.tsx b/packages/bank-ui/src/app.tsx @@ -47,6 +47,8 @@ import { revalidateBusinessAccounts, revalidateCashouts, revalidateConversionInfo, + revalidateConversionRateClassDetails, + revalidateConversionRateClasses, } from "./hooks/regional.js"; const WITH_LOCAL_STORAGE_CACHE = false; @@ -216,6 +218,19 @@ const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = { case TalerCoreBankCacheEviction.ABORT_WITHDRAWAL: case TalerCoreBankCacheEviction.CREATE_WITHDRAWAL: return; + case TalerCoreBankCacheEviction.UPDATE_CONVERSION_RATE_CLASS: + case TalerCoreBankCacheEviction.CREATE_CONVERSION_RATE_CLASS: + case TalerCoreBankCacheEviction.DELETE_CONVERSION_RATE_CLASS: { + await Promise.all([ + revalidateConversionInfo(), + revalidateCashouts(), + revalidateTransactions(), + revalidateConversionRateClassDetails(), + revalidateConversionRateClasses() + ]); + + } + return; default: assertUnreachable(op); } 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/components/Transactions/state.ts b/packages/bank-ui/src/components/Transactions/state.ts @@ -84,7 +84,7 @@ export function useComponentState({ error: undefined, routeCreateWireTransfer, transactions, - onGoNext: result.isLastPage ? undefined : result.loadNext, - onGoStart: result.isFirstPage ? undefined : result.loadFirst, + onGoNext: result.loadNext, + onGoStart: result.loadFirst, }; } diff --git a/packages/bank-ui/src/hooks/account.ts b/packages/bank-ui/src/hooks/account.ts @@ -29,6 +29,7 @@ import { useSessionState } from "./session.js"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 import { + PaginatedResult, useAsync, useBankCoreApiContext, useLongPolling, @@ -215,13 +216,6 @@ export function usePublicAccounts( ); } -type PaginatedResult<T> = OperationOk<T> & { - isLastPage: boolean; - isFirstPage: boolean; - loadNext(): void; - loadFirst(): void; -}; - // TODO: consider sending this to web-util export function buildPaginatedResult<DataType, OffsetId>( data: DataType[], @@ -241,14 +235,12 @@ export function buildPaginatedResult<DataType, OffsetId>( type: "ok", case: "ok", body: result, - isLastPage, - isFirstPage, - loadNext: () => { + loadNext: isLastPage ? undefined :() => { if (!result.length) return; const id = getId(result[result.length - 1]); setOffset(id); }, - loadFirst: () => { + loadFirst: isFirstPage ? undefined : () => { setOffset(undefined); }, }; diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts @@ -36,16 +36,18 @@ import { useState } from "preact/hooks"; import _useSWR, { SWRHook, mutate } from "swr"; import { PAGINATED_LIST_REQUEST } from "../utils.js"; import { buildPaginatedResult } from "./account.js"; +import { TalerBankConversionHttpClient } from "@gnu-taler/taler-util"; +import { assertUnreachable } from "@gnu-taler/taler-util"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 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, @@ -92,156 +94,219 @@ export function useConversionInfo() { return undefined; } -export function useCashinEstimator(): ConversionEstimators { +export function useConversionRateForUser( + username: string, + token: AccessToken | undefined, +) { const { - lib: { conversion }, + lib: { conversionForUser }, + config, } = useBankCoreApiContext(); - return { - estimateByCredit: async (fiatAmount, fee) => { - const resp = await conversion.getCashinRate({ - credit: fiatAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small"; - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - if (resp.detail) { - throw TalerError.fromUncheckedDetail(resp.detail); - } else { - throw TalerError.fromException( - new Error("failed to get conversion cashin rate"), - ); - } - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.sub(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; + + async function fetcher() { + return await conversionForUser(username).getRate(token); + } + const { data, error } = useSWR< + TalerBankConversionResultByMethod<"getRate">, + TalerHttpError + >( + !config.allow_conversion ? undefined : ["useConversionInfoForUser"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, }, - estimateByDebit: async (regionalAmount, fee) => { - const resp = await conversion.getCashinRate({ - debit: regionalAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small"; - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - if (resp.detail) { - throw TalerError.fromUncheckedDetail(resp.detail); - } else { - throw TalerError.fromException( - new Error("failed to get conversion cashin rate"), - ); - } + ); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function useConversionInfoForClass(classId: number) { + const { + lib: { conversionForClass }, + config, + } = useBankCoreApiContext(); + + async function fetcher() { + return await conversionForClass(classId).getConfig(); + } + const { data, error } = useSWR< + TalerBankConversionResultByMethod<"getConfig">, + TalerHttpError + >( + !config.allow_conversion ? undefined : ["useConversionInfoForClass"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (data) return data; + if (error) return error; + return undefined; +} + +function buildEstimatorWithTheBackend( + conversion: TalerBankConversionHttpClient, + token: AccessToken | undefined, + estimation: + | "cashin-rate-from-credit" + | "cashin-rate-from-debit" + | "cashout-rate-from-credit" + | "cashout-rate-from-debit", +): EstimatorFunction { + return async (amount, fee) => { + let resp; + switch (estimation) { + case "cashin-rate-from-credit": { + resp = await conversion.getCashinRate(token, { + credit: amount, + }); + break; + } + case "cashin-rate-from-debit": { + resp = await conversion.getCashinRate(token, { + debit: amount, + }); + break; + } + case "cashout-rate-from-credit": { + resp = await conversion.getCashoutRate(token, { + credit: amount, + }); + break; + } + case "cashout-rate-from-debit": { + resp = await conversion.getCashoutRate(token, { + debit: amount, + }); + break; + } + default: { + assertUnreachable(estimation); + } + } + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.Conflict: { + return "amount-is-too-small"; } + // this below can't happen + case HttpStatusCode.NotImplemented: //it should not be able to call this function + case HttpStatusCode.BadRequest: //we are using just one parameter + if (resp.detail) { + throw TalerError.fromUncheckedDetail(resp.detail); + } else { + throw TalerError.fromException( + new Error("failed to get conversion cashin rate"), + ); + } } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.add(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.sub(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }; +} + + +function buildConversionEstimatorsWithTheBackend( + conversion: TalerBankConversionHttpClient, + direction: "cashin" | "cashout" +): ConversionEstimators { + const { state } = useSessionState(); + const token = state.status === "loggedIn" ? state.token : undefined; + return { + estimateByCredit: buildEstimatorWithTheBackend( + conversion, + token, + direction == "cashin" ? "cashin-rate-from-credit" : "cashout-rate-from-credit", + ), + estimateByDebit: buildEstimatorWithTheBackend( + conversion, + token, + direction == "cashin" ? "cashin-rate-from-debit" : "cashout-rate-from-debit", + ), }; } +export function useCashinEstimator(): ConversionEstimators { + const { + lib: { conversion }, + } = useBankCoreApiContext(); + + return buildConversionEstimatorsWithTheBackend(conversion, "cashin") +} + export function useCashoutEstimator(): ConversionEstimators { const { lib: { conversion }, } = useBankCoreApiContext(); - return { - estimateByCredit: async (fiatAmount, fee) => { - const resp = await conversion.getCashoutRate({ - credit: fiatAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small"; - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - if (resp.detail) { - throw TalerError.fromUncheckedDetail(resp.detail); - } else { - throw TalerError.fromException( - new Error("failed to get conversion cashout rate"), - ); - } - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.sub(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - estimateByDebit: async (regionalAmount, fee) => { - const resp = await conversion.getCashoutRate({ - debit: regionalAmount, - }); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small"; - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - if (resp.detail) { - throw TalerError.fromUncheckedDetail(resp.detail); - } else { - throw TalerError.fromException( - new Error("failed to get conversion cashout rate"), - ); - } - } - } - const credit = Amounts.parseOrThrow(resp.body.amount_credit); - const debit = Amounts.parseOrThrow(resp.body.amount_debit); - const beforeFee = Amounts.add(credit, fee).amount; - - return { - debit, - beforeFee, - credit, - }; - }, - }; + return buildConversionEstimatorsWithTheBackend(conversion, "cashout") } -/** - * @deprecated use useCashoutEstimator - */ -export function useEstimator(): ConversionEstimators { - return useCashoutEstimator(); +export function useCashinEstimatorForClass( + classId: number, +): ConversionEstimators { + const { + lib: { conversionForClass }, + } = useBankCoreApiContext(); + return buildConversionEstimatorsWithTheBackend(conversionForClass(classId), "cashin") +} + +export function useCashoutEstimatorForClass( + classId: number, +): ConversionEstimators { + const { + lib: { conversionForClass }, + } = useBankCoreApiContext(); + return buildConversionEstimatorsWithTheBackend(conversionForClass(classId), "cashout") +} + +export function useCashinEstimatorByUser( + username: string, +): ConversionEstimators { + const { + lib: { conversionForUser }, + } = useBankCoreApiContext(); + return buildConversionEstimatorsWithTheBackend(conversionForUser(username), "cashin") +} + +export function useCashoutEstimatorByUser( + username: string, +): ConversionEstimators { + const { + lib: { conversionForUser }, + } = useBankCoreApiContext(); + return buildConversionEstimatorsWithTheBackend(conversionForUser(username), "cashout") } export async function revalidateBusinessAccounts() { return mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", + (key) => Array.isArray(key) && key[key.length - 1] === "listAccounts", undefined, { revalidate: true }, ); @@ -257,22 +322,17 @@ export function useBusinessAccounts() { const [offset, setOffset] = useState<number | undefined>(); function fetcher([token, aid]: [AccessToken, number]) { - // FIXME: add account name filter - return api.getAccounts( - token, - {}, - { - limit: PAGINATED_LIST_REQUEST, - offset: aid ? String(aid) : undefined, - order: "asc", - }, - ); + return api.listAccounts(token, { + limit: PAGINATED_LIST_REQUEST, + offset: aid ? String(aid) : undefined, + order: "asc", + }); } const { data, error } = useSWR< - TalerCoreBankResultByMethod<"getAccounts">, + TalerCoreBankResultByMethod<"listAccounts">, TalerHttpError - >([token, offset ?? 0, "getAccounts"], fetcher, { + >([token, offset ?? 0, "listAccounts"], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -530,3 +590,157 @@ export function useLastMonitorInfo( if (error) return error; return undefined; } + +export function revalidateConversionRateClasses() { + return mutate( + (key) => + Array.isArray(key) && key[key.length - 1] === "useConversionRateClasses", + undefined, + { revalidate: true }, + ); +} +export function useConversionRateClasses() { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + const [offset, setOffset] = useState<number | undefined>(); + + function fetcher([token, aid]: [AccessToken, number]) { + return api.listConversionRateClasses(token, { + limit: PAGINATED_LIST_REQUEST, + offset: aid ? String(aid) : undefined, + order: "asc", + }); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"listConversionRateClasses">, + TalerHttpError + >([token, offset ?? 0, "useConversionRateClasses"], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return buildPaginatedResult( + data.body.classes, + offset, + setOffset, + (d) => d.conversion_rate_class_id, + ); +} + +export function revalidateConversionRateClassDetails() { + return mutate( + (key) => + Array.isArray(key) && + key[key.length - 1] === "useConversionRateClassDetails", + undefined, + { revalidate: true }, + ); +} + +export function useConversionRateClassDetails(classId: number) { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + async function fetcher([username, token]: [number, AccessToken]) { + return await api.getConversionRateClass(token, username); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"getConversionRateClass">, + TalerHttpError + >([classId, token, "useConversionRateClassDetails"], fetcher, {}); + + if (data) return data; + if (error) return error; + return undefined; +} + +export function revalidateConversionRateClassUsers() { + return mutate( + (key) => + Array.isArray(key) && + key[key.length - 1] === "useConversionRateClassUsers", + undefined, + { revalidate: true }, + ); +} + +export function useConversionRateClassUsers( + classId: number | undefined, + username?: string, +) { + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + const [offset, setOffset] = useState<number | undefined>(); + + function fetcher([token, aid, username, classId]: [ + AccessToken, + number, + string, + number, + ]) { + return api.listAccounts(token, { + limit: PAGINATED_LIST_REQUEST, + offset: aid ? String(aid) : undefined, + order: "asc", + account: username, + conversionRateId: classId, + }); + } + + const { data, error } = useSWR< + TalerCoreBankResultByMethod<"listAccounts">, + TalerHttpError + >( + [token, offset ?? 0, username, classId, "useConversionRateClassUsers"], + fetcher, + { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }, + ); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return buildPaginatedResult( + data.body.accounts, + offset, + setOffset, + (d) => d.row_id!, + ); +} diff --git a/packages/bank-ui/src/hooks/session.ts b/packages/bank-ui/src/hooks/session.ts @@ -27,7 +27,11 @@ import { codecForString, codecOptionalDefault, } from "@gnu-taler/taler-util"; -import { buildStorageKey, useBankCoreApiContext, useLocalStorage } from "@gnu-taler/web-util/browser"; +import { + buildStorageKey, + useBankCoreApiContext, + useLocalStorage, +} from "@gnu-taler/web-util/browser"; import { mutate } from "swr"; import { SESSION_DURATION } from "../pages/LoginForm.js"; import { createRFC8959AccessTokenEncoded } from "@gnu-taler/taler-util"; @@ -60,7 +64,10 @@ export const codecForSessionStateLoggedIn = (): Codec<LoggedIn> => buildCodecForObject<LoggedIn>() .property("status", codecForConstString("loggedIn")) .property("username", codecForString()) - .property("expiration", codecOptionalDefault(codecForAbsoluteTime, AbsoluteTime.now())) + .property( + "expiration", + codecOptionalDefault(codecForAbsoluteTime, AbsoluteTime.now()), + ) .property("token", codecForString() as Codec<AccessToken>) .property("isUserAdministrator", codecForBoolean()) .build("SessionState.LoggedIn"); @@ -69,7 +76,10 @@ export const codecForSessionStateExpired = (): Codec<Expired> => buildCodecForObject<Expired>() .property("status", codecForConstString("expired")) .property("username", codecForString()) - .property("expiration", codecOptionalDefault(codecForAbsoluteTime, AbsoluteTime.now())) + .property( + "expiration", + codecOptionalDefault(codecForAbsoluteTime, AbsoluteTime.now()), + ) .property("isUserAdministrator", codecForBoolean()) .build("SessionState.Expired"); @@ -94,7 +104,11 @@ export interface SessionStateHandler { state: SessionState; logOut(): void; expired(): void; - logIn(info: { username: string; token: AccessToken, expiration: AbsoluteTime }): void; + logIn(info: { + username: string; + token: AccessToken; + expiration: AbsoluteTime; + }): void; } const SESSION_STATE_KEY = buildStorageKey( @@ -113,6 +127,21 @@ export function useSessionState(): SessionStateHandler { defaultState, ); + useEffect(() => { + if ( + state.status === "loggedIn" && + AbsoluteTime.isExpired(state.expiration) + ) { + const nextState: SessionState = { + status: "expired", + username: state.username, + expiration: state.expiration, + isUserAdministrator: state.username === "admin", + }; + update(nextState); + } + }); + return { state, logOut() { @@ -153,7 +182,7 @@ function cleanAllCache(): void { */ export function useRefreshSessionBeforeExpires() { const session = useSessionState(); - + const { lib: { bank }, } = useBankCoreApiContext(); @@ -170,7 +199,9 @@ export function useRefreshSessionBeforeExpires() { * we need to wait before refreshing the session. Waiting too much and the token will * be expired. So 20% before expiration should be close enough. */ - const timeLeftBeforeExpiration = Duration.getRemaining(refreshSession.expiration); + const timeLeftBeforeExpiration = Duration.getRemaining( + refreshSession.expiration, + ); const refreshWindow = Duration.multiply( Duration.fromTalerProtocolDuration(SESSION_DURATION), 0.2, @@ -184,6 +215,7 @@ export function useRefreshSessionBeforeExpires() { timeLeftBeforeExpiration.d_ms - refreshWindow.d_ms, 0, ); + const timeoutId = setTimeout(async () => { const result = await bank.createAccessToken( refreshSession.username, @@ -210,5 +242,4 @@ export function useRefreshSessionBeforeExpires() { clearTimeout(timeoutId); }; }, [refreshSession]); - } diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx @@ -0,0 +1,1249 @@ +import { + Amounts, + assertUnreachable, + HttpStatusCode, + InternationalizationAPI, + RoundingMode, + TalerBankConversionApi, + TalerCorebankApi, + TalerError, +} from "@gnu-taler/taler-util"; +import { + Attention, + InputText, + InputToggle, + Loading, + LocalNotificationBanner, + RenderAmount, + RouteDefinition, + ShowInputErrorLabel, + useBankCoreApiContext, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState, useEffect } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { + FormErrors, + FormStatus, + FormValues, + useFormState, +} from "../hooks/form.js"; +import { + revalidateConversionRateClassDetails, + revalidateConversionRateClassUsers, + TransferCalculation, + useCashinEstimator, + useCashinEstimatorForClass, + useCashoutEstimator, + useCashoutEstimatorForClass, + useConversionInfo, + useConversionInfoForClass, + useConversionRateClassDetails, + useConversionRateClassUsers, +} from "../hooks/regional.js"; +import { useSessionState } from "../hooks/session.js"; +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; + routeCancel: RouteDefinition; + onClassDeleted: () => void; +} + +type FormType = { + name: string; + description: string; + conv: Omit< + Omit<TalerBankConversionApi.ConversionRate, "cashout_tiny_amount">, + "cashin_tiny_amount" + >; +}; + +export function ConversionRateClassDetails({ + routeCancel, + classId, + onClassDeleted, +}: Props): VNode { + const { i18n } = useTranslationContext(); + + const detailsResult = useConversionRateClassDetails(classId); + const conversionInfoResult = useConversionInfo(); + const conversionInfo = + conversionInfoResult && + !(conversionInfoResult instanceof TalerError) && + conversionInfoResult.type === "ok" + ? conversionInfoResult.body + : undefined; + + if (!detailsResult || !conversionInfo) { + return <Loading />; + } + if (detailsResult instanceof TalerError) { + return <ErrorLoadingWithDebug error={detailsResult} />; + } + if (detailsResult.type === "fail") { + switch (detailsResult.case) { + case HttpStatusCode.Unauthorized: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.NotImplemented: + return ( + <Attention type="danger" title={i18n.str`Conversion is disabled`}> + <i18n.Translate> + Conversion 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(detailsResult); + } + } + return ( + <Form + conversionInfo={conversionInfo} + detailsResult={detailsResult.body} + routeCancel={routeCancel} + classId={classId} + onClassDeleted={onClassDeleted} + /> + ); +} + +function Form({ + conversionInfo, + detailsResult, + routeCancel, + classId, + onClassDeleted, +}: { + conversionInfo: TalerBankConversionApi.TalerConversionInfoConfig; + detailsResult: TalerCorebankApi.ConversionRateClass; + routeCancel: RouteDefinition; + classId: number; + onClassDeleted: () => void; +}) { + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { + lib: { bank }, + config, + } = useBankCoreApiContext(); + const [notification, notify, handleError] = useLocalNotification(); + + const [section, setSection] = useState< + "detail" | "cashout" | "cashin" | "users" | "test" | "delete" + >("detail"); + + const initalState: FormValues<FormType> = { + name: detailsResult.name, + description: detailsResult.description, + conv: { + cashin_min_amount: detailsResult.cashin_min_amount?.split(":")[1], + cashin_fee: detailsResult.cashin_fee?.split(":")[1], + cashin_ratio: detailsResult?.cashin_ratio, + cashin_rounding_mode: detailsResult?.cashin_rounding_mode, + cashout_min_amount: detailsResult.cashout_min_amount?.split(":")[1], + cashout_fee: detailsResult.cashout_fee?.split(":")[1], + cashout_ratio: detailsResult.cashout_ratio, + cashout_rounding_mode: detailsResult.cashout_rounding_mode, + }, + }; + + const [form, status] = useFormState<FormType>( + initalState, + createFormValidator( + i18n, + conversionInfo.regional_currency, + conversionInfo.fiat_currency, + ), + ); + + async function doDeleteClass() { + if (!creds) return; + await bank.deleteConversionRateClass(creds.token, classId); + onClassDeleted(); + } + + const doDelete = + !creds || section !== "delete" || detailsResult.num_users > 0 + ? undefined + : doDeleteClass; + + async function doUpdateClass() { + if (!creds) return; + if (status.status !== "ok") { + console.log("can submit due to form error", status.errors); + return; + } + + await bank.updateConversionRateClass(creds.token, classId, { + name: status.result.name, + description: status.result.description, + + cashin_fee: status.result.conv.cashin_fee, + cashin_min_amount: status.result.conv.cashin_min_amount, + cashin_ratio: status.result.conv.cashin_ratio, + cashin_rounding_mode: status.result.conv.cashin_rounding_mode, + + cashout_fee: status.result.conv.cashout_fee, + cashout_min_amount: status.result.conv.cashout_min_amount, + cashout_ratio: status.result.conv.cashout_ratio, + cashout_rounding_mode: status.result.conv.cashout_rounding_mode, + }); + setSection("detail"); + } + + const doUpdateDetails = + !creds || + section !== "detail" || + status.errors?.name || + status.errors?.description || + (status.result.name === initalState.name && + status.result.description === initalState.description) + ? undefined + : doUpdateClass; + + const doUpdateCashin = + !creds || + section !== "cashin" || + status.errors?.conv?.cashin_fee || + status.errors?.conv?.cashin_min_amount || + status.errors?.conv?.cashin_ratio || + status.errors?.conv?.cashin_rounding_mode + ? undefined + : doUpdateClass; + + const doUpdateCashout = + !creds || + section !== "cashout" || + // no errors on fields + status.errors?.conv?.cashout_fee || + status.errors?.conv?.cashout_min_amount || + status.errors?.conv?.cashout_ratio || + status.errors?.conv?.cashout_rounding_mode || + // at least on field changed + (status.result?.conv?.cashout_fee === initalState.conv.cashout_fee && + status.result?.conv?.cashout_min_amount === + initalState.conv.cashout_min_amount && + status.result?.conv?.cashout_ratio === initalState.conv.cashout_ratio && + status.result?.conv?.cashout_rounding_mode === + initalState.conv.cashout_rounding_mode) + ? undefined + : doUpdateClass; + + const default_rate = conversionInfo.conversion_rate; + + const final_cashin_ratio = + detailsResult.cashin_ratio ?? default_rate.cashin_ratio; + const final_cashin_fee = detailsResult.cashin_fee ?? default_rate.cashin_fee; + const final_cashin_min = + detailsResult.cashin_min_amount ?? default_rate.cashin_min_amount; + const final_cashin_rounding = + detailsResult.cashin_rounding_mode ?? default_rate.cashin_rounding_mode; + + const final_cashout_ratio = + detailsResult.cashout_ratio ?? default_rate.cashout_ratio; + const final_cashout_fee = + detailsResult.cashout_fee ?? default_rate.cashout_fee; + const final_cashout_min = + detailsResult.cashout_min_amount ?? default_rate.cashout_min_amount; + const final_cashout_rounding = + detailsResult.cashout_rounding_mode ?? default_rate.cashout_rounding_mode; + + const in_ratio = Number.parseFloat(final_cashin_ratio); + const out_ratio = Number.parseFloat(final_cashout_ratio); + + const both_high = in_ratio > 1 && out_ratio > 1; + const both_low = in_ratio < 1 && out_ratio < 1; + + return ( + <div> + <LocalNotificationBanner notification={notification} /> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Conversion rate group</i18n.Translate> + </h2> + <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> + <label + data-enabled={section === "detail"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Newsletter" + class="sr-only" + aria-labelledby="project-type-0-label" + aria-describedby="project-type-0-description-0 project-type-0-description-1" + onChange={() => { + setSection("detail"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Details</i18n.Translate> + </span> + </span> + </span> + </label> + <label + data-enabled={section === "cashout"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Existing Customers" + class="sr-only" + aria-labelledby="project-type-1-label" + aria-describedby="project-type-1-description-0 project-type-1-description-1" + onChange={() => { + setSection("cashout"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Config cashout</i18n.Translate> + </span> + </span> + </span> + </label> + <label + data-enabled={section === "cashin"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Existing Customers" + class="sr-only" + aria-labelledby="project-type-1-label" + aria-describedby="project-type-1-description-0 project-type-1-description-1" + onChange={() => { + setSection("cashin"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Config cashin</i18n.Translate> + </span> + </span> + </span> + </label> + <label + data-enabled={section === "users"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Newsletter" + class="sr-only" + aria-labelledby="project-type-0-label" + aria-describedby="project-type-0-description-0 project-type-0-description-1" + onChange={() => { + setSection("users"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Accounts</i18n.Translate> + </span> + </span> + </span> + </label> + <label + data-enabled={section === "test"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Newsletter" + class="sr-only" + aria-labelledby="project-type-0-label" + aria-describedby="project-type-0-description-0 project-type-0-description-1" + onChange={() => { + setSection("test"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Test</i18n.Translate> + </span> + </span> + </span> + </label>{" "} + <label + data-enabled={section === "delete"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Newsletter" + class="sr-only" + aria-labelledby="project-type-0-label" + aria-describedby="project-type-0-description-0 project-type-0-description-1" + onChange={() => { + setSection("delete"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Delete</i18n.Translate> + </span> + </span> + </span> + </label> + </div> + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + {section == "cashin" && ( + <ConversionForm + id="cashin" + inputCurrency={conversionInfo.fiat_currency} + outputCurrency={conversionInfo.regional_currency} + fee={form?.conv?.cashin_fee} + minimum={form?.conv?.cashin_min_amount} + ratio={form?.conv?.cashin_ratio} + rounding={form?.conv?.cashin_rounding_mode} + fallback_fee={default_rate.cashin_fee.split(":")[1]} + fallback_minimum={default_rate.cashin_min_amount.split(":")[1]} + fallback_ratio={default_rate.cashin_ratio} + fallback_rounding={default_rate.cashin_rounding_mode} + /> + )} + + {section == "cashout" && ( + <Fragment> + <ConversionForm + id="cashout" + inputCurrency={conversionInfo.regional_currency} + outputCurrency={conversionInfo.fiat_currency} + fee={form?.conv?.cashout_fee} + minimum={form?.conv?.cashout_min_amount} + ratio={form?.conv?.cashout_ratio} + rounding={form?.conv?.cashout_rounding_mode} + fallback_fee={default_rate.cashout_fee.split(":")[1]} + fallback_minimum={default_rate.cashout_min_amount.split(":")[1]} + fallback_ratio={default_rate.cashout_ratio} + fallback_rounding={default_rate.cashout_rounding_mode} + /> + </Fragment> + )} + + {section == "detail" && ( + <Fragment> + <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"> + <input + ref={doAutoFocus} + type="text" + name="name" + id="name" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={form?.name?.value ?? ""} + // disabled={fixedUser} + enterkeyhint="next" + placeholder="identification" + autocomplete="username" + title={i18n.str`Username of the account`} + required + onInput={(e): void => { + form?.name?.onUpdate(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={form?.name?.error} + isDirty={form?.name?.value !== undefined} + /> + </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>Description</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <input + type="text" + name="description" + id="description" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={form?.description?.value ?? ""} + enterkeyhint="next" + // placeholder="identification" + autocomplete="username" + title={i18n.str`Username of the account`} + onInput={(e): void => { + form?.description?.onUpdate(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={form?.description?.error} + isDirty={form?.description?.value !== undefined} + /> + </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</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <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> + </div> + + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashout</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <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>Users</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + {detailsResult.num_users} + </dd> + </div> + </div> + + {both_low || both_high ? ( + <div class="p-4"> + <Attention title={i18n.str`Bad ratios`} type="warning"> + <i18n.Translate> + One of the ratios should be higher or equal than 1 an the + other should be lower or equal than 1. + </i18n.Translate> + </Attention> + </div> + ) : undefined} + </Fragment> + )} + + {section == "users" && ( + <AccountsOnConversionClass classId={classId} /> + )} + {section == "delete" && ( + <DeleteConversionClass + classId={classId} + userCount={detailsResult.num_users} + /> + )} + + {section == "test" && <TestConversionClass classId={classId} />} + + <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4"> + <a + name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + {section == "cashin" ? ( + <Fragment> + <button + type="submit" + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!doUpdateCashin} + onClick={doUpdateCashin} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </Fragment> + ) : undefined} + {section == "cashout" ? ( + <Fragment> + <button + type="submit" + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!doUpdateCashout} + onClick={doUpdateCashout} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </Fragment> + ) : undefined} + {section == "detail" ? ( + <Fragment> + <button + type="submit" + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!doUpdateDetails} + onClick={doUpdateDetails} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </Fragment> + ) : undefined} + {section == "delete" ? ( + <Fragment> + <button + type="submit" + name="update conversion" + class="disabled:opacity-50 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" + disabled={!doDelete} + onClick={doDelete} + > + <i18n.Translate>Delete</i18n.Translate> + </button> + </Fragment> + ) : undefined} + </div> + </form> + </div> + </div> + ); +} + +export function createFormValidator( + i18n: InternationalizationAPI, + regional: string, + fiat: string, +) { + return function check(state: FormValues<FormType>): FormStatus<FormType> { + const cashin_min_amount = Amounts.parse( + `${fiat}:${state.conv.cashin_min_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_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`); + + const cashin_ratio_f = Number.parseFloat(state.conv.cashin_ratio ?? ""); + const cashout_ratio_f = Number.parseFloat(state.conv.cashout_ratio ?? ""); + + const cashin_ratio = Number.isNaN(cashin_ratio_f) + ? undefined + : cashin_ratio_f; + const cashout_ratio = Number.isNaN(cashout_ratio_f) + ? undefined + : cashout_ratio_f; + + const errors = undefinedIfEmpty<FormErrors<FormType>>({ + conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({ + cashin_min_amount: !state.conv.cashin_min_amount + ? undefined + : !cashin_min_amount + ? i18n.str`Invalid` + : undefined, + cashin_fee: !state.conv.cashin_fee + ? undefined + : !cashin_fee + ? i18n.str`Invalid` + : undefined, + + cashout_min_amount: !state.conv.cashout_min_amount + ? undefined + : !cashout_min_amount + ? i18n.str`Invalid` + : undefined, + cashout_fee: !state.conv.cashin_fee + ? undefined + : !cashout_fee + ? i18n.str`Invalid` + : undefined, + + cashin_rounding_mode: !state.conv.cashin_rounding_mode + ? undefined + : undefined, + cashout_rounding_mode: !state.conv.cashout_rounding_mode + ? undefined + : undefined, + + cashin_ratio: !state.conv.cashin_ratio + ? undefined + : Number.isNaN(cashin_ratio) + ? i18n.str`Invalid` + : undefined, + cashout_ratio: !state.conv.cashout_ratio + ? undefined + : Number.isNaN(cashout_ratio) + ? i18n.str`Invalid` + : undefined, + }), + + description: undefined, + name: !state.name ? i18n.str`Required` : undefined, + }); + + const result: RecursivePartial<FormType> = { + name: !errors?.name ? state.name : undefined, + description: state.description, + conv: { + cashin_fee: + !errors?.conv?.cashin_fee && cashin_fee + ? Amounts.stringify(cashin_fee) + : undefined, + cashin_min_amount: + !errors?.conv?.cashin_min_amount && cashin_min_amount + ? Amounts.stringify(cashin_min_amount) + : undefined, + cashin_ratio: + !errors?.conv?.cashin_ratio && cashin_ratio + ? String(cashin_ratio) + : undefined, + cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode + ? (state.conv.cashin_rounding_mode! as RoundingMode) + : undefined, + cashout_fee: + !errors?.conv?.cashout_fee && cashout_fee + ? Amounts.stringify(cashout_fee) + : undefined, + cashout_min_amount: + !errors?.conv?.cashout_min_amount && cashout_min_amount + ? Amounts.stringify(cashout_min_amount) + : undefined, + cashout_ratio: + !errors?.conv?.cashout_ratio && cashout_ratio + ? String(cashout_ratio) + : undefined, + cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode + ? (state.conv.cashout_rounding_mode! as RoundingMode) + : undefined, + }, + }; + return errors === undefined + ? { status: "ok", result: result as FormType, errors } + : { status: "fail", result: result as FormType, errors }; + }; +} + +function TestConversionClass({ classId }: { classId: number }): VNode { + const { i18n } = useTranslationContext(); + const [notification, notify, handleError] = useLocalNotification(); + + const result = useConversionInfoForClass(classId); + const info = + result && !(result instanceof TalerError) && result.type === "ok" + ? result.body + : undefined; + + const { estimateByDebit: calculateCashoutFromDebit } = + useCashoutEstimatorForClass(classId); + const { estimateByDebit: calculateCashinFromDebit } = + useCashinEstimatorForClass(classId); + + const [amount, setAmount] = useState<string>("100"); + const [error, setError] = useState<string>(); + + const [calculationResult, setCalc] = useState<{ + cashin: TransferCalculation; + cashout: TransferCalculation; + }>(); + + useEffect(() => { + async function doAsync() { + await handleError(async () => { + if (!info) return; + if (!amount || error) return; + const in_amount = Amounts.parseOrThrow( + `${info.fiat_currency}:${amount}`, + ); + const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); + const cashin = await calculateCashinFromDebit(in_amount, in_fee); + + if (cashin === "amount-is-too-small") { + setCalc(undefined); + return; + } + + const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); + const cashout = await calculateCashoutFromDebit(cashin.credit, out_fee); + + setCalc({ cashin, cashout }); + }); + } + doAsync(); + }, [amount]); + + if (!info) { + return <Loading />; + } + + const cashinCalc = + calculationResult?.cashin === "amount-is-too-small" + ? undefined + : calculationResult?.cashin; + const cashoutCalc = + calculationResult?.cashout === "amount-is-too-small" + ? undefined + : calculationResult?.cashout; + + return ( + <Fragment> + <div class="px-6 pt-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + for="amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Initial amount`}</label> + <InputAmount + name="amount" + left + currency={info.fiat_currency} + value={amount ?? ""} + onChange={(d) => { + setAmount(d); + }} + /> + <ShowInputErrorLabel + message={error} + isDirty={amount !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Use it to test how the conversion will affect the amount. + </i18n.Translate> + </p> + </div> + </div> + </div> + + {!cashoutCalc || !cashinCalc ? undefined : ( + <div class="px-6 pt-6"> + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Sending to this bank</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashinCalc.debit} + negative + withColor + spec={info.fiat_currency_specification} + /> + </dd> + </div> + + {Amounts.isZero(cashinCalc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between afu "> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Converted</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashinCalc.beforeFee} + spec={info.regional_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-gray-900 font-medium"> + <i18n.Translate>Cashin after fee</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={cashinCalc.credit} + withColor + spec={info.regional_currency_specification} + /> + </dd> + </div> + </dl> + </div> + + <div class="sm:col-span-5"> + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Sending from this bank</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashoutCalc.debit} + negative + withColor + spec={info.regional_currency_specification} + /> + </dd> + </div> + + {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between afu"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Converted</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={cashoutCalc.beforeFee} + spec={info.fiat_currency_specification} + /> + </dd> + </div> + )} + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-lg text-gray-900 font-medium"> + <i18n.Translate>Cashout after fee</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={cashoutCalc.credit} + withColor + spec={info.fiat_currency_specification} + /> + </dd> + </div> + </dl> + </div> + </div> + )} + </Fragment> + ); +} +function DeleteConversionClass({ + classId, + userCount, +}: { + classId: number; + userCount: number; +}): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <div class="px-4 mt-4"> + {userCount > 0 ? ( + <Attention + type="danger" + title={i18n.str`Can't remove the conversion rate group`} + > + <i18n.Translate> + There are some user associated to this group. You need to remove + them first. + </i18n.Translate> + </Attention> + ) : ( + <Attention + type="warning" + title={i18n.str`You are going to remove the conversion rate group`} + > + <i18n.Translate>This step can't be undone.</i18n.Translate> + </Attention> + )} + </div> + </Fragment> + ); +} + +function AccountsOnConversionClass({ classId }: { classId: number }): VNode { + const { i18n } = useTranslationContext(); + + const { + lib: { bank }, + 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<{ + showAll?: boolean; + classId?: number; + account?: string; + }>({ + showAll: classId === undefined, + classId, + }); + const userListResult = useConversionRateClassUsers( + filter.classId, + filter.account, + ); + if (!userListResult) { + return <Loading />; + } + if (userListResult instanceof TalerError) { + return <ErrorLoadingWithDebug error={userListResult} />; + } + if (userListResult.type === "fail") { + switch (userListResult.case) { + case HttpStatusCode.Unauthorized: + return ( + <Attention type="danger" title={i18n.str`Conversion is disabled`}> + <i18n.Translate> + Conversion 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(userListResult); + } + } + return ( + <Fragment> + <div class="px-4 mt-4"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Filters</i18n.Translate> + </h1> + </div> + </div> + </div> + <div class="px-4 mt-2"> + <InputToggle + label={i18n.str`Show from other groups`} + name="show_all" + threeState={false} + handler={{ + value: filter.showAll, + onChange(v) { + filter.showAll = !!v; + if (!v) { + filter.classId = classId; + } else { + filter.classId = undefined; + } + setFilter(structuredClone(filter)); + }, + name: "show_all", + }} + /> + <InputText + label={i18n.str`Account`} + name="account" + handler={{ + value: filter.account, + onChange(v) { + filter.account = v; + setFilter(structuredClone(filter)); + }, + name: "account", + }} + /> + {filter.showAll ? ( + <InputText + label={i18n.str`Group ID`} + name="crcid" + handler={{ + value: String(filter.classId), + onChange(v) { + const id = !v ? undefined : Number.parseInt(v, 10); + filter.classId = id; + setFilter(structuredClone(filter)); + }, + name: "crcid", + }} + /> + ) : undefined} + </div> + <div class="mt-4 flow-root"> + <div class="overflow-x-auto"> + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + {!userListResult.body.length ? ( + <div class="py-3.5 pl-4 pr-3 "> + <i18n.Translate> + No users in this conversion rate class + </i18n.Translate> + </div> + ) : ( + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{i18n.str`Name`}</th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{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" + >{i18n.str`Action`}</th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200"> + {userListResult.body.map((item, idx) => { + return ( + <tr + key={idx} + class="data-[status=deleted]:bg-gray-100" + data-status={item.status} + > + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + {item.name} + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + {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 === 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 () => { + if (token) { + await bank.updateAccount( + { username: item.username, token }, + { conversion_rate_class_id: null }, + ); + await revalidateConversionRateClassUsers(); + await revalidateConversionRateClassDetails(); + } + }} + > + <i18n.Translate>Remove</i18n.Translate> + </button> + ) : ( + <button + class="disabled:opacity-50 disabled:bg-gray-600 disabled:hover:bg-gray-600 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={async () => { + if (token) { + await bank.updateAccount( + { username: item.username, token }, + { conversion_rate_class_id: classId }, + ); + await revalidateConversionRateClassUsers(); + await revalidateConversionRateClassDetails(); + } + }} + > + <i18n.Translate>Add</i18n.Translate> + </button> + )} + </td> + </tr> + ); + })} + </tbody> + </table> + )} + </div> + {!userListResult.loadFirst && !userListResult.loadNext ? undefined : ( + <nav + class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" + aria-label="Pagination" + > + <div class="flex flex-1 justify-between sm:justify-end"> + <button + name="first page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!userListResult.loadFirst} + onClick={userListResult.loadFirst} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + name="next page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!userListResult.loadNext} + onClick={userListResult.loadNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> + )} + </div> + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/NewConversionRateClass.tsx b/packages/bank-ui/src/pages/NewConversionRateClass.tsx @@ -0,0 +1,107 @@ +import { + LocalNotificationBanner, + notifyInfo, + RouteDefinition, + useBankCoreApiContext, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useSessionState } from "../hooks/session.js"; +import { ConversionRateClassForm } from "./admin/ConversionRateClassForm.js"; +import { useState } from "preact/hooks"; +import { TalerCorebankApi } from "@gnu-taler/taler-util"; +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { assertUnreachable } from "@gnu-taler/taler-util"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; + +interface Props { + routeCancel: RouteDefinition; + onCreated: (id: number) => void; +} +export function NewConversionRateClass({ + routeCancel, + onCreated, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + const [notification, notify, handleError] = useLocalNotification(); + + const [submitData, setSubmitData] = useState< + TalerCorebankApi.ConversionRateClassInput | undefined + >(); + + async function doCreate() { + if (!submitData || !token) return; + await handleError(async () => { + const resp = await api.createConversionRateClass(token, submitData); + if (resp.type === "ok") { + notifyInfo(i18n.str`Conversion rate class created.`); + onCreated(resp.body.conversion_rate_class_id); + return; + } + switch (resp.case) { + case HttpStatusCode.Unauthorized: { + break; + } + case TalerErrorCode.BANK_NAME_REUSE: { + break; + } + case HttpStatusCode.Forbidden: { + break; + } + case HttpStatusCode.NotFound: { + break; + } + case HttpStatusCode.NotImplemented: { + break; + } + default: { + assertUnreachable(resp); + } + } + }); + } + + return ( + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>New conversion rate group</i18n.Translate> + </h2> + </div> + + <ConversionRateClassForm onChange={setSubmitData}> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="create" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!submitData} + onClick={(e) => { + e.preventDefault(); + doCreate(); + }} + > + <i18n.Translate>Create</i18n.Translate> + </button> + </div> + </ConversionRateClassForm> + </div> + ); +} 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/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx @@ -150,8 +150,10 @@ function RegistrationForm({ return i18n.str`Authentication channel is not supported.`; case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return i18n.str`Only an administrator is allowed to set the debt limit.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: - return i18n.str`Only the administrator can change the minimum cashout limit.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: + return i18n.str`Only the administrator can change the conversion rate.`; + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + return i18n.str`The conversion rate class doesn't exist.`; case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: return i18n.str`Only admin can create accounts with second factor authentication.`; case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -202,10 +202,19 @@ export function ShowAccountDetails({ when: AbsoluteTime.now(), }); } - case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: { + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: { return notify({ type: "error", - title: i18n.str`Only the administrator can change the minimum cashout limit.`, + title: i18n.str`Only the administrator can change the conversion rate.`, + description: resp.detail?.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: { + return notify({ + type: "error", + title: i18n.str`The conversion rate class doesn't exist.`, description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -54,7 +54,6 @@ const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; export type AccountFormData = { debit_threshold?: string; - min_cashout?: string; isExchange?: boolean; isPublic?: boolean; name?: string; @@ -116,9 +115,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ config.default_debit_threshold ?? `${config.currency}:0`, ), - min_cashout: Amounts.stringifyValue( - template?.min_cashout ?? `${config.currency}:0`, - ), isExchange: template?.is_taler_exchange, isPublic: template?.is_public, name: template?.name ?? "", @@ -148,18 +144,12 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ (config.allow_edit_cashout_payto_uri || userIsAdmin)); const editableThreshold = userIsAdmin && (purpose === "create" || purpose === "update"); - const editableMinCashout = - userIsAdmin && (purpose === "create" || purpose === "update"); const editableAccount = purpose === "create" && userIsAdmin; const hasPhone = !!defaultValue.phone || !!form.phone; const hasEmail = !!defaultValue.email || !!form.email; function updateForm(newForm: typeof defaultValue): void { - const trimmedMinCashoutStr = newForm.min_cashout?.trim(); - const parsedMinCashout = Amounts.parse( - `${config.currency}:${trimmedMinCashoutStr}`, - ); const trimmedDebitThresholdStr = newForm.debit_threshold?.trim(); const parsedDebitThreshold = Amounts.parse( `${config.currency}:${trimmedDebitThresholdStr}`, @@ -211,13 +201,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ : !parsedDebitThreshold ? i18n.str`Not valid` : undefined, - min_cashout: !editableMinCashout - ? undefined - : !trimmedMinCashoutStr - ? undefined - : !parsedMinCashout - ? i18n.str`Not valid` - : undefined, name: !editableName ? undefined // disabled : purpose === "update" && newForm.name === undefined @@ -277,9 +260,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ const threshold = !parsedDebitThreshold ? undefined : Amounts.stringify(parsedDebitThreshold); - const minCashout = !parsedMinCashout - ? undefined - : Amounts.stringify(parsedMinCashout); switch (purpose) { case "create": { @@ -294,7 +274,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ phone: !newForm.phone ? undefined : newForm.phone, }), debit_threshold: threshold ?? config.default_debit_threshold, - min_cashout: minCashout, cashout_payto_uri: cashoutURI === null ? undefined : cashoutURI, payto_uri: internalURI, is_public: newForm.isPublic, @@ -318,7 +297,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ phone: !newForm.phone ? undefined : newForm.phone, }), debit_threshold: threshold, - min_cashout: minCashout, is_public: newForm.isPublic, name: newForm.name, tan_channel: @@ -695,38 +673,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </div> <div class="sm:col-span-5"> - <label - for="minCashout" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Minimum cashout`}</label> - <InputAmount - name="minCashout" - left - currency={config.currency} - value={form.min_cashout ?? defaultValue.min_cashout} - onChange={ - !editableMinCashout - ? undefined - : (e) => { - form.min_cashout = e as AmountString; - updateForm(structuredClone(form)); - } - } - /> - <ShowInputErrorLabel - message={ - errors?.min_cashout ? String(errors?.min_cashout) : undefined - } - isDirty={form.min_cashout !== undefined} - /> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Custom minimum cashout amount for this account. - </i18n.Translate> - </p> - </div> - - <div class="sm:col-span-5"> <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> <span @@ -832,167 +778,3 @@ function getAccountId( return p.account; return "<unsupported>"; } - -{ - /* <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="cashout" - > - {} - </label> - <div class="mt-2"> - <input - type="text" - ref={focus && purpose === "update" ? doAutoFocus : undefined} - data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined} - class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="cashout" - id="cashout" - disabled={purpose === "show"} - value={form.cashout_payto_uri ?? defaultValue.cashout_payto_uri} - onChange={(e) => { - form.cashout_payto_uri = e.currentTarget.value as PaytoString; - if (!form.cashout_payto_uri) { - form.cashout_payto_uri = undefined - } - updateForm(structuredClone(form)); - }} - autocomplete="off" - /> - <ShowInputErrorLabel - message={errors?.cashout_payto_uri} - isDirty={form.cashout_payto_uri !== undefined} - /> - </div> - <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate></i18n.Translate> - </p> - </div> */ -} - -// function PaytoField({ -// name, -// label, -// help, -// type, -// value, -// disabled, -// onChange, -// error, -// }: { -// error: TranslatedString | undefined; -// name: string; -// label: TranslatedString; -// help: TranslatedString; -// onChange: (s: string) => void; -// type: "iban" | "x-taler-bank" | "bitcoin"; -// disabled?: boolean; -// value: string | undefined; -// }): VNode { -// if (type === "iban") { -// return ( -// <div class="sm:col-span-5"> -// <label -// class="block text-sm font-medium leading-6 text-gray-900" -// for={name} -// > -// {label} -// </label> -// <div class="mt-2"> -// <div class="flex justify-between"> -// <input -// type="text" -// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" -// name={name} -// id={name} -// disabled={disabled} -// value={value ?? ""} -// onChange={(e) => { -// onChange(e.currentTarget.value); -// }} -// /> -// <CopyButton -// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " -// getContent={() => value ?? ""} -// /> -// </div> -// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> -// </div> -// <p class="mt-2 text-sm text-gray-500">{help}</p> -// </div> -// ); -// } -// if (type === "x-taler-bank") { -// return ( -// <div class="sm:col-span-5"> -// <label -// class="block text-sm font-medium leading-6 text-gray-900" -// for={name} -// > -// {label} -// </label> -// <div class="mt-2"> -// <div class="flex justify-between"> -// <input -// type="text" -// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" -// name={name} -// id={name} -// disabled={disabled} -// value={value ?? ""} -// onChange={(e) => { -// onChange(e.currentTarget.value); -// }} -// /> -// <CopyButton -// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " -// getContent={() => value ?? ""} -// /> -// </div> -// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> -// </div> -// <p class="mt-2 text-sm text-gray-500"> -// {help} -// </p> -// </div> -// ); -// } -// if (type === "bitcoin") { -// return ( -// <div class="sm:col-span-5"> -// <label -// class="block text-sm font-medium leading-6 text-gray-900" -// for={name} -// > -// {label} -// </label> -// <div class="mt-2"> -// <div class="flex justify-between"> -// <input -// type="text" -// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" -// name={name} -// id={name} -// disabled={disabled} -// value={value ?? ""} -// /> -// <CopyButton -// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " -// getContent={() => value ?? ""} -// /> -// <ShowInputErrorLabel -// message={error} -// isDirty={value !== undefined} -// /> -// </div> -// </div> -// <p class="mt-2 text-sm text-gray-500"> -// {/* <i18n.Translate>bitcoin address</i18n.Translate> */} -// {help} -// </p> -// </div> -// ); -// } -// assertUnreachable(type); -// } diff --git a/packages/bank-ui/src/pages/admin/AccountList.tsx b/packages/bank-ui/src/pages/admin/AccountList.tsx @@ -65,9 +65,6 @@ export function AccountList({ assertUnreachable(result); } - const onGoStart = result.isFirstPage ? undefined : result.loadFirst; - const onGoNext = result.isLastPage ? undefined : result.loadNext; - const accounts = result.body; return ( <Fragment> @@ -214,16 +211,16 @@ export function AccountList({ <button name="first page" class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!onGoStart} - onClick={onGoStart} + disabled={!result.loadFirst} + onClick={result.loadFirst} > <i18n.Translate>First page</i18n.Translate> </button> <button name="next page" class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - disabled={!onGoNext} - onClick={onGoNext} + disabled={!result.loadNext} + onClick={result.loadNext} > <i18n.Translate>Next</i18n.Translate> </button> diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx @@ -39,6 +39,7 @@ import { useConversionInfo, useLastMonitorInfo } from "../../hooks/regional.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; import { WireTransfer } from "../WireTransfer.js"; import { AccountList } from "./AccountList.js"; +import { ConversionClassList } from "./ConversionClassList.js"; const TALER_SCREEN_ID = 122; @@ -46,7 +47,6 @@ const TALER_SCREEN_ID = 122; * Query account information and show QR code if there is pending withdrawal */ interface Props { - routeCreate: RouteDefinition; routeDownloadStats: RouteDefinition; routeCreateWireTransfer: RouteDefinition<{ account?: string; @@ -54,21 +54,27 @@ interface Props { amount?: string; }>; - routeShowAccount: RouteDefinition<{ account: string }>; + routeCreateAccount: RouteDefinition; routeRemoveAccount: RouteDefinition<{ account: string }>; + routeShowAccount: RouteDefinition<{ account: string }>; routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; routeShowCashoutsAccount: RouteDefinition<{ account: string }>; onAuthorizationRequired: () => void; + routeCreateConversionRateClass: RouteDefinition; + routeShowConversionRateClass: RouteDefinition<{ classId: string }>; } export function AdminHome({ - routeCreate, + routeCreateAccount, routeRemoveAccount, routeShowAccount, routeUpdatePasswordAccount, routeDownloadStats, routeCreateWireTransfer, + routeCreateConversionRateClass, + routeShowConversionRateClass, onAuthorizationRequired, }: Props): VNode { + const { config } = useBankCoreApiContext(); return ( <Fragment> <Metrics routeDownloadStats={routeDownloadStats} /> @@ -81,11 +87,17 @@ export function AdminHome({ routeCreateWireTransfer={routeCreateWireTransfer} /> <AccountList - routeCreate={routeCreate} + routeCreate={routeCreateAccount} routeRemoveAccount={routeRemoveAccount} routeShowAccount={routeShowAccount} routeUpdatePasswordAccount={routeUpdatePasswordAccount} /> + {!config.allow_conversion ? undefined : ( + <ConversionClassList + routeCreate={routeCreateConversionRateClass} + routeShowDetails={routeShowConversionRateClass} + /> + )} </Fragment> ); } diff --git a/packages/bank-ui/src/pages/admin/ConversionClassList.tsx b/packages/bank-ui/src/pages/admin/ConversionClassList.tsx @@ -0,0 +1,312 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AmountString, + Amounts, + DecimalNumber, + HttpStatusCode, + RoundingMode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + RouteDefinition, + useBankCoreApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { + useConversionInfo, + useConversionRateClasses, +} from "../../hooks/regional.js"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; +import { CurrencySpecification } from "@gnu-taler/taler-util"; + +const TALER_SCREEN_ID = 121; + +interface Props { + routeCreate: RouteDefinition; + routeShowDetails: RouteDefinition<{ classId: string }>; +} + +export function ConversionClassList({ + routeCreate, + routeShowDetails, +}: Props): VNode { + const result = useConversionRateClasses(); + const { i18n } = useTranslationContext(); + const resultInfo = useConversionInfo(); + + const convInfo = + !resultInfo || resultInfo instanceof Error || resultInfo.type === "fail" + ? undefined + : resultInfo.body; + + if (!convInfo) { + return <Fragment>-</Fragment>; + } + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + + if (result.type !== "ok") { + switch (result.case) { + case HttpStatusCode.Forbidden: + return ( + <Attention + type="warning" + title={i18n.str`No enough permission to access the conversion rate list.`} + ></Attention> + ); + case HttpStatusCode.NotFound: + return ( + <Attention + type="warning" + title={i18n.str`Conversion list not found. Maybe conversion rate is not supported.`} + ></Attention> + ); + case HttpStatusCode.NotImplemented: + return ( + <Attention + type="warning" + title={i18n.str`Conversion list not implemented.`} + ></Attention> + ); + case HttpStatusCode.Unauthorized: + return ( + <Attention + type="warning" + title={i18n.str`No enough permission to access the conversion rate list.`} + ></Attention> + ); + default: + assertUnreachable(result); + } + } + + const classes = result.body; + + return ( + <Fragment> + <div class="px-4 sm:px-6 lg:px-8 mt-8"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Conversion rate groups</i18n.Translate> + </h1> + </div> + <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> + <a + href={routeCreate.url({})} + name="create account" + type="button" + class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Create conversion rate group</i18n.Translate> + </a> + </div> + </div> + <div class="mt-4 flow-root"> + <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + {!classes.length ? ( + <div> + <i18n.Translate>No conversion rate group</i18n.Translate> + </div> + ) : ( + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + >{i18n.str`Name`}</th> + <th + scope="col" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + >{i18n.str`Description`}</th> + <th + scope="col" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + >{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> + </tr> + </thead> + <tbody class="divide-y divide-gray-200"> + {classes.map((row, idx) => { + return ( + <tr key={idx} class=""> + <td class="whitespace-nowrap py-3 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> + <a + href={routeShowDetails.url({ + classId: String(row.conversion_rate_class_id), + })} + > + {row.name} + </a> + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + <a + href={routeShowDetails.url({ + classId: String(row.conversion_rate_class_id), + })} + > + {row.description} + </a> + </td> + <td class="whitespace-nowrap px-3 py-2 text-sm text-gray-500"> + <a + href={routeShowDetails.url({ + classId: String(row.conversion_rate_class_id), + })} + > + <DescribeConversion + ratio={ + row.cashin_ratio ?? + convInfo.conversion_rate.cashin_ratio + } + fee={ + row.cashin_fee ?? + convInfo.conversion_rate.cashin_fee + } + min={ + row.cashin_min_amount ?? + convInfo.conversion_rate.cashin_min_amount + } + rounding={ + row.cashin_rounding_mode ?? + convInfo.conversion_rate.cashin_rounding_mode + } + minSpec={convInfo.fiat_currency_specification} + feeSpec={ + convInfo.regional_currency_specification + } + /> + </a> + </td> + <td class="whitespace-nowrap px-3 py-2 text-sm text-gray-500"> + <a + href={routeShowDetails.url({ + classId: String(row.conversion_rate_class_id), + })} + > + <DescribeConversion + ratio={ + row.cashout_ratio ?? + convInfo.conversion_rate.cashout_ratio + } + fee={ + row.cashout_fee ?? + convInfo.conversion_rate.cashout_fee + } + min={ + row.cashout_min_amount ?? + convInfo.conversion_rate.cashout_min_amount + } + rounding={ + row.cashout_rounding_mode ?? + convInfo.conversion_rate.cashout_rounding_mode + } + minSpec={ + convInfo.regional_currency_specification + } + feeSpec={convInfo.fiat_currency_specification} + /> + </a> + </td> + </tr> + ); + })} + </tbody> + </table> + )} + </div> + <nav + class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" + aria-label="Pagination" + > + <div class="flex flex-1 justify-between sm:justify-end"> + <button + name="first page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!result.loadFirst} + onClick={result.loadFirst} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + name="next page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!result.loadNext} + onClick={result.loadNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> + </div> + </div> + </div> + </Fragment> + ); +} + +export function DescribeConversion({ + fee, + min, + ratio, + rounding, + feeSpec, + minSpec, +}: { + min: AmountString; + ratio: DecimalNumber; + fee: AmountString; + rounding: RoundingMode; + minSpec: CurrencySpecification; + feeSpec: CurrencySpecification; +}): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + 1:{ratio} + {Amounts.isZero(min) ? undefined : ( + <Fragment> + <br /> + <i18n.Translate>min:</i18n.Translate> + <RenderAmount spec={minSpec} value={Amounts.parseOrThrow(min)} /> + </Fragment> + )} + {Amounts.isZero(fee) ? undefined : ( + <Fragment> + <br /> + <i18n.Translate>fee:</i18n.Translate> + <RenderAmount spec={feeSpec} value={Amounts.parseOrThrow(fee)} /> + </Fragment> + )} + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx b/packages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx @@ -0,0 +1,632 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AmountString, + Amounts, + DecimalNumber, + RoundingMode, + TalerCorebankApi, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + InputToggle, + ShowInputErrorLabel, + useBankCoreApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Fragment } from "preact/jsx-runtime"; +import { useSessionState } from "../../hooks/session.js"; +import { ErrorMessageMappingFor, undefinedIfEmpty } from "../../utils.js"; +import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; + +const TALER_SCREEN_ID = 120; + +const EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; + +export type ConversionRateClassFormData = { + name?: string; + description?: string; + + cashin_enabled?: boolean; + cashin_min_amount?: string; + cashin_ratio?: string; + cashin_fee?: string; + cashin_rounding_mode?: RoundingMode; + + cashout_enabled?: boolean; + cashout_min_amount?: string; + cashout_ratio?: DecimalNumber; + cashout_fee?: string; + cashout_rounding_mode?: RoundingMode; +}; + +// type ChangeByPurposeType = { +// create: (a: TalerCorebankApi.ConversionRateClassInput | undefined) => void; +// update: (a: TalerCorebankApi.ConversionRateClassInput | undefined) => void; +// show: undefined; +// }; +/** + * + * @param param0 + * @returns + */ +export function ConversionRateClassForm( + // < + // PurposeType extends keyof ChangeByPurposeType, + // > + { + onChange, + focus, + children, + }: { + focus?: boolean; + children: ComponentChildren; + // onChange: ChangeByPurposeType[PurposeType]; + onChange: ( + a: TalerCorebankApi.ConversionRateClassInput | undefined, + ) => void; + }, +): VNode { + // const { config, url } = useBankCoreApiContext(); + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const [form, setForm] = useState<ConversionRateClassFormData>({}); + + const [errors, setErrors] = useState< + ErrorMessageMappingFor<ConversionRateClassFormData> | undefined + >(undefined); + + // const defaultValue: ConversionRateClassFormData = { + // // cashin_fee: Amounts.stringifyValue( + // // template?.cashin_fee ?? `${config.currency}:0`, + // // ), + // // cashin_min_amount: Amounts.stringifyValue( + // // template?.cashin_min_amount ?? `${config.currency}:0`, + // // ), + // // cashout_fee: Amounts.stringifyValue( + // // template?.cashout_fee ?? `${config.currency}:0`, + // // ), + // // cashin_ratio: template?.cashin_ratio ?? "0", + // // cashin_rounding_mode: template?.cashin_rounding_mode, + + // // cashout_min_amount: Amounts.stringifyValue( + // // template?.cashout_min_amount ?? `${config.currency}:0`, + // // ), + // // cashout_ratio: template?.cashout_ratio ?? "0", + // // cashout_rounding_mode: template?.cashout_rounding_mode, + + // // cashin_enabled: + // // template?.cashin_ratio !== undefined && + // // Number.parseInt(template.cashin_ratio, 10) > 0, + + // // cashout_enabled: + // // template?.cashout_ratio !== undefined && + // // Number.parseInt(template.cashout_ratio, 10) > 0, + + // name: template?.name, + // description: template?.description, + // }; + + const userIsAdmin = + credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; + + const editableForm = userIsAdmin; + + function updateForm(newForm: ConversionRateClassFormData): void { + const errors = undefinedIfEmpty< + ErrorMessageMappingFor<ConversionRateClassFormData> + >({ + name: !editableForm + ? undefined // disabled + : !newForm.name + ? i18n.str`Required` + : undefined, + // cashin_fee: + // !editableForm || !newForm.cashin_enabled + // ? undefined + // : !newForm.cashin_fee + // ? i18n.str`Required` + // : undefined, + // cashout_fee: + // !editableForm || !newForm.cashout_fee + // ? undefined + // : !newForm.cashout_fee + // ? i18n.str`Required` + // : undefined, + // cashin_min_amount: + // !editableForm || !newForm.cashin_min_amount + // ? undefined + // : !newForm.cashin_min_amount + // ? i18n.str`Required` + // : undefined, + // cashout_min_amount: + // !editableForm || !newForm.cashout_min_amount + // ? undefined + // : !newForm.cashout_min_amount + // ? i18n.str`Required` + // : undefined, + }); + setErrors(errors); + + setForm(newForm); + if (!onChange) return; + + if (errors) { + onChange(undefined); + } else { + const result: TalerCorebankApi.ConversionRateClassInput = { + name: newForm.name!, + description: newForm.description, + }; + onChange(result); + // switch (purpose) { + // case "create": { + // // typescript doesn't correctly narrow a generic type + // const callback = onChange as ChangeByPurposeType["create"]; + // const result: TalerCorebankApi.ConversionRateClassInput = { + // name: newForm.name!, + // description: newForm.description, + // }; + // callback(result); + // return; + // } + // case "update": { + // // typescript doesn't correctly narrow a generic type + // const callback = onChange as ChangeByPurposeType["update"]; + + // const result: TalerCorebankApi.ConversionRateClassInput = { + // name: newForm.name!, + // }; + // callback(result); + // return; + // } + // case "show": { + // return; + // } + // default: { + // assertUnreachable(purpose); + // } + // } + } + } + return ( + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="username" + > + {i18n.str`Name`} + {editableForm && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="username" + id="username" + data-error={!!errors?.name && form.name !== undefined} + disabled={!editableForm} + value={form.name ?? ""} + onChange={(e) => { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.name} + isDirty={form.name !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Conversion rate name</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="username" + > + {i18n.str`Description`} + </label> + <div class="mt-2"> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="username" + id="username" + data-error={ + !!errors?.description && form.description !== undefined + } + disabled={!editableForm} + value={form.description ?? ""} + onChange={(e) => { + form.description = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.description} + isDirty={form.description !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Short description of the class</i18n.Translate> + </p> + </div> + + {/* <InputToggle + label={i18n.str`Enable cashin`} + name="cashin" + threeState={false} + disabled={!editableForm} + handler={{ + value: form.cashin_enabled ?? defaultValue.cashin_enabled, + onChange(v) { + form.cashin_enabled = v; + updateForm(structuredClone(form)); + }, + name: "cashin", + }} + /> + <InputToggle + label={i18n.str`Enable cashout`} + name="cashout" + threeState={false} + disabled={!editableForm} + handler={{ + value: form.cashout_enabled ?? defaultValue.cashout_enabled, + onChange(v) { + form.cashout_enabled = v; + updateForm(structuredClone(form)); + }, + name: "cashout", + }} + /> */} + + {/* {!form.cashin_enabled ? undefined : ( + <Fragment> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Cashin rounding mode`} + </label> + <div class="mt-2 max-w-xl text-sm text-gray-500"> + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> + {(["nearest", "zero", "up"] as Array<RoundingMode>).map( + (ROUNDING_MODE) => { + let LABEL: TranslatedString; + switch (ROUNDING_MODE) { + case "zero": { + LABEL = i18n.str`To zero`; + break; + } + case "up": { + LABEL = i18n.str`Round up`; + break; + } + case "nearest": { + LABEL = i18n.str`To nearest int`; + break; + } + default: { + assertUnreachable(ROUNDING_MODE); + } + } + return ( + <label + onClick={(e) => { + form.cashin_rounding_mode = ROUNDING_MODE; + updateForm(structuredClone(form)); + e.preventDefault(); + }} + data-disabled={purpose === "show"} + data-selected={ + (form.cashin_rounding_mode ?? + defaultValue.cashin_rounding_mode) === + ROUNDING_MODE + } + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Newsletter" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-0-label" + class="block text-sm font-medium text-gray-900 " + > + {LABEL} + </span> + </span> + </span> + <svg + data-selected={ + (form.cashin_rounding_mode ?? + defaultValue.cashin_rounding_mode) === + ROUNDING_MODE + } + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + ); + }, + )} + </div> + </div> + </div> + + <div class="sm:col-span-5"> + <label + for="cashin_fee" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Cashin fee`}</label> + <InputAmount + name="cashin_fee" + left + currency={config.currency} + value={form.cashin_fee ?? defaultValue.cashin_fee} + onChange={ + !editableForm + ? undefined + : (e) => { + form.cashin_fee = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.cashin_fee ? String(errors?.cashin_fee) : undefined + } + isDirty={form.cashin_fee !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>FIXME.</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + for="debit" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Cashin min amount`}</label> + <InputAmount + name="debit" + left + currency={config.currency} + value={ + form.cashin_min_amount ?? defaultValue.cashin_min_amount + } + onChange={ + !editableForm + ? undefined + : (e) => { + form.cashin_min_amount = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.cashin_min_amount + ? String(errors?.cashin_min_amount) + : undefined + } + isDirty={form.cashin_min_amount !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>FIXME.</i18n.Translate> + </p> + </div> + </Fragment> + )} + + {!form.cashout_enabled ? undefined : ( + <Fragment> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Cashout rounding mode`} + </label> + <div class="mt-2 max-w-xl text-sm text-gray-500"> + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> + {(["nearest", "zero", "up"] as Array<RoundingMode>).map( + (ROUNDING_MODE) => { + let LABEL: TranslatedString; + switch (ROUNDING_MODE) { + case "zero": { + LABEL = i18n.str`To zero`; + break; + } + case "up": { + LABEL = i18n.str`Round up`; + break; + } + case "nearest": { + LABEL = i18n.str`To nearest int`; + break; + } + default: { + assertUnreachable(ROUNDING_MODE); + } + } + return ( + <label + onClick={(e) => { + form.cashout_rounding_mode = ROUNDING_MODE; + updateForm(structuredClone(form)); + e.preventDefault(); + }} + data-disabled={purpose === "show"} + data-selected={ + (form.cashout_rounding_mode ?? + defaultValue.cashout_rounding_mode) === + ROUNDING_MODE + } + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Newsletter" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-0-label" + class="block text-sm font-medium text-gray-900 " + > + {LABEL} + </span> + </span> + </span> + <svg + data-selected={ + (form.cashout_rounding_mode ?? + defaultValue.cashout_rounding_mode) === + ROUNDING_MODE + } + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + ); + }, + )} + </div> + </div> + </div> + + <div class="sm:col-span-5"> + <label + for="cashout_min_amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Cashout min amount`}</label> + <InputAmount + name="cashout_min_amount" + left + currency={config.currency} + value={ + form.cashout_min_amount ?? defaultValue.cashout_min_amount + } + onChange={ + !editableForm + ? undefined + : (e) => { + form.cashout_min_amount = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.cashout_min_amount + ? String(errors?.cashout_min_amount) + : undefined + } + isDirty={form.cashout_min_amount !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>FIXME.</i18n.Translate> + </p> + </div> + <div class="sm:col-span-5"> + <label + for="debit" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Cashout fee`}</label> + <InputAmount + name="debit" + left + currency={config.currency} + value={form.cashout_fee ?? defaultValue.cashout_fee} + onChange={ + !editableForm + ? undefined + : (e) => { + form.cashout_fee = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.cashout_fee + ? String(errors?.cashout_fee) + : undefined + } + isDirty={form.cashout_fee !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>FIXME.</i18n.Translate> + </p> + </div> + </Fragment> + )} */} + </div> + </div> + {children} + </form> + ); +} diff --git a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx @@ -148,10 +148,19 @@ export function CreateNewAccount({ debug: resp.detail, when: AbsoluteTime.now(), }); - case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: { + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: { return notify({ type: "error", - title: i18n.str`Only the administrator can change the minimum cashout limit.`, + title: i18n.str`Only the administrator can change the conversion rate.`, + description: resp.detail?.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + } + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: { + return notify({ + type: "error", + title: i18n.str`The conversion rate class doesn't exist.`, description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -27,34 +27,37 @@ import { import { Attention, InternationalizationAPI, + Loading, LocalNotificationBanner, + RouteDefinition, ShowInputErrorLabel, + useBankCoreApiContext, useLocalNotification, useTranslationContext, utils, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; -import { useSessionState } from "../../hooks/session.js"; +import { + FormErrors, + FormStatus, + FormValues, + RecursivePartial, + UIField, + useFormState, +} from "../../hooks/form.js"; import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo, } from "../../hooks/regional.js"; -import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { useSessionState } from "../../hooks/session.js"; import { undefinedIfEmpty } from "../../utils.js"; import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; -import { - FormErrors, - FormStatus, - FormValues, - RecursivePartial, - UIField, - useFormState, -} from "../../hooks/form.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(); @@ -116,18 +133,18 @@ function useComponentState({ amount: "100", conv: { cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1], - cashin_tiny_amount: - info.conversion_rate.cashin_tiny_amount.split(":")[1], 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], cashout_min_amount: info.conversion_rate.cashout_min_amount.split(":")[1], - cashout_tiny_amount: - info.conversion_rate.cashout_tiny_amount.split(":")[1], cashout_fee: info.conversion_rate.cashout_fee.split(":")[1], cashout_ratio: info.conversion_rate.cashout_ratio, cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode, + cashout_tiny_amount: + info.conversion_rate.cashout_tiny_amount.split(":")[1], }, }; @@ -342,7 +359,6 @@ function useComponentState({ minimum={form?.conv?.cashin_min_amount} ratio={form?.conv?.cashin_ratio} rounding={form?.conv?.cashin_rounding_mode} - tiny={form?.conv?.cashin_tiny_amount} /> )} @@ -356,7 +372,6 @@ function useComponentState({ minimum={form?.conv?.cashout_min_amount} ratio={form?.conv?.cashout_ratio} rounding={form?.conv?.cashout_rounding_mode} - tiny={form?.conv?.cashout_tiny_amount} /> </Fragment> )} @@ -366,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> @@ -377,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> @@ -439,7 +468,7 @@ function useComponentState({ value={cashinCalc.debit} negative withColor - spec={info.regional_currency_specification} + spec={info.fiat_currency_specification} /> </dd> </div> @@ -454,7 +483,7 @@ function useComponentState({ <dd class="text-sm text-gray-900"> <RenderAmount value={cashinCalc.beforeFee} - spec={info.fiat_currency_specification} + spec={info.regional_currency_specification} /> </dd> </div> @@ -467,7 +496,7 @@ function useComponentState({ <RenderAmount value={cashinCalc.credit} withColor - spec={info.fiat_currency_specification} + spec={info.regional_currency_specification} /> </dd> </div> @@ -487,7 +516,7 @@ function useComponentState({ value={cashoutCalc.debit} negative withColor - spec={info.fiat_currency_specification} + spec={info.regional_currency_specification} /> </dd> </div> @@ -502,7 +531,7 @@ function useComponentState({ <dd class="text-sm text-gray-900"> <RenderAmount value={cashoutCalc.beforeFee} - spec={info.regional_currency_specification} + spec={info.fiat_currency_specification} /> </dd> </div> @@ -515,7 +544,7 @@ function useComponentState({ <RenderAmount value={cashoutCalc.credit} withColor - spec={info.regional_currency_specification} + spec={info.fiat_currency_specification} /> </dd> </div> @@ -618,11 +647,6 @@ function createFormValidator( : !cashin_min_amount ? i18n.str`Invalid` : undefined, - cashin_tiny_amount: !state.conv.cashin_tiny_amount - ? i18n.str`Required` - : !cashin_tiny_amount - ? i18n.str`Invalid` - : undefined, cashin_fee: !state.conv.cashin_fee ? i18n.str`Required` : !cashin_fee @@ -634,11 +658,6 @@ function createFormValidator( : !cashout_min_amount ? i18n.str`Invalid` : undefined, - cashout_tiny_amount: !state.conv.cashin_tiny_amount - ? i18n.str`Required` - : !cashout_tiny_amount - ? i18n.str`Invalid` - : undefined, cashout_fee: !state.conv.cashin_fee ? i18n.str`Required` : !cashout_fee @@ -680,30 +699,30 @@ 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, cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode ? state.conv.cashin_rounding_mode! : undefined, - cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount - ? Amounts.stringify(cashin_tiny_amount!) - : undefined, cashout_fee: !errors?.conv?.cashout_fee ? Amounts.stringify(cashout_fee!) : undefined, 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, cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode ? state.conv.cashout_rounding_mode! : undefined, - cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount - ? Amounts.stringify(cashout_tiny_amount!) - : undefined, }, }; return errors === undefined @@ -712,7 +731,7 @@ function createFormValidator( }; } -function ConversionForm({ +export function ConversionForm({ id, inputCurrency, outputCurrency, @@ -720,15 +739,21 @@ function ConversionForm({ minimum, ratio, rounding, - tiny, + fallback_fee, + fallback_minimum, + fallback_ratio, + fallback_rounding, }: { inputCurrency: string; outputCurrency: string; minimum: UIField | undefined; - tiny: UIField | undefined; + fallback_minimum?: string; fee: UIField | undefined; + fallback_fee?: string; rounding: UIField | undefined; + fallback_rounding?: string; ratio: UIField | undefined; + fallback_ratio?: string; id: string; }): VNode { const { i18n } = useTranslationContext(); @@ -747,6 +772,7 @@ function ConversionForm({ currency={inputCurrency} value={minimum?.value ?? ""} onChange={minimum?.onUpdate} + placeholder={fallback_minimum} /> <ShowInputErrorLabel message={minimum?.error} @@ -754,8 +780,9 @@ function ConversionForm({ /> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> - Only cashout operation above this threshold will be allowed + Only cashout operation above this threshold will be allowed. </i18n.Translate> + </p> </div> </div> @@ -780,6 +807,7 @@ function ConversionForm({ ratio?.onUpdate(e.currentTarget.value); }} autocomplete="off" + placeholder={fallback_ratio ?? "1.0"} /> <ShowInputErrorLabel message={ratio?.error} @@ -794,8 +822,8 @@ function ConversionForm({ <div class="px-6 pt-4"> <Attention title={i18n.str`Example conversion`}> <i18n.Translate> - 1 {inputCurrency} will be converted into {ratio?.value}{" "} - {outputCurrency} + 1 {inputCurrency} will be converted into{" "} + {ratio?.value ?? fallback_ratio} {outputCurrency} </i18n.Translate> </Attention> </div> @@ -804,34 +832,6 @@ function ConversionForm({ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> <div class="sm:col-span-5"> <label - for={`${id}_tiny_amount`} - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Rounding value`}</label> - <InputAmount - name={`${id}_tiny_amount`} - left - currency={outputCurrency} - value={tiny?.value ?? ""} - onChange={tiny?.onUpdate} - /> - <ShowInputErrorLabel - message={tiny?.error} - isDirty={tiny?.value !== undefined} - /> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Smallest difference between two amounts after the ratio is - applied. - </i18n.Translate> - </p> - </div> - </div> - </div> - - <div class="px-6 pt-6"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label class="block text-sm font-medium leading-6 text-gray-900" for={`${id}_channel`} > @@ -957,6 +957,14 @@ function ConversionForm({ </svg> </label> </div> + {!fallback_rounding ? undefined : ( + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + If none specified the fallback value is "{fallback_rounding} + ". + </i18n.Translate> + </p> + )} </div> </div> </div> @@ -1134,7 +1142,7 @@ 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> @@ -1154,6 +1162,7 @@ function ConversionForm({ currency={outputCurrency} value={fee?.value ?? ""} onChange={fee?.onUpdate} + placeholder={fallback_fee} /> <ShowInputErrorLabel message={fee?.error} diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -43,8 +43,8 @@ import { useAccountDetails } from "../../hooks/account.js"; import { useBankState } from "../../hooks/bank-state.js"; import { TransferCalculation, - useCashoutEstimator, - useConversionInfo, + useCashoutEstimatorByUser, + useConversionRateForUser, } from "../../hooks/regional.js"; import { useSessionState } from "../../hooks/session.js"; import { TanChannel, undefinedIfEmpty } from "../../utils.js"; @@ -89,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(); @@ -100,7 +100,7 @@ export function CreateCashout({ } = useBankCoreApiContext(); const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); const [notification, notify, handleError] = useLocalNotification(); - const info = useConversionInfo(); + const resp = useConversionRateForUser(accountName, creds?.token); if (!config.allow_conversion) { return ( @@ -149,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`}> @@ -169,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> ); @@ -182,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: - resultAccount.body.min_cashout === undefined - ? regionalZero - : Amounts.parseOrThrow(resultAccount.body.min_cashout), }; const limit = account.balanceIsDebit @@ -211,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 @@ -227,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) { @@ -261,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(); @@ -440,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> @@ -644,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 @@ -703,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> @@ -716,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-harness/src/integrationtests/test-withdrawal-conversion.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts @@ -96,12 +96,12 @@ async function runTestfakeConversionService(): Promise<TestfakeConversionService cashin_min_amount: "A:0.1" as AmountString, cashin_ratio: "1", cashin_rounding_mode: "zero", - cashin_tiny_amount: "A:1" as AmountString, + cashin_tiny_amount: "A:0.1" as AmountString, cashout_fee: "A:1" as AmountString, cashout_min_amount: "A:0.1" as AmountString, cashout_ratio: "1", cashout_rounding_mode: "zero", - cashout_tiny_amount: "A:1" as AmountString, + cashout_tiny_amount: "A:0.1" as AmountString, }, } satisfies TalerBankConversionApi.TalerConversionInfoConfig), ); 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,10 +101,38 @@ 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 * */ - async getCashinRate(conversion: { debit?: AmountJson; credit?: AmountJson }) { + async getCashinRate( + auth: AccessToken | undefined, + conversion: { debit?: AmountJson; credit?: AmountJson }, + ) { const url = new URL(`cashin-rate`, this.baseUrl); if (conversion.debit) { url.searchParams.set("amount_debit", Amounts.stringify(conversion.debit)); @@ -114,8 +143,13 @@ export class TalerBankConversionHttpClient { Amounts.stringify(conversion.credit), ); } + const headers: Record<string, string> = {}; + if (auth) { + headers.Authorization = makeBearerTokenAuthHeader(auth); + } const resp = await this.httpLib.fetch(url.href, { method: "GET", + headers, }); switch (resp.status) { case HttpStatusCode.Ok: @@ -147,10 +181,13 @@ export class TalerBankConversionHttpClient { * https://docs.taler.net/core/api-bank-conversion-info.html#get--cashout-rate * */ - async getCashoutRate(conversion: { - debit?: AmountJson; - credit?: AmountJson; - }) { + async getCashoutRate( + auth: AccessToken | undefined, + conversion: { + debit?: AmountJson; + credit?: AmountJson; + }, + ) { const url = new URL(`cashout-rate`, this.baseUrl); if (conversion.debit) { url.searchParams.set("amount_debit", Amounts.stringify(conversion.debit)); @@ -161,8 +198,13 @@ export class TalerBankConversionHttpClient { Amounts.stringify(conversion.credit), ); } + const headers: Record<string, string> = {}; + if (auth) { + headers.Authorization = makeBearerTokenAuthHeader(auth); + } const resp = await this.httpLib.fetch(url.href, { method: "GET", + headers, }); switch (resp.status) { case HttpStatusCode.Ok: diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts @@ -53,6 +53,19 @@ import { } from "../operation.js"; import { WithdrawalOperationStatusFlag } from "../types-taler-bank-integration.js"; import { + AccountPasswordChange, + AccountReconfiguration, + BankAccountConfirmWithdrawalRequest, + BankAccountCreateWithdrawalRequest, + CashoutRequest, + Challenge, + ChallengeSolve, + ConversionRateClassInput, + CreateTransactionRequest, + CreateTransactionResponse, + MonitorTimeframeParam, + RegisterAccountRequest, + TalerCorebankConfigResponse, codecForAccountData, codecForBankAccountCreateWithdrawalResponse, codecForBankAccountTransactionInfo, @@ -61,6 +74,9 @@ import { codecForCashoutStatusResponse, codecForCashouts, codecForChallenge, + codecForConversionRateClass, + codecForConversionRateClassResponse, + codecForConversionRateClasses, codecForCoreBankConfig, codecForCreateTransactionResponse, codecForGlobalCashouts, @@ -80,8 +96,6 @@ import { nullEvictor, } from "./utils.js"; -import * as TalerCorebankApi from "../types-taler-corebank.js"; - export type TalerCoreBankResultByMethod< prop extends keyof TalerCoreBankHttpClient, > = ResultByMethod<TalerCoreBankHttpClient, prop>; @@ -99,6 +113,9 @@ export enum TalerCoreBankCacheEviction { ABORT_WITHDRAWAL, CREATE_WITHDRAWAL, CREATE_CASHOUT, + CREATE_CONVERSION_RATE_CLASS, + UPDATE_CONVERSION_RATE_CLASS, + DELETE_CONVERSION_RATE_CLASS, } export type Credentials = BasicCredentials | BearerCredentials; @@ -120,7 +137,7 @@ export type BearerCredentials = { * Uses libtool's current:revision:age versioning. */ export class TalerCoreBankHttpClient { - public readonly PROTOCOL_VERSION = "8:0:0"; + public readonly PROTOCOL_VERSION = "9:0:0"; httpLib: HttpRequestLibrary; cacheEvictor: CacheEvictor<TalerCoreBankCacheEviction>; @@ -262,7 +279,7 @@ export class TalerCoreBankHttpClient { */ async getConfig(): Promise< | OperationFail<HttpStatusCode.NotFound> - | OperationOk<TalerCorebankApi.TalerCorebankConfigResponse> + | OperationOk<TalerCorebankConfigResponse> > { const url = new URL(`config`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { @@ -316,23 +333,8 @@ export class TalerCoreBankHttpClient { */ async createAccount( auth: AccessToken | undefined, - body: TalerCorebankApi.RegisterAccountRequest, - ): Promise< - | OperationOk<TalerCorebankApi.RegisterAccountResponse> - | OperationFail<HttpStatusCode.BadRequest> - | OperationFail<HttpStatusCode.Unauthorized> - | OperationFail<TalerErrorCode.BANK_REGISTER_USERNAME_REUSE> - | OperationFail<TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE> - | OperationFail<TalerErrorCode.BANK_UNALLOWED_DEBIT> - | OperationFail<TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT> - | OperationFail<TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT> - | OperationFail<TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT> - | OperationFail<TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL> - | OperationFail<TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED> - | OperationFail<TalerErrorCode.BANK_MISSING_TAN_INFO> - | OperationFail<TalerErrorCode.BANK_PASSWORD_TOO_SHORT> - | OperationFail<TalerErrorCode.BANK_PASSWORD_TOO_LONG> - > { + body: RegisterAccountRequest, + ) { const url = new URL(`accounts`, this.baseUrl); const headers: Record<string, string> = {}; if (auth) { @@ -367,7 +369,7 @@ export class TalerCoreBankHttpClient { return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return opKnownTalerFailure(details.code, details); - case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: return opKnownTalerFailure(details.code, details); @@ -379,6 +381,8 @@ export class TalerCoreBankHttpClient { return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_PASSWORD_TOO_LONG: return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + return opKnownTalerFailure(details.code, details); default: return opUnknownHttpFailure(resp, details); } @@ -438,7 +442,7 @@ export class TalerCoreBankHttpClient { */ async updateAccount( auth: UserAndToken, - body: TalerCorebankApi.AccountReconfiguration, + body: AccountReconfiguration, cid?: string, ) { const url = new URL(`accounts/${auth.username}`, this.baseUrl); @@ -475,7 +479,7 @@ export class TalerCoreBankHttpClient { return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: return opKnownTalerFailure(details.code, details); - case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: return opKnownTalerFailure(details.code, details); @@ -485,6 +489,8 @@ export class TalerCoreBankHttpClient { return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_PASSWORD_TOO_LONG: return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + return opKnownTalerFailure(details.code, details); default: return opUnknownHttpFailure(resp, details); } @@ -500,7 +506,7 @@ export class TalerCoreBankHttpClient { */ async updatePassword( auth: UserAndToken, - body: TalerCorebankApi.AccountPasswordChange, + body: AccountPasswordChange, cid?: string, ) { const url = new URL(`accounts/${auth.username}/auth`, this.baseUrl); @@ -579,15 +585,20 @@ export class TalerCoreBankHttpClient { * https://docs.taler.net/core/api-corebank.html#get--accounts * */ - async getAccounts( + async listAccounts( auth: AccessToken, - filter: { account?: string } = {}, - pagination?: PaginationParams, + params?: PaginationParams & { account?: string; conversionRateId?: number }, ) { const url = new URL(`accounts`, this.baseUrl); - addPaginationParams(url, pagination); - if (filter.account !== undefined) { - url.searchParams.set("filter_name", filter.account); + addPaginationParams(url, params); + if (params?.account !== undefined) { + url.searchParams.set("filter_name", params.account); + } + if (params?.conversionRateId !== undefined) { + url.searchParams.set( + "conversion_rate_class_id", + String(params.conversionRateId), + ); } const resp = await this.httpLib.fetch(url.href, { method: "GET", @@ -702,12 +713,12 @@ export class TalerCoreBankHttpClient { */ async createTransaction( auth: UserAndToken, - body: TalerCorebankApi.CreateTransactionRequest, + body: CreateTransactionRequest, cid?: string, ): Promise< //manually definition all return types because of recursion - | OperationOk<TalerCorebankApi.CreateTransactionResponse> - | OperationAlternative<HttpStatusCode.Accepted, TalerCorebankApi.Challenge> + | OperationOk<CreateTransactionResponse> + | OperationAlternative<HttpStatusCode.Accepted, Challenge> | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.BadRequest> | OperationFail<HttpStatusCode.Unauthorized> @@ -776,7 +787,7 @@ export class TalerCoreBankHttpClient { */ async createWithdrawal( auth: UserAndToken, - body: TalerCorebankApi.BankAccountCreateWithdrawalRequest, + body: BankAccountCreateWithdrawalRequest, ) { const url = new URL(`accounts/${auth.username}/withdrawals`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { @@ -813,7 +824,7 @@ export class TalerCoreBankHttpClient { */ async confirmWithdrawalById( auth: UserAndToken, - body: TalerCorebankApi.BankAccountConfirmWithdrawalRequest, + body: BankAccountConfirmWithdrawalRequest, wid: string, cid?: string, ) { @@ -943,11 +954,7 @@ export class TalerCoreBankHttpClient { * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-cashouts * */ - async createCashout( - auth: UserAndToken, - body: TalerCorebankApi.CashoutRequest, - cid?: string, - ) { + async createCashout(auth: UserAndToken, body: CashoutRequest, cid?: string) { const url = new URL(`accounts/${auth.username}/cashouts`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -1088,6 +1095,206 @@ export class TalerCoreBankHttpClient { } // + // CONVERSION RATE CLASS + // + + /** + * https://docs.taler.net/core/api-corebank.html#post--conversion-rate-classes + * + */ + async createConversionRateClass( + auth: AccessToken, + body: ConversionRateClassInput, + ) { + const url = new URL(`conversion-rate-classes`, this.baseUrl); + + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + headers: { + Authorization: makeBearerTokenAuthHeader(auth), + }, + body, + }); + + switch (resp.status) { + case HttpStatusCode.Ok: + await this.cacheEvictor.notifySuccess( + TalerCoreBankCacheEviction.CREATE_CONVERSION_RATE_CLASS, + ); + return opSuccessFromHttp(resp, codecForConversionRateClassResponse()); + case HttpStatusCode.Unauthorized: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Forbidden: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Conflict: { + const details = await readTalerErrorResponse(resp); + switch (details.code) { + case TalerErrorCode.BANK_NAME_REUSE: + return opKnownTalerFailure(details.code, details); + default: + return opUnknownHttpFailure(resp, details); + } + } + case HttpStatusCode.NotImplemented: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** + * https://docs.taler.net/core/api-corebank.html#patch--conversion-rate-classes-CLASS_ID + * + */ + async updateConversionRateClass( + auth: AccessToken, + cid: number, + body: ConversionRateClassInput, + ) { + const url = new URL(`conversion-rate-classes/${cid}`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "PATCH", + headers: { + Authorization: makeBearerTokenAuthHeader(auth), + }, + body, + }); + switch (resp.status) { + case HttpStatusCode.NoContent: + await this.cacheEvictor.notifySuccess( + TalerCoreBankCacheEviction.UPDATE_CONVERSION_RATE_CLASS, + ); + return opEmptySuccess(); + case HttpStatusCode.Unauthorized: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Forbidden: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Conflict: { + const details = await readTalerErrorResponse(resp); + switch (details.code) { + case TalerErrorCode.BANK_NAME_REUSE: + return opKnownTalerFailure(details.code, details); + default: + return opUnknownHttpFailure(resp, details); + } + } + case HttpStatusCode.NotImplemented: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** + * https://docs.taler.net/core/api-corebank.html#get--accounts-$USERNAME-cashouts + * + */ + async deleteConversionRateClass(auth: AccessToken, cid: number) { + const url = new URL(`conversion-rate-classes/${cid}`, this.baseUrl); + + const resp = await this.httpLib.fetch(url.href, { + method: "DELETE", + headers: { + Authorization: makeBearerTokenAuthHeader(auth), + }, + }); + switch (resp.status) { + case HttpStatusCode.NoContent: + await this.cacheEvictor.notifySuccess( + TalerCoreBankCacheEviction.DELETE_CONVERSION_RATE_CLASS, + ); + return opEmptySuccess(); + case HttpStatusCode.Unauthorized: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Forbidden: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + // case HttpStatusCode.Conflict: { + // const details = await readTalerErrorResponse(resp); + // switch (details.code) { + // case TalerErrorCode.BANK_LI: + // return opKnownTalerFailure(details.code, details); + // default: + // return opUnknownHttpFailure(resp, details); + // } + // } + case HttpStatusCode.NotImplemented: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** + * https://docs.taler.net/core/api-corebank.html#get--conversion-rate-classes-CLASS_ID + * + */ + async getConversionRateClass(auth: AccessToken, cid: number) { + const url = new URL(`conversion-rate-classes/${cid}`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + headers: { + Authorization: makeBearerTokenAuthHeader(auth), + }, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForConversionRateClass()); + case HttpStatusCode.Unauthorized: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Forbidden: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotImplemented: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** + * https://docs.taler.net/core/api-corebank.html#get--conversion-rate-classes + * + */ + async listConversionRateClasses( + auth: AccessToken, + params: PaginationParams & { className?: string } = {}, + ) { + const url = new URL(`conversion-rate-classes`, this.baseUrl); + addPaginationParams(url, params); + if (params.className) { + url.searchParams.set("filter_name", params.className); + } + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + headers: { + Authorization: makeBearerTokenAuthHeader(auth), + }, + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForConversionRateClasses()); + case HttpStatusCode.NoContent: + return opFixedSuccess({ classes: [], default: {} as any }); + case HttpStatusCode.Unauthorized: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Forbidden: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotImplemented: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + // // 2FA // @@ -1161,7 +1368,7 @@ export class TalerCoreBankHttpClient { async confirmChallenge( auth: UserAndToken, cid: string, - body: TalerCorebankApi.ChallengeSolve, + body: ChallengeSolve, ) { return this.__interal_confirmChallenge( auth.username, @@ -1178,7 +1385,7 @@ export class TalerCoreBankHttpClient { async confirmLoginChallenge( auth: UserAndPassword, cid: string, - body: TalerCorebankApi.ChallengeSolve, + body: ChallengeSolve, ) { return this.__interal_confirmChallenge( auth.username, @@ -1192,7 +1399,7 @@ export class TalerCoreBankHttpClient { username: string, Authorization: string | undefined, cid: string, - body: TalerCorebankApi.ChallengeSolve, + body: ChallengeSolve, ) { const url = new URL( `accounts/${username}/challenge/${cid}/confirm`, @@ -1239,7 +1446,7 @@ export class TalerCoreBankHttpClient { async getMonitor( auth: AccessToken, params: { - timeframe?: TalerCorebankApi.MonitorTimeframeParam; + timeframe?: MonitorTimeframeParam; date?: AbsoluteTime; } = {}, ) { @@ -1247,7 +1454,7 @@ export class TalerCoreBankHttpClient { if (params.timeframe) { url.searchParams.set( "timeframe", - TalerCorebankApi.MonitorTimeframeParam[params.timeframe], + MonitorTimeframeParam[params.timeframe], ); } if (params.date) { @@ -1303,7 +1510,26 @@ export class TalerCoreBankHttpClient { } /** - * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-token + * https://docs.taler.net/core/api-corebank.html#any--accounts-$USERNAME-conversion-info-* + * + */ + getConversionInfoAPIForUser(username: string): URL { + return new URL(`accounts/${username}/conversion-info/`, this.baseUrl); + } + + /** + * https://docs.taler.net/core/api-corebank.html#any--conversion-rate-classes-$CLASS_ID-conversion-info-* + * + */ + getConversionInfoAPIForClass(classId: number): URL { + return new URL( + `conversion-rate-classes/${String(classId)}/conversion-info/`, + this.baseUrl, + ); + } + + /** + * https://docs.taler.net/core/api-corebank.html#any--conversion-info-* * */ getConversionInfoAPI(): URL { diff --git a/packages/taler-util/src/types-taler-bank-conversion.ts b/packages/taler-util/src/types-taler-bank-conversion.ts @@ -32,37 +32,37 @@ import { codecForDecimalNumber, } from "./types-taler-common.js"; -export interface ConversionInfo { - // Exchange rate to buy regional currency from fiat - cashin_ratio: DecimalNumber; +// export interface ConversionInfo { +// // Exchange rate to buy regional currency from fiat +// cashin_ratio: DecimalNumber; - // Exchange rate to sell regional currency for fiat - cashout_ratio: DecimalNumber; +// // Exchange rate to sell regional currency for fiat +// cashout_ratio: DecimalNumber; - // Fee to subtract after applying the cashin ratio. - cashin_fee: AmountString; +// // Fee to subtract after applying the cashin ratio. +// cashin_fee: AmountString; - // Fee to subtract after applying the cashout ratio. - cashout_fee: AmountString; +// // Fee to subtract after applying the cashout ratio. +// cashout_fee: AmountString; - // Minimum amount authorised for cashin, in fiat before conversion - cashin_min_amount: AmountString; +// // Minimum amount authorised for cashin, in fiat before conversion +// cashin_min_amount: AmountString; - // Minimum amount authorised for cashout, in regional before conversion - cashout_min_amount: AmountString; +// // Minimum amount authorised for cashout, in regional before conversion +// cashout_min_amount: AmountString; - // Smallest possible regional amount, converted amount is rounded to this amount - cashin_tiny_amount: AmountString; +// // Smallest possible regional amount, converted amount is rounded to this amount +// cashin_tiny_amount: AmountString; - // Smallest possible fiat amount, converted amount is rounded to this amount - cashout_tiny_amount: AmountString; +// // Smallest possible fiat amount, converted amount is rounded to this amount +// cashout_tiny_amount: AmountString; - // Rounding mode used during cashin conversion - cashin_rounding_mode: "zero" | "up" | "nearest"; +// // Rounding mode used during cashin conversion +// cashin_rounding_mode: RoundingMode; - // Rounding mode used during cashout conversion - cashout_rounding_mode: "zero" | "up" | "nearest"; -} +// // Rounding mode used during cashout conversion +// cashout_rounding_mode: RoundingMode; +// } export interface TalerConversionInfoConfig { // libtool-style representation of the Bank protocol version, see @@ -73,6 +73,10 @@ export interface TalerConversionInfoConfig { // Name of the API. name: "taler-conversion-info"; + // URN of the implementation (needed to interpret 'revision' in version). + // @since v4, may become mandatory in the future. + implementation?: string; + // Currency used by this bank. regional_currency: string; @@ -85,9 +89,11 @@ export interface TalerConversionInfoConfig { // How the bank SPA should render this currency. fiat_currency_specification: CurrencySpecification; - // Extra conversion rate information. - // Only present if server opts in to report the static conversion rate. - conversion_rate: ConversionInfo; + // Global exchange rate between the regional currency and the fiat + // currency of the banking system. Use /rate to get the user specific + // rate. + // FIXME spec: changed on v2, breaking change insteand of deprecating + conversion_rate: ConversionRate; } export interface CashinConversionResponse { @@ -111,20 +117,23 @@ export interface CashoutConversionResponse { export type RoundingMode = "zero" | "up" | "nearest"; export interface ConversionRate { + // Minimum amount authorised for cashin, in fiat before conversion + cashin_min_amount: AmountString; + // Exchange rate to buy regional currency from fiat cashin_ratio: DecimalNumber; // Fee to subtract after applying the cashin ratio. cashin_fee: AmountString; - // Minimum amount authorised for cashin, in fiat before conversion - cashin_min_amount: AmountString; + // Rounding mode used during cashin conversion + cashin_rounding_mode: RoundingMode; // Smallest possible regional amount, converted amount is rounded to this amount cashin_tiny_amount: AmountString; - // Rounding mode used during cashin conversion - cashin_rounding_mode: RoundingMode; + // Minimum amount authorised for cashout, in regional before conversion + cashout_min_amount: AmountString; // Exchange rate to sell regional currency for fiat cashout_ratio: DecimalNumber; @@ -132,14 +141,11 @@ export interface ConversionRate { // Fee to subtract after applying the cashout ratio. cashout_fee: AmountString; - // Minimum amount authorised for cashout, in regional before conversion - cashout_min_amount: AmountString; + // Rounding mode used during cashout conversion + cashout_rounding_mode: RoundingMode; // Smallest possible fiat amount, converted amount is rounded to this amount cashout_tiny_amount: AmountString; - - // Rounding mode used during cashout conversion - cashout_rounding_mode: RoundingMode; } export const codecForCashoutConversionResponse = @@ -156,8 +162,8 @@ export const codecForCashinConversionResponse = .property("amount_debit", codecForAmountString()) .build("TalerCorebankApi.CashinConversionResponse"); -export const codecForConversionInfo = (): Codec<ConversionInfo> => - buildCodecForObject<ConversionInfo>() +export const codecForConversionRate = (): Codec<ConversionRate> => + buildCodecForObject<ConversionRate>() .property("cashin_fee", codecForAmountString()) .property("cashin_min_amount", codecForAmountString()) .property("cashin_ratio", codecForDecimalNumber()) @@ -184,17 +190,18 @@ export const codecForConversionInfo = (): Codec<ConversionInfo> => .property("cashout_tiny_amount", codecForAmountString()) .build("ConversionBankConfig.ConversionInfo"); -export const codecForConversionBankConfig = (): Codec<TalerConversionInfoConfig> => - buildCodecForObject<TalerConversionInfoConfig>() - .property("name", codecForConstString("taler-conversion-info")) - .property("version", codecForString()) - .property("regional_currency", codecForString()) - .property( - "regional_currency_specification", - codecForCurrencySpecificiation(), - ) - .property("fiat_currency", codecForString()) - .property("fiat_currency_specification", codecForCurrencySpecificiation()) - - .property("conversion_rate", codecForConversionInfo()) - .build("ConversionBankConfig.IntegrationConfig"); +export const codecForConversionBankConfig = + (): Codec<TalerConversionInfoConfig> => + buildCodecForObject<TalerConversionInfoConfig>() + .property("name", codecForConstString("taler-conversion-info")) + .property("version", codecForString()) + .property("regional_currency", codecForString()) + .property( + "regional_currency_specification", + codecForCurrencySpecificiation(), + ) + .property("fiat_currency", codecForString()) + .property("fiat_currency_specification", codecForCurrencySpecificiation()) + + .property("conversion_rate", codecForConversionRate()) + .build("ConversionBankConfig.IntegrationConfig"); diff --git a/packages/taler-util/src/types-taler-common.ts b/packages/taler-util/src/types-taler-common.ts @@ -421,8 +421,11 @@ export interface TokenInfo { // Time when the token was last used. last_access: Timestamp; - // Opaque unique ID used for pagination. - row_id: Integer; + // ID identifying the token + token_id: Integer; + + // deprecated since **v9**. Use *token_id* instead. + // row_id?: Integer; } export const codecForTokenInfo = (): Codec<TokenInfo> => @@ -433,7 +436,7 @@ export const codecForTokenInfo = (): Codec<TokenInfo> => .property("refreshable", codecForBoolean()) .property("description", codecOptional(codecForString())) .property("last_access", codecForTimestamp) - .property("row_id", codecForNumber()) + .property("token_id", codecForNumber()) .build("TokenInfo"); export const codecForTokenInfoList = (): Codec<TokenInfos> => diff --git a/packages/taler-util/src/types-taler-corebank.ts b/packages/taler-util/src/types-taler-corebank.ts @@ -27,12 +27,15 @@ import { import { buildCodecForUnion, codecForAmountString, + codecForConversionRate, codecForEither, codecForList, codecForNumber, codecForTalerUriString, codecForTimestamp, codecOptionalDefault, + ConversionRate, + RoundingMode, } from "./index.js"; import { PaytoString, codecForPaytoString } from "./payto.js"; import { TalerUriString } from "./taleruri.js"; @@ -115,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[]; @@ -258,6 +269,7 @@ export interface RegisterAccountResponse { export interface RegisterAccountRequest { // Username + // Must match [a-zA-Z0-9\-\._~]{1, 126} username: string; // Password. @@ -295,15 +307,18 @@ export interface RegisterAccountRequest { // Only admin can set this property. debit_threshold?: AmountString; - // If present, set a custom minimum cashout amount for this account. - // Only admin can set this property - // @since v4 - min_cashout?: AmountString; + // If present, set the user conversion rate class + // Only admin can set this property. + // @since **v9** + conversion_rate_class_id?: Integer; // If present, enables 2FA and set the TAN channel used for challenges // Only admin can set this property, other user can reconfig their account // after creation. tan_channel?: TanChannel; + + // @deprecated in **v9**, use conversion_rate_class_id instead + // min_cashout?: Amount; } export type EmailAddress = string; @@ -344,13 +359,16 @@ export interface AccountReconfiguration { // Only admin can change this property. debit_threshold?: AmountString; - // If present, change the custom minimum cashout amount for this account. - // Only admin can set this property - // @since v4 - min_cashout?: AmountString; + // If present, set the user conversion rate class + // Only admin can set this property. + // @since **v9** + conversion_rate_class_id?: Integer | null; // If present, enables 2FA and set the TAN channel used for challenges tan_channel?: TanChannel | null; + + // @deprecated in **v9**, user conversion rate classes instead + // min_cashout?: Amount; } export interface AccountPasswordChange { @@ -413,8 +431,9 @@ export interface AccountMinimalData { // Custom minimum cashout amount for this account. // If null or absent, the global conversion fee is used. - // @since v4 - min_cashout?: AmountString; + // @since v6 + // @deprecated in **v9**, use conversion_rate_class_id instead + // min_cashout?: AmountString; // Is this account visible to anyone? is_public: boolean; @@ -428,16 +447,134 @@ export interface AccountMinimalData { // Is the account locked. // Defaults to false. - // @since **v7** - is_locked?: boolean; + // @deprecated since **v7** + // is_locked?: boolean; // Current status of the account // active: the account can be used + // locked: the account can be used but cannot create new tokens + // @since **v7** // deleted: the account has been deleted but is retained for compliance // reasons, only the administrator can access it - // Default to 'active' is missing - // @since v4, will become mandatory in the next version. - status?: "active" | "deleted"; + // 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"; + +export interface ConversionRateClass { + // The name of this class + name: string; + + // A description of the class + description?: string; + + // Class unique ID + conversion_rate_class_id: Integer; + + // Number of users affected to this class + num_users: Integer; + + // Minimum fiat amount authorised for cashin before conversion + cashin_min_amount?: AmountString; + + // Exchange rate to buy regional currency from fiat + cashin_ratio?: DecimalNumber; + + // Regional amount fee to subtract after applying the cashin ratio. + cashin_fee?: AmountString; + + // Rounding mode used during cashin conversion + cashin_rounding_mode?: RoundingMode; + + // Minimum regional amount authorised for cashout before conversion + cashout_min_amount?: AmountString; + + // Exchange rate to sell regional currency for fiat + cashout_ratio?: DecimalNumber; + + // Fiat amount fee to subtract after applying the cashout ratio. + cashout_fee?: AmountString; + + // Rounding mode used during cashout conversion + cashout_rounding_mode?: RoundingMode; +} + +export interface ConversionRateClasses { + classes: ConversionRateClass[]; +} + +export interface AccountConversionRateClass { + // Class unique ID + conversion_rate_class_id: Integer; + + // Minimum fiat amount authorised for cashin before conversion + cashin_min_amount?: AmountString; + + // Exchange rate to buy regional currency from fiat + cashin_ratio?: DecimalNumber; + + // Regional amount fee to subtract after applying the cashin ratio. + cashin_fee?: AmountString; + + // Rounding mode used during cashin conversion + cashin_rounding_mode?: RoundingMode; + + // Minimum regional amount authorised for cashout before conversion + cashout_min_amount?: AmountString; + + // Exchange rate to sell regional currency for fiat + cashout_ratio?: DecimalNumber; + + // Fiat amount fee to subtract after applying the cashout ratio. + cashout_fee?: AmountString; + + // Rounding mode used during cashout conversion + cashout_rounding_mode?: RoundingMode; +} + +export interface ConversionRateClassInput { + // The name of this class + name: string; + + // A description of the class + description?: string; + + // Minimum fiat amount authorised for cashin before conversion + cashin_min_amount?: AmountString; + + // Exchange rate to buy regional currency from fiat + cashin_ratio?: DecimalNumber; + + // Regional amount fee to subtract after applying the cashin ratio. + cashin_fee?: AmountString; + + // Rounding mode used during cashin conversion + cashin_rounding_mode?: RoundingMode; + + // Minimum regional amount authorised for cashout before conversion + cashout_min_amount?: AmountString; + + // Exchange rate to sell regional currency for fiat + cashout_ratio?: DecimalNumber; + + // Fiat amount fee to subtract after applying the cashout ratio. + cashout_fee?: AmountString; + + // Rounding mode used during cashout conversion + cashout_rounding_mode?: RoundingMode; +} + +export interface ConversionRateClassResponse { + // ID identifying the conversion rate class being created + conversion_rate_class_id: Integer; } export interface AccountData { @@ -455,13 +592,20 @@ export interface AccountData { // Custom minimum cashout amount for this account. // If null or absent, the global conversion fee is used. - // @since v4 - min_cashout?: AmountString; + // @since v6 + // @deprecated in **v9**, use conversion_rate_class_id instead + // min_cashout?: AmountString; + // Addresses where to send the TAN for transactions. + // Currently only used for cashouts. + // If missing, cashouts will fail. + // In the future, might be used for other transactions + // as well. contact_data?: ChallengeContactData; - // 'payto' address pointing the bank account - // where to send cashouts. This field is optional + // Full 'payto' URI of a fiat bank account where to send cashouts with + // ``name`` as the 'receiver-name'. + // This field is optional // because not all the accounts are required to participate // in the merchants' circuit. One example is the exchange: // that never cashouts. Registering these accounts can @@ -476,19 +620,29 @@ export interface AccountData { // Is the account locked. // Defaults to false. - // @since **v7** - is_locked?: boolean; + // @deprecated since **v7** + // is_locked?: boolean; // Is 2FA enabled and what channel is used for challenges? tan_channel?: TanChannel; // Current status of the account // active: the account can be used + // locked: the account can be used but cannot create new tokens + // @since **v7** // deleted: the account has been deleted but is retained for compliance // reasons, only the administrator can access it - // Default to 'active' is missing - // @since v4, will become mandatory in the next version. - status?: "active" | "deleted"; + // 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; + + // Conversion rate available to the user + // Only present if conversion is activated on the server + // @since **v9** + conversion_rate?: ConversionRate; } export interface CashoutRequest { @@ -708,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( @@ -760,8 +916,6 @@ export const codecForAccountMinimalData = (): Codec<AccountMinimalData> => .property("balance", codecForBalance()) .property("row_id", codecForNumber()) .property("debit_threshold", codecForAmountString()) - .property("min_cashout", codecOptional(codecForAmountString())) - .property("is_locked", codecOptional(codecForBoolean())) .property("is_public", codecForBoolean()) .property("is_taler_exchange", codecForBoolean()) .property( @@ -769,10 +923,13 @@ export const codecForAccountMinimalData = (): Codec<AccountMinimalData> => codecOptional( codecForEither( codecForConstString("active"), + codecForConstString("locked"), codecForConstString("deleted"), ), ), ) + .property("conversion_rate_class_id", codecOptional(codecForNumber())) + .property("conversion_rate", codecOptional(codecForConversionRate())) .build("TalerCorebankApi.AccountMinimalData"); export const codecForListBankAccountsResponse = @@ -787,12 +944,12 @@ export const codecForAccountData = (): Codec<AccountData> => .property("balance", codecForBalance()) .property("payto_uri", codecForPaytoString()) .property("debit_threshold", codecForAmountString()) - .property("min_cashout", codecOptional(codecForAmountString())) .property("contact_data", codecOptional(codecForChallengeContactData())) .property("cashout_payto_uri", codecOptional(codecForPaytoString())) .property("is_public", codecForBoolean()) - .property("is_locked", codecOptional(codecForBoolean())) .property("is_taler_exchange", codecForBoolean()) + .property("conversion_rate_class_id", codecOptional(codecForNumber())) + .property("conversion_rate", codecOptional(codecForConversionRate())) .property( "tan_channel", codecOptional( @@ -807,12 +964,56 @@ export const codecForAccountData = (): Codec<AccountData> => codecOptional( codecForEither( codecForConstString("active"), + codecForConstString("locked"), codecForConstString("deleted"), ), ), ) .build("TalerCorebankApi.AccountData"); +export const codecForConversionRateClassResponse = + (): Codec<ConversionRateClassResponse> => + buildCodecForObject<ConversionRateClassResponse>() + .property("conversion_rate_class_id", codecForNumber()) + .build("TalerCorebankApi.ConversionRateClassResponse"); + +export const codecForConversionRateClass = (): Codec<ConversionRateClass> => + buildCodecForObject<ConversionRateClass>() + .property("cashin_fee", codecOptional(codecForAmountString())) + .property("cashin_min_amount", codecOptional(codecForAmountString())) + .property("cashin_ratio", codecOptional(codecForDecimalNumber())) + .property( + "cashin_rounding_mode", + codecOptional( + codecForEither( + codecForConstString("zero"), + codecForConstString("up"), + codecForConstString("nearest"), + ), + ), + ) + .property("cashout_fee", codecOptional(codecForAmountString())) + .property("cashout_min_amount", codecOptional(codecForAmountString())) + .property("cashout_ratio", codecOptional(codecForDecimalNumber())) + .property( + "cashout_rounding_mode", + codecOptional(codecForEither( + codecForConstString("zero"), + codecForConstString("up"), + codecForConstString("nearest"), + )), + ) + .property("conversion_rate_class_id", codecForNumber()) + .property("description", codecOptional(codecForString())) + .property("name", codecForString()) + .property("num_users", codecForNumber()) + .build("TalerCorebankApi.ConversionRateClass"); + +export const codecForConversionRateClasses = (): Codec<ConversionRateClasses> => + buildCodecForObject<ConversionRateClasses>() + .property("classes", codecForList(codecForConversionRateClass())) + .build("TalerCorebankApi.ConversionRateClasses"); + export const codecForChallengeContactData = (): Codec<ChallengeContactData> => buildCodecForObject<ChallengeContactData>() .property("email", codecOptional(codecForString())) diff --git a/packages/web-util/src/context/activity.ts b/packages/web-util/src/context/activity.ts @@ -73,6 +73,8 @@ export interface ExchangeLib { export interface BankLib { bank: TalerCoreBankHttpClient; conversion: TalerBankConversionHttpClient; + conversionForUser(username:string): TalerBankConversionHttpClient; + conversionForClass(classId: number): TalerBankConversionHttpClient; } export interface ChallengerLib { diff --git a/packages/web-util/src/context/bank-api.ts b/packages/web-util/src/context/bank-api.ts @@ -203,6 +203,20 @@ function buildBankApiClient( lib: { bank, conversion, + conversionForClass(classId) { + return new TalerBankConversionHttpClient( + bank.getConversionInfoAPIForClass(classId).href, + httpLib, + evictors.conversion, + ); + }, + conversionForUser(username) { + return new TalerBankConversionHttpClient( + bank.getConversionInfoAPIForUser(username).href, + httpLib, + evictors.conversion, + ); + }, }, onActivity: tracker.subscribe, cancelRequest: httpLib.cancelRequest,