commit 4f55f3f224ff5683c54e010364fc28afa3029c8f parent eeea4783750d2237567e568d0c20a64cb0736b8b Author: Sebastian <sebasjm@gmail.com> Date: Mon, 23 Sep 2024 16:06:27 -0300 show instructions on how to complete kyc auth Diffstat:
12 files changed, 636 insertions(+), 244 deletions(-)
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -209,7 +209,7 @@ export function PaytoWireTransferForm({ return notify({ type: "error", title: i18n.str`The request was invalid or the payto://-URI used unacceptable features.`, - description: resp.detail?.hint as TranslatedString , + description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); @@ -217,7 +217,7 @@ export function PaytoWireTransferForm({ return notify({ type: "error", title: i18n.str`Not enough permission to complete the operation.`, - description: resp.detail?.hint as TranslatedString , + description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); @@ -225,7 +225,7 @@ export function PaytoWireTransferForm({ return notify({ type: "error", title: i18n.str`Bank administrator can't be the transfer creditor.`, - description: resp.detail?.hint as TranslatedString , + description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); @@ -235,7 +235,7 @@ export function PaytoWireTransferForm({ title: i18n.str`The destination account "${ acName ?? puri }" was not found.`, - description: resp.detail?.hint as TranslatedString , + description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); @@ -243,7 +243,7 @@ export function PaytoWireTransferForm({ 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 , + description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); @@ -251,7 +251,7 @@ export function PaytoWireTransferForm({ return notify({ type: "error", title: i18n.str`Your balance is not enough.`, - description: resp.detail?.hint as TranslatedString , + description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); @@ -259,7 +259,7 @@ export function PaytoWireTransferForm({ return notify({ type: "error", title: i18n.str`The origin account "${puri}" was not found.`, - description: resp.detail?.hint as TranslatedString , + description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); @@ -267,7 +267,7 @@ export function PaytoWireTransferForm({ 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 , + description: resp.detail?.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); @@ -341,7 +341,9 @@ export function PaytoWireTransferForm({ setAmount(Amounts.stringifyValue(amount)); } } - const subject = parsed.params["message"]; + const subject = !parsed.params["message"] + ? parsed.params["subject"] + : parsed.params["message"]; if (subject) { setSubject(subject); } @@ -865,10 +867,12 @@ function validateRawPayto( 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`; + if (!parsed.params.message && !parsed.params.subject) { + return i18n.str`Missing the "message" or "subject" parameter to specify a reference text for the transfer`; } - const subject = parsed.params.message; + const subject = !parsed.params.message + ? parsed.params.subject + : parsed.params.message; result = validateSubject(subject, i18n); if (result) return result; diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -610,7 +610,13 @@ export function Routing(_p: Props): VNode { }} /> - <Route path={InstancePaths.kyc} component={ListKYCPage} /> + <Route + path={InstancePaths.kyc} + component={ListKYCPage} + // onSelect={(id: string) => { + // route(InstancePaths.bank_update.replace(":bid", id)); + // }} + /> <Route path={InstancePaths.interface} component={Settings} /> {/** * Example pages @@ -688,8 +694,10 @@ function KycBanner(): VNode { kycStatus !== undefined && !(kycStatus instanceof TalerError) && kycStatus.type === "ok" && - !!kycStatus.body; - + !!kycStatus.body && + kycStatus.body.kyc_data.findIndex( + (d) => d.payto_kycauths !== undefined || d.access_token !== undefined, + ) !== -1; const hidden = AbsoluteTime.cmp(now, prefs.hideKycUntil) < 1; if (hidden || !needsToBeShown) return <Fragment />; diff --git a/packages/merchant-backoffice-ui/src/components/Amount.tsx b/packages/merchant-backoffice-ui/src/components/Amount.tsx @@ -0,0 +1,107 @@ +/* + 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 { + amountFractionalBase, + amountFractionalLength, + AmountJson, + Amounts, + AmountString, +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; + +export function Amount({ + value, + maxFracSize, + negative, + hideCurrency, + signType = "standard", + signDisplay = "auto", +}: { + negative?: boolean; + value: AmountJson | AmountString; + maxFracSize?: number; + hideCurrency?: boolean; + signType?: "accounting" | "standard"; + signDisplay?: "auto" | "always" | "never" | "exceptZero"; +}): VNode { + const aj = Amounts.jsonifyAmount(value); + const minFractional = + maxFracSize !== undefined && maxFracSize < 2 ? maxFracSize : 2; + const af = aj.fraction % amountFractionalBase; + let s = ""; + if ((af && maxFracSize) || minFractional > 0) { + s += "."; + let n = af; + for ( + let i = 0; + (maxFracSize === undefined || i < maxFracSize) && + i < amountFractionalLength; + i++ + ) { + if (!n && i >= minFractional) { + break; + } + s = s + Math.floor((n / amountFractionalBase) * 10).toString(); + n = (n * 10) % amountFractionalBase; + } + } + const fontSize = 18; + const letterSpacing = 0; + const mult = 0.7; + return ( + <span style={{ textAlign: "right", whiteSpace: "nowrap" }}> + <span + style={{ + display: "inline-block", + fontFamily: "monospace", + fontSize, + }} + > + {negative ? (signType === "accounting" ? "(" : "-") : ""} + <span + style={{ + display: "inline-block", + textAlign: "right", + fontFamily: "monospace", + fontSize, + letterSpacing, + }} + > + {aj.value} + </span> + <span + style={{ + display: "inline-block", + width: !maxFracSize ? undefined : `${(maxFracSize + 1) * mult}em`, + textAlign: "left", + fontFamily: "monospace", + fontSize, + letterSpacing, + }} + > + {s} + {negative && signType === "accounting" ? ")" : ""} + </span> + </span> + {hideCurrency ? undefined : ( + <Fragment> + + <span>{aj.currency}</span> + </Fragment> + )} + </span> + ); +} diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -44,7 +44,10 @@ export function Sidebar({ mobile }: Props): VNode { kycStatus !== undefined && !(kycStatus instanceof TalerError) && kycStatus.type === "ok" && - !!kycStatus.body; + !!kycStatus.body && + kycStatus.body.kyc_data.findIndex( + (d) => d.payto_kycauths !== undefined || d.access_token !== undefined, + ) !== -1; const isLoggedIn = state.status === "loggedIn"; const hasToken = isLoggedIn && state.token !== undefined; @@ -143,8 +146,15 @@ export function Sidebar({ mobile }: Props): VNode { </li> ) : undefined} {needKYC && ( - <li> - <a href={"/kyc"} class="has-icon"> + <li class="is-warning"> + <a + href={"/kyc"} + class="has-icon" + style={{ + backgroundColor: "darkorange", + color: "black", + }} + > <span class="icon"> <i class="mdi mdi-account-check" /> </span> diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -25,16 +25,18 @@ import { PaytoString, PaytoUri, stringifyPaytoUri, + TranslatedString, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { useSessionContext } from "../../context/session.js"; import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js"; import { undefinedIfEmpty } from "../../utils/table.js"; import { Spinner } from "../exception/loading.js"; import { FormErrors, FormProvider } from "../form/FormProvider.js"; import { Input } from "../form/Input.js"; +import { Amount } from "../Amount.js"; interface Props { active?: boolean; @@ -420,6 +422,253 @@ export function CompareAccountsModal({ ); } +interface ValidateBankAccountModalProps { + onCancel: () => void; + origin: PaytoUri; + targets: PaytoUri[]; +} +export function ValidBankAccount({ + onCancel, + origin, + targets, +}: ValidateBankAccountModalProps): VNode { + const { i18n } = useTranslationContext(); + const payto = targets[0]; + const subject = payto.params["subject"]; + + const accountPart = !payto.isKnown ? ( + <Fragment> + <Row name={i18n.str`Account`} value={payto.targetPath} /> + </Fragment> + ) : payto.targetType === "x-taler-bank" ? ( + <Fragment> + <Row name={i18n.str`Bank host`} value={payto.host} /> + <Row name={i18n.str`Bank account`} value={payto.account} /> + </Fragment> + ) : payto.targetType === "iban" ? ( + <Fragment> + {payto.bic !== undefined ? ( + <Row name={i18n.str`BIC`} value={payto.bic} /> + ) : undefined} + <Row name={i18n.str`IBAN`} value={payto.iban} /> + </Fragment> + ) : undefined; + + const receiver = + payto.params["receiver-name"] || payto.params["receiver"] || undefined; + + const from = !origin.isKnown + ? origin.targetPath + : origin.targetType === "iban" + ? origin.iban + : origin.targetType === "bitcoin" + ? `${origin.address.substring(0, 8)}...` + : origin.account; + + return ( + <ConfirmModal + label={i18n.str`Ok`} + description={i18n.str`Validate bank account: ${from}`} + active + onCancel={onCancel} + // onConfirm={onConfirm} + > + <p style={{ paddingTop: 0 }}> + <i18n.Translate> + You need to make a bank transfer with the specified subject to + validate that you are the owner of the account. + </i18n.Translate> + </p> + <div class="table-container"> + <table> + <tbody> + <tr> + <td colSpan={3}> + <i18n.Translate>Step 1:</i18n.Translate> + + <i18n.Translate> + Copy this code and paste it into the subject/purpose field in + your banking app or bank website + </i18n.Translate> + </td> + </tr> + <Row name={i18n.str`Subject`} value={subject} literal /> + + <tr> + <td colSpan={3}> + <i18n.Translate>Step 2:</i18n.Translate> + + <i18n.Translate> + Copy and paste this IBAN and the name into the receiver fields + in your banking app or website + </i18n.Translate> + </td> + </tr> + {accountPart} + {receiver ? ( + <Row name={i18n.str`Receiver name`} value={receiver} /> + ) : undefined} + + <tr> + <td colSpan={3}> + <i18n.Translate>Step 3:</i18n.Translate> + + <i18n.Translate> + Finish the wire transfer setting smallest amount in your + banking app or website. + </i18n.Translate> + </td> + </tr> + {/* <Row + name={i18n.str`Amount`} + value={ + <Amount + value={payto.params["amount"] as AmountString} + hideCurrency + /> + } + /> */} + + <tr> + <td colSpan={3}> + {/* <WarningBox style={{ margin: 0 }}> */} + <b> + <i18n.Translate> + Make sure ALL data is correct, including the subject and you + are using your selected bank account. You can use the copy + buttons (<CopyIcon />) to prevent typing errors or the + "payto://" URI below to copy just one value. + </i18n.Translate> + </b> + {/* </WarningBox> */} + </td> + </tr> + + <tr> + <td colSpan={2} width="100%" style={{ wordBreak: "break-all" }}> + <i18n.Translate> + Alternative if your bank already supports PayTo URI, you can + use this{" "} + <a + target="_bank" + rel="noreferrer" + title="RFC 8905 for designating targets for payments" + href="https://tools.ietf.org/html/rfc8905" + > + PayTo URI + </a>{" "} + link instead + </i18n.Translate> + </td> + <td> + <CopyButton getContent={() => stringifyPaytoUri(payto)} /> + </td> + </tr> + </tbody> + </table> + </div> + </ConfirmModal> + ); +} + +function Row({ + name, + value, + literal, +}: { + name: TranslatedString; + value: string | VNode; + literal?: boolean; +}): VNode { + const preRef = useRef<HTMLPreElement>(null); + const tdRef = useRef<HTMLTableCellElement>(null); + + function getContent(): string { + return preRef.current?.textContent || tdRef.current?.textContent || ""; + } + + return ( + <tr> + <td style={{ padding: 4, width: "1%", whiteSpace: "nowrap" }}> + <b>{name}</b> + </td> + {literal ? ( + <td style={{ padding: 4 }}> + <pre + ref={preRef} + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + padding: 4, + }} + > + {value} + </pre> + </td> + ) : ( + <td ref={tdRef} style={{ padding: 4 }}> + {value} + </td> + )} + <td style={{ padding: 4 }}> + <CopyButton getContent={getContent} /> + </td> + </tr> + ); +} + +function CopyButton({ getContent }: { getContent: () => string }): VNode { + const [copied, setCopied] = useState(false); + function copyText(): void { + navigator.clipboard.writeText(getContent() || ""); + setCopied(true); + } + useEffect(() => { + if (copied) { + setTimeout(() => { + setCopied(false); + }, 1000); + } + }, [copied]); + + if (!copied) { + return ( + <button onClick={copyText}> + <CopyIcon /> + </button> + ); + } + return ( + // <TooltipLeft content="Copied"> + <button disabled> + <CopiedIcon /> + </button> + // </TooltipLeft> + ); +} + +export const CopyIcon = (): VNode => ( + <svg height="16" viewBox="0 0 16 16" width="16"> + <path + fill-rule="evenodd" + d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z" + /> + <path + fill-rule="evenodd" + d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z" + /> + </svg> +); + +export const CopiedIcon = (): VNode => ( + <svg height="16" viewBox="0 0 16 16" width="16"> + <path + fill-rule="evenodd" + d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" + /> + </svg> +); + interface DeleteModalProps { element: { id: string; name: string }; onCancel: () => void; diff --git a/packages/merchant-backoffice-ui/src/hooks/urls.ts b/packages/merchant-backoffice-ui/src/hooks/urls.ts @@ -40,10 +40,7 @@ export const API_GET_ORDER_BY_ID = ( url: `http://backend/instances/default/private/orders/${id}`, }); -export const API_LIST_ORDERS: Query< - unknown, - TalerMerchantApi.OrderHistory -> = { +export const API_LIST_ORDERS: Query<unknown, TalerMerchantApi.OrderHistory> = { method: "GET", url: "http://backend/instances/default/private/orders", }; @@ -76,13 +73,11 @@ export const API_DELETE_ORDER = ( // TRANSFER //////////////////// -export const API_LIST_TRANSFERS: Query< - unknown, - TalerMerchantApi.TransferList -> = { - method: "GET", - url: "http://backend/instances/default/private/transfers", -}; +export const API_LIST_TRANSFERS: Query<unknown, TalerMerchantApi.TransferList> = + { + method: "GET", + url: "http://backend/instances/default/private/transfers", + }; export const API_INFORM_TRANSFERS: Query< TalerMerchantApi.TransferInformation, @@ -155,7 +150,7 @@ export const API_GET_INSTANCE_BY_ID = ( export const API_GET_INSTANCE_KYC_BY_ID = ( id: string, -): Query<unknown, TalerMerchantApi.LegacyAccountKycRedirects> => ({ +): Query<unknown, TalerMerchantApi.MerchantAccountKycRedirectsResponse> => ({ method: "GET", url: `http://backend/management/instances/${id}/kyc`, }); @@ -170,20 +165,14 @@ export const API_LIST_INSTANCES: Query< export const API_UPDATE_INSTANCE_BY_ID = ( id: string, -): Query< - TalerMerchantApi.InstanceReconfigurationMessage, - unknown -> => ({ +): Query<TalerMerchantApi.InstanceReconfigurationMessage, unknown> => ({ method: "PATCH", url: `http://backend/management/instances/${id}`, }); export const API_UPDATE_INSTANCE_AUTH_BY_ID = ( id: string, -): Query< - TalerMerchantApi.InstanceAuthConfigurationMessage, - unknown -> => ({ +): Query<TalerMerchantApi.InstanceAuthConfigurationMessage, unknown> => ({ method: "POST", url: `http://backend/management/instances/${id}/auth`, }); @@ -207,7 +196,7 @@ export const API_GET_CURRENT_INSTANCE: Query< export const API_GET_CURRENT_INSTANCE_KYC: Query< unknown, - TalerMerchantApi.LegacyAccountKycRedirects + TalerMerchantApi.MerchantAccountKycRedirectsResponse > = { method: "GET", url: `http://backend/instances/default/private/kyc`, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx @@ -34,23 +34,29 @@ export default { export const Example = tests.createExample(TestedComponent, { status: { - timeout_kycs: [], - pending_kycs: [ + // timeout_kycs: [], + kyc_data: [ { exchange_url: "http://exchange.taler", payto_uri: "payto://iban/de123123123" as PaytoString, - kyc_url: "http://exchange.taler/kyc", + // kyc_url: "http://exchange.taler/kyc", exchange_http_status: 0, + auth_conflict: false, + no_keys: false, }, { exchange_http_status: 1, exchange_url: "http://exchange.taler", payto_uri: "payto://iban/de123123123" as PaytoString, + auth_conflict: false, + no_keys: false, }, { exchange_http_status: 2, exchange_url: "http://exchange.taler", payto_uri: "payto://iban/de123123123" as PaytoString, + auth_conflict: false, + no_keys: false, }, ], }, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx @@ -19,15 +19,27 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerMerchantApi } from "@gnu-taler/taler-util"; +import { + AccessToken, + encodeCrock, + hashPaytoUri, + hashWire, + TalerMerchantApi, +} from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; export interface Props { - status: TalerMerchantApi.LegacyAccountKycRedirects; + status: TalerMerchantApi.MerchantAccountKycRedirectsResponse; + // onGetInfo: (url: string, token: AccessToken) => void; + onShowInstructions: (toAccounts: string[], fromAccount: string) => void; } -export function ListPage({ status }: Props): VNode { +export function ListPage({ + status, + // onGetInfo, + onShowInstructions, +}: Props): VNode { const { i18n } = useTranslationContext(); return ( @@ -46,8 +58,12 @@ export function ListPage({ status }: Props): VNode { <div class="card-content"> <div class="b-table has-pagination"> <div class="table-wrapper has-mobile-cards"> - {status.pending_kycs.length > 0 ? ( - <PendingTable entries={status.pending_kycs} /> + {status.kyc_data.length > 0 ? ( + <PendingTable + entries={status.kyc_data} + // onGetInfo={onGetInfo} + onShowInstructions={onShowInstructions} + /> ) : ( <EmptyTable /> )} @@ -55,44 +71,28 @@ export function ListPage({ status }: Props): VNode { </div> </div> </div> - - {status.timeout_kycs.length > 0 ? ( - <div class="card has-table"> - <header class="card-header"> - <p class="card-header-title"> - <span class="icon"> - <i class="mdi mdi-clock" /> - </span> - <i18n.Translate>Timed out</i18n.Translate> - </p> - - <div class="card-header-icon" aria-label="more options" /> - </header> - <div class="card-content"> - <div class="b-table has-pagination"> - <div class="table-wrapper has-mobile-cards"> - {status.timeout_kycs.length > 0 ? ( - <TimedOutTable entries={status.timeout_kycs} /> - ) : ( - <EmptyTable /> - )} - </div> - </div> - </div> - </div> - ) : undefined} </section> ); } interface PendingTableProps { +<<<<<<< HEAD entries: TalerMerchantApi.LegacyMerchantAccountKycRedirect[]; +======= + entries: TalerMerchantApi.MerchantAccountKycRedirect[]; + // onGetInfo: (url: string, token: AccessToken) => void; + onShowInstructions: (toAccounts: string[], fromAccount: string) => void; +>>>>>>> 452d4eb69 (show instructions on how to complete kyc auth) } interface TimedOutTableProps { entries: TalerMerchantApi.ExchangeKycTimeout[]; } -function PendingTable({ entries }: PendingTableProps): VNode { +function PendingTable({ + entries, + onShowInstructions, + // onGetInfo, +}: PendingTableProps): VNode { const { i18n } = useTranslationContext(); return ( <div class="table-container"> @@ -103,7 +103,7 @@ function PendingTable({ entries }: PendingTableProps): VNode { <i18n.Translate>Exchange</i18n.Translate> </th> <th> - <i18n.Translate>Target account</i18n.Translate> + <i18n.Translate>Account</i18n.Translate> </th> <th> <i18n.Translate>Reason</i18n.Translate> @@ -112,31 +112,37 @@ function PendingTable({ entries }: PendingTableProps): VNode { </thead> <tbody> {entries.map((e, i) => { - if (e.kyc_url === undefined) { - // blocked by AML + if (e.payto_kycauths === undefined) { + const spa = new URL(`kyc-spa/${e.access_token}`, e.exchange_url) + .href; return ( <tr key={i}> <td>{e.exchange_url}</td> <td>{e.payto_uri}</td> <td> - <i18n.Translate> - There is an anti-money laundering process pending to - complete. - </i18n.Translate> - + <a href={spa} target="_black" rel="noreferrer"> + <i18n.Translate> + Pending KYC process, click here to complete + </i18n.Translate> + </a> </td> </tr> ); } else { - // blocked by KYC + const accounts = e.payto_kycauths; return ( <tr key={i}> <td>{e.exchange_url}</td> - <td>{e.payto_uri}</td> + <td + onClick={() => onShowInstructions(accounts, e.payto_uri)} + style={{ cursor: "pointer" }} + > + {e.payto_uri} + </td> <td> - <a href={e.kyc_url} target="_black" rel="noreferrer"> + <a href={e.access_token} target="_black" rel="noreferrer"> <i18n.Translate> - Pending KYC process, click here to complete + The exchange require a account verification. </i18n.Translate> </a> </td> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx @@ -19,21 +19,34 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { HttpStatusCode, TalerError, assertUnreachable } from "@gnu-taler/taler-util"; -import { VNode, h } from "preact"; +import { + HttpStatusCode, + TalerError, + TalerExchangeHttpClient, + assertUnreachable, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; import { Loading } from "../../../../components/exception/loading.js"; import { useInstanceKYCDetails } from "../../../../hooks/instance.js"; import { ListPage } from "./ListPage.js"; +import { useState } from "preact/hooks"; +import { ValidBankAccount } from "../../../../components/modal/index.js"; interface Props { + // onGetInfo: (id: string) => void; + // onShowInstructions: (id: string) => void; } export default function ListKYC(_p: Props): VNode { const result = useInstanceKYCDetails(); - if (!result) return <Loading /> + const [showingInstructions, setShowingInstructions] = useState< + { toAccounts: string[]; fromAccount: string } | undefined + >(undefined); + if (!result) return <Loading />; if (result instanceof TalerError) { - return <ErrorLoadingMerchant error={result} /> + return <ErrorLoadingMerchant error={result} />; } /** * This component just render known kyc requirements. @@ -42,23 +55,31 @@ export default function ListKYC(_p: Props): VNode { if (result.type === "fail") { switch (result.case) { case HttpStatusCode.GatewayTimeout: { - return <div /> + return <div />; } case HttpStatusCode.BadGateway: { - return <ListPage status={result.body} />; - + break; + // return ( + // <ListPage + // status={result.body} + // onGetInfo={_p.onGetInfo} + // onShowInstructions={() => { + // setShowingInstructions(true) + // }} + // /> + // ); } case HttpStatusCode.ServiceUnavailable: { - return <div /> + return <div />; } case HttpStatusCode.Unauthorized: { - return <div /> + return <div />; } case HttpStatusCode.NotFound: { return <div />; } default: { - assertUnreachable(result) + assertUnreachable(result); } } } @@ -67,5 +88,28 @@ export default function ListKYC(_p: Props): VNode { if (!status) { return <div>no kyc required</div>; } - return <ListPage status={status} />; + return ( + <Fragment> + {showingInstructions !== undefined ? ( + <Fragment> + <ValidBankAccount + origin={parsePaytoUri(showingInstructions.fromAccount)!} + targets={showingInstructions.toAccounts.map( + (d) => parsePaytoUri(d)!, + )} + onCancel={() => setShowingInstructions(undefined)} + /> + </Fragment> + ) : undefined} + <ListPage + status={status} + // onGetInfo={async (exchange, ac) => { + // new URL() + // }} + onShowInstructions={(toAccounts, fromAccount) => { + setShowingInstructions({ toAccounts, fromAccount }); + }} + /> + </Fragment> + ); } diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -29,7 +29,7 @@ import { TalerMerchantConfigResponse, codecForAbortResponse, codecForAccountAddResponse, - codecForLegacyAccountKycRedirects, + codecForAccountKycRedirects, codecForAccountsSummaryResponse, codecForBankAccountDetail, codecForCategoryListResponse, @@ -278,7 +278,11 @@ export class TalerMerchantInstanceHttpClient { case HttpStatusCode.GatewayTimeout: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.UnavailableForLegalReasons: - return opKnownAlternativeFailure(resp, resp.status, codecForPaymentDeniedLegallyResponse()) + return opKnownAlternativeFailure( + resp, + resp.status, + codecForPaymentDeniedLegallyResponse(), + ); default: return opUnknownFailure(resp, await readTalerErrorResponse(resp)); } @@ -435,7 +439,11 @@ export class TalerMerchantInstanceHttpClient { case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.UnavailableForLegalReasons: - return opKnownAlternativeFailure(resp, resp.status, codecForPaymentDeniedLegallyResponse()) + return opKnownAlternativeFailure( + resp, + resp.status, + codecForPaymentDeniedLegallyResponse(), + ); default: return opUnknownFailure(resp, await readTalerErrorResponse(resp)); } @@ -609,8 +617,10 @@ export class TalerMerchantInstanceHttpClient { }); switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForAccountKycRedirects()); case HttpStatusCode.Accepted: - return opSuccessFromHttp(resp, codecForLegacyAccountKycRedirects()); + return opSuccessFromHttp(resp, codecForAccountKycRedirects()); case HttpStatusCode.NoContent: return opEmptySuccess(resp); case HttpStatusCode.Unauthorized: // FIXME: missing in docs @@ -621,7 +631,7 @@ export class TalerMerchantInstanceHttpClient { return opKnownAlternativeFailure( resp, resp.status, - codecForLegacyAccountKycRedirects(), + codecForAccountKycRedirects(), ); case HttpStatusCode.ServiceUnavailable: return opKnownHttpFailure(resp.status, resp); @@ -1240,7 +1250,11 @@ export class TalerMerchantInstanceHttpClient { case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.UnavailableForLegalReasons: - return opKnownAlternativeFailure(resp, resp.status, codecForPaymentDeniedLegallyResponse()) + return opKnownAlternativeFailure( + resp, + resp.status, + codecForPaymentDeniedLegallyResponse(), + ); case HttpStatusCode.Conflict: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Gone: @@ -1479,7 +1493,11 @@ export class TalerMerchantInstanceHttpClient { case HttpStatusCode.Conflict: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.UnavailableForLegalReasons: - return opKnownAlternativeFailure(resp, resp.status, codecForPaymentDeniedLegallyResponse()) + return opKnownAlternativeFailure( + resp, + resp.status, + codecForPaymentDeniedLegallyResponse(), + ); default: return opUnknownFailure(resp, await readTalerErrorResponse(resp)); } @@ -2575,7 +2593,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp }); switch (resp.status) { case HttpStatusCode.Accepted: - return opSuccessFromHttp(resp, codecForLegacyAccountKycRedirects()); + return opSuccessFromHttp(resp, codecForAccountKycRedirects()); case HttpStatusCode.NoContent: return opEmptySuccess(resp); case HttpStatusCode.NotFound: @@ -2593,3 +2611,29 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp } } } + +// 2024-09-23T01:23:14.421Z http.ts INFO malformed error response (status 200): { +// "kyc_data": [ +// { +// "payto_uri": "payto://iban/DE1327812254798?receiver-name=the%20name%20of%20merchant", +// "exchange_url": "http://exchange.taler.test:1180/", +// "no_keys": false, +// "auth_conflict": false, +// "exchange_http_status": 204, +// "limits": [], +// "payto_kycauths": [ +// "payto://iban/DE9714548806481?receiver-name=Exchanger+Normal&subject=54DR9R0CEWA1A7FK3QWABJ1PRBCD2X6S418Y5DE0P9Q1ASKTX770" +// ] +// }, +// { +// "payto_uri": "payto://iban/DE1327812254798?receiver-name=the%20name%20of%20merchant", +// "exchange_url": "https://exchange.demo.taler.net/", +// "no_keys": false, +// "auth_conflict": false, +// "exchange_http_status": 400, +// "exchange_code": 26, +// "access_token": "0000000000000000000000000000000000000000000000000000", +// "limits": [] +// } +// ] +// } diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -69,6 +69,7 @@ import { RsaSignature, Timestamp, WireTransferIdentifierRawP, + codecForAccessToken, codecForCurrencySpecificiation, codecForInternationalizedString, codecForURLString, @@ -1312,7 +1313,6 @@ export interface QueryInstancesResponse { method: "external" | "token"; }; } - export interface MerchantAccountKycRedirectsResponse { // Array of KYC status information for // the exchanges and bank accounts selected @@ -1350,60 +1350,7 @@ export interface MerchantAccountKycRedirect { // Access token needed to open the KYC SPA and/or // access the /kyc-info/ endpoint. - access_token?: string; - - // Array with limitations that currently apply to this - // account and that may be increased or lifted if the - // KYC check is passed. - // Note that additional limits *may* exist and not be - // communicated to the client. If such limits are - // reached, this *may* be indicated by the account - // going into aml_review state. However, it is - // also possible that the exchange may legally have - // to deny operations without being allowed to provide - // any justification. - // The limits should be used by the client to - // possibly structure their operations (e.g. withdraw - // what is possible below the limit, ask the user to - // pass KYC checks or withdraw the rest after the time - // limit is passed, warn the user to not withdraw too - // much or even prevent the user from generating a - // request that would cause it to exceed hard limits). - limits?: AccountLimit[]; - - // Array of wire transfer instructions (including - // optional amount and subject) for a KYC auth wire - // transfer. Set only if this is required - // to get the given exchange working. - // Array because the exchange may have multiple - // bank accounts, in which case any of these - // accounts will do. - // Optional. Since protocol **v17**. - payto_kycauths?: string[]; -} - -/** - * @deprecated - */ -export interface LegacyAccountKycRedirects { - // Array of pending KYCs. - pending_kycs: LegacyMerchantAccountKycRedirect[]; - - // Array of exchanges with no reply. - timeout_kycs: ExchangeKycTimeout[]; -} - -/** - * @deprecated - */ -export interface LegacyMerchantAccountKycRedirect { - // URL that the user should open in a browser to - // proceed with the KYC process (as returned - // by the exchange's /kyc-check/ endpoint). - // Optional, missing if the account is blocked - // due to the need for a KYC auth transfer. - // (See payto_kycauth in that case.) - kyc_url?: string; + access_token?: AccessToken; // Array with limitations that currently apply to this // account and that may be increased or lifted if the @@ -1424,23 +1371,6 @@ export interface LegacyMerchantAccountKycRedirect { // request that would cause it to exceed hard limits). limits?: AccountLimit[]; - // Base URL of the exchange this is about. - exchange_url: string; - - // Numeric error code indicating errors the exchange - // returned, or TALER_EC_INVALID for none. - // Optional (as there may not always have - // been an error code). Since protocol **v17**. - exchange_code?: number; - - // HTTP status code returned by the exchange when we asked for - // information about the KYC status. - // Since protocol **v17**. - exchange_http_status: number; - - // Our bank wire account this is about. - payto_uri: PaytoString; - // Array of wire transfer instructions (including // optional amount and subject) for a KYC auth wire // transfer. Set only if this is required @@ -3057,52 +2987,24 @@ export const codecForQueryInstancesResponse = ) .build("TalerMerchantApi.QueryInstancesResponse"); -export const codecForMerchantAccountKycRedirectsResponse = +export const codecForAccountKycRedirects = (): Codec<MerchantAccountKycRedirectsResponse> => buildCodecForObject<MerchantAccountKycRedirectsResponse>() .property("kyc_data", codecForList(codecForMerchantAccountKycRedirect())) - .build("MerchantAccountKycRedirectsResponse"); + + .build("TalerMerchantApi.MerchantAccountKycRedirectsResponse"); export const codecForMerchantAccountKycRedirect = (): Codec<MerchantAccountKycRedirect> => buildCodecForObject<MerchantAccountKycRedirect>() - .property("limits", codecOptional(codecForList(codecForAccountLimit()))) + .property("payto_uri", codecForPaytoString()) .property("exchange_url", codecForURLString()) - .property("exchange_code", codecOptional(codecForNumber())) .property("exchange_http_status", codecForNumber()) - .property("payto_uri", codecForPaytoString()) - .property("payto_kycauths", codecOptional(codecForList(codecForString()))) - .property("access_token", codecOptional(codecForString())) - .property("auth_conflict", codecForBoolean()) .property("no_keys", codecForBoolean()) - .build("MerchantAccountKycRedirect"); - -/** - * @deprecated - */ -export const codecForLegacyAccountKycRedirects = - (): Codec<LegacyAccountKycRedirects> => - buildCodecForObject<LegacyAccountKycRedirects>() - .property( - "pending_kycs", - codecForList(codecForLegacyMerchantAccountKycRedirect()), - ) - .property("timeout_kycs", codecForList(codecForExchangeKycTimeout())) - - .build("TalerMerchantApi.AccountKycRedirects"); - -/** - * @deprecated - */ -export const codecForLegacyMerchantAccountKycRedirect = - (): Codec<LegacyMerchantAccountKycRedirect> => - buildCodecForObject<LegacyMerchantAccountKycRedirect>() - .property("kyc_url", codecOptional(codecForURLString())) + .property("auth_conflict", codecForBoolean()) + .property("exchange_code", codecOptional(codecForNumber())) + .property("access_token", codecOptional(codecForAccessToken())) .property("limits", codecOptional(codecForList(codecForAccountLimit()))) - .property("exchange_url", codecForURLString()) - .property("exchange_code", codecForNumber()) - .property("exchange_http_status", codecForNumber()) - .property("payto_uri", codecForPaytoString()) .property("payto_kycauths", codecOptional(codecForList(codecForString()))) .build("TalerMerchantApi.MerchantAccountKycRedirect"); diff --git a/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx b/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx @@ -160,8 +160,8 @@ function IBANAccountInfoTable({ }) { const { i18n } = useTranslationContext(); const api = useBackendContext(); - const [showBanks, setShowBanks] = useState(false) - const [showQrs, setShowQrs] = useState(false) + const [showBanks, setShowBanks] = useState(false); + const [showQrs, setShowQrs] = useState(false); const hook = useAsyncAsHook(async () => { const qrs = await api.wallet.call(WalletApiOperation.GetQrCodesForPayto, { @@ -267,7 +267,7 @@ function IBANAccountInfoTable({ </tr> <tr> - <td colSpan={2} width="100%" style={{ wordBreak: "break-all" }}> + <td colSpan={3} width="100%" style={{ wordBreak: "break-all" }}> <i18n.Translate> Alternative if your bank already supports PayTo URI, you can use this{" "} @@ -287,34 +287,57 @@ function IBANAccountInfoTable({ </td> </tr> - {banksSites.length < 1 ? undefined : <Fragment> - <div> - <a href="#" onClick={(e) => { - setShowBanks(true); - e.preventDefault(); - }}> - <i18n.Translate>Continue with banking app or website</i18n.Translate> - </a> - </div> - - {showBanks ? <ShowBanksForPaytoPopup banks={banksSites} onClose={{ - onClick: (async () => setShowBanks(false)) as SafeHandler<void> - }} /> : undefined} - </Fragment>} - - {qrCodes.length < 1 ? undefined : <Fragment> - <div> - <a href="#" onClick={(e) => { - setShowQrs(true); - e.preventDefault(); - }}> - <i18n.Translate>Show QR code</i18n.Translate> - </a> - </div> - {showQrs ? <ShowQRsForPaytoPopup qrs={qrCodes} onClose={{ - onClick: (async () => setShowQrs(false)) as SafeHandler<void> - }} /> : undefined} - </Fragment>} + {banksSites.length < 1 ? undefined : ( + <Fragment> + <div> + <a + href="#" + onClick={(e) => { + setShowBanks(true); + e.preventDefault(); + }} + > + <i18n.Translate> + Continue with banking app or website + </i18n.Translate> + </a> + </div> + + {showBanks ? ( + <ShowBanksForPaytoPopup + banks={banksSites} + onClose={{ + onClick: (async () => + setShowBanks(false)) as SafeHandler<void>, + }} + /> + ) : undefined} + </Fragment> + )} + + {qrCodes.length < 1 ? undefined : ( + <Fragment> + <div> + <a + href="#" + onClick={(e) => { + setShowQrs(true); + e.preventDefault(); + }} + > + <i18n.Translate>Show QR code</i18n.Translate> + </a> + </div> + {showQrs ? ( + <ShowQRsForPaytoPopup + qrs={qrCodes} + onClose={{ + onClick: (async () => setShowQrs(false)) as SafeHandler<void>, + }} + /> + ) : undefined} + </Fragment> + )} </tbody> </table> );