taler-typescript-core

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

commit 617dd2bc3efbe851bd7c009ce9b8488236fa910b
parent c0cf3c03f69a47b3713fdba5eadecae30e3bf22b
Author: Sebastian <sebasjm@gmail.com>
Date:   Fri, 24 Oct 2025 19:06:09 -0300

bank

Diffstat:
Mpackages/bank-ui/src/hooks/regional.ts | 2+-
Mpackages/bank-ui/src/pages/ConversionRateClassDetails.tsx | 308+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/bank-ui/src/pages/regional/ConversionConfig.tsx | 167++++++++++++++++++++++++++++++++++---------------------------------------------
Mpackages/bank-ui/src/pages/regional/CreateCashout.tsx | 19++++++++++++-------
Mpackages/taler-util/src/http-client/bank-conversion.ts | 7++++---
5 files changed, 263 insertions(+), 240 deletions(-)

diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts @@ -48,7 +48,7 @@ export type TransCalc = { credit: AmountJson; beforeFee: AmountJson; }; -export type TransferCalculation = TransCalc | "amount-is-too-small"; +export type TransferCalculation = TransCalc | undefined; type EstimatorFunction = ( amount: AmountJson, fee: AmountJson, diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx @@ -48,6 +48,9 @@ import { DescribeConversion } from "./admin/ConversionClassList.js"; import { doAutoFocus, InputAmount } from "./PaytoWireTransferForm.js"; import { ConversionForm } from "./regional/ConversionConfig.js"; import { AccessToken } from "@gnu-taler/taler-util"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; +import { opFixedSuccess } from "@gnu-taler/taler-util"; +import { AmountJson } from "@gnu-taler/taler-util"; interface Props { classId: number; @@ -184,31 +187,7 @@ function Form({ } }; - async function doUpdateClass1() { - 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 updateRequest: TalerCorebankApi.ConversionRateClassInput | undefined = + const input: TalerCorebankApi.ConversionRateClassInput | undefined = status.status === "fail" ? undefined : { @@ -226,25 +205,52 @@ function Form({ cashout_rounding_mode: status.result.conv.cashout_rounding_mode, }; - const updateClassTemplate = safeFunctionHandler( - ( - token: AccessToken, - updateRequest: TalerCorebankApi.ConversionRateClassInput, - ) => bank.updateConversionRateClass(token, classId, updateRequest), + const updateClass = safeFunctionHandler( + bank.updateConversionRateClass, + !creds || !input ? undefined : [creds.token, classId, input], ); - - updateClassTemplate.onSuccess = () => { + updateClass.onSuccess = () => { setSection("detail"); }; - updateClassTemplate.onFail = (fail) => { + updateClass.onFail = (fail) => { switch (fail.case) { - default: + case HttpStatusCode.Unauthorized: + return i18n.str``; + case HttpStatusCode.Forbidden: + return i18n.str``; + case HttpStatusCode.NotFound: + return i18n.str``; + case HttpStatusCode.NotImplemented: + return i18n.str``; + case TalerErrorCode.BANK_NAME_REUSE: return i18n.str``; } }; - const updateDetails = updateClassTemplate.lambda( - (t: AccessToken, r: TalerCorebankApi.ConversionRateClassInput) => [t, r], + const updateRequest: TalerCorebankApi.ConversionRateClassInput | undefined = + status.status === "fail" + ? undefined + : { + 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, + }; + + const updateDetails = updateClass.lambda( + ( + t: AccessToken, + id: number, + r: TalerCorebankApi.ConversionRateClassInput, + ) => [t, id, r], !creds || !updateRequest || section !== "detail" || @@ -253,46 +259,87 @@ function Form({ (status.result.name === initalState.name && status.result.description === initalState.description) ? undefined - : [creds.token, updateRequest], + : [creds.token, classId, updateRequest], ); - - const doUpdateDetails1 = - !creds || - section !== "detail" || - status.errors?.name || - status.errors?.description || - (status.result.name === initalState.name && - status.result.description === initalState.description) - ? undefined - : doUpdateClass2; - const doUpdateCashin1 = + // const doUpdateDetails1 = + // !creds || + // section !== "detail" || + // status.errors?.name || + // status.errors?.description || + // (status.result.name === initalState.name && + // status.result.description === initalState.description) + // ? undefined + // : doUpdateClass2; + + const updateCashin = updateClass.lambda( + ( + t: AccessToken, + id: number, + r: TalerCorebankApi.ConversionRateClassInput, + ) => [t, id, r], !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 + !updateRequest || + 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 - : doUpdateClass2; - - const doUpdateCashout1 = + : [creds.token, classId, updateRequest], + ); + // const doUpdateCashin1 = + // !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 + // : doUpdateClass2; + + const updateCashout = updateClass.lambda( + ( + t: AccessToken, + id: number, + r: TalerCorebankApi.ConversionRateClassInput, + ) => [t, id, r], !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) + !updateRequest || + 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 - : doUpdateClass2; + : [creds.token, classId, updateRequest], + ); + + // const doUpdateCashout1 = + // !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 + // : doUpdateClass2; const default_rate = conversionInfo.conversion_rate; @@ -645,7 +692,9 @@ function Form({ /> )} - {section == "test" && <TestConversionClass classId={classId} />} + {section == "test" && ( + <TestConversionClass classId={classId} info={conversionInfo} /> + )} <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4"> <a @@ -685,7 +734,7 @@ function Form({ 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" - onClick={updateDelete} + onClick={updateDetails} > <i18n.Translate>Update</i18n.Translate> </ButtonBetter> @@ -826,14 +875,15 @@ export function createFormValidator( }; } -function TestConversionClass({ classId }: { classId: number }): VNode { +function TestConversionClass({ + classId, + info, +}: { + classId: number; + info: TalerBankConversionApi.TalerConversionInfoConfig; +}): VNode { const { i18n } = useTranslationContext(); const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const result = useConversionInfo(); - const info = - result && !(result instanceof TalerError) && result.type === "ok" - ? result.body - : undefined; const { estimateByDebit: calculateCashoutFromDebit } = useCashoutEstimatorForClass(classId); @@ -848,65 +898,57 @@ function TestConversionClass({ classId }: { classId: number }): VNode { 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 respCashin = await calculateCashinFromDebit(in_amount, in_fee); - - const cashin = - respCashin.type === "ok" - ? respCashin.body - : respCashin.case === HttpStatusCode.Conflict - ? ("amount-is-too-small" as const) - : undefined; - - if (!cashin || cashin === "amount-is-too-small") { - setCalc(undefined); // silent failure - return; - } - - const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); - const respCashout = await calculateCashoutFromDebit( - cashin.credit, - out_fee, - ); - - const cashout = - respCashout.type === "ok" - ? respCashout.body - : respCashout.case === HttpStatusCode.Conflict - ? ("amount-is-too-small" as const) - : undefined; - - if (!cashout) { - setCalc(undefined); // silent failure - return; - } + const in_amount = !amount + ? undefined + : Amounts.parseOrThrow(`${info.fiat_currency}:${amount}`); + + const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); + const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); + + const calculate = safeFunctionHandler( + async (amount: AmountJson) => { + const respCashin = await calculateCashinFromDebit(amount, in_fee); + if (respCashin.type === "fail") { + return respCashin; + } + const cashin = respCashin.body; + const respCashout = await calculateCashoutFromDebit( + cashin.credit, + out_fee, + ); + if (respCashout.type === "fail") { + return respCashout; + } + const cashout = respCashout.body; + return opFixedSuccess({ cashin, cashout }); + }, + !in_amount || !!error ? undefined : [in_amount], + ); - setCalc({ cashin, cashout }); - }); + calculate.onSuccess = (resp) => setCalc(resp.body); + calculate.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The server didn't undertand the request.`; + case HttpStatusCode.Conflict: + return i18n.str`The amount is too small`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is not implemented.`; + case TalerErrorCode.GENERIC_PARAMETER_MISSING: + return i18n.str`At least debit or credit needs to be provided`; + case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: + return i18n.str`The amount is malfored`; + case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: + return i18n.str`The currency is not supported`; } - doAsync(); - }, [amount]); + }; - if (!info) { - return <Loading />; - } + useEffect(() => { + calculate.call(); + }, [amount]); - const cashinCalc = - calculationResult?.cashin === "amount-is-too-small" - ? undefined - : calculationResult?.cashin; - const cashoutCalc = - calculationResult?.cashout === "amount-is-too-small" - ? undefined - : calculationResult?.cashout; + const cashinCalc = calculationResult?.cashin; + const cashoutCalc = calculationResult?.cashout; return ( <Fragment> diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -26,6 +26,7 @@ import { } from "@gnu-taler/taler-util"; import { Attention, + ButtonBetter, ErrorLoading, InternationalizationAPI, Loading, @@ -59,6 +60,9 @@ import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; import { DescribeConversion } from "../admin/ConversionClassList.js"; +import { opEmptySuccess } from "@gnu-taler/taler-util"; +import { opFixedSuccess } from "@gnu-taler/taler-util"; +import { TalerErrorCode } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 126; @@ -164,53 +168,52 @@ function useComponentState({ cashout: TransferCalculation; }>(); - useEffect(() => { - async function doAsync() { - await handleError(async () => { - if (!info) return; - if (!form.amount?.value || form.amount.error) return; - const in_amount = Amounts.parseOrThrow( - `${info.fiat_currency}:${form.amount.value}`, - ); - const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); - const respCashin = await calculateCashinFromDebit(in_amount, in_fee); - - const cashin = - respCashin.type === "ok" - ? respCashin.body - : respCashin.case === HttpStatusCode.Conflict - ? ("amount-is-too-small" as const) - : undefined; - - if (!cashin || cashin === "amount-is-too-small") { - setCalc(undefined); - return; - } - // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`) - const out_fee = Amounts.parseOrThrow( - info.conversion_rate.cashout_fee, - ); - const respCashout = await calculateCashoutFromDebit( - cashin.credit, - out_fee, - ); - - const cashout = - respCashout.type === "ok" - ? respCashout.body - : respCashout.case === HttpStatusCode.Conflict - ? ("amount-is-too-small" as const) - : undefined; - - if (!cashout) { - setCalc(undefined); // silent failure - return; - } - - setCalc({ cashin, cashout }); - }); + const in_amount = !form.amount + ? undefined + : Amounts.parseOrThrow(`${info.fiat_currency}:${form.amount.value}`); + + const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); + const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); + + const calculate = safeFunctionHandler( + async (amount: AmountJson) => { + const respCashin = await calculateCashinFromDebit(amount, in_fee); + if (respCashin.type === "fail") { + return respCashin; + } + const cashin = respCashin.body; + const respCashout = await calculateCashoutFromDebit( + cashin.credit, + out_fee, + ); + if (respCashout.type === "fail") { + return respCashout; + } + const cashout = respCashout.body; + return opFixedSuccess({ cashin, cashout }); + }, + !in_amount || status.status === "fail" ? undefined : [in_amount], + ); + calculate.onSuccess = (resp) => setCalc(resp.body); + calculate.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The server didn't undertand the request.`; + case HttpStatusCode.Conflict: + return i18n.str`The amount is too small`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is not implemented.`; + case TalerErrorCode.GENERIC_PARAMETER_MISSING: + return i18n.str`At least debit or credit needs to be provided`; + case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: + return i18n.str`The amount is malfored`; + case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: + return i18n.str`The currency is not supported`; } - doAsync(); + }; + + useEffect(() => { + calculate.call(); }, [ form.amount?.value, form.conv?.cashin_fee?.value, @@ -220,53 +223,27 @@ function useComponentState({ const [section, setSection] = useState<"detail" | "cashout" | "cashin">( "detail", ); - const cashinCalc = - calculationResult?.cashin === "amount-is-too-small" - ? undefined - : calculationResult?.cashin; - const cashoutCalc = - calculationResult?.cashout === "amount-is-too-small" - ? undefined - : calculationResult?.cashout; + const cashinCalc = calculationResult?.cashin; + const cashoutCalc = calculationResult?.cashout; + const update = safeFunctionHandler( + conversion.updateConversionRate, + !creds || status.status === "fail" + ? undefined + : [creds.token, status.result.conv], + ); - - async function doUpdate() { - if (!creds) return; - await handleError(async () => { - if (status.status === "fail") return; - const resp = await conversion.updateConversionRate( - creds.token, - status.result.conv, - ); - if (resp.type === "ok") { - setSection("detail"); - } else { - switch (resp.case) { - case HttpStatusCode.Unauthorized: { - return notify({ - type: "error", - title: i18n.str`Wrong credentials`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case HttpStatusCode.NotImplemented: { - return notify({ - type: "error", - title: i18n.str`Conversion is disabled`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - default: - assertUnreachable(resp); - } - } - }); - } + update.onSuccess = () => { + setSection("detail"); + }; + update.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is disabled`; + } + }; const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio); const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio); @@ -607,16 +584,14 @@ function useComponentState({ </a> {section == "cashin" || section == "cashout" ? ( <Fragment> - <button + <ButtonBetter 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" - onClick={async () => { - doUpdate(); - }} + onClick={update} > <i18n.Translate>Update</i18n.Translate> - </button> + </ButtonBetter> </Fragment> ) : ( <div /> diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -284,12 +284,18 @@ function CreateCashoutInternal({ conversionCalculator.onSuccess = (success) => setCalculation(success.body); conversionCalculator.onFail = (fail) => { switch (fail.case) { - case HttpStatusCode.Conflict: - return i18n.str`The amount is too small.`; case HttpStatusCode.BadRequest: - return i18n.str`Server didn't like our request`; + return i18n.str`The server didn't undertand the request.`; + case HttpStatusCode.Conflict: + return i18n.str`The amount is too small`; case HttpStatusCode.NotImplemented: - return i18n.str`Conversion is not enabled.`; + return i18n.str`Conversion is not implemented.`; + case TalerErrorCode.GENERIC_PARAMETER_MISSING: + return i18n.str`At least debit or credit needs to be provided`; + case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: + return i18n.str`The amount is malfored`; + case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: + return i18n.str`The currency is not supported`; } }; @@ -297,8 +303,7 @@ function CreateCashoutInternal({ conversionCalculator.call(); }, [form.amount, form.isDebit, notZero, higerThanMin, rate.cashout_fee]); - const calc = - calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult; + const calc = !calculationResult ? zeroCalc : calculationResult; const balanceAfter = IntAmounts.toIntAmount( account.balance, @@ -314,7 +319,7 @@ function CreateCashoutInternal({ ? i18n.str`Required` : !inputAmount ? i18n.str`Invalid` - : calculationResult === "amount-is-too-small" + : !calculationResult ? i18n.str`Amount needs to be higher` : Amounts.isZero( balanceLimit diff --git a/packages/taler-util/src/http-client/bank-conversion.ts b/packages/taler-util/src/http-client/bank-conversion.ts @@ -28,6 +28,7 @@ import { carefullyParseConfig, opEmptySuccess, opKnownHttpFailure, + opKnownTalerFailure, opSuccessFromHttp, opUnknownHttpFailure, } from "../operation.js"; @@ -163,11 +164,11 @@ export class TalerBankConversionHttpClient { const details = codecForTalerErrorDetail().decode(body); switch (details.code) { case TalerErrorCode.GENERIC_PARAMETER_MISSING: - return opKnownHttpFailure(resp.status, resp); + return opKnownTalerFailure(details.code, details); case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: - return opKnownHttpFailure(resp.status, resp); + return opKnownTalerFailure(details.code, details); case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: - return opKnownHttpFailure(resp.status, resp); + return opKnownTalerFailure(details.code, details); default: return opUnknownHttpFailure(resp, details); }