diff options
Diffstat (limited to 'packages/bank-ui/src/pages/regional/CreateCashout.tsx')
-rw-r--r-- | packages/bank-ui/src/pages/regional/CreateCashout.tsx | 717 |
1 files changed, 717 insertions, 0 deletions
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..8e54bbd4e --- /dev/null +++ b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -0,0 +1,717 @@ +/* + 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 { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { + TransferCalculation, + useCashoutEstimator, + useConversionInfo, +} from "../../hooks/regional.js"; +import { useSessionState } from "../../hooks/session.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; +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 { + lib: { 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> + ); + } + + 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, + }); + 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 = + 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, + when: AbsoluteTime.now(), + }); + 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, + when: AbsoluteTime.now(), + }); + 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, + when: AbsoluteTime.now(), + }); + 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, + when: AbsoluteTime.now(), + }); + case HttpStatusCode.NotImplemented: + return notify({ + type: "error", + title: i18n.str`Cashout are disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); + 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, + when: AbsoluteTime.now(), + }); + 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, + when: AbsoluteTime.now(), + }); + } + 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-6 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> + )} + </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> + ); +} |