summaryrefslogtreecommitdiff
path: root/packages/bank-ui/src/components/Transactions
diff options
context:
space:
mode:
Diffstat (limited to 'packages/bank-ui/src/components/Transactions')
-rw-r--r--packages/bank-ui/src/components/Transactions/index.ts84
-rw-r--r--packages/bank-ui/src/components/Transactions/state.ts90
-rw-r--r--packages/bank-ui/src/components/Transactions/stories.tsx44
-rw-r--r--packages/bank-ui/src/components/Transactions/test.ts202
-rw-r--r--packages/bank-ui/src/components/Transactions/views.tsx252
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" }}>
+ &lt;{i18n.str`Invalid value`}&gt;
+ </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" }}>
+ &lt;{i18n.str`Invalid value`}&gt;
+ </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>
+ );
+}