diff options
Diffstat (limited to 'packages/bank-ui/src/pages/regional')
3 files changed, 1979 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx new file mode 100644 index 000000000..8845ec9a0 --- /dev/null +++ b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -0,0 +1,978 @@ +/* + 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 { + AmountJson, + Amounts, + HttpStatusCode, + TalerBankConversionApi, + TalerError, + TranslatedString, + assertUnreachable +} from "@gnu-taler/taler-util"; +import { + Attention, + InternationalizationAPI, + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotification, + useTranslationContext, + utils +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useSessionState } from "../../hooks/session.js"; +import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.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"; + +interface Props { + routeMyAccountDetails: RouteDefinition; + routeMyAccountDelete: RouteDefinition; + routeMyAccountPassword: RouteDefinition; + routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; + routeCancel: RouteDefinition; + onUpdateSuccess: () => void; +} + +type FormType = { amount: AmountJson, conv: TalerBankConversionApi.ConversionRate } + + +function useComponentState({ + onUpdateSuccess, + routeCancel, + routeConversionConfig, + routeMyAccountCashout, + routeMyAccountDelete, + routeMyAccountDetails, + routeMyAccountPassword, +}: 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 () => { + const { i18n } = useTranslationContext(); + + const { bank, conversion, config } = useBankCoreApiContext(); + + const [notification, notify, handleError] = useLocalNotification(); + + const initalState: FormValues<FormType> = { + 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, + 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, + } + } + + const [form, status] = useFormState<FormType>( + initalState, + createFormValidator(i18n, info.regional_currency, info.fiat_currency) + ) + + const { + estimateByDebit: calculateCashoutFromDebit, + } = useCashoutEstimator(); + + const { + estimateByDebit: calculateCashinFromDebit, + } = useCashinEstimator(); + + const [calculationResult, setCalc] = useState<{ cashin: TransferCalculation, 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 cashin = await calculateCashinFromDebit(in_amount, in_fee); + + if (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 cashout = await calculateCashoutFromDebit(cashin.credit, out_fee); + + setCalc({ cashin, cashout }); + }); + } + doAsync(); + }, [form.amount?.value, form.conv?.cashin_fee?.value, form.conv?.cashout_fee?.value]); + + 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 + 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, + }); + } + case HttpStatusCode.NotImplemented: { + return notify({ + type: "error", + title: i18n.str`Conversion is disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + default: + assertUnreachable(resp); + } + } + }); + } + + const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio) + const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio) + + const both_high = in_ratio > 1 && out_ratio > 1; + const both_low = in_ratio < 1 && out_ratio < 1; + + + return ( + <div> + <ProfileNavigation current="conversion" + routeMyAccountCashout={routeMyAccountCashout} + routeMyAccountDelete={routeMyAccountDelete} + routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountPassword={routeMyAccountPassword} + routeConversionConfig={routeConversionConfig} + /> + + <LocalNotificationBanner notification={notification} /> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 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> + </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> + </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={info.fiat_currency} + outputCurrency={info.regional_currency} + fee={form?.conv?.cashin_fee} + minimum={form?.conv?.cashin_min_amount} + ratio={form?.conv?.cashin_ratio} + rounding={form?.conv?.cashin_rounding_mode} + tiny={form?.conv?.cashin_tiny_amount} + />} + + {section == "cashout" && <Fragment> + <ConversionForm id="cashout" + inputCurrency={info.regional_currency} + outputCurrency={info.fiat_currency} + fee={form?.conv?.cashout_fee} + minimum={form?.conv?.cashout_min_amount} + ratio={form?.conv?.cashout_ratio} + rounding={form?.conv?.cashout_rounding_mode} + tiny={form?.conv?.cashout_tiny_amount} + /> + </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>Cashin ratio</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + {info.conversion_rate.cashin_ratio} + </dd> + </div> + </div> + + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Cashout ratio</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + {info.conversion_rate.cashout_ratio} + </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} + + <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={form.amount?.value ?? ""} + onChange={form.amount?.onUpdate} + /> + <ShowInputErrorLabel + message={form.amount?.error} + isDirty={form.amount?.value !== 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.regional_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.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>Cashin after fee</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={cashinCalc.credit} + withColor + spec={info.fiat_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.fiat_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.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>Cashout after fee</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={cashoutCalc.credit} + withColor + spec={info.regional_currency_specification} + /> + </dd> + </div> + </dl> + </div> + + {cashoutCalc && status.status === "ok" && 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>} + + + <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" || 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" + onClick={async () => { + doUpdate() + }} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </Fragment> : <div />} + </div> + + + </form> + </div> + </div> + ); + + } +} + +export const ConversionConfig = utils.recursive(useComponentState); + +/** + * + * @param i18n + * @param regional + * @param fiat + * @returns form validator + */ +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_tiny_amount = Amounts.parse(`${regional}:${state.conv.cashin_tiny_amount}`) + const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`) + + const cashout_min_amount = Amounts.parse(`${regional}:${state.conv.cashout_min_amount}`) + const cashout_tiny_amount = Amounts.parse(`${fiat}:${state.conv.cashout_tiny_amount}`) + const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`) + + const am = Amounts.parse(`${fiat}:${state.amount}`) + + const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? "") + const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? "") + + const errors = undefinedIfEmpty<FormErrors<FormType>>({ + conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({ + cashin_min_amount: !state.conv.cashin_min_amount ? i18n.str`required` : + !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 ? i18n.str`invalid` : + undefined, + + cashout_min_amount: !state.conv.cashout_min_amount ? i18n.str`required` : + !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 ? i18n.str`invalid` : + undefined, + + cashin_rounding_mode: !state.conv.cashin_rounding_mode ? i18n.str`required` : undefined, + cashout_rounding_mode: !state.conv.cashout_rounding_mode ? i18n.str`required` : undefined, + + cashin_ratio: !state.conv.cashin_ratio ? i18n.str`required` : Number.isNaN(cashin_ratio) ? i18n.str`invalid` : undefined, + cashout_ratio: !state.conv.cashout_ratio ? i18n.str`required` : Number.isNaN(cashout_ratio) ? i18n.str`invalid` : undefined, + }), + + amount: !state.amount ? i18n.str`required` : + !am ? i18n.str`invalid` : + undefined, + }) + + const result: RecursivePartial<FormType> = { + amount: am, + conv: { + cashin_fee: !errors?.conv?.cashin_fee ? Amounts.stringify(cashin_fee!) : undefined, + cashin_min_amount: !errors?.conv?.cashin_min_amount ? Amounts.stringify(cashin_min_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_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 ? + { status: "ok", result: result as FormType, errors } : + { status: "fail", result, errors } + } +} + + +function ConversionForm({ id, inputCurrency, outputCurrency, fee, minimum, ratio, rounding, tiny }: { + inputCurrency: string, + outputCurrency: string, + minimum: UIField | undefined, + tiny: UIField | undefined, + fee: UIField | undefined, + rounding: UIField | undefined, + ratio: UIField | undefined, + id: string, +}): VNode { + const { i18n } = useTranslationContext(); + 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="cashin_min_amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Minimum amount`}</label> + <InputAmount + name="cashin_min_amount" + left + currency={inputCurrency} + value={minimum?.value ?? ""} + onChange={minimum?.onUpdate} + /> + <ShowInputErrorLabel + message={minimum?.error} + isDirty={minimum?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Only cashout operation above this threshold will be allowed</i18n.Translate> + </p> + </div> + </div> + </div> + + <div class="px-6 pt-6"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="password" + > + {i18n.str`Ratio`} + </label> + <div class="mt-2"> + <input + type="number" + class="block 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="current" + id="cashin_ratio" + data-error={!!ratio?.error && ratio?.value !== undefined} + value={ratio?.value ?? ""} + onChange={(e) => { + ratio?.onUpdate(e.currentTarget.value); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={ratio?.error} + isDirty={ratio?.value !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Conversion ratio between currencies + </i18n.Translate> + </p> + </div> + + <div class="px-6 pt-4"> + <Attention title={i18n.str`Example conversion`}> + <i18n.Translate>1 {inputCurrency} will be converted into {ratio?.value} {outputCurrency}</i18n.Translate> + </Attention> + </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 + for="cashin_tiny_amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Rounding value`}</label> + <InputAmount + name="cashin_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="channel" + > + {i18n.str`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"> + <label + onClick={(e) => { + e.preventDefault(); + rounding?.onUpdate("zero") + }} + data-selected={rounding?.value === "zero"} + 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 " + > + <i18n.Translate>Zero</i18n.Translate> + </span> + <i18n.Translate>Amount will be round below to the largest possible value smaller than the input.</i18n.Translate> + </span> + </span> + <svg + data-selected={rounding?.value === "zero"} + 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> + + <label + onClick={(e) => { + e.preventDefault(); + rounding?.onUpdate("up") + }} + data-selected={rounding?.value === "up"} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border 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="Existing Customers" + 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 " + > + <i18n.Translate>Up</i18n.Translate> + </span> + <i18n.Translate>Amount will be round up to the smallest possible value larger than the input.</i18n.Translate> + </span> + </span> + <svg + data-selected={rounding?.value === "up"} + 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> + <label + onClick={(e) => { + e.preventDefault(); + rounding?.onUpdate("nearest") + }} + data-selected={rounding?.value === "nearest"} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border 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="Existing Customers" + 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 " + > + <i18n.Translate>Nearest</i18n.Translate> + </span> + <i18n.Translate>Amount will be round to the closest possible value.</i18n.Translate> + </span> + </span> + <svg + data-selected={rounding?.value === "nearest"} + 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> + </div> + + <div class="px-6 pt-4"> + <Attention title={i18n.str`Examples`}> + <section class="grid grid-cols-1 gap-y-3 text-gray-600"> + <details class="group text-sm"> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.24 with rounding value 0.1 + </i18n.Translate> + <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 mt-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + </details> + <details class="group "> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.26 with rounding value 0.1 + </i18n.Translate> + <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.1 the possible values closest to 1.24 are: 1.1, 1.2, 1.3, 1.4. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + </details> + <details class="group "> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.24 with rounding value 0.3 + </i18n.Translate> + <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.5 + </i18n.Translate> + </p> + </details> + <details class="group "> + <summary class="flex cursor-pointer flex-row items-center justify-between "> + <i18n.Translate> + Rounding an amount of 1.26 with rounding value 0.3 + </i18n.Translate> + <svg class="h-6 w-6 rotate-0 transform group-open:rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> + </svg> + </summary> + <p class="text-gray-900 my-4"> + <i18n.Translate> + Given the rounding value of 0.3 the possible values closest to 1.24 are: 0.9, 1.2, 1.5, 1.8. + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "zero" mode the value will be rounded to 1.2 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "nearest" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + <p class="text-gray-900 my-4"> + <i18n.Translate> + With the "up" mode the value will be rounded to 1.3 + </i18n.Translate> + </p> + </details> + </section> + </Attention> + </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 + for="cashin_fee" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Fee`}</label> + <InputAmount + name="cashin_fee" + left + currency={outputCurrency} + value={fee?.value ?? ""} + onChange={fee?.onUpdate} + /> + <ShowInputErrorLabel + message={fee?.error} + isDirty={fee?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Amount to be deducted before amount is credited.</i18n.Translate> + </p> + </div> + </div> + </div> + + </Fragment> +} diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx new file mode 100644 index 000000000..2f15d16b4 --- /dev/null +++ b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -0,0 +1,809 @@ +/* + 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 { + AbsoluteTime, + Amounts, + HttpStatusCode, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, + encodeCrock, + getRandomBytes, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + LocalNotificationBanner, + ShowInputErrorLabel, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { TransferCalculation, useCashoutEstimator, useConversionInfo, useEstimator } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.js"; +import { TanChannel, undefinedIfEmpty } from "../../utils.js"; +import { LoginForm } from "../LoginForm.js"; +import { + InputAmount, + RenderAmount, + doAutoFocus, +} from "../PaytoWireTransferForm.js"; + +interface Props { + account: string; + focus?: boolean; + onAuthorizationRequired: () => void; + routeClose: RouteDefinition; + routeHere: RouteDefinition; +} + +type FormType = { + isDebit: boolean; + amount: string; + subject: string; + channel: TanChannel; +}; +type ErrorFrom<T> = { + [P in keyof T]+?: string; +}; + +export function CreateCashout({ + account: accountName, + onAuthorizationRequired, + focus, + routeHere, + routeClose, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const resultAccount = useAccountDetails(accountName); + const { + estimateByCredit: calculateFromCredit, + estimateByDebit: calculateFromDebit, + } = useCashoutEstimator(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const [, updateBankState] = useBankState(); + + const { bank: api, config, hints } = useBankCoreApiContext(); + const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); + const [notification, notify, handleError] = useLocalNotification(); + const info = useConversionInfo(); + + if (!config.allow_conversion) { + return ( + <Fragment> + <Attention type="warning" title={i18n.str`Unable to create a cashout`}> + <i18n.Translate> + The bank configuration does not support cashout operations. + </i18n.Translate> + </Attention> + <div class="mt-5 sm:mt-6"> + <a + href={routeClose.url({})} + name="close" + class="inline-flex w-full justify-center 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" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </Fragment> + ); + } + + const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; + + if (!resultAccount) { + return <Loading />; + } + if (resultAccount instanceof TalerError) { + return <ErrorLoadingWithDebug error={resultAccount} />; + } + if (resultAccount.type === "fail") { + switch (resultAccount.case) { + case HttpStatusCode.Unauthorized: + return <LoginForm currentUser={accountName} />; + case HttpStatusCode.NotFound: + return <LoginForm currentUser={accountName} />; + default: + assertUnreachable(resultAccount); + } + } + if (!info) { + return <Loading />; + } + + if (info instanceof TalerError) { + return <ErrorLoadingWithDebug error={info} />; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention + type="danger" + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> + ); + } + default: + assertUnreachable(info.case); + } + } + + const conversionInfo = info.body.conversion_rate; + if (!conversionInfo) { + return ( + <div>conversion enabled but server replied without conversion_rate</div> + ); + } + + const account = { + balance: Amounts.parseOrThrow(resultAccount.body.balance.amount), + balanceIsDebit: + resultAccount.body.balance.credit_debit_indicator == "debit", + debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), + }; + + const { + fiat_currency, + regional_currency, + fiat_currency_specification, + regional_currency_specification, + } = info.body; + const regionalZero = Amounts.zeroOfCurrency(regional_currency); + const fiatZero = Amounts.zeroOfCurrency(fiat_currency); + const limit = account.balanceIsDebit + ? Amounts.sub(account.debitThreshold, account.balance).amount + : Amounts.add(account.balance, account.debitThreshold).amount; + + const zeroCalc = { + debit: regionalZero, + credit: fiatZero, + beforeFee: fiatZero, + }; + const [calculationResult, setCalculation] = useState<TransferCalculation>(zeroCalc); + const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee); + const sellRate = conversionInfo.cashout_ratio; + /** + * can be in regional currency or fiat currency + * depending on the isDebit flag + */ + const inputAmount = Amounts.parseOrThrow( + `${form.isDebit ? regional_currency : fiat_currency}:${!form.amount ? "0" : form.amount + }`, + ); + + useEffect(() => { + async function doAsync() { + await handleError(async () => { + const higerThanMin = form.isDebit ? + Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1 : true; + const notZero = Amounts.isNonZero(inputAmount) + if (notZero && higerThanMin) { + const resp = await (form.isDebit + ? calculateFromDebit(inputAmount, sellFee) + : calculateFromCredit(inputAmount, sellFee)); + setCalculation(resp); + } else { + setCalculation(zeroCalc) + } + }); + } + doAsync(); + }, [form.amount, form.isDebit]); + + const calc = calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult + + const balanceAfter = Amounts.sub(account.balance, calc.debit).amount; + + function updateForm(newForm: typeof form): void { + setForm(newForm); + } + const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({ + subject: !form.subject ? i18n.str`Required` : undefined, + amount: !form.amount + ? i18n.str`Required` + : !inputAmount + ? i18n.str`Invalid` + : Amounts.cmp(limit, calc.debit) === -1 + ? i18n.str`Balance is not enough` + : form.isDebit && Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1 + ? i18n.str`Needs to be higher than ${Amounts.stringifyValueWithSpec(Amounts.parseOrThrow(conversionInfo.cashout_min_amount), regional_currency_specification).normal}` + : calculationResult === "amount-is-too-small" + ? i18n.str`Amount needs to be higher` + : Amounts.isZero(calc.credit) + ? i18n.str`The total transfer at destination will be zero` + : undefined, + channel: OLD_CASHOUT_API && !form.channel ? i18n.str`Required` : undefined, + }); + const trimmedAmountStr = form.amount?.trim(); + + async function createCashout() { + const request_uid = encodeCrock(getRandomBytes(32)); + await handleError(async () => { + // new cashout api doesn't require channel + const validChannel = + !OLD_CASHOUT_API || + config.supported_tan_channels.length === 0 || + form.channel; + + if (!creds || !form.subject || !validChannel) return; + const request = { + request_uid, + amount_credit: Amounts.stringify(calc.credit), + amount_debit: Amounts.stringify(calc.debit), + subject: form.subject, + tan_channel: form.channel, + }; + const resp = await api.createCashout(creds, request); + if (resp.type === "ok") { + notifyInfo(i18n.str`Cashout created`); + } else { + switch (resp.case) { + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "create-cashout", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + location: routeHere.url({}), + request, + }); + return onAuthorizationRequired(); + } + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`Account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: + return notify({ + type: "error", + title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_BAD_CONVERSION: + return notify({ + type: "error", + title: i18n.str`The conversion rate was incorrectly applied`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`The account does not have sufficient funds`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.NotImplemented: + return notify({ + type: "error", + title: i18n.str`Cashout are disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return notify({ + type: "error", + title: i18n.str`Missing cashout URI in the profile`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + return notify({ + type: "error", + title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + assertUnreachable(resp); + } + }); + } + const cashoutDisabled = + config.supported_tan_channels.length < 1 || + !resultAccount.body.cashout_payto_uri; + + const cashoutAccount = !resultAccount.body.cashout_payto_uri + ? undefined + : parsePaytoUri(resultAccount.body.cashout_payto_uri); + const cashoutAccountName = !cashoutAccount + ? undefined + : cashoutAccount.targetPath; + + const cashoutLegalName = !cashoutAccount + ? undefined + : cashoutAccount.params["receiver-name"]; + + return ( + <div> + <LocalNotificationBanner notification={notification} /> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <section class="mt-4 rounded-sm px-4 py-6 p-8 "> + <h2 id="summary-heading" class="font-medium text-lg"> + <i18n.Translate>Cashout</i18n.Translate> + </h2> + + <dl class="mt-4 space-y-4"> + <div class="justify-between items-center flex"> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Conversion rate</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900">{sellRate}</dd> + </div> + + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Balance</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={account.balance} + spec={regional_currency_specification} + /> + </dd> + </div> + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Fee</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={sellFee} + spec={fiat_currency_specification} + /> + </dd> + </div> + {cashoutAccountName && cashoutLegalName ? ( + <Fragment> + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>To account</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900">{cashoutAccountName}</dd> + </div> + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Legal name</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900">{cashoutLegalName}</dd> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>If this name doesn't match the account holder's name your transaction may fail.</i18n.Translate> + </p> + </Fragment> + ) : ( + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <Attention type="warning" title={i18n.str`No cashout account`}> + <i18n.Translate> + Before doing a cashout you need to complete your profile + </i18n.Translate> + </Attention> + </div> + )} + </dl> + </section> + <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"> + {/* subject */} + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="subject" + > + {i18n.str`Transfer subject`} + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full rounded-md disabled:bg-gray-200 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="subject" + id="subject" + disabled={cashoutDisabled} + data-error={!!errors?.subject && form.subject !== undefined} + value={form.subject ?? ""} + onChange={(e) => { + form.subject = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.subject} + isDirty={form.subject !== undefined} + /> + </div> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="subject" + > + {i18n.str`Currency`} + </label> + + <div class="mt-2"> + <button + type="button" + name="set 50" + class=" inline-flex p-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + form.isDebit = true; + updateForm(structuredClone(form)); + }} + > + {form.isDebit ? + <svg + class="self-center flex-none h-5 w-5 text-indigo-600" + 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> + + : + <svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> + <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> + </svg> + } + + <i18n.Translate>Send {regional_currency}</i18n.Translate> + </button> + <button + type="button" + name="set 25" + class=" -ml-px -mr-px inline-flex p-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + onClick={(e) => { + e.preventDefault(); + form.isDebit = false; + updateForm(structuredClone(form)); + }} + > + {!form.isDebit ? + <svg + class="self-center flex-none h-5 w-5 text-indigo-600" + 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> + + : + <svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"> + <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> + </svg> + } + + <i18n.Translate>Receive {fiat_currency}</i18n.Translate> + </button> + </div> + </div> + + {/* amount */} + <div class="sm:col-span-5"> + <div class="flex justify-between"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="amount" + > + {i18n.str`Amount`} + <b style={{ color: "red" }}> *</b> + </label> + {/* <button + type="button" + data-enabled={form.isDebit} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + form.isDebit = !form.isDebit; + updateForm(structuredClone(form)); + }} + > + <span + aria-hidden="true" + data-enabled={form.isDebit} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> */} + </div> + <div class="mt-2"> + <InputAmount + name="amount" + left + currency={form.isDebit ? regional_currency : fiat_currency} + value={trimmedAmountStr} + onChange={ + cashoutDisabled + ? undefined + : (value) => { + form.amount = value; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={errors?.amount} + isDirty={form.amount !== undefined} + /> + </div> + </div> + + {Amounts.isZero(calc.credit) ? undefined : ( + <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>Total cost</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={calc.debit} + negative + withColor + spec={regional_currency_specification} + /> + </dd> + </div> + + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Balance left</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={balanceAfter} + spec={regional_currency_specification} + /> + </dd> + </div> + {Amounts.isZero(sellFee) || + Amounts.isZero(calc.beforeFee) ? undefined : ( + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Before fee</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={calc.beforeFee} + spec={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>Total cashout transfer</i18n.Translate> + </dt> + <dd class="text-lg text-gray-900 font-medium"> + <RenderAmount + value={calc.credit} + withColor + spec={fiat_currency_specification} + /> + </dd> + </div> + </dl> + </div> + )} + + {/* channel, not shown if new cashout api */} + {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels + .length === 0 ? ( + <div class="sm:col-span-5"> + <Attention + type="warning" + title={i18n.str`No cashout channel available`} + > + <i18n.Translate> + Before doing a cashout the server need to provide an + second channel to confirm the operation + </i18n.Translate> + </Attention> + </div> + ) : ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Second factor authentication`} + </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"> + {config.supported_tan_channels.indexOf( + TanChannel.EMAIL, + ) === -1 ? undefined : ( + <label + onClick={() => { + if (!resultAccount.body.contact_data?.email) return; + form.channel = TanChannel.EMAIL; + updateForm(structuredClone(form)); + }} + data-disabled={ + !resultAccount.body.contact_data?.email + } + data-selected={form.channel === TanChannel.EMAIL} + 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 " + > + <i18n.Translate>Email</i18n.Translate> + </span> + {!resultAccount.body.contact_data?.email && + i18n.str`Add a email in your profile to enable this option`} + </span> + </span> + <svg + data-selected={form.channel === TanChannel.EMAIL} + 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> + )} + + {config.supported_tan_channels.indexOf(TanChannel.SMS) === + -1 ? undefined : ( + <label + onClick={() => { + if (!resultAccount.body.contact_data?.phone) return; + form.channel = TanChannel.SMS; + updateForm(structuredClone(form)); + }} + data-disabled={ + !resultAccount.body.contact_data?.phone + } + data-selected={form.channel === TanChannel.SMS} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border 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="Existing Customers" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-1-label" + class="block text-sm font-medium text-gray-900" + > + <i18n.Translate>SMS</i18n.Translate> + </span> + {!resultAccount.body.contact_data?.phone && + i18n.str`Add a phone number in your profile to enable this option`} + </span> + </span> + <svg + data-selected={form.channel === TanChannel.SMS} + 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> + </div> + + <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={routeClose.url({})} + name="cancel" + type="button" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="cashout" + 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={!!errors} + onClick={(e) => { + e.preventDefault(); + createCashout(); + }} + > + <i18n.Translate>Cashout</i18n.Translate> + </button> + </div> + </form> + </div > + </div > + ); +} diff --git a/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx new file mode 100644 index 000000000..415f88868 --- /dev/null +++ b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx @@ -0,0 +1,192 @@ +/* + 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 { + AbsoluteTime, + Amounts, + Duration, + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { VNode, h } from "preact"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useCashoutDetails, useConversionInfo } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.js"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; +import { Time } from "../../components/Time.js"; + +interface Props { + id: string; + routeClose: RouteDefinition; +} +export function ShowCashoutDetails({ id, routeClose }: Props): VNode { + const { i18n, dateLocale } = useTranslationContext(); + const cid = Number.parseInt(id, 10); + + const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid); + const info = useConversionInfo(); + + if (Number.isNaN(cid)) { + return ( + <Attention + type="danger" + title={i18n.str`Cashout id should be a number`} + /> + ); + } + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: + return ( + <Attention + type="warning" + title={i18n.str`This cashout not found. Maybe already aborted.`} + ></Attention> + ); + case HttpStatusCode.NotImplemented: + return ( + <Attention + type="warning" + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> + ); + default: + assertUnreachable(result); + } + } + if (!info) { + return <Loading />; + } + + if (info instanceof TalerError) { + return <ErrorLoadingWithDebug error={info} />; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention type="danger" + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> + ); + } + default: + assertUnreachable(info.case); + } + } + + const { fiat_currency_specification, regional_currency_specification } = + info.body; + + return ( + <div> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <section class="rounded-sm px-4"> + <h2 id="summary-heading" class="font-medium text-lg"> + <i18n.Translate>Cashout detail</i18n.Translate> + </h2> + <dl class="mt-8 space-y-4"> + <div class="justify-between items-center flex"> + <dt class="text-sm text-gray-600"> + <i18n.Translate>Subject</i18n.Translate> + </dt> + <dd class="text-sm ">{result.body.subject}</dd> + </div> + </dl> + </section> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <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"> + <dl class="space-y-4"> + {result.body.creation_time.t_s !== "never" ? ( + <div class="justify-between items-center flex "> + <dt class=" text-gray-600"> + <i18n.Translate>Created</i18n.Translate> + </dt> + <dd class="text-sm "> + <Time format="dd/MM/yyyy HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp(result.body.creation_time)} + // relative={Duration.fromSpec({ days: 1 })} + /> + </dd> + </div> + ) : undefined} + + <div class="flex justify-between items-center border-t-2 afu pt-4"> + <dt class="text-gray-600"> + <i18n.Translate>Debited</i18n.Translate> + </dt> + <dd class=" font-medium"> + <RenderAmount + value={Amounts.parseOrThrow(result.body.amount_debit)} + negative + withColor + spec={regional_currency_specification} + /> + </dd> + </div> + + <div class="flex items-center justify-between border-t-2 afu pt-4"> + <dt class="flex items-center text-gray-600"> + <span> + <i18n.Translate>Credited</i18n.Translate> + </span> + </dt> + <dd class="text-sm "> + <RenderAmount + value={Amounts.parseOrThrow(result.body.amount_credit)} + withColor + spec={fiat_currency_specification} + /> + </dd> + </div> + </dl> + </div> + </div> + </div> + </div> + </div> + + <br /> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <a + href={routeClose.url({})} + name="close" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </div> + ); +} |