diff options
Diffstat (limited to 'packages/bank-ui/src/pages/PaytoWireTransferForm.tsx')
-rw-r--r-- | packages/bank-ui/src/pages/PaytoWireTransferForm.tsx | 792 |
1 files changed, 792 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx new file mode 100644 index 000000000..791a3b440 --- /dev/null +++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -0,0 +1,792 @@ +/* + 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, + AmountJson, + AmountString, + Amounts, + CurrencySpecification, + FRAC_SEPARATOR, + HttpStatusCode, + PaytoString, + PaytoUri, + TalerErrorCode, + TranslatedString, + assertUnreachable, + buildPayto, + parsePaytoUri, + stringifyPaytoUri +} from "@gnu-taler/taler-util"; +import { + InternationalizationAPI, + LocalNotificationBanner, + ShowInputErrorLabel, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { mutate } from "swr"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useSessionState } from "../hooks/session.js"; +import { useBankState } from "../hooks/bank-state.js"; +import { EmptyObject, RouteDefinition } from "../route.js"; +import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; + +interface Props { + title: TranslatedString; + focus?: boolean; + withAccount?: string; + withSubject?: string; + withAmount?: string; + onSuccess: () => void; + onAuthorizationRequired: () => void; + routeCancel?: RouteDefinition; + routeCashout?: RouteDefinition; + routeHere: RouteDefinition<{ + account?: string, + subject?: string, + amount?: string, + }>; + limit: AmountJson; +} + +export function PaytoWireTransferForm({ + focus, + title, + withAccount, + withSubject, + withAmount, + onSuccess, + routeCancel, + routeCashout, + routeHere, + onAuthorizationRequired, + limit, +}: Props): VNode { + const [isRawPayto, setIsRawPayto] = useState(false); + const { state: credentials } = useSessionState(); + const { bank: api, config, url } = useBankCoreApiContext(); + + const sendingToFixedAccount = withAccount !== undefined; + + const [account, setAccount] = useState<string | undefined>(withAccount); + const [subject, setSubject] = useState<string | undefined>(withSubject); + const [amount, setAmount] = useState<string | undefined>(withAmount); + const [, updateBankState] = useBankState(); + + const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>( + undefined, + ); + const { i18n } = useTranslationContext(); + + const trimmedAmountStr = amount?.trim(); + const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`); + const [notification, notify, handleError] = useLocalNotification(); + + const paytoType = config.wire_type === "X_TALER_BANK" ? "x-taler-bank" as const : "iban" as const; + + const errorsWire = undefinedIfEmpty({ + account: !account + ? i18n.str`Required` + : paytoType === "iban" ? validateIBAN(account, i18n) : + paytoType === "x-taler-bank" ? validateTalerBank(account, i18n) : + undefined, + subject: !subject ? i18n.str`Required` : validateSubject(subject, i18n), + amount: !trimmedAmountStr + ? i18n.str`Required` + : !parsedAmount + ? i18n.str`Not valid` + : validateAmount(parsedAmount, limit, i18n), + }); + + const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); + + + const errorsPayto = undefinedIfEmpty({ + rawPaytoInput: !rawPaytoInput + ? i18n.str`Required` + : !parsed ? i18n.str`Does not follow the pattern` + : validateRawPayto(parsed, limit, url.host, i18n, paytoType), + }); + + async function doSend() { + let payto_uri: PaytoString | undefined; + let sendingAmount: AmountString | undefined; + + if (credentials.status !== "loggedIn") return; + if (isRawPayto) { + const p = parsePaytoUri(rawPaytoInput!); + if (!p) return; + sendingAmount = p.params.amount as AmountString; + delete p.params.amount; + // if this payto is valid then it already have message + payto_uri = stringifyPaytoUri(p); + } else { + if (!account || !subject) return; + let payto; + switch (paytoType) { + case "x-taler-bank": { + payto = buildPayto("x-taler-bank", url.host, account); + break; + } + case "iban": { + payto = buildPayto("iban", account, undefined); + break; + } + default: assertUnreachable(paytoType) + } + + payto.params.message = encodeURIComponent(subject); + payto_uri = stringifyPaytoUri(payto); + sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString; + } + const puri = payto_uri; + const sAmount = sendingAmount; + + await handleError(async () => { + const request = { + payto_uri: puri, + amount: sAmount, + }; + const resp = await api.createTransaction(credentials, request); + mutate(() => true); + if (resp.type === "fail") { + switch (resp.case) { + case HttpStatusCode.BadRequest: + return notify({ + type: "error", + title: i18n.str`The request was invalid or the payto://-URI used unacceptable features.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.Unauthorized: + return notify({ + type: "error", + title: i18n.str`Not enough permission to complete the operation.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_UNKNOWN_CREDITOR: + return notify({ + type: "error", + title: i18n.str`The destination account "${puri}" was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_SAME_ACCOUNT: + return notify({ + type: "error", + title: i18n.str`The origin and the destination of the transfer can't be the same.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`Your balance is not enough.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The origin account "${puri}" was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "create-transaction", + id: String(resp.body.challenge_id), + location: routeHere.url({ account: account ?? "", amount, subject }), + sent: AbsoluteTime.never(), + request, + }); + return onAuthorizationRequired(); + } + default: + assertUnreachable(resp); + } + } + notifyInfo(i18n.str`Wire transfer created!`); + onSuccess(); + setAmount(undefined); + setAccount(undefined); + setSubject(undefined); + rawPaytoInputSetter(undefined); + }); + } + + return ( + <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"> + {/** + * FIXME: Scan a qr code + */} + <div class=""> + <h2 class="text-base font-semibold leading-7 text-gray-900">{title}</h2> + <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> + <label + class={ + "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + + (!isRawPayto + ? "border-indigo-600 ring-2 ring-indigo-600" + : "border-gray-300") + } + > + <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={() => { + if (parsed && parsed.isKnown) { + switch (parsed.targetType) { + case "iban": { + setAccount(parsed.iban); + break; + } + case "x-taler-bank": { + setAccount(parsed.account); + break; + } + case "bitcoin": { + break; + } + default: { + assertUnreachable(parsed) + } + } + const amountStr = !parsed.params ? undefined : parsed.params["amount"]; + if (amountStr) { + const amount = Amounts.parse(amountStr); + if (amount) { + setAmount(Amounts.stringifyValue(amount)); + } + } + const subject = parsed.params["message"]; + if (subject) { + setSubject(subject); + } + } + setIsRawPayto(false); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Using a form</i18n.Translate> + </span> + </span> + </span> + </label> + + {sendingToFixedAccount ? undefined : ( + <label + class={ + "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + + (isRawPayto + ? "border-indigo-600 ring-2 ring-indigo-600" + : "border-gray-300") + } + > + <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={() => { + if (account) { + let payto; + switch (paytoType) { + case "x-taler-bank": { + payto = buildPayto("x-taler-bank", url.host, account); + if (parsedAmount) { + payto.params["amount"] = + Amounts.stringify(parsedAmount); + } + if (subject) { + payto.params["message"] = subject; + } + break; + } + case "iban": { + payto = buildPayto("iban", account, undefined); + if (parsedAmount) { + payto.params["amount"] = + Amounts.stringify(parsedAmount); + } + if (subject) { + payto.params["message"] = subject; + } + break; + } + default: assertUnreachable(paytoType) + } + rawPaytoInputSetter(stringifyPaytoUri(payto)); + } + setIsRawPayto(true); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Import payto:// URI</i18n.Translate> + </span> + </span> + </span> + </label> + )} + {routeCashout ? ( + <a + name="do cashout" + href={routeCashout.url({})} + class="bg-white p-4 rounded-lg text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cashout</i18n.Translate> + </a> + ) : ( + undefined + )} + </div> + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md sm:rounded-xl md:col-span-2 w-fit mx-auto" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="p-4 sm:p-8"> + {!isRawPayto ? ( + <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + {(() => { + switch (paytoType) { + case "x-taler-bank": { + return <TextField + id="x-taler-bank" + label={i18n.str`Recipient`} + help={i18n.str`Id of the recipient's account`} + error={errorsWire?.account} + onChange={setAccount} + value={account} + placeholder={i18n.str`username`} + focus={focus} + disabled={sendingToFixedAccount} + /> + } + case "iban": { + return <TextField + id="iban" + label={i18n.str`Recipient`} + help={i18n.str`IBAN of the recipient's account`} + placeholder={"CC0123456789" as TranslatedString} + error={errorsWire?.account} + onChange={(v) => setAccount(v.toUpperCase())} + value={account} + focus={focus} + disabled={sendingToFixedAccount} + /> + } + default: assertUnreachable(paytoType) + } + })()} + + <div class="sm:col-span-5"> + <label + for="subject" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Transfer subject`}</label> + <div class="mt-2"> + <input + type="text" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="subject" + id="subject" + autocomplete="off" + placeholder={i18n.str`Subject`} + value={subject ?? ""} + required + onInput={(e): void => { + setSubject(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errorsWire?.subject} + isDirty={subject !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Some text to identify the transfer + </i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + for="amount" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Amount`}</label> + <InputAmount + name="amount" + left + currency={limit.currency} + value={trimmedAmountStr} + onChange={(d) => { + setAmount(d); + }} + /> + <ShowInputErrorLabel + message={errorsWire?.amount} + isDirty={trimmedAmountStr !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Amount to transfer</i18n.Translate> + </p> + </div> + </div> + ) : ( + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6 w-full"> + <div class="sm:col-span-6"> + <label + for="address" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Payto URI:`}</label> + <div class="mt-2"> + <textarea + ref={focus ? doAutoFocus : undefined} + name="address" + id="address" + type="textarea" + rows={5} + class="block overflow-hidden w-44 sm:w-96 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={rawPaytoInput ?? ""} + required + title={i18n.str`Uniform resource identifier of the target account`} + + placeholder={((): TranslatedString => { + switch (paytoType) { + case "x-taler-bank": return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limit.currency}:X.Y]` + case "iban": return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]` + } + })()} + onInput={(e): void => { + rawPaytoInputSetter(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errorsPayto?.rawPaytoInput} + isDirty={rawPaytoInput !== undefined} + /> + </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"> + {routeCancel ? ( + <a + name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + ) : ( + <div /> + )} + <button + type="submit" + name="send" + 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={isRawPayto ? !!errorsPayto : !!errorsWire} + onClick={(e) => { + e.preventDefault(); + doSend(); + }} + > + <i18n.Translate>Send</i18n.Translate> + </button> + </div> + <LocalNotificationBanner notification={notification} /> + </form> + </div> + ); +} + +/** + * Show the element when the load ended + * @param element + */ +export function doAutoFocus(element: HTMLElement | null) { + if (element) { + setTimeout(() => { + element.focus({ preventScroll: true }); + element.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + }, 100); + } +} + +export function InputAmount( + { + currency, + name, + value, + error, + left, + onChange, + }: { + error?: string; + currency: string; + name: string; + left?: boolean | undefined; + value: string | undefined; + onChange?: (s: string) => void; + }, + ref: Ref<HTMLInputElement>, +): VNode { + const { config } = useBankCoreApiContext(); + return ( + <div class="mt-2"> + <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> + <div class="pointer-events-none inset-y-0 flex items-center px-3"> + <span class="text-gray-500 sm:text-sm">{currency}</span> + </div> + <input + type="number" + data-left={left} + class="disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6" + placeholder="0.00" + aria-describedby="price-currency" + ref={ref} + name={name} + id={name} + autocomplete="off" + value={value ?? ""} + disabled={!onChange} + onInput={(e) => { + if (!onChange) return; + const l = e.currentTarget.value.length; + const sep_pos = e.currentTarget.value.indexOf(FRAC_SEPARATOR); + if ( + sep_pos !== -1 && + l - sep_pos - 1 > + config.currency_specification.num_fractional_input_digits + ) { + e.currentTarget.value = e.currentTarget.value.substring( + 0, + sep_pos + + config.currency_specification.num_fractional_input_digits + + 1, + ); + } + onChange(e.currentTarget.value); + }} + /> + </div> + <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> + </div> + ); +} + +export function RenderAmount({ + value, + spec, + negative, + withColor, + hideSmall, +}: { + spec: CurrencySpecification; + value: AmountJson; + hideSmall?: boolean; + negative?: boolean; + withColor?: boolean; +}): VNode { + const neg = !!negative; // convert to true or false + + const { currency, normal, small } = Amounts.stringifyValueWithSpec( + value, + spec, + ); + + return ( + <span + data-negative={withColor ? neg : undefined} + class="whitespace-nowrap data-[negative=false]:text-green-600 data-[negative=true]:text-red-600" + > + {negative ? "- " : undefined} + {currency} {normal}{" "} + {!hideSmall && small && <sup class="-ml-1">{small}</sup>} + </span> + ); +} + + +function validateRawPayto(parsed: PaytoUri, limit: AmountJson, host: string, i18n: InternationalizationAPI, type: "iban" | "x-taler-bank"): TranslatedString | undefined { + if (!parsed.isKnown) { + return i18n.str`The target type is unknown, use "${type}"` + } + let result: TranslatedString | undefined; + switch (type) { + case "x-taler-bank": { + if (parsed.targetType !== "x-taler-bank") { + return i18n.str`Only "x-taler-bank" target are supported` + } + + if (parsed.host !== host) { + return i18n.str`Only this host is allowed. Use "${host}"` + } + + if (!parsed.account) { + return i18n.str`Missing account name` + } + const result = validateTalerBank(parsed.account, i18n) + if (result) return result + break; + } + case "iban": { + if (parsed.targetType !== "iban") { + return i18n.str`Only "IBAN" target are supported` + } + const result = validateIBAN(parsed.iban, i18n) + if (result) return result + break; + } + default: assertUnreachable(type) + } + if (!parsed.params.amount) { + return i18n.str`Missing "amount" parameter to specify the amount to be transferred` + } + const amount = Amounts.parse(parsed.params.amount) + if (!amount) { + return i18n.str`The "amount" parameter is not valid` + } + result = validateAmount(amount, limit, i18n) + if (result) return result; + + if (!parsed.params.message) { + return i18n.str`Missing the "message" parameter to specify a reference text for the transfer` + } + const subject = parsed.params.message + result = validateSubject(subject, i18n) + if (result) return result; + + return undefined +} + +function validateAmount(amount: AmountJson, limit: AmountJson, i18n: InternationalizationAPI): TranslatedString | undefined { + if (amount.currency !== limit.currency) { + return i18n.str`The only currency allowed is "${limit.currency}"` + } + if (Amounts.isZero(amount)) { + return i18n.str`Can't transfer zero amount` + } + if (Amounts.cmp(limit, amount) === -1) { + return i18n.str`Balance is not enough` + } + return undefined +} + +function validateSubject(text: string, i18n: InternationalizationAPI): TranslatedString | undefined { + if (text.length < 2) { + return i18n.str`Use a longer subject` + } + return undefined +} + +interface PaytoFieldProps { + id: string, + label: TranslatedString; + help?: TranslatedString; + placeholder?: TranslatedString; + error: string | undefined; + value: string | undefined; + rightIcons?: VNode; + onChange: (p: string) => void; + focus?: boolean; + disabled?: boolean; +} + +function Wrapper({ withIcon, children }: { withIcon: boolean, children: ComponentChildren }): VNode { + if (withIcon) { + return <div class="flex justify-between"> + {children} + </div> + } + return <Fragment>{children}</Fragment> +} + +export function TextField({ + id, + label, + help, + focus, + disabled, + onChange, + placeholder, + rightIcons, + value, + error, +}: PaytoFieldProps): VNode { + return <div class="sm:col-span-5"> + <label + for={id} + class="block text-sm font-medium leading-6 text-gray-900" + >{label}</label> + <div class="mt-2"> + <Wrapper withIcon={rightIcons !== undefined}> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name={id} + id={id} + disabled={disabled} + value={value ?? ""} + placeholder={placeholder} + autocomplete="off" + required + onInput={(e): void => { + onChange(e.currentTarget.value); + }} + /> + {rightIcons} + </Wrapper> + <ShowInputErrorLabel + message={error} + isDirty={value !== undefined} + /> + </div> + {help && + <p class="mt-2 text-sm text-gray-500"> + {help} + </p> + } + </div> +} |