aboutsummaryrefslogtreecommitdiff
path: root/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2022-10-24 10:46:14 +0200
committerFlorian Dold <florian@dold.me>2022-10-24 10:46:14 +0200
commit3e060b80428943c6562250a6ff77eff10a0259b7 (patch)
treed08472bc5ca28621c62ac45b229207d8215a9ea7 /packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
parentfb52ced35ac872349b0e1062532313662552ff6c (diff)
downloadwallet-core-3e060b80428943c6562250a6ff77eff10a0259b7.tar.xz
repo: integrate packages from former merchant-backoffice.git
Diffstat (limited to 'packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx')
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx392
1 files changed, 392 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
new file mode 100644
index 000000000..9cfef07cf
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
@@ -0,0 +1,392 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 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 { h, VNode, Fragment } from "preact";
+import { useCallback, useState } from "preact/hooks";
+import { Translate, Translator, useTranslator } from "../../i18n";
+import { COUNTRY_TABLE } from "../../utils/constants";
+import { FormErrors, FormProvider } from "./FormProvider";
+import { Input } from "./Input";
+import { InputGroup } from "./InputGroup";
+import { InputSelector } from "./InputSelector";
+import { InputProps, useField } from "./useField";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+}
+
+// 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;
+ // options of the payto uri
+ options: {
+ "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: Translator): 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`This is not a valid bitcoin address.`;
+}
+
+function validateEthereum(addr: string, i18n: Translator): string | undefined {
+ try {
+ const valid = isEthereumAddress(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n`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: Translator): string | undefined {
+ // Check total length
+ if (iban.length < 4)
+ return i18n`IBAN numbers usually have more that 4 digits`;
+ if (iban.length > 34)
+ return i18n`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`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`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 = { target: noTargetValue, options: {} };
+
+function undefinedIfEmpty<T>(obj: T): T | undefined {
+ return Object.keys(obj).some((k) => (obj as any)[k] !== undefined)
+ ? obj
+ : undefined;
+}
+
+export function InputPaytoForm<T>({
+ name,
+ readonly,
+ label,
+ tooltip,
+}: Props<keyof T>): VNode {
+ const { value: paytos, onChange } = useField<T>(name);
+
+ const [value, valueHandler] = useState<Partial<Entity>>(defaultTarget);
+
+ let payToPath;
+ if (value.target === "iban" && value.path1) {
+ payToPath = `/${value.path1.toUpperCase()}`;
+ } else if (value.path1) {
+ if (value.path2) {
+ payToPath = `/${value.path1}/${value.path2}`;
+ } else {
+ payToPath = `/${value.path1}`;
+ }
+ }
+ const i18n = useTranslator();
+
+ const ops = value.options!;
+ const url = tryUrl(`payto://${value.target}${payToPath}`);
+ if (url) {
+ Object.keys(ops).forEach((opt_key) => {
+ const opt_value = ops[opt_key];
+ if (opt_value) url.searchParams.set(opt_key, opt_value);
+ });
+ }
+ const paytoURL = !url ? "" : url.toString();
+
+ const errors: FormErrors<Entity> = {
+ target: value.target === noTargetValue ? i18n`required` : undefined,
+ path1: !value.path1
+ ? i18n`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`required`
+ : undefined
+ : undefined,
+ options: undefinedIfEmpty({
+ "receiver-name": !value.options?.["receiver-name"]
+ ? i18n`required`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined
+ );
+
+ const submit = useCallback((): void => {
+ const alreadyExists =
+ paytos.findIndex((x: string) => x === paytoURL) !== -1;
+ if (!alreadyExists) {
+ onChange([paytoURL, ...paytos] 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={valueHandler}
+ >
+ <InputSelector<Entity>
+ name="target"
+ label={i18n`Target type`}
+ tooltip={i18n`Method to use for wire transfer`}
+ values={targets}
+ toStr={(v) => (v === noTargetValue ? i18n`Choose one...` : v)}
+ />
+
+ {value.target === "ach" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n`Routing`}
+ tooltip={i18n`Routing number.`}
+ />
+ <Input<Entity>
+ name="path2"
+ label={i18n`Account`}
+ tooltip={i18n`Account number.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "bic" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n`Code`}
+ tooltip={i18n`Business Identifier Code.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "iban" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n`Account`}
+ tooltip={i18n`Bank Account Number.`}
+ inputExtra={{ style: { textTransform: "uppercase" } }}
+ />
+ </Fragment>
+ )}
+ {value.target === "upi" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n`Account`}
+ tooltip={i18n`Unified Payment Interface.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "bitcoin" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n`Address`}
+ tooltip={i18n`Bitcoin protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "ethereum" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n`Address`}
+ tooltip={i18n`Ethereum protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "ilp" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n`Address`}
+ tooltip={i18n`Interledger protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "void" && <Fragment />}
+ {value.target === "x-taler-bank" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n`Host`}
+ tooltip={i18n`Bank host.`}
+ />
+ <Input<Entity>
+ name="path2"
+ label={i18n`Account`}
+ tooltip={i18n`Bank account.`}
+ />
+ </Fragment>
+ )}
+
+ {value.target !== noTargetValue && (
+ <Input
+ name="options.receiver-name"
+ label={i18n`Name`}
+ tooltip={i18n`Bank account owner's name.`}
+ />
+ )}
+
+ <div class="field is-horizontal">
+ <div class="field-label is-normal" />
+ <div class="field-body" style={{ display: "block" }}>
+ {paytos.map((v: any, i: number) => (
+ <div
+ key={i}
+ class="tags has-addons mt-3 mb-0 mr-3"
+ style={{ flexWrap: "nowrap" }}
+ >
+ <span
+ class="tag is-medium is-info mb-0"
+ style={{ maxWidth: "90%" }}
+ >
+ {v}
+ </span>
+ <a
+ class="tag is-medium is-danger is-delete mb-0"
+ onClick={() => {
+ onChange(paytos.filter((f: any) => f !== v) as any);
+ }}
+ />
+ </div>
+ ))}
+ {!paytos.length && i18n`No accounts yet.`}
+ </div>
+ </div>
+
+ {value.target !== noTargetValue && (
+ <div class="buttons is-right mt-5">
+ <button
+ class="button is-info"
+ data-tooltip={i18n`add tax to the tax list`}
+ disabled={hasErrors}
+ onClick={submit}
+ >
+ <Translate>Add</Translate>
+ </button>
+ </div>
+ )}
+ </FormProvider>
+ </InputGroup>
+ );
+}
+
+function tryUrl(s: string): URL | undefined {
+ try {
+ return new URL(s);
+ } catch (e) {
+ return undefined;
+ }
+}