/* 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 */ import { AbsoluteTime, AmountJson, AmountString, Amounts, CurrencySpecification, FRAC_SEPARATOR, HttpStatusCode, PaytoString, PaytoUri, TalerCorebankApi, TalerErrorCode, TranslatedString, assertUnreachable, buildPayto, parsePaytoUri, stringifyPaytoUri, } from "@gnu-taler/taler-util"; import { InternationalizationAPI, LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, notifyInfo, useBankCoreApiContext, 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 { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js"; import { useBankState } from "../hooks/bank-state.js"; import { useSessionState } from "../hooks/session.js"; import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; interface Props { 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; balance: AmountJson; } export function PaytoWireTransferForm({ focus, withAccount, withSubject, withAmount, onSuccess, routeCancel, routeCashout, routeHere, onAuthorizationRequired, limit, balance, }: Props): VNode { const [inputType, setInputType] = useState<"form" | "payto" | "qr">("form"); const isRawPayto = inputType !== "form"; const { state: credentials } = useSessionState(); const { lib: { bank: api }, config, url, } = useBankCoreApiContext(); const sendingToFixedAccount = withAccount !== undefined; const [account, setAccount] = useState(withAccount); const [subject, setSubject] = useState(withSubject); const [amount, setAmount] = useState(withAmount); const [, updateBankState] = useBankState(); const [rawPaytoInput, rawPaytoInputSetter] = useState( 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 wireFee = config.wire_transfer_fees === undefined ? Amounts.zeroOfCurrency(config.currency) : Amounts.parseOrThrow(config.wire_transfer_fees); const limitWithFee = Amounts.cmp(limit, wireFee) === 1 ? Amounts.sub(limit, wireFee).amount : Amounts.zeroOfAmount(limit); 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, limitWithFee, 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, limitWithFee, url.host, i18n, paytoType), }); async function doSend() { let payto_uri: PaytoString | undefined; let sendingAmount: AmountString | undefined; if (credentials.status !== "loggedIn") return; let acName: string | undefined; 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); acName = !p.isKnown ? undefined : p.targetType === "iban" ? p.iban : p.targetType === "bitcoin" ? p.address : p.targetType === "x-taler-bank" ? p.account : assertUnreachable(p); } else { if (!account || !subject) return; let payto; acName = account; 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 function createTransactionHandleError() { const request: TalerCorebankApi.CreateTransactionRequest = { payto_uri: puri, amount: sAmount, }; const check = IdempotencyRetry.tryFiveTimes(); const resp = await api.createTransaction(credentials, request, check); 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, when: AbsoluteTime.now(), }); 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, when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_ADMIN_CREDITOR: return notify({ type: "error", title: i18n.str`Bank administrator can't be the transfer creditor.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_UNKNOWN_CREDITOR: return notify({ type: "error", title: i18n.str`The destination account "${ acName ?? puri }" was not found.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); 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, when: AbsoluteTime.now(), }); 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, when: AbsoluteTime.now(), }); 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, when: AbsoluteTime.now(), }); case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: { return notify({ type: "error", title: i18n.str`Tried to create the transaction ${check.maxTries} times with different UID but failed.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); } 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 ( Input wire transfer detail { 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); } } setInputType("form"); }} checked={inputType === "form"} value="form" class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600" /> {/* */} Using a form {sendingToFixedAccount ? undefined : ( { 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)); } setInputType("payto"); }} checked={inputType === "payto"} value="payto" class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600" /> payto:// URI A special URI that indicate the transfer amount and account target. { //FIXME: add QR support false && ( { setInputType("qr"); }} checked={inputType === "qr"} value="qr" class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600" /> QR code If you have a camera in this device you can import a payto:// URI from a QR code. ) } )} {routeCashout ? ( Cashout ) : undefined} { e.preventDefault(); }} > {!isRawPayto ? ( {(() => { switch (paytoType) { case "x-taler-bank": { return ( ); } case "iban": { return ( setAccount(v.toUpperCase())} value={account} focus={focus} disabled={sendingToFixedAccount} /> ); } default: assertUnreachable(paytoType); } })()} {i18n.str`Transfer subject`} * { setSubject(e.currentTarget.value); }} /> Some text to identify the transfer {i18n.str`Amount`} * { setAmount(d); }} /> Amount to transfer ) : ( {i18n.str`Payto URI:`} * { 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); }} /> )} {Amounts.cmp(limitWithFee, balance) > 0 ? ( You can transfer{" "} ) : undefined} {Amounts.isZero(wireFee) ? undefined : ( Cost )} {routeCancel ? ( Cancel ) : ( )} { e.preventDefault(); doSend(); }} > Send ); } /** * 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, ): VNode { const { config } = useBankCoreApiContext(); return ( {currency} { 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); }} /> ); } 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 ( {negative ? "- " : undefined} {currency} {normal}{" "} {!hideSmall && small && {small}} ); } 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; required?: boolean; 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 {children}; } return {children}; } export function TextField({ id, label, help, focus, disabled, onChange, placeholder, rightIcons, required, value, error, }: PaytoFieldProps): VNode { return ( {label} {required && *} { onChange(e.currentTarget.value); }} /> {rightIcons} {help && {help}} ); }
Some text to identify the transfer
Amount to transfer
You can transfer{" "}
{help}