diff options
Diffstat (limited to 'packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx')
-rw-r--r-- | packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx | 397 |
1 files changed, 397 insertions, 0 deletions
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx new file mode 100644 index 000000000..32545c89a --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx @@ -0,0 +1,397 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { parsePaytoUri, PaytoUriGeneric, stringifyPaytoUri } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { COUNTRY_TABLE } from "../../utils/constants.js"; +import { undefinedIfEmpty } from "../../utils/table.js"; +import { FormErrors, FormProvider } from "./FormProvider.js"; +import { Input } from "./Input.js"; +import { InputGroup } from "./InputGroup.js"; +import { InputSelector } from "./InputSelector.js"; +import { InputProps, useField } from "./useField.js"; +import { useEffect, useState } from "preact/hooks"; + +export interface Props<T> extends InputProps<T> { + isValid?: (e: any) => boolean; +} + +// type Entity = PaytoUriGeneric +// https://datatracker.ietf.org/doc/html/rfc8905 +type Entity = { + // iban, bitcoin, x-taler-bank. it defined the format + target: string; + // path1 if the first field to be used + path1?: string; + // path2 if the second field to be used, optional + path2?: string; + // params of the payto uri + params: { + "receiver-name"?: string; + sender?: string; + message?: string; + amount?: string; + instruction?: string; + [name: string]: string | undefined; + }; +}; + +function isEthereumAddress(address: string) { + if (!/^(0x)?[0-9a-f]{40}$/i.test(address)) { + return false; + } else if ( + /^(0x|0X)?[0-9a-f]{40}$/.test(address) || + /^(0x|0X)?[0-9A-F]{40}$/.test(address) + ) { + return true; + } + return checkAddressChecksum(address); +} + +function checkAddressChecksum(address: string) { + //TODO implement ethereum checksum + return true; +} + +function validateBitcoin( + addr: string, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): string | undefined { + try { + const valid = /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/.test(addr); + if (valid) return undefined; + } catch (e) { + console.log(e); + } + return i18n.str`This is not a valid bitcoin address.`; +} + +function validateEthereum( + addr: string, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): string | undefined { + try { + const valid = isEthereumAddress(addr); + if (valid) return undefined; + } catch (e) { + console.log(e); + } + return i18n.str`This is not a valid Ethereum address.`; +} + +/** + * An IBAN is validated by converting it into an integer and performing a + * basic mod-97 operation (as described in ISO 7064) on it. + * If the IBAN is valid, the remainder equals 1. + * + * The algorithm of IBAN validation is as follows: + * 1.- Check that the total IBAN length is correct as per the country. If not, the IBAN is invalid + * 2.- Move the four initial characters to the end of the string + * 3.- Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35 + * 4.- Interpret the string as a decimal integer and compute the remainder of that number on division by 97 + * + * If the remainder is 1, the check digit test is passed and the IBAN might be valid. + * + */ +function validateIBAN( + iban: string, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): string | undefined { + // Check total length + if (iban.length < 4) + return i18n.str`IBAN numbers usually have more that 4 digits`; + if (iban.length > 34) + return i18n.str`IBAN numbers usually have less that 34 digits`; + + const A_code = "A".charCodeAt(0); + const Z_code = "Z".charCodeAt(0); + const IBAN = iban.toUpperCase(); + // check supported country + const code = IBAN.substr(0, 2); + const found = code in COUNTRY_TABLE; + if (!found) return i18n.str`IBAN country code not found`; + + // 2.- Move the four initial characters to the end of the string + const step2 = IBAN.substr(4) + iban.substr(0, 4); + const step3 = Array.from(step2) + .map((letter) => { + const code = letter.charCodeAt(0); + if (code < A_code || code > Z_code) return letter; + return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`; + }) + .join(""); + + function calculate_iban_checksum(str: string): number { + const numberStr = str.substr(0, 5); + const rest = str.substr(5); + const number = parseInt(numberStr, 10); + const result = number % 97; + if (rest.length > 0) { + return calculate_iban_checksum(`${result}${rest}`); + } + return result; + } + + const checksum = calculate_iban_checksum(step3); + if (checksum !== 1) + return i18n.str`IBAN number is not valid, checksum is wrong`; + return undefined; +} + +// const targets = ['ach', 'bic', 'iban', 'upi', 'bitcoin', 'ilp', 'void', 'x-taler-bank'] +const targets = [ + "Choose one...", + "iban", + "x-taler-bank", + "bitcoin", + "ethereum", +]; +const noTargetValue = targets[0]; +const defaultTarget: Entity = { + target: noTargetValue, + params: {}, +}; + +export function InputPaytoForm<T>({ + name, + readonly, + label, + tooltip, +}: Props<keyof T>): VNode { + const { value: initialValueStr, onChange } = useField<T>(name); + + const initialPayto = parsePaytoUri(initialValueStr ?? "") + const paths = !initialPayto ? [] : initialPayto.targetPath.split("/") + const initialPath1 = paths.length >= 1 ? paths[0] : undefined; + const initialPath2 = paths.length >= 2 ? paths[1] : undefined; + const initial: Entity = initialPayto === undefined ? defaultTarget : { + target: initialPayto.targetType, + params: initialPayto.params, + path1: initialPath1, + path2: initialPath2, + } + const [value, setValue] = useState<Partial<Entity>>(initial) + + const { i18n } = useTranslationContext(); + + const errors: FormErrors<Entity> = { + target: + value.target === noTargetValue + ? i18n.str`required` + : undefined, + path1: !value.path1 + ? i18n.str`required` + : value.target === "iban" + ? validateIBAN(value.path1, i18n) + : value.target === "bitcoin" + ? validateBitcoin(value.path1, i18n) + : value.target === "ethereum" + ? validateEthereum(value.path1, i18n) + : undefined, + path2: + value.target === "x-taler-bank" + ? !value.path2 + ? i18n.str`required` + : undefined + : undefined, + params: undefinedIfEmpty({ + "receiver-name": !value.params?.["receiver-name"] + ? i18n.str`required` + : undefined, + }), + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + const str = hasErrors || !value.target ? undefined : stringifyPaytoUri({ + targetType: value.target, + targetPath: value.path2 ? `${value.path1}/${value.path2}` : (value.path1 ?? ""), + params: value.params ?? {} as any, + isKnown: false, + }) + useEffect(() => { + onChange(str as any) + }, [str]) + + // const submit = useCallback((): void => { + // // const accounts: MerchantBackend.BankAccounts.AccountAddDetails[] = paytos; + // // const alreadyExists = + // // accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1; + // // if (!alreadyExists) { + // const newValue: MerchantBackend.BankAccounts.AccountAddDetails = { + // payto_uri: paytoURL, + // }; + // if (value.auth) { + // if (value.auth.url) { + // newValue.credit_facade_url = value.auth.url; + // } + // if (value.auth.type === "none") { + // newValue.credit_facade_credentials = { + // type: "none", + // }; + // } + // if (value.auth.type === "basic") { + // newValue.credit_facade_credentials = { + // type: "basic", + // username: value.auth.username ?? "", + // password: value.auth.password ?? "", + // }; + // } + // } + // onChange(newValue as any); + // // } + // // valueHandler(defaultTarget); + // }, [value]); + + //FIXME: translating plural singular + return ( + <InputGroup name="payto" label={label} fixed tooltip={tooltip}> + <FormProvider<Entity> + name="tax" + errors={errors} + object={value} + valueHandler={setValue} + > + <InputSelector<Entity> + name="target" + label={i18n.str`Account type`} + tooltip={i18n.str`Method to use for wire transfer`} + values={targets} + readonly={readonly} + toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)} + /> + + {value.target === "ach" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n.str`Routing`} + readonly={readonly} + tooltip={i18n.str`Routing number.`} + /> + <Input<Entity> + name="path2" + label={i18n.str`Account`} + readonly={readonly} + tooltip={i18n.str`Account number.`} + /> + </Fragment> + )} + {value.target === "bic" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n.str`Code`} + readonly={readonly} + tooltip={i18n.str`Business Identifier Code.`} + /> + </Fragment> + )} + {value.target === "iban" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n.str`IBAN`} + tooltip={i18n.str`International Bank Account Number.`} + readonly={readonly} + placeholder="DE1231231231" + inputExtra={{ style: { textTransform: "uppercase" } }} + /> + </Fragment> + )} + {value.target === "upi" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Account`} + tooltip={i18n.str`Unified Payment Interface.`} + /> + </Fragment> + )} + {value.target === "bitcoin" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Address`} + tooltip={i18n.str`Bitcoin protocol.`} + /> + </Fragment> + )} + {value.target === "ethereum" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Address`} + tooltip={i18n.str`Ethereum protocol.`} + /> + </Fragment> + )} + {value.target === "ilp" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Address`} + tooltip={i18n.str`Interledger protocol.`} + /> + </Fragment> + )} + {value.target === "void" && <Fragment />} + {value.target === "x-taler-bank" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Host`} + tooltip={i18n.str`Bank host.`} + /> + <Input<Entity> + name="path2" + readonly={readonly} + label={i18n.str`Account`} + tooltip={i18n.str`Bank account.`} + /> + </Fragment> + )} + + {/** + * Show additional fields apart from the payto + */} + {value.target !== noTargetValue && ( + <Fragment> + <Input + name="params.receiver-name" + readonly={readonly} + label={i18n.str`Owner's name`} + tooltip={i18n.str`Legal name of the person holding the account.`} + /> + </Fragment> + )} + + </FormProvider> + </InputGroup> + ); +} + |