diff options
Diffstat (limited to 'packages/bank-ui/src/components/Transactions')
5 files changed, 672 insertions, 0 deletions
diff --git a/packages/bank-ui/src/components/Transactions/index.ts b/packages/bank-ui/src/components/Transactions/index.ts new file mode 100644 index 000000000..6fccfcd79 --- /dev/null +++ b/packages/bank-ui/src/components/Transactions/index.ts @@ -0,0 +1,84 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { AbsoluteTime, AmountJson, TalerError } from "@gnu-taler/taler-util"; +import { Loading, utils } from "@gnu-taler/web-util/browser"; +import { ErrorLoadingWithDebug } from "../ErrorLoadingWithDebug.js"; +import { useComponentState } from "./state.js"; +import { ReadyView } from "./views.js"; +import { RouteDefinition } from "@gnu-taler/web-util/browser"; + +export interface Props { + account: string; + routeCreateWireTransfer: + | RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }> + | undefined; +} + +export type State = State.Loading | State.LoadingUriError | State.Ready; + +export namespace State { + export interface Loading { + status: "loading"; + error: undefined; + } + + export interface LoadingUriError { + status: "loading-error"; + error: TalerError; + } + + export interface BaseInfo { + error: undefined; + } + export interface Ready extends BaseInfo { + status: "ready"; + error: undefined; + routeCreateWireTransfer: + | RouteDefinition<{ + account?: string; + subject?: string; + amount?: string; + }> + | undefined; + transactions: Transaction[]; + onGoStart?: () => void; + onGoNext?: () => void; + } +} + +export interface Transaction { + negative: boolean; + counterpart: string; + when: AbsoluteTime; + amount: AmountJson | undefined; + subject: string; +} + +const viewMapping: utils.StateViewMap<State> = { + loading: Loading, + "loading-error": ErrorLoadingWithDebug, + ready: ReadyView, +}; + +export const Transactions = utils.compose( + (p: Props) => useComponentState(p), + viewMapping, +); diff --git a/packages/bank-ui/src/components/Transactions/state.ts b/packages/bank-ui/src/components/Transactions/state.ts new file mode 100644 index 000000000..ce6338e57 --- /dev/null +++ b/packages/bank-ui/src/components/Transactions/state.ts @@ -0,0 +1,90 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + Amounts, + HttpStatusCode, + TalerError, + assertUnreachable, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { useTransactions } from "../../hooks/account.js"; +import { Props, State, Transaction } from "./index.js"; + +export function useComponentState({ + account, + routeCreateWireTransfer, +}: Props): State { + const result = useTransactions(account); + if (!result) { + return { + status: "loading", + error: undefined, + }; + } + if (result instanceof TalerError) { + return { + status: "loading-error", + error: result, + }; + } + if (result.type === "fail") { + return { + status: "loading", + error: undefined, + }; + } + + const transactions = result.body + .map((tx) => { + const negative = tx.direction === "debit"; + const cp = parsePaytoUri( + negative ? tx.creditor_payto_uri : tx.debtor_payto_uri, + ); + const counterpart = + (cp === undefined || !cp.isKnown + ? undefined + : cp.targetType === "iban" + ? cp.iban + : cp.targetType === "x-taler-bank" + ? cp.account + : cp.targetType === "bitcoin" + ? `${cp.address.substring(0, 6)}...` + : undefined) ?? "unknown"; + + const when = AbsoluteTime.fromProtocolTimestamp(tx.date); + const amount = Amounts.parse(tx.amount); + const subject = tx.subject; + return { + negative, + counterpart, + when, + amount, + subject, + }; + }) + .filter((x): x is Transaction => x !== undefined); + + return { + status: "ready", + error: undefined, + routeCreateWireTransfer, + transactions, + onGoNext: result.isLastPage ? undefined : result.loadNext, + onGoStart: result.isFirstPage ? undefined : result.loadFirst, + }; +} diff --git a/packages/bank-ui/src/components/Transactions/stories.tsx b/packages/bank-ui/src/components/Transactions/stories.tsx new file mode 100644 index 000000000..95014574b --- /dev/null +++ b/packages/bank-ui/src/components/Transactions/stories.tsx @@ -0,0 +1,44 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { ReadyView } from "./views.js"; +import { AbsoluteTime } from "@gnu-taler/taler-util"; + +export default { + title: "transaction list", +}; + +export const Ready = tests.createExample(ReadyView, { + transactions: [ + { + amount: { + currency: "USD", + fraction: 0, + value: 1, + }, + counterpart: "ASD", + negative: false, + subject: "Some", + when: AbsoluteTime.now(), + }, + ], +}); diff --git a/packages/bank-ui/src/components/Transactions/test.ts b/packages/bank-ui/src/components/Transactions/test.ts new file mode 100644 index 000000000..d9442c742 --- /dev/null +++ b/packages/bank-ui/src/components/Transactions/test.ts @@ -0,0 +1,202 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TalerErrorCode } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { SwrMockEnvironment } from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; +import { Props } from "./index.js"; +import { useComponentState } from "./state.js"; + +describe("Transaction states", () => { + it.skip("should query backend and render transactions", async () => { + const env = new SwrMockEnvironment(); + + const props: Props = { + account: "myAccount", + routeCreateWireTransfer: undefined, + }; + + // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, { + // response: { + // data: { + // transactions: [ + // { + // creditorIban: "DE159593", + // creditorBic: "SANDBOXX", + // creditorName: "exchange company", + // debtorIban: "DE118695", + // debtorBic: "SANDBOXX", + // debtorName: "Name unknown", + // amount: "1", + // currency: "KUDOS", + // subject: + // "Taler Withdrawal N588V8XE9TR49HKAXFQ20P0EQ0EYW2AC9NNANV8ZP5P59N6N0410", + // date: "2022-12-12Z", + // uid: "8PPFR9EM", + // direction: "DBIT", + // pmtInfId: null, + // msgId: null, + // }, + // { + // creditorIban: "DE159593", + // creditorBic: "SANDBOXX", + // creditorName: "exchange company", + // debtorIban: "DE118695", + // debtorBic: "SANDBOXX", + // debtorName: "Name unknown", + // amount: "5.00", + // currency: "KUDOS", + // subject: "HNEWWT679TQC5P1BVXJS48FX9NW18FWM6PTK2N80Z8GVT0ACGNK0", + // date: "2022-12-07Z", + // uid: "7FZJC3RJ", + // direction: "DBIT", + // pmtInfId: null, + // msgId: null, + // }, + // { + // creditorIban: "DE118695", + // creditorBic: "SANDBOXX", + // creditorName: "Name unknown", + // debtorIban: "DE579516", + // debtorBic: "SANDBOXX", + // debtorName: "The Bank", + // amount: "100", + // currency: "KUDOS", + // subject: "Sign-up bonus", + // date: "2022-12-07Z", + // uid: "I31A06J8", + // direction: "CRDT", + // pmtInfId: null, + // msgId: null, + // }, + // ], + // }, + // }, + // }); + + const hookBehavior = await tests.hookBehaveLikeThis( + useComponentState, + props, + [ + ({ status, error }) => { + expect(status).equals("loading"); + expect(error).undefined; + }, + ({ status, error }) => { + expect(status).equals("ready"); + expect(error).undefined; + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); + + // it("should show error message on not found", async () => { + // const env = new SwrMockEnvironment(); + + // const props: Props = { + // account: "myAccount", + // }; + + // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, { + // response: { + // error: { + // description: "Transaction page 0 could not be retrieved.", + // }, + // }, + // }); + + // const hookBehavior = await tests.hookBehaveLikeThis( + // useComponentState, + // props, + // [ + // ({ status, error }) => { + // expect(status).equals("loading"); + // expect(error).undefined; + // }, + // ({ status, error }) => { + // expect(status).equals("loading-error"); + // if (error === undefined || error.type !== ErrorType.CLIENT) { + // throw Error("not the expected error"); + // } + // expect(error.payload).deep.equal({ + // error: { + // description: "Transaction page 0 could not be retrieved.", + // }, + // }); + // }, + // ], + // env.buildTestingContext(), + // ); + + // expect(hookBehavior).deep.eq({ result: "ok" }); + // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + // }); + + it.skip("should show error message on server error", async () => { + const env = new SwrMockEnvironment(); + + const props: Props = { + account: "myAccount", + routeCreateWireTransfer: undefined, + }; + + // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, { + // response: { + // error: { + // code: TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + // }, + // }, + // }); + + const hookBehavior = await tests.hookBehaveLikeThis( + useComponentState, + props, + [ + ({ status, error }) => { + expect(status).equals("loading"); + expect(error).undefined; + }, + ({ status, error }) => { + expect(status).equals("loading-error"); + if ( + error === undefined || + !error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED) + ) { + throw Error("not the expected error"); + } + expect(error.errorDetail.code).deep.equal( + TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, + ); + }, + ], + env.buildTestingContext(), + ); + + expect(hookBehavior).deep.eq({ result: "ok" }); + expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" }); + }); +}); diff --git a/packages/bank-ui/src/components/Transactions/views.tsx b/packages/bank-ui/src/components/Transactions/views.tsx new file mode 100644 index 000000000..10d63e6af --- /dev/null +++ b/packages/bank-ui/src/components/Transactions/views.tsx @@ -0,0 +1,252 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { + Attention, + useBankCoreApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { RenderAmount } from "../../pages/PaytoWireTransferForm.js"; +import { Time } from "../Time.js"; +import { State } from "./index.js"; + +export function ReadyView({ + transactions, + routeCreateWireTransfer, + onGoNext, + onGoStart, +}: State.Ready): VNode { + const { i18n, dateLocale } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + + if (!transactions.length) { + return ( + <div class="px-4 mt-4"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Transactions history</i18n.Translate> + </h1> + </div> + </div> + + <Attention type="low" title={i18n.str`No transactions yet.`}> + <i18n.Translate> + You can start sending a wire transfer or withdrawing to your wallet. + </i18n.Translate> + </Attention> + </div> + ); + } + + const txByDate = transactions.reduce( + (prev, cur) => { + const d = + cur.when.t_ms === "never" + ? "" + : format(cur.when.t_ms, "dd/MM/yyyy", { locale: dateLocale }); + if (!prev[d]) { + prev[d] = []; + } + prev[d].push(cur); + return prev; + }, + {} as Record<string, typeof transactions>, + ); + return ( + <div class="px-4 mt-8"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Transactions history</i18n.Translate> + </h1> + </div> + </div> + <div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white"> + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " + >{i18n.str`Date`}</th> + <th + scope="col" + class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " + >{i18n.str`Amount`}</th> + <th + scope="col" + class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " + >{i18n.str`Counterpart`}</th> + <th + scope="col" + class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 " + >{i18n.str`Subject`}</th> + </tr> + </thead> + <tbody> + {Object.entries(txByDate).map(([date, txs], idx) => { + return ( + <Fragment key={idx}> + <tr class="border-t border-gray-200"> + <th + colSpan={4} + scope="colgroup" + class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3" + > + {date} + </th> + </tr> + {txs.map((item) => { + return ( + <tr + key={idx} + class="border-b border-gray-200 last:border-none" + > + <td class="relative py-2 pl-2 pr-2 text-sm "> + <div class="font-medium text-gray-900"> + <Time + format="HH:mm:ss" + timestamp={item.when} + // relative={Duration.fromSpec({ days: 1 })} + /> + </div> + <dl class="font-normal sm:hidden"> + <dt class="sr-only sm:hidden"> + <i18n.Translate>Amount</i18n.Translate> + </dt> + <dd class="mt-1 truncate text-gray-700"> + {item.negative + ? i18n.str`sent` + : i18n.str`received`}{" "} + {item.amount ? ( + <span + data-negative={ + item.negative ? "true" : "false" + } + class="data-[negative=false]:text-green-600 data-[negative=true]:text-red-600" + > + <RenderAmount + value={item.amount} + spec={config.currency_specification} + /> + </span> + ) : ( + <span style={{ color: "grey" }}> + <{i18n.str`Invalid value`}> + </span> + )} + </dd> + + <dt class="sr-only sm:hidden"> + <i18n.Translate>Counterpart</i18n.Translate> + </dt> + <dd class="mt-1 truncate text-gray-500 sm:hidden"> + {item.negative ? i18n.str`to` : i18n.str`from`}{" "} + {!routeCreateWireTransfer ? ( + item.counterpart + ) : ( + <a + name={`transfer to ${item.counterpart}`} + href={routeCreateWireTransfer.url({ + account: item.counterpart, + })} + class="text-indigo-600 hover:text-indigo-900" + > + {item.counterpart} + </a> + )} + </dd> + <dd class="mt-1 text-gray-500 sm:hidden"> + <pre class="break-words w-56 whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100"> + {item.subject} + </pre> + </dd> + </dl> + </td> + <td + data-negative={item.negative ? "true" : "false"} + class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 " + > + {item.amount ? ( + <RenderAmount + value={item.amount} + negative={item.negative} + withColor + spec={config.currency_specification} + /> + ) : ( + <span style={{ color: "grey" }}> + <{i18n.str`Invalid value`}> + </span> + )} + </td> + <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500"> + {!routeCreateWireTransfer ? ( + item.counterpart + ) : ( + <a + name={`wire transfer to ${item.counterpart}`} + href={routeCreateWireTransfer.url({ + account: item.counterpart, + })} + class="text-indigo-600 hover:text-indigo-900" + > + {item.counterpart} + </a> + )} + </td> + <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> + {item.subject} + </td> + </tr> + ); + })} + </Fragment> + ); + })} + </tbody> + </table> + + <nav + class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" + aria-label="Pagination" + > + <div class="flex flex-1 justify-between sm:justify-end"> + <button + name="first page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoStart} + onClick={onGoStart} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + name="next page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoNext} + onClick={onGoNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> + </div> + </div> + ); +} |