taler-typescript-core

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

commit 3f7e8ab611d02cc2a4b66d1e8619b74c01544b0d
parent 6adddedd819be7514de3448066c95b8eff2314f3
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu,  3 Jul 2025 11:08:11 -0300

test conversion for conversion group

Diffstat:
Mpackages/bank-ui/src/Routing.tsx | 11+++++------
Mpackages/bank-ui/src/hooks/regional.ts | 431+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/bank-ui/src/pages/ConversionRateClassDetails.tsx | 315++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/bank-ui/src/pages/NewConversionRateClass.tsx | 14++++----------
Mpackages/bank-ui/src/pages/admin/ConversionClassList.tsx | 171++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx | 252+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/taler-util/src/http-client/bank-conversion.ts | 26+++++++++++++++++++++-----
Mpackages/taler-util/src/http-client/bank-core.ts | 27+++++++++++++++++++++++++--
Mpackages/web-util/src/context/activity.ts | 2++
Mpackages/web-util/src/context/bank-api.ts | 14++++++++++++++
10 files changed, 890 insertions(+), 373 deletions(-)

diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -325,12 +325,12 @@ const privatePages = { ({ wopid }) => `#/operation/${wopid}`, ), conversionRateClassCreate: urlPattern( - /\/new-conversion-rate-class/, - () => "#/new-conversion-rate-class", + /\/new-conversion-rate-group/, + () => "#/new-conversion-rate-group", ), conversionRateClassDetails: urlPattern<{ classId: string }>( - /\/conversion-rate-class\/(?<classId>[0-9]+)\/details/, - ({ classId }) => `#/conversion-rate-class/${classId}/details`, + /\/conversion-rate-group\/(?<classId>[0-9]+)\/details/, + ({ classId }) => `#/conversion-rate-group/${classId}/details`, ), }; @@ -684,7 +684,7 @@ function PrivateRouting({ case "conversionRateClassCreate": { return ( <NewConversionRateClass - onCreated={() => navigateTo(privatePages.home.url({}))} + onCreated={(id) => navigateTo(privatePages.conversionRateClassDetails.url({classId: String(id)}))} routeCancel={privatePages.home} /> ); @@ -694,7 +694,6 @@ function PrivateRouting({ if (Number.isNaN(id)) { return <div>class id is not a number "{location.values.classId}"</div>; } - // return <div>2222</div> return ( <ConversionRateClassDetails classId={id} diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts @@ -36,6 +36,8 @@ 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; @@ -92,73 +94,157 @@ export function useConversionInfo() { return undefined; } -export function useCashinEstimator(): ConversionEstimators { +export function useConversionInfoForUser(username: string) { 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).getConfig(); + } + const { data, error } = useSWR< + TalerBankConversionResultByMethod<"getConfig">, + 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, + }; + }; +} + +export function useCashinEstimator(): ConversionEstimators { + const { + lib: { conversion }, + } = useBankCoreApiContext(); + + return { + estimateByCredit: buildEstimatorWithTheBackend( + conversion, + undefined, + "cashin-rate-from-credit", + ), + estimateByDebit: buildEstimatorWithTheBackend( + conversion, + undefined, + "cashin-rate-from-debit", + ), }; } @@ -166,77 +252,109 @@ 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, - }; - }, + estimateByCredit: buildEstimatorWithTheBackend( + conversion, + undefined, + "cashout-rate-from-credit", + ), + estimateByDebit: buildEstimatorWithTheBackend( + conversion, + undefined, + "cashout-rate-from-debit", + ), }; } -/** - * @deprecated use useCashoutEstimator - */ -export function useEstimator(): ConversionEstimators { - return useCashoutEstimator(); +export function useCashinEstimatorForClass( + classId: number, +): ConversionEstimators { + const { + lib: { conversionForClass }, + } = useBankCoreApiContext(); + const { state } = useSessionState(); + const token = state.status === "loggedIn" ? state.token : undefined; + + return { + estimateByCredit: buildEstimatorWithTheBackend( + conversionForClass(classId), + token, + "cashin-rate-from-credit", + ), + estimateByDebit: buildEstimatorWithTheBackend( + conversionForClass(classId), + token, + "cashin-rate-from-debit", + ), + }; +} + +export function useCashoutEstimatorForClass( + classId: number, +): ConversionEstimators { + const { + lib: { conversionForClass }, + } = useBankCoreApiContext(); + const { state } = useSessionState(); + const token = state.status === "loggedIn" ? state.token : undefined; + return { + estimateByCredit: buildEstimatorWithTheBackend( + conversionForClass(classId), + token, + "cashout-rate-from-credit", + ), + estimateByDebit: buildEstimatorWithTheBackend( + conversionForClass(classId), + token, + "cashout-rate-from-debit", + ), + }; +} + +export function useCashinEstimatorByUser( + username: string, +): ConversionEstimators { + const { + lib: { conversionForUser }, + } = useBankCoreApiContext(); + const { state } = useSessionState(); + const token = state.status === "loggedIn" ? state.token : undefined; + + return { + estimateByCredit: buildEstimatorWithTheBackend( + conversionForUser(username), + token, + "cashin-rate-from-credit", + ), + estimateByDebit: buildEstimatorWithTheBackend( + conversionForUser(username), + token, + "cashin-rate-from-debit", + ), + }; +} + +export function useCashoutEstimatorByUser( + username: string, +): ConversionEstimators { + const { + lib: { conversionForUser }, + } = useBankCoreApiContext(); + const { state } = useSessionState(); + const token = state.status === "loggedIn" ? state.token : undefined; + return { + estimateByCredit: buildEstimatorWithTheBackend( + conversionForUser(username), + token, + "cashout-rate-from-credit", + ), + estimateByDebit: buildEstimatorWithTheBackend( + conversionForUser(username), + token, + "cashout-rate-from-debit", + ), + }; } export async function revalidateBusinessAccounts() { @@ -257,14 +375,11 @@ export function useBusinessAccounts() { const [offset, setOffset] = useState<number | undefined>(); function fetcher([token, aid]: [AccessToken, number]) { - return api.listAccounts( - 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< @@ -585,7 +700,8 @@ export function useConversionRateClasses() { export function revalidateConversionRateClassDetails() { return mutate( (key) => - Array.isArray(key) && key[key.length - 1] === "useConversionRateClassDetails", + Array.isArray(key) && + key[key.length - 1] === "useConversionRateClassDetails", undefined, { revalidate: true }, ); @@ -611,10 +727,12 @@ export function useConversionRateClassDetails(classId: number) { if (data) return data; if (error) return error; return undefined; - } -export function useConversionRateClassUsers(classId: number | undefined, username?: string) { +export function useConversionRateClassUsers( + classId: number | undefined, + username?: string, +) { const { state: credentials } = useSessionState(); const token = credentials.status !== "loggedIn" ? undefined : credentials.token; @@ -624,7 +742,12 @@ export function useConversionRateClassUsers(classId: number | undefined, usernam const [offset, setOffset] = useState<number | undefined>(); - function fetcher([token, aid, username, classId]: [AccessToken, number, string, number]) { + function fetcher([token, aid, username, classId]: [ + AccessToken, + number, + string, + number, + ]) { return api.listAccounts(token, { limit: PAGINATED_LIST_REQUEST, offset: aid ? String(aid) : undefined, @@ -637,17 +760,21 @@ export function useConversionRateClassUsers(classId: number | undefined, usernam 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, - }); + >( + [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; diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx @@ -10,6 +10,7 @@ import { } from "@gnu-taler/taler-util"; import { Attention, + InputText, InputToggle, Loading, LocalNotificationBanner, @@ -21,7 +22,7 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; +import { useState, useEffect } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { FormErrors, @@ -30,14 +31,21 @@ import { useFormState, } from "../hooks/form.js"; import { + 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 } from "./PaytoWireTransferForm.js"; +import { doAutoFocus, InputAmount } from "./PaytoWireTransferForm.js"; import { ConversionForm } from "./regional/ConversionConfig.js"; +import { AmountJson } from "@gnu-taler/taler-util"; interface Props { classId: number; @@ -123,8 +131,8 @@ function Form({ const [notification, notify, handleError] = useLocalNotification(); const [section, setSection] = useState< - "detail" | "cashout" | "cashin" | "users" - >("detail"); + "detail" | "cashout" | "cashin" | "users" | "test" + >("test"); const initalState: FormValues<FormType> = { name: detailsResult.name, @@ -212,7 +220,6 @@ function Form({ ? undefined : doUpdateClass; - console.log("ERROR", status.errors); const default_rate = conversionInfo.conversion_rate; const final_cashin_ratio = @@ -244,7 +251,7 @@ function Form({ <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</i18n.Translate> + <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 @@ -341,6 +348,29 @@ function Form({ </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> </div> </div> @@ -536,6 +566,8 @@ function Form({ <AccountsOnConversionClass classId={classId} /> )} + {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" @@ -706,10 +738,216 @@ export function createFormValidator( }; } +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> + + {/* {cashoutCalc && + error === undefined && + Amounts.cmp(status.result.amount, cashoutCalc.credit) < 0 ? ( + <div class="p-4"> + <Attention title={i18n.str`Bad configuration`} type="warning"> + <i18n.Translate> + This configuration allows users to cash out more of what has + been cashed in. + </i18n.Translate> + </Attention> + </div> + ) : undefined} */} + </div> + )} + </Fragment> + ); +} function AccountsOnConversionClass({ classId }: { classId: number }): VNode { const { i18n } = useTranslationContext(); - const [filter, setFilter] = useState<{ classId?: number; account?: string }>({ + const [filter, setFilter] = useState<{ + showAll?: boolean; + classId?: number; + account?: string; + }>({ + showAll: classId === undefined, classId, }); const userListResult = useConversionRateClassUsers( @@ -739,29 +977,59 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { } return ( <Fragment> - <div class="px-4 mt-8"> + <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>Accounts</i18n.Translate> + <i18n.Translate>Filters</i18n.Translate> </h1> </div> </div> </div> - <div class="px-4 mt-8"> + <div class="px-4 mt-2"> <InputToggle - label={i18n.str`Show all`} + label={i18n.str`Show from other groups`} name="show_all" threeState={false} handler={{ - value: filter.classId === undefined, + value: filter.showAll, onChange(v) { - filter.classId = !v ? classId : undefined; + filter.showAll = !!v; + if (!v) { + filter.classId = classId; + } 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"> @@ -778,16 +1046,16 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { <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`Username`}</th> - <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`Conversion rate`}</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"> @@ -798,15 +1066,22 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { class="data-[status=deleted]:bg-gray-100" data-status={item.status} > - <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> - {item.username} - </td> <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"> {"<pending>"} </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + <a + href="" + 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" + > + <i18n.Translate> + Quit from this group + </i18n.Translate> + </a> + </td> </tr> ); })} diff --git a/packages/bank-ui/src/pages/NewConversionRateClass.tsx b/packages/bank-ui/src/pages/NewConversionRateClass.tsx @@ -17,7 +17,7 @@ import { TalerErrorCode } from "@gnu-taler/taler-util"; interface Props { routeCancel: RouteDefinition; - onCreated: () => void; + onCreated: (id: number) => void; } export function NewConversionRateClass({ routeCancel, @@ -43,7 +43,7 @@ export function NewConversionRateClass({ const resp = await api.createConversionRateClass(token, submitData); if (resp.type === "ok") { notifyInfo(i18n.str`Conversion rate class created.`); - onCreated(); + onCreated(resp.body.conversion_rate_class_id); return; } switch (resp.case) { @@ -75,17 +75,11 @@ export function NewConversionRateClass({ <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>New conversion rate class</i18n.Translate> + <i18n.Translate>New conversion rate group</i18n.Translate> </h2> </div> - <ConversionRateClassForm - template={undefined} - purpose="create" - onChange={(a) => { - setSubmitData(a); - }} - > + <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({})} diff --git a/packages/bank-ui/src/pages/admin/ConversionClassList.tsx b/packages/bank-ui/src/pages/admin/ConversionClassList.tsx @@ -31,8 +31,12 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { useConversionRateClasses } from "../../hooks/regional.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; @@ -47,8 +51,16 @@ export function ConversionClassList({ }: Props): VNode { const result = useConversionRateClasses(); const { i18n } = useTranslationContext(); - const { config } = useBankCoreApiContext(); + const resultInfo = useConversionInfo(); + + const convInfo = + !resultInfo || resultInfo instanceof Error || resultInfo.type === "fail" + ? undefined + : resultInfo.body; + if (!convInfo) { + return <Fragment>-</Fragment>; + } if (!result) { return <Loading />; } @@ -93,42 +105,13 @@ export function ConversionClassList({ const classes = result.body; - function DescribeRatio({ - fee, - min, - ratio, - rounding, - }: { - min?: AmountString; - ratio?: DecimalNumber; - fee?: AmountString; - rounding?: RoundingMode; - }): VNode { - const { config } = useBankCoreApiContext(); - - if (!rounding || !ratio) { - return <Fragment>-</Fragment>; - } - return ( - <Fragment> - {ratio} - {!min ? undefined : ( - <RenderAmount - spec={config.currency_specification} - value={Amounts.parseOrThrow(min)} - /> - )} - </Fragment> - ); - } - 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 classes</i18n.Translate> + <i18n.Translate>Conversion rate groups</i18n.Translate> </h1> </div> <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> @@ -138,7 +121,7 @@ export function ConversionClassList({ 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>New conversion class</i18n.Translate> + <i18n.Translate>Create conversion rate group</i18n.Translate> </a> </div> </div> @@ -147,7 +130,7 @@ export function ConversionClassList({ <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> {!classes.length ? ( <div> - <i18n.Translate>No conversion classes</i18n.Translate> + <i18n.Translate>No conversion rate group</i18n.Translate> </div> ) : ( <table class="min-w-full divide-y divide-gray-300"> @@ -175,7 +158,7 @@ export function ConversionClassList({ {classes.map((row, idx) => { return ( <tr key={idx} class=""> - <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> + <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), @@ -185,23 +168,73 @@ export function ConversionClassList({ </a> </td> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - {row.description} + <a + href={routeShowDetails.url({ + classId: String(row.conversion_rate_class_id), + })} + > + {row.description} + </a> </td> - <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - <DescribeRatio - ratio={row.cashin_ratio} - fee={row.cashin_fee} - min={row.cashin_min_amount} - rounding={row.cashin_rounding_mode} - /> + <td class="whitespace-nowrap px-3 py-2 text-sm text-gray-500"> + <a + href={routeShowDetails.url({ + classId: String(row.conversion_rate_class_id), + })} + > + <DescribeRatio + 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-4 text-sm text-gray-500"> - <DescribeRatio - ratio={row.cashout_ratio} - fee={row.cashout_fee} - min={row.cashout_min_amount} - rounding={row.cashout_rounding_mode} - /> + <td class="whitespace-nowrap px-3 py-2 text-sm text-gray-500"> + <a + href={routeShowDetails.url({ + classId: String(row.conversion_rate_class_id), + })} + > + <DescribeRatio + 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> ); @@ -239,3 +272,41 @@ export function ConversionClassList({ </Fragment> ); } + +function DescribeRatio({ + 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>&nbsp; + <RenderAmount spec={minSpec} value={Amounts.parseOrThrow(min)} /> + </Fragment> + )} + {Amounts.isZero(fee) ? undefined : ( + <Fragment> + <br /> + <i18n.Translate>fee:</i18n.Translate>&nbsp; + <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 @@ -20,26 +20,20 @@ import { RoundingMode, TalerCorebankApi, TranslatedString, - assertUnreachable + assertUnreachable, } from "@gnu-taler/taler-util"; import { InputToggle, ShowInputErrorLabel, useBankCoreApiContext, - useTranslationContext + 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"; +import { ErrorMessageMappingFor, undefinedIfEmpty } from "../../utils.js"; +import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; const TALER_SCREEN_ID = 120; @@ -64,114 +58,111 @@ export type ConversionRateClassFormData = { cashout_rounding_mode?: RoundingMode; }; -type ChangeByPurposeType = { - create: (a: TalerCorebankApi.ConversionRateClassInput | undefined) => void; - update: (a: TalerCorebankApi.ConversionRateClassInput | undefined) => void; - show: undefined; -}; +// 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, ->({ - template, - username, - purpose, - onChange, - focus, - children, -}: { - focus?: boolean; - children: ComponentChildren; - username?: string; - template: TalerCorebankApi.ConversionRateClass | undefined; - onChange: ChangeByPurposeType[PurposeType]; - purpose: PurposeType; -}): VNode { - const { config, url } = useBankCoreApiContext(); +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<typeof defaultValue> | undefined + 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, + // 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, + // // 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, + // // 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, + // // cashout_enabled: + // // template?.cashout_ratio !== undefined && + // // Number.parseInt(template.cashout_ratio, 10) > 0, - name: template?.name, - description: template?.description, - }; + // name: template?.name, + // description: template?.description, + // }; const userIsAdmin = credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; - const editableForm = - userIsAdmin && (purpose === "create" || purpose === "update"); + const editableForm = userIsAdmin; - function updateForm(newForm: typeof defaultValue): void { + function updateForm(newForm: ConversionRateClassFormData): void { const errors = undefinedIfEmpty< - ErrorMessageMappingFor<typeof defaultValue> + ErrorMessageMappingFor<ConversionRateClassFormData> >({ name: !editableForm ? undefined // disabled - : purpose === "update" && newForm.name === undefined - ? undefined // the field hasn't been changed - : !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, + : !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); @@ -181,33 +172,39 @@ export function ConversionRateClassForm< if (errors) { onChange(undefined); } else { - 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!, - }; - 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!, + 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); - } - } + // const result: TalerCorebankApi.ConversionRateClassInput = { + // name: newForm.name!, + // }; + // callback(result); + // return; + // } + // case "show": { + // return; + // } + // default: { + // assertUnreachable(purpose); + // } + // } } } return ( @@ -219,7 +216,6 @@ export function ConversionRateClassForm< e.preventDefault(); }} > - <pre>{JSON.stringify(errors, undefined, 2)}</pre> <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"> @@ -232,14 +228,14 @@ export function ConversionRateClassForm< </label> <div class="mt-2"> <input - ref={focus && purpose === "create" ? doAutoFocus : undefined} + 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 ?? defaultValue.name} + value={form.name ?? ""} onChange={(e) => { form.name = e.currentTarget.value; updateForm(structuredClone(form)); @@ -266,7 +262,7 @@ export function ConversionRateClassForm< </label> <div class="mt-2"> <input - ref={focus && purpose === "create" ? doAutoFocus : undefined} + 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" @@ -275,7 +271,7 @@ export function ConversionRateClassForm< !!errors?.description && form.description !== undefined } disabled={!editableForm} - value={form.description ?? defaultValue.description} + value={form.description ?? ""} onChange={(e) => { form.description = e.currentTarget.value; updateForm(structuredClone(form)); @@ -293,7 +289,7 @@ export function ConversionRateClassForm< </p> </div> - <InputToggle + {/* <InputToggle label={i18n.str`Enable cashin`} name="cashin" threeState={false} @@ -320,9 +316,9 @@ export function ConversionRateClassForm< }, name: "cashout", }} - /> + /> */} - {!form.cashin_enabled ? undefined : ( + {/* {!form.cashin_enabled ? undefined : ( <Fragment> <div class="sm:col-span-5"> <label @@ -627,7 +623,7 @@ export function ConversionRateClassForm< </p> </div> </Fragment> - )} + )} */} </div> </div> {children} diff --git a/packages/taler-util/src/http-client/bank-conversion.ts b/packages/taler-util/src/http-client/bank-conversion.ts @@ -103,7 +103,10 @@ export class TalerBankConversionHttpClient { * 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 +117,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 +155,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 +172,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 @@ -1148,7 +1148,11 @@ export class TalerCoreBankHttpClient { * https://docs.taler.net/core/api-corebank.html#patch--conversion-rate-classes-CLASS_ID * */ - async updateConversionRateClass(auth: AccessToken, cid: number, body: ConversionRateClassInput) { + 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", @@ -1506,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/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,