/*
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
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
import { useCallback, useState } from "preact/hooks";
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";
export interface Props extends InputProps {
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: ReturnType["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["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["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 = { target: noTargetValue, options: {} };
export function InputPaytoForm({
name,
readonly,
label,
tooltip,
}: Props): VNode {
const { value: paytos, onChange } = useField(name);
const [value, valueHandler] = useState>(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 } = useTranslationContext();
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.href;
const errors: FormErrors = {
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,
options: undefinedIfEmpty({
"receiver-name": !value.options?.["receiver-name"]
? i18n.str`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 (
name="tax"
errors={errors}
object={value}
valueHandler={valueHandler}
>
name="target"
label={i18n.str`Target type`}
tooltip={i18n.str`Method to use for wire transfer`}
values={targets}
toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)}
/>
{value.target === "ach" && (
name="path1"
label={i18n.str`Routing`}
tooltip={i18n.str`Routing number.`}
/>
name="path2"
label={i18n.str`Account`}
tooltip={i18n.str`Account number.`}
/>
)}
{value.target === "bic" && (
name="path1"
label={i18n.str`Code`}
tooltip={i18n.str`Business Identifier Code.`}
/>
)}
{value.target === "iban" && (
name="path1"
label={i18n.str`Account`}
tooltip={i18n.str`Bank Account Number.`}
inputExtra={{ style: { textTransform: "uppercase" } }}
/>
)}
{value.target === "upi" && (
name="path1"
label={i18n.str`Account`}
tooltip={i18n.str`Unified Payment Interface.`}
/>
)}
{value.target === "bitcoin" && (
name="path1"
label={i18n.str`Address`}
tooltip={i18n.str`Bitcoin protocol.`}
/>
)}
{value.target === "ethereum" && (
name="path1"
label={i18n.str`Address`}
tooltip={i18n.str`Ethereum protocol.`}
/>
)}
{value.target === "ilp" && (
name="path1"
label={i18n.str`Address`}
tooltip={i18n.str`Interledger protocol.`}
/>
)}
{value.target === "void" && }
{value.target === "x-taler-bank" && (
name="path1"
label={i18n.str`Host`}
tooltip={i18n.str`Bank host.`}
/>
name="path2"
label={i18n.str`Account`}
tooltip={i18n.str`Bank account.`}
/>
)}
{value.target !== noTargetValue && (
)}