441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
/*
|
|
This file is part of GNU Taler
|
|
(C) 2022 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/>
|
|
*/
|
|
|
|
import { Amounts, Logger, parsePaytoUri } from "@gnu-taler/taler-util";
|
|
import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser";
|
|
import { h, VNode } from "preact";
|
|
import { StateUpdater, useEffect, useRef, useState } from "preact/hooks";
|
|
import { useBackendContext } from "../context/backend.js";
|
|
import { PageStateType, usePageContext } from "../context/pageState.js";
|
|
import {
|
|
InternationalizationAPI,
|
|
useTranslationContext,
|
|
} from "@gnu-taler/web-util/lib/index.browser";
|
|
import { BackendState } from "../hooks/backend.js";
|
|
import { prepareHeaders, undefinedIfEmpty } from "../utils.js";
|
|
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
|
|
|
|
const logger = new Logger("PaytoWireTransferForm");
|
|
|
|
export function PaytoWireTransferForm({
|
|
focus,
|
|
currency,
|
|
}: {
|
|
focus?: boolean;
|
|
currency?: string;
|
|
}): VNode {
|
|
const backend = useBackendContext();
|
|
const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button?
|
|
|
|
const [submitData, submitDataSetter] = useWireTransferRequestType();
|
|
|
|
const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
|
|
undefined,
|
|
);
|
|
const { i18n } = useTranslationContext();
|
|
const ibanRegex = "^[A-Z][A-Z][0-9]+$";
|
|
let transactionData: TransactionRequestType;
|
|
const ref = useRef<HTMLInputElement>(null);
|
|
useEffect(() => {
|
|
if (focus) ref.current?.focus();
|
|
}, [focus, pageState.isRawPayto]);
|
|
|
|
let parsedAmount = undefined;
|
|
|
|
const errorsWire = undefinedIfEmpty({
|
|
iban: !submitData?.iban
|
|
? i18n.str`Missing IBAN`
|
|
: !/^[A-Z0-9]*$/.test(submitData.iban)
|
|
? i18n.str`IBAN should have just uppercased letters and numbers`
|
|
: undefined,
|
|
subject: !submitData?.subject ? i18n.str`Missing subject` : undefined,
|
|
amount: !submitData?.amount
|
|
? i18n.str`Missing amount`
|
|
: !(parsedAmount = Amounts.parse(`${currency}:${submitData.amount}`))
|
|
? i18n.str`Amount is not valid`
|
|
: Amounts.isZero(parsedAmount)
|
|
? i18n.str`Should be greater than 0`
|
|
: undefined,
|
|
});
|
|
|
|
if (!pageState.isRawPayto)
|
|
return (
|
|
<div>
|
|
<form
|
|
class="pure-form"
|
|
name="wire-transfer-form"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
<p>
|
|
<label for="iban">{i18n.str`Receiver IBAN:`}</label>
|
|
<input
|
|
ref={ref}
|
|
type="text"
|
|
id="iban"
|
|
name="iban"
|
|
value={submitData?.iban ?? ""}
|
|
placeholder="CC0123456789"
|
|
required
|
|
pattern={ibanRegex}
|
|
onInput={(e): void => {
|
|
submitDataSetter((submitData) => ({
|
|
...submitData,
|
|
iban: e.currentTarget.value,
|
|
}));
|
|
}}
|
|
/>
|
|
<br />
|
|
<ShowInputErrorLabel
|
|
message={errorsWire?.iban}
|
|
isDirty={submitData?.iban !== undefined}
|
|
/>
|
|
<br />
|
|
<label for="subject">{i18n.str`Transfer subject:`}</label>
|
|
<input
|
|
type="text"
|
|
name="subject"
|
|
id="subject"
|
|
placeholder="subject"
|
|
value={submitData?.subject ?? ""}
|
|
required
|
|
onInput={(e): void => {
|
|
submitDataSetter((submitData) => ({
|
|
...submitData,
|
|
subject: e.currentTarget.value,
|
|
}));
|
|
}}
|
|
/>
|
|
<br />
|
|
<ShowInputErrorLabel
|
|
message={errorsWire?.subject}
|
|
isDirty={submitData?.subject !== undefined}
|
|
/>
|
|
<br />
|
|
<label for="amount">{i18n.str`Amount:`}</label>
|
|
<div style={{ width: "max-content" }}>
|
|
<input
|
|
type="text"
|
|
readonly
|
|
class="currency-indicator"
|
|
size={currency?.length}
|
|
maxLength={currency?.length}
|
|
tabIndex={-1}
|
|
value={currency}
|
|
/>
|
|
|
|
<input
|
|
type="number"
|
|
name="amount"
|
|
id="amount"
|
|
placeholder="amount"
|
|
required
|
|
value={submitData?.amount ?? ""}
|
|
onInput={(e): void => {
|
|
submitDataSetter((submitData) => ({
|
|
...submitData,
|
|
amount: e.currentTarget.value,
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
<ShowInputErrorLabel
|
|
message={errorsWire?.amount}
|
|
isDirty={submitData?.amount !== undefined}
|
|
/>
|
|
</p>
|
|
|
|
<p style={{ display: "flex", justifyContent: "space-between" }}>
|
|
<input
|
|
type="submit"
|
|
class="pure-button pure-button-primary"
|
|
disabled={!!errorsWire}
|
|
value="Send"
|
|
onClick={async (e) => {
|
|
e.preventDefault();
|
|
if (
|
|
typeof submitData === "undefined" ||
|
|
typeof submitData.iban === "undefined" ||
|
|
submitData.iban === "" ||
|
|
typeof submitData.subject === "undefined" ||
|
|
submitData.subject === "" ||
|
|
typeof submitData.amount === "undefined" ||
|
|
submitData.amount === ""
|
|
) {
|
|
logger.error("Not all the fields were given.");
|
|
pageStateSetter((prevState: PageStateType) => ({
|
|
...prevState,
|
|
|
|
error: {
|
|
title: i18n.str`Field(s) missing.`,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
transactionData = {
|
|
paytoUri: `payto://iban/${
|
|
submitData.iban
|
|
}?message=${encodeURIComponent(submitData.subject)}`,
|
|
amount: `${currency}:${submitData.amount}`,
|
|
};
|
|
return await createTransactionCall(
|
|
transactionData,
|
|
backend.state,
|
|
pageStateSetter,
|
|
() =>
|
|
submitDataSetter((p) => ({
|
|
amount: undefined,
|
|
iban: undefined,
|
|
subject: undefined,
|
|
})),
|
|
i18n,
|
|
);
|
|
}}
|
|
/>
|
|
<input
|
|
type="button"
|
|
class="pure-button"
|
|
value="Clear"
|
|
onClick={async (e) => {
|
|
e.preventDefault();
|
|
submitDataSetter((p) => ({
|
|
amount: undefined,
|
|
iban: undefined,
|
|
subject: undefined,
|
|
}));
|
|
}}
|
|
/>
|
|
</p>
|
|
</form>
|
|
<p>
|
|
<a
|
|
href="/account"
|
|
onClick={() => {
|
|
logger.trace("switch to raw payto form");
|
|
pageStateSetter((prevState) => ({
|
|
...prevState,
|
|
isRawPayto: true,
|
|
}));
|
|
}}
|
|
>
|
|
{i18n.str`Want to try the raw payto://-format?`}
|
|
</a>
|
|
</p>
|
|
</div>
|
|
);
|
|
|
|
const errorsPayto = undefinedIfEmpty({
|
|
rawPaytoInput: !rawPaytoInput
|
|
? i18n.str`Missing payto address`
|
|
: !parsePaytoUri(rawPaytoInput)
|
|
? i18n.str`Payto does not follow the pattern`
|
|
: undefined,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<p>{i18n.str`Transfer money to account identified by payto:// URI:`}</p>
|
|
<div class="pure-form" name="payto-form">
|
|
<p>
|
|
<label for="address">{i18n.str`payto URI:`}</label>
|
|
<input
|
|
name="address"
|
|
type="text"
|
|
size={50}
|
|
ref={ref}
|
|
id="address"
|
|
value={rawPaytoInput ?? ""}
|
|
required
|
|
placeholder={i18n.str`payto address`}
|
|
// pattern={`payto://iban/[A-Z][A-Z][0-9]+?message=[a-zA-Z0-9 ]+&amount=${currency}:[0-9]+(.[0-9]+)?`}
|
|
onInput={(e): void => {
|
|
rawPaytoInputSetter(e.currentTarget.value);
|
|
}}
|
|
/>
|
|
<ShowInputErrorLabel
|
|
message={errorsPayto?.rawPaytoInput}
|
|
isDirty={rawPaytoInput !== undefined}
|
|
/>
|
|
<br />
|
|
<div class="hint">
|
|
Hint:
|
|
<code>
|
|
payto://iban/[receiver-iban]?message=[subject]&amount=[{currency}
|
|
:X.Y]
|
|
</code>
|
|
</div>
|
|
</p>
|
|
<p>
|
|
<input
|
|
class="pure-button pure-button-primary"
|
|
type="button"
|
|
disabled={!!errorsPayto}
|
|
value={i18n.str`Send`}
|
|
onClick={async () => {
|
|
// empty string evaluates to false.
|
|
if (!rawPaytoInput) {
|
|
logger.error("Didn't get any raw Payto string!");
|
|
return;
|
|
}
|
|
transactionData = { paytoUri: rawPaytoInput };
|
|
if (
|
|
typeof transactionData.paytoUri === "undefined" ||
|
|
transactionData.paytoUri.length === 0
|
|
)
|
|
return;
|
|
|
|
return await createTransactionCall(
|
|
transactionData,
|
|
backend.state,
|
|
pageStateSetter,
|
|
() => rawPaytoInputSetter(undefined),
|
|
i18n,
|
|
);
|
|
}}
|
|
/>
|
|
</p>
|
|
<p>
|
|
<a
|
|
href="/account"
|
|
onClick={() => {
|
|
logger.trace("switch to wire-transfer-form");
|
|
pageStateSetter((prevState) => ({
|
|
...prevState,
|
|
isRawPayto: false,
|
|
}));
|
|
}}
|
|
>
|
|
{i18n.str`Use wire-transfer form?`}
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stores in the state a object representing a wire transfer,
|
|
* in order to avoid losing the handle of the data entered by
|
|
* the user in <input> fields. FIXME: name not matching the
|
|
* purpose, as this is not a HTTP request body but rather the
|
|
* state of the <input>-elements.
|
|
*/
|
|
type WireTransferRequestTypeOpt = WireTransferRequestType | undefined;
|
|
function useWireTransferRequestType(
|
|
state?: WireTransferRequestType,
|
|
): [WireTransferRequestTypeOpt, StateUpdater<WireTransferRequestTypeOpt>] {
|
|
const ret = useLocalStorage(
|
|
"wire-transfer-request-state",
|
|
JSON.stringify(state),
|
|
);
|
|
const retObj: WireTransferRequestTypeOpt = ret[0]
|
|
? JSON.parse(ret[0])
|
|
: ret[0];
|
|
const retSetter: StateUpdater<WireTransferRequestTypeOpt> = function (val) {
|
|
const newVal =
|
|
val instanceof Function
|
|
? JSON.stringify(val(retObj))
|
|
: JSON.stringify(val);
|
|
ret[1](newVal);
|
|
};
|
|
return [retObj, retSetter];
|
|
}
|
|
|
|
/**
|
|
* This function creates a new transaction. It reads a Payto
|
|
* address entered by the user and POSTs it to the bank. No
|
|
* sanity-check of the input happens before the POST as this is
|
|
* already conducted by the backend.
|
|
*/
|
|
async function createTransactionCall(
|
|
req: TransactionRequestType,
|
|
backendState: BackendState,
|
|
pageStateSetter: StateUpdater<PageStateType>,
|
|
/**
|
|
* Optional since the raw payto form doesn't have
|
|
* a stateful management of the input data yet.
|
|
*/
|
|
cleanUpForm: () => void,
|
|
i18n: InternationalizationAPI,
|
|
): Promise<void> {
|
|
if (backendState.status === "loggedOut") {
|
|
logger.error("No credentials found.");
|
|
pageStateSetter((prevState) => ({
|
|
...prevState,
|
|
|
|
error: {
|
|
title: i18n.str`No credentials found.`,
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
let res: Response;
|
|
try {
|
|
const { username, password } = backendState;
|
|
const headers = prepareHeaders(username, password);
|
|
const url = new URL(
|
|
`access-api/accounts/${backendState.username}/transactions`,
|
|
backendState.url,
|
|
);
|
|
res = await fetch(url.href, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(req),
|
|
});
|
|
} catch (error) {
|
|
logger.error("Could not POST transaction request to the bank", error);
|
|
pageStateSetter((prevState) => ({
|
|
...prevState,
|
|
|
|
error: {
|
|
title: i18n.str`Could not create the wire transfer`,
|
|
description: (error as any).error.description,
|
|
debug: JSON.stringify(error),
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
// POST happened, status not sure yet.
|
|
if (!res.ok) {
|
|
const response = await res.json();
|
|
logger.error(
|
|
`Transfer creation gave response error: ${response} (${res.status})`,
|
|
);
|
|
pageStateSetter((prevState) => ({
|
|
...prevState,
|
|
|
|
error: {
|
|
title: i18n.str`Transfer creation gave response error`,
|
|
description: response.error.description,
|
|
debug: JSON.stringify(response),
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
// status is 200 OK here, tell the user.
|
|
logger.trace("Wire transfer created!");
|
|
pageStateSetter((prevState) => ({
|
|
...prevState,
|
|
|
|
info: i18n.str`Wire transfer created!`,
|
|
}));
|
|
|
|
// Only at this point the input data can
|
|
// be discarded.
|
|
cleanUpForm();
|
|
}
|