summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src/cta/Payment
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2022-07-30 20:55:41 -0300
committerSebastian <sebasjm@gmail.com>2022-08-01 10:55:17 -0300
commit614a3e3c8702bb7436398acb911880caae0fdee7 (patch)
tree18aed0268f98642f2ca4bc7b7ac23297ad4f2cc8 /packages/taler-wallet-webextension/src/cta/Payment
parent979cd2daf2cca2ff14a8e8a2d68712358344e9c4 (diff)
downloadwallet-core-614a3e3c8702bb7436398acb911880caae0fdee7.tar.gz
wallet-core-614a3e3c8702bb7436398acb911880caae0fdee7.tar.bz2
wallet-core-614a3e3c8702bb7436398acb911880caae0fdee7.zip
standarizing components
Diffstat (limited to 'packages/taler-wallet-webextension/src/cta/Payment')
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/index.ts93
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/state.ts171
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/stories.tsx356
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/test.ts447
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/views.tsx393
5 files changed, 1460 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/index.ts b/packages/taler-wallet-webextension/src/cta/Payment/index.ts
new file mode 100644
index 000000000..0e67a4991
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/index.ts
@@ -0,0 +1,93 @@
+/*
+ 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 { AmountJson, ConfirmPayResult, PreparePayResult } from "@gnu-taler/taler-util";
+import { Loading } from "../../components/Loading.js";
+import { HookError } from "../../hooks/useAsyncAsHook.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import * as wxApi from "../../wxApi.js";
+import { useComponentState } from "./state.js";
+import { LoadingUriView, BaseView } from "./views.js";
+
+
+
+export interface Props {
+ talerPayUri?: string;
+ goToWalletManualWithdraw: (currency?: string) => Promise<void>;
+ goBack: () => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.Ready
+ | State.NoEnoughBalance
+ | State.NoBalanceForCurrency
+ | State.Confirmed;
+
+export namespace State {
+
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+ export interface LoadingUriError {
+ status: "loading-uri";
+ error: HookError;
+ }
+
+ interface BaseInfo {
+ amount: AmountJson;
+ totalFees: AmountJson;
+ payStatus: PreparePayResult;
+ uri: string;
+ error: undefined;
+ goToWalletManualWithdraw: (currency?: string) => Promise<void>;
+ goBack: () => Promise<void>;
+ }
+ export interface NoBalanceForCurrency extends BaseInfo {
+ status: "no-balance-for-currency"
+ balance: undefined;
+ }
+ export interface NoEnoughBalance extends BaseInfo {
+ status: "no-enough-balance"
+ balance: AmountJson;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ payHandler: ButtonHandler;
+ balance: AmountJson;
+ }
+
+ export interface Confirmed extends BaseInfo {
+ status: "confirmed";
+ payResult: ConfirmPayResult;
+ payHandler: ButtonHandler;
+ balance: AmountJson;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ "loading-uri": LoadingUriView,
+ "no-balance-for-currency": BaseView,
+ "no-enough-balance": BaseView,
+ confirmed: BaseView,
+ ready: BaseView,
+};
+
+export const PaymentPage = compose("Payment", (p: Props) => useComponentState(p, wxApi), viewMapping)
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/state.ts b/packages/taler-wallet-webextension/src/cta/Payment/state.ts
new file mode 100644
index 000000000..3c819ec8f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/state.ts
@@ -0,0 +1,171 @@
+/*
+ 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 { AmountJson, Amounts, ConfirmPayResult, ConfirmPayResultType, NotificationType, PreparePayResultType, TalerErrorCode } from "@gnu-taler/taler-util";
+import { TalerError } from "@gnu-taler/taler-wallet-core";
+import { useEffect, useState } from "preact/hooks";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import * as wxApi from "../../wxApi.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState(
+ { talerPayUri, goBack, goToWalletManualWithdraw }: Props,
+ api: typeof wxApi,
+): State {
+ const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>(
+ undefined,
+ );
+ const [payErrMsg, setPayErrMsg] = useState<TalerError | undefined>(undefined);
+
+ const hook = useAsyncAsHook(async () => {
+ if (!talerPayUri) throw Error("ERROR_NO-URI-FOR-PAYMENT");
+ const payStatus = await api.preparePay(talerPayUri);
+ const balance = await api.getBalance();
+ return { payStatus, balance, uri: talerPayUri };
+ });
+
+ useEffect(() => {
+ api.onUpdateNotification([NotificationType.CoinWithdrawn], () => {
+ hook?.retry();
+ });
+ });
+
+ const hookResponse = !hook || hook.hasError ? undefined : hook.response;
+
+ useEffect(() => {
+ if (!hookResponse) return;
+ const { payStatus } = hookResponse;
+ if (
+ payStatus &&
+ payStatus.status === PreparePayResultType.AlreadyConfirmed &&
+ payStatus.paid
+ ) {
+ const fu = payStatus.contractTerms.fulfillment_url;
+ if (fu) {
+ setTimeout(() => {
+ document.location.href = fu;
+ }, 3000);
+ }
+ }
+ }, [hookResponse]);
+
+ if (!hook) return { status: "loading", error: undefined };
+ if (hook.hasError) {
+ return {
+ status: "loading-uri",
+ error: hook,
+ };
+ }
+ const { payStatus } = hook.response;
+ const amount = Amounts.parseOrThrow(payStatus.amountRaw);
+
+ const foundBalance = hook.response.balance.balances.find(
+ (b) => Amounts.parseOrThrow(b.available).currency === amount.currency,
+ );
+
+
+ let totalFees = Amounts.getZero(amount.currency);
+ if (payStatus.status === PreparePayResultType.PaymentPossible) {
+ const amountEffective: AmountJson = Amounts.parseOrThrow(
+ payStatus.amountEffective,
+ );
+ totalFees = Amounts.sub(amountEffective, amount).amount;
+ }
+
+ const baseResult = {
+ uri: hook.response.uri,
+ amount,
+ totalFees,
+ payStatus,
+ error: undefined,
+ goBack, goToWalletManualWithdraw
+ }
+
+ if (!foundBalance) {
+ return {
+ status: "no-balance-for-currency",
+ balance: undefined,
+ ...baseResult,
+ }
+ }
+
+ const foundAmount = Amounts.parseOrThrow(foundBalance.available);
+
+ async function doPayment(): Promise<void> {
+ try {
+ if (payStatus.status !== "payment-possible") {
+ throw TalerError.fromUncheckedDetail({
+ code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
+ hint: `payment is not possible: ${payStatus.status}`,
+ });
+ }
+ const res = await api.confirmPay(payStatus.proposalId, undefined);
+ if (res.type !== ConfirmPayResultType.Done) {
+ throw TalerError.fromUncheckedDetail({
+ code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
+ hint: `could not confirm payment`,
+ payResult: res,
+ });
+ }
+ const fu = res.contractTerms.fulfillment_url;
+ if (fu) {
+ if (typeof window !== "undefined") {
+ document.location.href = fu;
+ } else {
+ console.log(`should d to ${fu}`);
+ }
+ }
+ setPayResult(res);
+ } catch (e) {
+ if (e instanceof TalerError) {
+ setPayErrMsg(e);
+ }
+ }
+ }
+
+ if (payStatus.status === PreparePayResultType.InsufficientBalance) {
+ return {
+ status: 'no-enough-balance',
+ balance: foundAmount,
+ ...baseResult,
+ }
+ }
+
+ const payHandler: ButtonHandler = {
+ onClick: payErrMsg ? undefined : doPayment,
+ error: payErrMsg,
+ };
+
+ if (!payResult) {
+ return {
+ status: "ready",
+ payHandler,
+ ...baseResult,
+ balance: foundAmount
+ };
+ }
+
+ return {
+ status: "confirmed",
+ balance: foundAmount,
+ payResult,
+ payHandler: {},
+ ...baseResult,
+ };
+}
+
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx
new file mode 100644
index 000000000..603a9cb33
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx
@@ -0,0 +1,356 @@
+/*
+ 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ Amounts,
+ ContractTerms,
+ PreparePayResultType,
+} from "@gnu-taler/taler-util";
+import { createExample } from "../../test-utils.js";
+import { BaseView } from "./views.js";
+
+export default {
+ title: "cta/payment",
+ component: BaseView,
+ argTypes: {},
+};
+
+export const NoBalance = createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: undefined,
+ payHandler: {
+ onClick: async () => {
+ null;
+ },
+ },
+ totalFees: Amounts.parseOrThrow("USD:0"),
+
+ uri: "",
+ payStatus: {
+ status: PreparePayResultType.InsufficientBalance,
+ noncePriv: "",
+ proposalId: "proposal1234",
+ contractTerms: {
+ merchant: {
+ name: "someone",
+ },
+ summary: "some beers",
+ amount: "USD:10",
+ } as Partial<ContractTerms> as any,
+ amountRaw: "USD:10",
+ },
+});
+
+export const NoEnoughBalance = createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 9,
+ },
+ payHandler: {
+ onClick: async () => {
+ null;
+ },
+ },
+ totalFees: Amounts.parseOrThrow("USD:0"),
+
+ uri: "",
+ payStatus: {
+ status: PreparePayResultType.InsufficientBalance,
+ noncePriv: "",
+ proposalId: "proposal1234",
+ contractTerms: {
+ merchant: {
+ name: "someone",
+ },
+ summary: "some beers",
+ amount: "USD:10",
+ } as Partial<ContractTerms> as any,
+ amountRaw: "USD:10",
+ },
+});
+
+export const EnoughBalanceButRestricted = createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 19,
+ },
+ payHandler: {
+ onClick: async () => {
+ null;
+ },
+ },
+ totalFees: Amounts.parseOrThrow("USD:0"),
+
+ uri: "",
+ payStatus: {
+ status: PreparePayResultType.InsufficientBalance,
+ noncePriv: "",
+ proposalId: "proposal1234",
+ contractTerms: {
+ merchant: {
+ name: "someone",
+ },
+ summary: "some beers",
+ amount: "USD:10",
+ } as Partial<ContractTerms> as any,
+ amountRaw: "USD:10",
+ },
+});
+
+export const PaymentPossible = createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: async () => {
+ null;
+ },
+ },
+ totalFees: Amounts.parseOrThrow("USD:0"),
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ status: PreparePayResultType.PaymentPossible,
+ amountEffective: "USD:10",
+ amountRaw: "USD:10",
+ noncePriv: "",
+ contractTerms: {
+ nonce: "123213123",
+ merchant: {
+ name: "someone",
+ },
+ amount: "USD:10",
+ summary: "some beers",
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "proposal1234",
+ },
+});
+
+export const PaymentPossibleWithFee = createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: async () => {
+ null;
+ },
+ },
+ totalFees: Amounts.parseOrThrow("USD:0.20"),
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ status: PreparePayResultType.PaymentPossible,
+ amountEffective: "USD:10.20",
+ amountRaw: "USD:10",
+ noncePriv: "",
+ contractTerms: {
+ nonce: "123213123",
+ merchant: {
+ name: "someone",
+ },
+ amount: "USD:10",
+ summary: "some beers",
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "proposal1234",
+ },
+});
+
+import beer from "../../../static-dev/beer.png";
+
+export const TicketWithAProductList = createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: async () => {
+ null;
+ },
+ },
+ totalFees: Amounts.parseOrThrow("USD:0.20"),
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ status: PreparePayResultType.PaymentPossible,
+ amountEffective: "USD:10.20",
+ amountRaw: "USD:10",
+ noncePriv: "",
+ contractTerms: {
+ nonce: "123213123",
+ merchant: {
+ name: "someone",
+ },
+ amount: "USD:10",
+ summary: "some beers",
+ products: [
+ {
+ description: "ten beers",
+ price: "USD:1",
+ quantity: 10,
+ image: beer,
+ },
+ {
+ description: "beer without image",
+ price: "USD:1",
+ quantity: 10,
+ },
+ {
+ description: "one brown beer",
+ price: "USD:2",
+ quantity: 1,
+ image: beer,
+ },
+ ],
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "proposal1234",
+ },
+});
+
+export const AlreadyConfirmedByOther = createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: async () => {
+ null;
+ },
+ },
+ totalFees: Amounts.parseOrThrow("USD:0.20"),
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ status: PreparePayResultType.AlreadyConfirmed,
+ amountEffective: "USD:10",
+ amountRaw: "USD:10",
+ contractTerms: {
+ merchant: {
+ name: "someone",
+ },
+ summary: "some beers",
+ amount: "USD:10",
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "proposal1234",
+ paid: false,
+ },
+});
+
+export const AlreadyPaidWithoutFulfillment = createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: async () => {
+ null;
+ },
+ },
+ totalFees: Amounts.parseOrThrow("USD:0.20"),
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ status: PreparePayResultType.AlreadyConfirmed,
+ amountEffective: "USD:10",
+ amountRaw: "USD:10",
+ contractTerms: {
+ merchant: {
+ name: "someone",
+ },
+ summary: "some beers",
+ amount: "USD:10",
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "proposal1234",
+ paid: true,
+ },
+});
+
+export const AlreadyPaidWithFulfillment = createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: async () => {
+ null;
+ },
+ },
+ totalFees: Amounts.parseOrThrow("USD:0.20"),
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ status: PreparePayResultType.AlreadyConfirmed,
+ amountEffective: "USD:10",
+ amountRaw: "USD:10",
+ contractTerms: {
+ merchant: {
+ name: "someone",
+ },
+ fulfillment_message:
+ "congratulations! you are looking at the fulfillment message! ",
+ summary: "some beers",
+ amount: "USD:10",
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "proposal1234",
+ paid: true,
+ },
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/test.ts b/packages/taler-wallet-webextension/src/cta/Payment/test.ts
new file mode 100644
index 000000000..aea70b7ca
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/test.ts
@@ -0,0 +1,447 @@
+/*
+ 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AmountJson,
+ Amounts,
+ BalancesResponse,
+ ConfirmPayResult,
+ ConfirmPayResultType,
+ NotificationType,
+ PreparePayResult,
+ PreparePayResultType,
+} from "@gnu-taler/taler-util";
+import { expect } from "chai";
+import { mountHook } from "../../test-utils.js";
+import { useComponentState } from "./state.js";
+import * as wxApi from "../../wxApi.js";
+
+const nullFunction: any = () => null;
+type VoidFunction = () => void;
+
+type Subs = {
+ [key in NotificationType]?: VoidFunction;
+};
+
+export class SubsHandler {
+ private subs: Subs = {};
+
+ constructor() {
+ this.saveSubscription = this.saveSubscription.bind(this);
+ }
+
+ saveSubscription(
+ messageTypes: NotificationType[],
+ callback: VoidFunction,
+ ): VoidFunction {
+ messageTypes.forEach((m) => {
+ this.subs[m] = callback;
+ });
+ return nullFunction;
+ }
+
+ notifyEvent(event: NotificationType): void {
+ const cb = this.subs[event];
+ if (cb === undefined)
+ expect.fail(`Expected to have a subscription for ${event}`);
+ cb();
+ }
+}
+
+describe("Payment CTA states", () => {
+ it("should tell the user that the URI is missing", async () => {
+ const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
+ mountHook(() =>
+ useComponentState({ talerPayUri: undefined, goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, {
+ onUpdateNotification: nullFunction,
+ } as Partial<typeof wxApi> as any),
+ );
+
+ {
+ const { status, error } = getLastResultOrThrow();
+ expect(status).equals("loading");
+ expect(error).undefined;
+ }
+
+ await waitNextUpdate();
+
+ {
+ const { status, error } = getLastResultOrThrow();
+
+ expect(status).equals("loading-uri");
+ if (error === undefined) expect.fail();
+ expect(error.hasError).true;
+ expect(error.operational).false;
+ }
+
+ await assertNoPendingUpdate();
+ });
+
+ it("should response with no balance", async () => {
+ const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
+ mountHook(() =>
+ useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, {
+ onUpdateNotification: nullFunction,
+ preparePay: async () =>
+ ({
+ amountRaw: "USD:10",
+ status: PreparePayResultType.InsufficientBalance,
+ } as Partial<PreparePayResult>),
+ getBalance: async () =>
+ ({
+ balances: [],
+ } as Partial<BalancesResponse>),
+ } as Partial<typeof wxApi> as any),
+ );
+
+ {
+ const { status, error } = getLastResultOrThrow();
+ expect(status).equals("loading");
+ expect(error).undefined;
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "no-balance-for-currency") expect.fail();
+ expect(r.balance).undefined;
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10"));
+ }
+
+ await assertNoPendingUpdate();
+ });
+
+ it("should not be able to pay if there is no enough balance", async () => {
+ const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
+ mountHook(() =>
+ useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, {
+ onUpdateNotification: nullFunction,
+ preparePay: async () =>
+ ({
+ amountRaw: "USD:10",
+ status: PreparePayResultType.InsufficientBalance,
+ } as Partial<PreparePayResult>),
+ getBalance: async () =>
+ ({
+ balances: [
+ {
+ available: "USD:5",
+ },
+ ],
+ } as Partial<BalancesResponse>),
+ } as Partial<typeof wxApi> as any),
+ );
+
+ {
+ const { status, error } = getLastResultOrThrow();
+ expect(status).equals("loading");
+ expect(error).undefined;
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "no-enough-balance") expect.fail();
+ expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:5"));
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10"));
+ }
+
+ await assertNoPendingUpdate();
+ });
+
+ it("should be able to pay (without fee)", async () => {
+ const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
+ mountHook(() =>
+ useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, {
+ onUpdateNotification: nullFunction,
+ preparePay: async () =>
+ ({
+ amountRaw: "USD:10",
+ amountEffective: "USD:10",
+ status: PreparePayResultType.PaymentPossible,
+ } as Partial<PreparePayResult>),
+ getBalance: async () =>
+ ({
+ balances: [
+ {
+ available: "USD:15",
+ },
+ ],
+ } as Partial<BalancesResponse>),
+ } as Partial<typeof wxApi> as any),
+ );
+
+ {
+ const { status, error } = getLastResultOrThrow();
+ expect(status).equals("loading");
+ expect(error).undefined;
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "ready") expect.fail();
+ expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:10"));
+ expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:0"));
+ expect(r.payHandler.onClick).not.undefined;
+ }
+
+ await assertNoPendingUpdate();
+ });
+
+ it("should be able to pay (with fee)", async () => {
+ const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
+ mountHook(() =>
+ useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, {
+ onUpdateNotification: nullFunction,
+ preparePay: async () =>
+ ({
+ amountRaw: "USD:9",
+ amountEffective: "USD:10",
+ status: PreparePayResultType.PaymentPossible,
+ } as Partial<PreparePayResult>),
+ getBalance: async () =>
+ ({
+ balances: [
+ {
+ available: "USD:15",
+ },
+ ],
+ } as Partial<BalancesResponse>),
+ } as Partial<typeof wxApi> as any),
+ );
+
+ {
+ const { status, error } = getLastResultOrThrow();
+ expect(status).equals("loading");
+ expect(error).undefined;
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "ready") expect.fail();
+ expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ expect(r.payHandler.onClick).not.undefined;
+ }
+
+ await assertNoPendingUpdate();
+ });
+
+ it("should get confirmation done after pay successfully", async () => {
+ const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
+ mountHook(() =>
+ useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, {
+ onUpdateNotification: nullFunction,
+ preparePay: async () =>
+ ({
+ amountRaw: "USD:9",
+ amountEffective: "USD:10",
+ status: PreparePayResultType.PaymentPossible,
+ } as Partial<PreparePayResult>),
+ getBalance: async () =>
+ ({
+ balances: [
+ {
+ available: "USD:15",
+ },
+ ],
+ } as Partial<BalancesResponse>),
+ confirmPay: async () =>
+ ({
+ type: ConfirmPayResultType.Done,
+ contractTerms: {},
+ } as Partial<ConfirmPayResult>),
+ } as Partial<typeof wxApi> as any),
+ );
+
+ {
+ const { status, error } = getLastResultOrThrow();
+ expect(status).equals("loading");
+ expect(error).undefined;
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "ready") expect.fail();
+ expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ if (r.payHandler.onClick === undefined) expect.fail();
+ r.payHandler.onClick();
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "confirmed") expect.fail();
+ expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ if (r.payResult.type !== ConfirmPayResultType.Done) expect.fail();
+ expect(r.payResult.contractTerms).not.undefined;
+ expect(r.payHandler.onClick).undefined;
+ }
+
+ await assertNoPendingUpdate();
+ });
+
+ it("should not stay in ready state after pay with error", async () => {
+ const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
+ mountHook(() =>
+ useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, {
+ onUpdateNotification: nullFunction,
+ preparePay: async () =>
+ ({
+ amountRaw: "USD:9",
+ amountEffective: "USD:10",
+ status: PreparePayResultType.PaymentPossible,
+ } as Partial<PreparePayResult>),
+ getBalance: async () =>
+ ({
+ balances: [
+ {
+ available: "USD:15",
+ },
+ ],
+ } as Partial<BalancesResponse>),
+ confirmPay: async () =>
+ ({
+ type: ConfirmPayResultType.Pending,
+ lastError: { code: 1 },
+ } as Partial<ConfirmPayResult>),
+ } as Partial<typeof wxApi> as any),
+ );
+
+ {
+ const { status, error } = getLastResultOrThrow();
+ expect(status).equals("loading");
+ expect(error).undefined;
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "ready") expect.fail();
+ expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ if (r.payHandler.onClick === undefined) expect.fail();
+ r.payHandler.onClick();
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "ready") expect.fail();
+ expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ expect(r.payHandler.onClick).undefined;
+ if (r.payHandler.error === undefined) expect.fail();
+ //FIXME: error message here is bad
+ expect(r.payHandler.error.errorDetail.hint).eq(
+ "could not confirm payment",
+ );
+ expect(r.payHandler.error.errorDetail.payResult).deep.equal({
+ type: ConfirmPayResultType.Pending,
+ lastError: { code: 1 },
+ });
+ }
+
+ await assertNoPendingUpdate();
+ });
+
+ it("should update balance if a coins is withdraw", async () => {
+ const subscriptions = new SubsHandler();
+ let availableBalance = Amounts.parseOrThrow("USD:10");
+
+ function notifyCoinWithdrawn(newAmount: AmountJson): void {
+ availableBalance = Amounts.add(availableBalance, newAmount).amount;
+ subscriptions.notifyEvent(NotificationType.CoinWithdrawn);
+ }
+
+ const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
+ mountHook(() =>
+ useComponentState({ talerPayUri: "taller://pay", goBack: nullFunction, goToWalletManualWithdraw: nullFunction }, {
+ onUpdateNotification: subscriptions.saveSubscription,
+ preparePay: async () =>
+ ({
+ amountRaw: "USD:9",
+ amountEffective: "USD:10",
+ status: PreparePayResultType.PaymentPossible,
+ } as Partial<PreparePayResult>),
+ getBalance: async () =>
+ ({
+ balances: [
+ {
+ available: Amounts.stringify(availableBalance),
+ },
+ ],
+ } as Partial<BalancesResponse>),
+ } as Partial<typeof wxApi> as any),
+ );
+
+ {
+ const { status, error } = getLastResultOrThrow();
+ expect(status).equals("loading");
+ expect(error).undefined;
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "ready") expect.fail();
+ expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:10"));
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ expect(r.payHandler.onClick).not.undefined;
+
+ notifyCoinWithdrawn(Amounts.parseOrThrow("USD:5"));
+ }
+
+ await waitNextUpdate();
+
+ {
+ const r = getLastResultOrThrow();
+ if (r.status !== "ready") expect.fail();
+ expect(r.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(r.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ expect(r.payHandler.onClick).not.undefined;
+ }
+
+ await assertNoPendingUpdate();
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx
new file mode 100644
index 000000000..a8c9a640a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx
@@ -0,0 +1,393 @@
+/*
+ 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,
+ ConfirmPayResultType,
+ ContractTerms,
+ PreparePayResultType,
+ Product,
+} from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Amount } from "../../components/Amount.js";
+import { ErrorTalerOperation } from "../../components/ErrorTalerOperation.js";
+import { LoadingError } from "../../components/LoadingError.js";
+import { LogoHeader } from "../../components/LogoHeader.js";
+import { Part } from "../../components/Part.js";
+import { QR } from "../../components/QR.js";
+import {
+ Link,
+ LinkSuccess,
+ SmallLightText,
+ SubTitle,
+ SuccessBox,
+ WalletAction,
+ WarningBox,
+} from "../../components/styled/index.js";
+import { useTranslationContext } from "../../context/translation.js";
+import { Button } from "../../mui/Button.js";
+import { State } from "./index.js";
+
+export function LoadingUriView({ error }: State.LoadingUriError): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <LoadingError
+ title={<i18n.Translate>Could not load pay status</i18n.Translate>}
+ error={error}
+ />
+ );
+}
+
+type SupportedStates =
+ | State.Ready
+ | State.Confirmed
+ | State.NoBalanceForCurrency
+ | State.NoEnoughBalance;
+
+export function BaseView(state: SupportedStates): VNode {
+ const { i18n } = useTranslationContext();
+ const contractTerms: ContractTerms = state.payStatus.contractTerms;
+
+ return (
+ <WalletAction>
+ <LogoHeader />
+
+ <SubTitle>
+ <i18n.Translate>Digital cash payment</i18n.Translate>
+ </SubTitle>
+
+ <ShowImportantMessage state={state} />
+
+ <section>
+ {state.payStatus.status !== PreparePayResultType.InsufficientBalance &&
+ Amounts.isNonZero(state.totalFees) && (
+ <Part
+ big
+ title={<i18n.Translate>Total to pay</i18n.Translate>}
+ text={<Amount value={state.payStatus.amountEffective} />}
+ kind="negative"
+ />
+ )}
+ <Part
+ big
+ title={<i18n.Translate>Purchase amount</i18n.Translate>}
+ text={<Amount value={state.payStatus.amountRaw} />}
+ kind="neutral"
+ />
+ {Amounts.isNonZero(state.totalFees) && (
+ <Fragment>
+ <Part
+ big
+ title={<i18n.Translate>Fee</i18n.Translate>}
+ text={<Amount value={state.totalFees} />}
+ kind="negative"
+ />
+ </Fragment>
+ )}
+ <Part
+ title={<i18n.Translate>Merchant</i18n.Translate>}
+ text={contractTerms.merchant.name}
+ kind="neutral"
+ />
+ <Part
+ title={<i18n.Translate>Purchase</i18n.Translate>}
+ text={contractTerms.summary}
+ kind="neutral"
+ />
+ {contractTerms.order_id && (
+ <Part
+ title={<i18n.Translate>Receipt</i18n.Translate>}
+ text={`#${contractTerms.order_id}`}
+ kind="neutral"
+ />
+ )}
+ {contractTerms.products && contractTerms.products.length > 0 && (
+ <ProductList products={contractTerms.products} />
+ )}
+ </section>
+ <ButtonsSection
+ state={state}
+ goToWalletManualWithdraw={state.goToWalletManualWithdraw}
+ />
+ <section>
+ <Link upperCased onClick={state.goBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Link>
+ </section>
+ </WalletAction>
+ );
+}
+
+export function ProductList({ products }: { products: Product[] }): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <SmallLightText style={{ margin: ".5em" }}>
+ <i18n.Translate>List of products</i18n.Translate>
+ </SmallLightText>
+ <dl>
+ {products.map((p, i) => {
+ if (p.price) {
+ const pPrice = Amounts.parseOrThrow(p.price);
+ return (
+ <div key={i} style={{ display: "flex", textAlign: "left" }}>
+ <div>
+ <img
+ src={p.image ? p.image : undefined}
+ style={{ width: 32, height: 32 }}
+ />
+ </div>
+ <div>
+ <dt>
+ {p.quantity ?? 1} x {p.description}{" "}
+ <span style={{ color: "gray" }}>
+ {Amounts.stringify(pPrice)}
+ </span>
+ </dt>
+ <dd>
+ <b>
+ {Amounts.stringify(
+ Amounts.mult(pPrice, p.quantity ?? 1).amount,
+ )}
+ </b>
+ </dd>
+ </div>
+ </div>
+ );
+ }
+ return (
+ <div key={i} style={{ display: "flex", textAlign: "left" }}>
+ <div>
+ <img src={p.image} style={{ width: 32, height: 32 }} />
+ </div>
+ <div>
+ <dt>
+ {p.quantity ?? 1} x {p.description}
+ </dt>
+ <dd>
+ <i18n.Translate>Total</i18n.Translate>
+ {` `}
+ {p.price ? (
+ `${Amounts.stringifyValue(
+ Amounts.mult(
+ Amounts.parseOrThrow(p.price),
+ p.quantity ?? 1,
+ ).amount,
+ )} ${p}`
+ ) : (
+ <i18n.Translate>free</i18n.Translate>
+ )}
+ </dd>
+ </div>
+ </div>
+ );
+ })}
+ </dl>
+ </Fragment>
+ );
+}
+
+function ShowImportantMessage({ state }: { state: SupportedStates }): VNode {
+ const { i18n } = useTranslationContext();
+ const { payStatus } = state;
+ if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
+ if (payStatus.paid) {
+ if (payStatus.contractTerms.fulfillment_url) {
+ return (
+ <SuccessBox>
+ <i18n.Translate>
+ Already paid, you are going to be redirected to{" "}
+ <a href={payStatus.contractTerms.fulfillment_url}>
+ {payStatus.contractTerms.fulfillment_url}
+ </a>
+ </i18n.Translate>
+ </SuccessBox>
+ );
+ }
+ return (
+ <SuccessBox>
+ <i18n.Translate>Already paid</i18n.Translate>
+ </SuccessBox>
+ );
+ }
+ return (
+ <WarningBox>
+ <i18n.Translate>Already claimed</i18n.Translate>
+ </WarningBox>
+ );
+ }
+
+ if (state.status == "confirmed") {
+ const { payResult, payHandler } = state;
+ if (payHandler.error) {
+ return <ErrorTalerOperation error={payHandler.error.errorDetail} />;
+ }
+ if (payResult.type === ConfirmPayResultType.Done) {
+ return (
+ <SuccessBox>
+ <h3>
+ <i18n.Translate>Payment complete</i18n.Translate>
+ </h3>
+ <p>
+ {!payResult.contractTerms.fulfillment_message ? (
+ payResult.contractTerms.fulfillment_url ? (
+ <i18n.Translate>
+ You are going to be redirected to $
+ {payResult.contractTerms.fulfillment_url}
+ </i18n.Translate>
+ ) : (
+ <i18n.Translate>You can close this page.</i18n.Translate>
+ )
+ ) : (
+ payResult.contractTerms.fulfillment_message
+ )}
+ </p>
+ </SuccessBox>
+ );
+ }
+ }
+ return <Fragment />;
+}
+
+function PayWithMobile({ state }: { state: State.Ready }): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [showQR, setShowQR] = useState<boolean>(false);
+
+ const privateUri =
+ state.payStatus.status !== PreparePayResultType.AlreadyConfirmed
+ ? `${state.uri}&n=${state.payStatus.noncePriv}`
+ : state.uri;
+ return (
+ <section>
+ <LinkSuccess upperCased onClick={() => setShowQR((qr) => !qr)}>
+ {!showQR ? (
+ <i18n.Translate>Pay with a mobile phone</i18n.Translate>
+ ) : (
+ <i18n.Translate>Hide QR</i18n.Translate>
+ )}
+ </LinkSuccess>
+ {showQR && (
+ <div>
+ <QR text={privateUri} />
+ <i18n.Translate>
+ Scan the QR code or
+ <a href={privateUri}>
+ <i18n.Translate>click here</i18n.Translate>
+ </a>
+ </i18n.Translate>
+ </div>
+ )}
+ </section>
+ );
+}
+
+function ButtonsSection({
+ state,
+ goToWalletManualWithdraw,
+}: {
+ state: SupportedStates;
+ goToWalletManualWithdraw: (currency: string) => Promise<void>;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ if (state.status === "ready") {
+ const { payStatus } = state;
+ if (payStatus.status === PreparePayResultType.PaymentPossible) {
+ return (
+ <Fragment>
+ <section>
+ <Button
+ variant="contained"
+ color="success"
+ onClick={state.payHandler.onClick}
+ >
+ <i18n.Translate>
+ Pay {<Amount value={payStatus.amountEffective} />}
+ </i18n.Translate>
+ </Button>
+ </section>
+ <PayWithMobile state={state} />
+ </Fragment>
+ );
+ }
+ if (payStatus.status === PreparePayResultType.InsufficientBalance) {
+ let BalanceMessage = "";
+ if (!state.balance) {
+ BalanceMessage = i18n.str`You have no balance for this currency. Withdraw digital cash first.`;
+ } else {
+ const balanceShouldBeEnough =
+ Amounts.cmp(state.balance, state.amount) !== -1;
+ if (balanceShouldBeEnough) {
+ BalanceMessage = i18n.str`Could not find enough coins to pay this order. Even if you have enough ${state.balance.currency} some restriction may apply.`;
+ } else {
+ BalanceMessage = i18n.str`Your current balance is not enough for this order.`;
+ }
+ }
+ return (
+ <Fragment>
+ <section>
+ <WarningBox>{BalanceMessage}</WarningBox>
+ </section>
+ <section>
+ <Button
+ variant="contained"
+ color="success"
+ onClick={() => goToWalletManualWithdraw(state.amount.currency)}
+ >
+ <i18n.Translate>Withdraw digital cash</i18n.Translate>
+ </Button>
+ </section>
+ <PayWithMobile state={state} />
+ </Fragment>
+ );
+ }
+ if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
+ return (
+ <Fragment>
+ <section>
+ {payStatus.paid &&
+ state.payStatus.contractTerms.fulfillment_message && (
+ <Part
+ title={<i18n.Translate>Merchant message</i18n.Translate>}
+ text={state.payStatus.contractTerms.fulfillment_message}
+ kind="neutral"
+ />
+ )}
+ </section>
+ {!payStatus.paid && <PayWithMobile state={state} />}
+ </Fragment>
+ );
+ }
+ }
+
+ if (state.status === "confirmed") {
+ if (state.payResult.type === ConfirmPayResultType.Pending) {
+ return (
+ <section>
+ <div>
+ <p>
+ <i18n.Translate>Processing</i18n.Translate>...
+ </p>
+ </div>
+ </section>
+ );
+ }
+ }
+
+ return <Fragment />;
+}