merchant-backoffice

ZZZ: Inactive/Deprecated
Log | Files | Refs | Submodules | README

commit 54723c9d6cec1b46e577222a2f66b37c85ef10ac
parent b56c499a55de0b54b3cf407bdc955abd51c36d16
Author: Sebastian <sebasjm@gmail.com>
Date:   Tue, 30 Nov 2021 16:19:38 -0300

fixing issue taken from christian comments:

 * iban validaton client side
 * iban account should be CAP
 * added claim token option when creating an order
 * added wire transfer deadline, was missing
 * default wire transfer delay is now optional
 * refund deadline is now optional
 * fix update instance: do not redirect when there is a server error, stay in the same view and display the server error

Diffstat:
Mpackages/merchant-backoffice/src/components/form/InputPaytoForm.tsx | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mpackages/merchant-backoffice/src/components/instance/DefaultInstanceFormFields.tsx | 5+++--
Mpackages/merchant-backoffice/src/i18n/index.tsx | 6+++++-
Mpackages/merchant-backoffice/src/paths/instance/orders/create/CreatePage.tsx | 39++++++++++++++++++++++++++++++++++-----
Mpackages/merchant-backoffice/src/paths/instance/update/UpdatePage.tsx | 4+---
Mpackages/merchant-backoffice/src/paths/instance/update/index.tsx | 17++++++++++++++++-
Mpackages/merchant-backoffice/src/schemas/index.ts | 8++++----
Mpackages/merchant-backoffice/src/utils/constants.ts | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 282 insertions(+), 31 deletions(-)

diff --git a/packages/merchant-backoffice/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice/src/components/form/InputPaytoForm.tsx @@ -20,7 +20,8 @@ */ import { h, VNode, Fragment } from "preact"; import { useCallback, useState } from "preact/hooks"; -import { Translate, useTranslator } from "../../i18n"; +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"; @@ -33,12 +34,13 @@ export interface Props<T> extends InputProps<T> { // https://datatracker.ietf.org/doc/html/rfc8905 type Entity = { + // iban, bitcoin, x-taler-bank. it defined the format target: string; - path: string; + // path1 if the first field to be used path1: string; - path2: string; - host: string; - account: string; + // path2 if the second field to be used, optional + path2?: string; + // options of the payto uri options: { "receiver-name"?: string; sender?: string; @@ -49,6 +51,61 @@ type Entity = { }; }; +/** + * 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 = ["iban", "x-taler-bank"]; const defaultTarget = { target: "iban", options: {} }; @@ -69,16 +126,19 @@ export function InputPaytoForm<T>({ const [value, valueHandler] = useState<Partial<Entity>>(defaultTarget); - if (value.path1) { + let payToPath; + if (value.target === "iban" && value.path1) { + payToPath = `/${value.path1.toUpperCase()}`; + } else if (value.path1) { if (value.path2) { - value.path = `/${value.path1}/${value.path2}`; + payToPath = `/${value.path1}/${value.path2}`; } else { - value.path = `/${value.path1}`; + payToPath = `/${value.path1}`; } } const i18n = useTranslator(); - const url = new URL(`payto://${value.target}${value.path}`); + const url = new URL(`payto://${value.target}${payToPath}`); const ops = value.options!; Object.keys(ops).forEach((opt_key) => { const opt_value = ops[opt_key]; @@ -91,11 +151,7 @@ export function InputPaytoForm<T>({ path1: !value.path1 ? i18n`required` : value.target === "iban" - ? value.path1.length < 4 - ? i18n`IBAN numbers usually have more that 4 digits` - : value.path1.length > 34 - ? i18n`IBAN numbers usually have less that 34 digits` - : undefined + ? validateIBAN(value.path1, i18n) : undefined, path2: value.target === "x-taler-bank" @@ -168,6 +224,7 @@ export function InputPaytoForm<T>({ name="path1" label={i18n`Account`} tooltip={i18n`Bank Account Number.`} + inputExtra={{ style: { textTransform: "uppercase" } }} /> </Fragment> )} @@ -198,7 +255,7 @@ export function InputPaytoForm<T>({ /> </Fragment> )} - {value.target === "void" && <Fragment></Fragment>} + {value.target === "void" && <Fragment />} {value.target === "x-taler-bank" && ( <Fragment> <Input<Entity> diff --git a/packages/merchant-backoffice/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice/src/components/instance/DefaultInstanceFormFields.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Fragment, h } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useBackendContext } from "../../context/backend"; import { useTranslator } from "../../i18n"; import { Entity } from "../../paths/admin/create/CreatePage"; @@ -37,7 +37,7 @@ export function DefaultInstanceFormFields({ }: { readonlyId?: boolean; showId: boolean; -}) { +}): VNode { const i18n = useTranslator(); const backend = useBackendContext(); return ( @@ -109,6 +109,7 @@ export function DefaultInstanceFormFields({ name="default_wire_transfer_delay" label={i18n`Default wire transfer delay`} tooltip={i18n`Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.`} + withForever /> </Fragment> ); diff --git a/packages/merchant-backoffice/src/i18n/index.tsx b/packages/merchant-backoffice/src/i18n/index.tsx @@ -25,7 +25,11 @@ import { ComponentChild, ComponentChildren, h, Fragment, VNode } from "preact"; import { useTranslationContext } from "../context/translation"; -export function useTranslator() { +export type Translator = ( + stringSeq: TemplateStringsArray, + ...values: any[] +) => string; +export function useTranslator(): Translator { const ctx = useTranslationContext(); const jed = ctx.handler; return function str( diff --git a/packages/merchant-backoffice/src/paths/instance/orders/create/CreatePage.tsx b/packages/merchant-backoffice/src/paths/instance/orders/create/CreatePage.tsx @@ -41,6 +41,7 @@ import { rate } from "../../../../utils/amount"; import { InventoryProductForm } from "../../../../components/product/InventoryProductForm"; import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm"; import { InputNumber } from "../../../../components/form/InputNumber"; +import { InputBoolean } from "../../../../components/form/InputBoolean"; interface Props { onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; @@ -71,6 +72,7 @@ function with_defaults(config: InstanceConfig): Partial<Entity> { wire_fee_amortization: config.default_wire_fee_amortization, pay_deadline: defaultPayDeadline, refund_deadline: defaultPayDeadline, + createToken: true, }, shipping: {}, extra: "", @@ -98,10 +100,12 @@ interface Shipping { interface Payments { refund_deadline?: Date; pay_deadline?: Date; + wire_transfer_deadline?: Date; auto_refund_deadline?: Date; max_fee?: string; max_wire_fee?: string; wire_fee_amortization?: number; + createToken: boolean; } interface Entity { inventoryProducts: ProductMap; @@ -157,17 +161,29 @@ export function CreatePage({ : undefined, payments: undefinedIfEmpty({ refund_deadline: !value.payments?.refund_deadline - ? i18n`required` + ? undefined : !isFuture(value.payments.refund_deadline) ? i18n`should be in the future` : value.payments.pay_deadline && isBefore(value.payments.refund_deadline, value.payments.pay_deadline) - ? i18n`pay deadline cannot be before refund deadline` + ? i18n`refund deadline cannot be before pay deadline` + : value.payments.wire_transfer_deadline && + isBefore( + value.payments.wire_transfer_deadline, + value.payments.refund_deadline + ) + ? i18n`wire transfer deadline cannot be before refund deadline` : undefined, pay_deadline: !value.payments?.pay_deadline - ? i18n`required` + ? undefined : !isFuture(value.payments.pay_deadline) ? i18n`should be in the future` + : value.payments.wire_transfer_deadline && + isBefore( + value.payments.wire_transfer_deadline, + value.payments.pay_deadline + ) + ? i18n`wire transfer deadline cannot be before pay deadline` : undefined, auto_refund_deadline: !value.payments?.auto_refund_deadline ? undefined @@ -211,10 +227,12 @@ export function CreatePage({ Math.floor(value.payments.pay_deadline.getTime() / 1000) * 1000, } : undefined, - wire_transfer_deadline: value.payments.pay_deadline + wire_transfer_deadline: value.payments.wire_transfer_deadline ? { t_ms: - Math.floor(value.payments.pay_deadline.getTime() / 1000) * 1000, + Math.floor( + value.payments.wire_transfer_deadline.getTime() / 1000 + ) * 1000, } : undefined, refund_deadline: value.payments.refund_deadline @@ -238,6 +256,7 @@ export function CreatePage({ product_id: p.product.id, quantity: p.quantity, })), + create_token: value.payments.createToken, }; onCreate(request); @@ -455,6 +474,11 @@ export function CreatePage({ tooltip={i18n`Time until which the order can be refunded by the merchant.`} /> <InputDate + name="payments.wire_transfer_deadline" + label={i18n`Wire transfer deadline`} + tooltip={i18n`Deadline for the exchange to make the wire transfer.`} + /> + <InputDate name="payments.auto_refund_deadline" label={i18n`Auto-refund deadline`} tooltip={i18n`Time until which the wallet will automatically check for refunds without user interaction.`} @@ -475,6 +499,11 @@ export function CreatePage({ label={i18n`Wire fee amortization`} tooltip={i18n`Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.`} /> + <InputBoolean + name="payments.createToken" + label={i18n`Create token`} + tooltip={i18n`Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.`} + /> </InputGroup> <InputGroup diff --git a/packages/merchant-backoffice/src/paths/instance/update/UpdatePage.tsx b/packages/merchant-backoffice/src/paths/instance/update/UpdatePage.tsx @@ -153,9 +153,7 @@ export function UpdatePage({ (k) => (errors as any)[k] !== undefined ); const submit = async (): Promise<void> => { - await onUpdate(schema.cast(value)); - await onBack(); - return Promise.resolve(); + await onUpdate(value as Entity); }; const [active, setActive] = useState(false); diff --git a/packages/merchant-backoffice/src/paths/instance/update/index.tsx b/packages/merchant-backoffice/src/paths/instance/update/index.tsx @@ -14,7 +14,9 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; import { Loading } from "../../../components/exception/loading"; +import { NotificationCard } from "../../../components/menu"; import { useInstanceContext } from "../../../context/instance"; import { MerchantBackend } from "../../../declaration"; import { HttpError, HttpResponse } from "../../../hooks/backend"; @@ -24,6 +26,8 @@ import { useManagedInstanceDetails, useManagementAPI, } from "../../../hooks/instance"; +import { useTranslator } from "../../../i18n"; +import { Notification } from "../../../utils/types"; import { UpdatePage } from "./UpdatePage"; export interface Props { @@ -65,6 +69,8 @@ function CommonUpdate( setNewToken: any ): VNode { const { changeToken } = useInstanceContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const i18n = useTranslator(); if (result.clientError && result.isUnauthorized) return onUnauthorized(); if (result.clientError && result.isNotfound) return onNotFound(); @@ -73,6 +79,7 @@ function CommonUpdate( return ( <Fragment> + <NotificationCard notification={notif} /> <UpdatePage onBack={onBack} isLoading={false} @@ -80,7 +87,15 @@ function CommonUpdate( onUpdate={( d: MerchantBackend.Instances.InstanceReconfigurationMessage ): Promise<void> => { - return updateInstance(d).then(onConfirm).catch(onUpdateError); + return updateInstance(d) + .then(onConfirm) + .catch((error: Error) => + setNotif({ + message: i18n`Failed to create instance`, + type: "ERROR", + description: error.message, + }) + ); }} onChangeAuth={( d: MerchantBackend.Instances.InstanceAuthConfigurationMessage diff --git a/packages/merchant-backoffice/src/schemas/index.ts b/packages/merchant-backoffice/src/schemas/index.ts @@ -98,10 +98,10 @@ export const InstanceSchema = yup.object().shape({ district: yup.string().optional(), country_subdivision: yup.string().optional(), }).meta({ type: 'group' }), - default_pay_delay: yup.object() - .shape({ d_ms: yup.number() }) - .required() - .meta({ type: 'duration' }), + // default_pay_delay: yup.object() + // .shape({ d_ms: yup.number() }) + // .required() + // .meta({ type: 'duration' }), // .transform(numberToDuration), default_wire_transfer_delay: yup.object() .shape({ d_ms: yup.number() }) diff --git a/packages/merchant-backoffice/src/utils/constants.ts b/packages/merchant-backoffice/src/utils/constants.ts @@ -45,3 +45,150 @@ export const DEFAULT_REQUEST_TIMEOUT = 10; export const MAX_IMAGE_SIZE = 1024 * 1024; export const INSTANCE_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.@-]+$/ + +export const COUNTRY_TABLE = { + AE: "U.A.E.", + AF: "Afghanistan", + AL: "Albania", + AM: "Armenia", + AN: "Netherlands Antilles", + AR: "Argentina", + AT: "Austria", + AU: "Australia", + AZ: "Azerbaijan", + BA: "Bosnia and Herzegovina", + BD: "Bangladesh", + BE: "Belgium", + BG: "Bulgaria", + BH: "Bahrain", + BN: "Brunei Darussalam", + BO: "Bolivia", + BR: "Brazil", + BT: "Bhutan", + BY: "Belarus", + BZ: "Belize", + CA: "Canada", + CG: "Congo", + CH: "Switzerland", + CI: "Cote d'Ivoire", + CL: "Chile", + CM: "Cameroon", + CN: "People's Republic of China", + CO: "Colombia", + CR: "Costa Rica", + CS: "Serbia and Montenegro", + CZ: "Czech Republic", + DE: "Germany", + DK: "Denmark", + DO: "Dominican Republic", + DZ: "Algeria", + EC: "Ecuador", + EE: "Estonia", + EG: "Egypt", + ER: "Eritrea", + ES: "Spain", + ET: "Ethiopia", + FI: "Finland", + FO: "Faroe Islands", + FR: "France", + GB: "United Kingdom", + GD: "Caribbean", + GE: "Georgia", + GL: "Greenland", + GR: "Greece", + GT: "Guatemala", + HK: "Hong Kong", + // HK: "Hong Kong S.A.R.", + HN: "Honduras", + HR: "Croatia", + HT: "Haiti", + HU: "Hungary", + ID: "Indonesia", + IE: "Ireland", + IL: "Israel", + IN: "India", + IQ: "Iraq", + IR: "Iran", + IS: "Iceland", + IT: "Italy", + JM: "Jamaica", + JO: "Jordan", + JP: "Japan", + KE: "Kenya", + KG: "Kyrgyzstan", + KH: "Cambodia", + KR: "South Korea", + KW: "Kuwait", + KZ: "Kazakhstan", + LA: "Laos", + LB: "Lebanon", + LI: "Liechtenstein", + LK: "Sri Lanka", + LT: "Lithuania", + LU: "Luxembourg", + LV: "Latvia", + LY: "Libya", + MA: "Morocco", + MC: "Principality of Monaco", + MD: "Moldava", + // MD: "Moldova", + ME: "Montenegro", + MK: "Former Yugoslav Republic of Macedonia", + ML: "Mali", + MM: "Myanmar", + MN: "Mongolia", + MO: "Macau S.A.R.", + MT: "Malta", + MV: "Maldives", + MX: "Mexico", + MY: "Malaysia", + NG: "Nigeria", + NI: "Nicaragua", + NL: "Netherlands", + NO: "Norway", + NP: "Nepal", + NZ: "New Zealand", + OM: "Oman", + PA: "Panama", + PE: "Peru", + PH: "Philippines", + PK: "Islamic Republic of Pakistan", + PL: "Poland", + PR: "Puerto Rico", + PT: "Portugal", + PY: "Paraguay", + QA: "Qatar", + RE: "Reunion", + RO: "Romania", + RS: "Serbia", + RU: "Russia", + RW: "Rwanda", + SA: "Saudi Arabia", + SE: "Sweden", + SG: "Singapore", + SI: "Slovenia", + SK: "Slovak", + SN: "Senegal", + SO: "Somalia", + SR: "Suriname", + SV: "El Salvador", + SY: "Syria", + TH: "Thailand", + TJ: "Tajikistan", + TM: "Turkmenistan", + TN: "Tunisia", + TR: "Turkey", + TT: "Trinidad and Tobago", + TW: "Taiwan", + TZ: "Tanzania", + UA: "Ukraine", + US: "United States", + UY: "Uruguay", + VA: "Vatican", + VE: "Venezuela", + VN: "Viet Nam", + YE: "Yemen", + ZA: "South Africa", + ZW: "Zimbabwe" +} +