gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] branch master updated: payments test case


From: gnunet
Subject: [taler-wallet-core] branch master updated: payments test case
Date: Thu, 21 Apr 2022 19:24:26 +0200

This is an automated email from the git hooks/post-receive script.

sebasjm pushed a commit to branch master
in repository wallet-core.

The following commit(s) were added to refs/heads/master by this push:
     new 64acf8e2 payments test case
64acf8e2 is described below

commit 64acf8e2b1083de6f78b7d21dd2701af2fee1911
Author: Sebastian <sebasjm@gmail.com>
AuthorDate: Thu Apr 21 14:23:53 2022 -0300

    payments test case
---
 .../src/cta/Pay.stories.tsx                        | 418 +++++++++-----
 .../taler-wallet-webextension/src/cta/Pay.test.ts  | 408 ++++++++++++++
 packages/taler-wallet-webextension/src/cta/Pay.tsx | 619 +++++++++++++--------
 .../src/cta/Withdraw.test.ts                       |   6 +-
 .../taler-wallet-webextension/src/cta/Withdraw.tsx |  14 +-
 .../src/hooks/useAsyncAsHook.ts                    |  47 ++
 .../taler-wallet-webextension/src/test-utils.ts    |  30 +-
 .../src/wallet/CreateManualWithdraw.tsx            |   3 +-
 8 files changed, 1154 insertions(+), 391 deletions(-)

diff --git a/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx 
b/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx
index 7dbb7723..3656bbbd 100644
--- a/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx
+++ b/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx
@@ -19,9 +19,13 @@
  * @author Sebastian Javier Marchano (sebasjm)
  */
 
-import { ContractTerms, PreparePayResultType } from "@gnu-taler/taler-util";
+import {
+  Amounts,
+  ContractTerms,
+  PreparePayResultType,
+} from "@gnu-taler/taler-util";
 import { createExample } from "../test-utils.js";
-import { PaymentRequestView as TestedComponent } from "./Pay.js";
+import { View as TestedComponent } from "./Pay.js";
 
 export default {
   title: "cta/pay",
@@ -30,175 +34,323 @@ export default {
 };
 
 export const NoBalance = createExample(TestedComponent, {
-  payStatus: {
-    status: PreparePayResultType.InsufficientBalance,
-    noncePriv: "",
-    proposalId: "proposal1234",
-    contractTerms: {
-      merchant: {
-        name: "someone",
+  state: {
+    status: "ready",
+    hook: undefined,
+    amount: Amounts.parseOrThrow("USD:10"),
+    balance: undefined,
+    payHandler: {
+      onClick: async () => {
+        null;
       },
-      summary: "some beers",
-      amount: "USD:10",
-    } as Partial<ContractTerms> as any,
-    amountRaw: "USD:10",
+    },
+    totalFees: Amounts.parseOrThrow("USD:0"),
+    payResult: undefined,
+    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",
+    },
   },
+  goBack: () => null,
+  goToWalletManualWithdraw: () => null,
 });
 
 export const NoEnoughBalance = createExample(TestedComponent, {
-  payStatus: {
-    status: PreparePayResultType.InsufficientBalance,
-    noncePriv: "",
-    proposalId: "proposal1234",
-    contractTerms: {
-      merchant: {
-        name: "someone",
+  state: {
+    status: "ready",
+    hook: undefined,
+    amount: Amounts.parseOrThrow("USD:10"),
+    balance: {
+      currency: "USD",
+      fraction: 40000000,
+      value: 9,
+    },
+    payHandler: {
+      onClick: async () => {
+        null;
       },
-      summary: "some beers",
-      amount: "USD:10",
-    } as Partial<ContractTerms> as any,
-    amountRaw: "USD:10",
-  },
-  balance: {
-    currency: "USD",
-    fraction: 40000000,
-    value: 9,
+    },
+    totalFees: Amounts.parseOrThrow("USD:0"),
+    payResult: undefined,
+    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",
+    },
   },
+  goBack: () => null,
+  goToWalletManualWithdraw: () => null,
 });
 
 export const PaymentPossible = createExample(TestedComponent, {
-  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",
+  state: {
+    status: "ready",
+    hook: undefined,
+    amount: Amounts.parseOrThrow("USD:10"),
+    balance: {
+      currency: "USD",
+      fraction: 40000000,
+      value: 11,
+    },
+    payHandler: {
+      onClick: async () => {
+        null;
       },
-      amount: "USD:10",
-      summary: "some beers",
-    } as Partial<ContractTerms> as any,
-    contractTermsHash: "123456",
-    proposalId: "proposal1234",
+    },
+    totalFees: Amounts.parseOrThrow("USD:0"),
+    payResult: undefined,
+    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",
+    },
   },
+  goBack: () => null,
+  goToWalletManualWithdraw: () => null,
 });
 
 export const PaymentPossibleWithFee = createExample(TestedComponent, {
-  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",
+  state: {
+    status: "ready",
+    hook: undefined,
+    amount: Amounts.parseOrThrow("USD:10"),
+    balance: {
+      currency: "USD",
+      fraction: 40000000,
+      value: 11,
+    },
+    payHandler: {
+      onClick: async () => {
+        null;
       },
-      amount: "USD:10",
-      summary: "some beers",
-    } as Partial<ContractTerms> as any,
-    contractTermsHash: "123456",
-    proposalId: "proposal1234",
+    },
+    totalFees: Amounts.parseOrThrow("USD:0.20"),
+    payResult: undefined,
+    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",
+    },
   },
+  goBack: () => null,
+  goToWalletManualWithdraw: () => null,
 });
 
 import beer from "../../static-dev/beer.png";
 
 export const TicketWithAProductList = createExample(TestedComponent, {
-  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",
+  state: {
+    status: "ready",
+    hook: undefined,
+    amount: Amounts.parseOrThrow("USD:10"),
+    balance: {
+      currency: "USD",
+      fraction: 40000000,
+      value: 11,
+    },
+    payHandler: {
+      onClick: async () => {
+        null;
       },
-      amount: "USD:10",
-      products: [
-        {
-          description: "ten beers",
-          price: "USD:1",
-          quantity: 10,
-          image: beer,
+    },
+    totalFees: Amounts.parseOrThrow("USD:0.20"),
+    payResult: undefined,
+    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",
         },
-        {
-          description: "beer without image",
-          price: "USD:1",
-          quantity: 10,
-        },
-        {
-          description: "one brown beer",
-          price: "USD:2",
-          quantity: 1,
-          image: beer,
-        },
-      ],
-      summary: "some beers",
-    } as Partial<ContractTerms> as any,
-    contractTermsHash: "123456",
-    proposalId: "proposal1234",
+        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",
+    },
   },
+  goBack: () => null,
+  goToWalletManualWithdraw: () => null,
 });
 
 export const AlreadyConfirmedByOther = createExample(TestedComponent, {
-  payStatus: {
-    status: PreparePayResultType.AlreadyConfirmed,
-    amountEffective: "USD:10",
-    amountRaw: "USD:10",
-    contractTerms: {
-      merchant: {
-        name: "someone",
+  state: {
+    status: "ready",
+    hook: undefined,
+    amount: Amounts.parseOrThrow("USD:10"),
+    balance: {
+      currency: "USD",
+      fraction: 40000000,
+      value: 11,
+    },
+    payHandler: {
+      onClick: async () => {
+        null;
       },
-      summary: "some beers",
-      amount: "USD:10",
-    } as Partial<ContractTerms> as any,
-    contractTermsHash: "123456",
-    proposalId: "proposal1234",
-    paid: false,
+    },
+    totalFees: Amounts.parseOrThrow("USD:0.20"),
+    payResult: undefined,
+    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,
+    },
   },
+  goBack: () => null,
+  goToWalletManualWithdraw: () => null,
 });
 
 export const AlreadyPaidWithoutFulfillment = createExample(TestedComponent, {
-  payStatus: {
-    status: PreparePayResultType.AlreadyConfirmed,
-    amountEffective: "USD:10",
-    amountRaw: "USD:10",
-    contractTerms: {
-      merchant: {
-        name: "someone",
+  state: {
+    status: "ready",
+    hook: undefined,
+    amount: Amounts.parseOrThrow("USD:10"),
+    balance: {
+      currency: "USD",
+      fraction: 40000000,
+      value: 11,
+    },
+    payHandler: {
+      onClick: async () => {
+        null;
       },
-      summary: "some beers",
-      amount: "USD:10",
-    } as Partial<ContractTerms> as any,
-    contractTermsHash: "123456",
-    proposalId: "proposal1234",
-    paid: true,
+    },
+    totalFees: Amounts.parseOrThrow("USD:0.20"),
+    payResult: undefined,
+    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,
+    },
   },
+  goBack: () => null,
+  goToWalletManualWithdraw: () => null,
 });
 
 export const AlreadyPaidWithFulfillment = createExample(TestedComponent, {
-  payStatus: {
-    status: PreparePayResultType.AlreadyConfirmed,
-    amountEffective: "USD:10",
-    amountRaw: "USD:10",
-    contractTerms: {
-      merchant: {
-        name: "someone",
+  state: {
+    status: "ready",
+    hook: undefined,
+    amount: Amounts.parseOrThrow("USD:10"),
+    balance: {
+      currency: "USD",
+      fraction: 40000000,
+      value: 11,
+    },
+    payHandler: {
+      onClick: async () => {
+        null;
       },
-      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,
+    },
+    totalFees: Amounts.parseOrThrow("USD:0.20"),
+    payResult: undefined,
+    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,
+    },
   },
+  goBack: () => null,
+  goToWalletManualWithdraw: () => null,
 });
diff --git a/packages/taler-wallet-webextension/src/cta/Pay.test.ts 
b/packages/taler-wallet-webextension/src/cta/Pay.test.ts
new file mode 100644
index 00000000..4c0fe45c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Pay.test.ts
@@ -0,0 +1,408 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 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 * as wxApi from "../wxApi.js";
+import { useComponentState } from "./Pay.jsx";
+
+const nullFunction: any = () => null;
+type VoidFunction = () => void;
+
+type Subs = {
+  [key in NotificationType]?: VoidFunction
+}
+
+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("Pay CTA states", () => {
+  it("should tell the user that the URI is missing", async () => {
+
+    const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = 
mountHook(() =>
+      useComponentState(undefined, {
+        onUpdateNotification: nullFunction,
+      } as Partial<typeof wxApi> as any)
+    );
+
+    {
+      const { status, hook } = getLastResultOrThrow()
+      expect(status).equals('loading')
+      expect(hook).undefined;
+    }
+
+    await waitNextUpdate()
+
+    {
+      const { status, hook } = getLastResultOrThrow()
+
+      expect(status).equals('loading')
+      if (hook === undefined) expect.fail()
+      expect(hook.hasError).true;
+      expect(hook.operational).false;
+    }
+
+    await assertNoPendingUpdate()
+  });
+
+  it("should response with no balance", async () => {
+
+    const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = 
mountHook(() =>
+      useComponentState('taller://pay', {
+        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, hook } = getLastResultOrThrow()
+      expect(status).equals('loading')
+      expect(hook).undefined;
+    }
+
+    await waitNextUpdate();
+
+    {
+      const r = getLastResultOrThrow()
+      if (r.status !== 'ready') expect.fail()
+      expect(r.balance).undefined;
+      expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:10'))
+      expect(r.payHandler.onClick).undefined;
+    }
+
+    await assertNoPendingUpdate()
+  });
+
+  it("should not be able to pay if there is no enough balance", async () => {
+
+    const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = 
mountHook(() =>
+      useComponentState('taller://pay', {
+        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, hook } = getLastResultOrThrow()
+      expect(status).equals('loading')
+      expect(hook).undefined;
+    }
+
+    await waitNextUpdate();
+
+    {
+      const r = getLastResultOrThrow()
+      if (r.status !== 'ready') expect.fail()
+      expect(r.balance).deep.equal(Amounts.parseOrThrow('USD:5'));
+      expect(r.amount).deep.equal(Amounts.parseOrThrow('USD:10'))
+      expect(r.payHandler.onClick).undefined;
+    }
+
+    await assertNoPendingUpdate()
+  });
+
+  it("should be able to pay (without fee)", async () => {
+
+    const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = 
mountHook(() =>
+      useComponentState('taller://pay', {
+        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, hook } = getLastResultOrThrow()
+      expect(status).equals('loading')
+      expect(hook).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('taller://pay', {
+        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, hook } = getLastResultOrThrow()
+      expect(status).equals('loading')
+      expect(hook).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('taller://pay', {
+        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, hook } = getLastResultOrThrow()
+      expect(status).equals('loading')
+      expect(hook).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('taller://pay', {
+        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, hook } = getLastResultOrThrow()
+      expect(status).equals('loading')
+      expect(hook).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('taller://pay', {
+        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, hook } = getLastResultOrThrow()
+      expect(status).equals('loading')
+      expect(hook).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()
+  });
+
+
+});
\ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/cta/Pay.tsx 
b/packages/taler-wallet-webextension/src/cta/Pay.tsx
index f2661308..0d5d5737 100644
--- a/packages/taler-wallet-webextension/src/cta/Pay.tsx
+++ b/packages/taler-wallet-webextension/src/cta/Pay.tsx
@@ -27,9 +27,7 @@
 
 import {
   AmountJson,
-  AmountLike,
   Amounts,
-  AmountString,
   ConfirmPayResult,
   ConfirmPayResultDone,
   ConfirmPayResultType,
@@ -38,12 +36,14 @@ import {
   PreparePayResult,
   PreparePayResultType,
   Product,
+  TalerErrorCode,
 } from "@gnu-taler/taler-util";
 import { TalerError } from "@gnu-taler/taler-wallet-core";
 import { Fragment, h, VNode } from "preact";
 import { useEffect, useState } from "preact/hooks";
 import { Amount } from "../components/Amount.js";
 import { ErrorMessage } from "../components/ErrorMessage.js";
+import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js";
 import { Loading } from "../components/Loading.js";
 import { LoadingError } from "../components/LoadingError.js";
 import { LogoHeader } from "../components/LogoHeader.js";
@@ -60,7 +60,12 @@ import {
   WarningBox,
 } from "../components/styled/index.js";
 import { useTranslationContext } from "../context/translation.js";
-import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import {
+  HookError,
+  useAsyncAsHook,
+  useAsyncAsHook2,
+} from "../hooks/useAsyncAsHook.js";
+import { ButtonHandler } from "../wallet/CreateManualWithdraw.js";
 import * as wxApi from "../wxApi.js";
 
 interface Props {
@@ -69,47 +74,88 @@ interface Props {
   goBack: () => void;
 }
 
-const doPayment = async (
+async function doPayment(
   payStatus: PreparePayResult,
-): Promise<ConfirmPayResultDone> => {
+  api: typeof wxApi,
+): Promise<ConfirmPayResultDone> {
   if (payStatus.status !== "payment-possible") {
-    throw Error(`invalid state: ${payStatus.status}`);
+    throw TalerError.fromUncheckedDetail({
+      code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
+      hint: `payment is not possible: ${payStatus.status}`,
+    });
   }
   const proposalId = payStatus.proposalId;
-  const res = await wxApi.confirmPay(proposalId, undefined);
+  const res = await api.confirmPay(proposalId, undefined);
   if (res.type !== ConfirmPayResultType.Done) {
-    throw Error("payment pending");
+    throw TalerError.fromUncheckedDetail({
+      code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
+      hint: `could not confirm payment`,
+      payResult: res,
+    });
   }
   const fu = res.contractTerms.fulfillment_url;
   if (fu) {
     document.location.href = fu;
   }
   return res;
-};
+}
 
-export function PayPage({
-  talerPayUri,
-  goToWalletManualWithdraw,
-  goBack,
-}: Props): VNode {
-  const { i18n } = useTranslationContext();
+type State = Loading | Ready | Confirmed;
+interface Loading {
+  status: "loading";
+  hook: HookError | undefined;
+}
+interface Ready {
+  status: "ready";
+  hook: undefined;
+  uri: string;
+  amount: AmountJson;
+  totalFees: AmountJson;
+  payStatus: PreparePayResult;
+  balance: AmountJson | undefined;
+  payHandler: ButtonHandler;
+  payResult: undefined;
+}
+
+interface Confirmed {
+  status: "confirmed";
+  hook: undefined;
+  uri: string;
+  amount: AmountJson;
+  totalFees: AmountJson;
+  payStatus: PreparePayResult;
+  balance: AmountJson | undefined;
+  payResult: ConfirmPayResult;
+  payHandler: ButtonHandler;
+}
+
+export function useComponentState(
+  talerPayUri: string | undefined,
+  api: typeof wxApi,
+): State {
   const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>(
     undefined,
   );
-  const [payErrMsg, setPayErrMsg] = useState<TalerError | string | undefined>(
-    undefined,
-  );
+  const [payErrMsg, setPayErrMsg] = useState<TalerError | 
undefined>(undefined);
 
-  const hook = useAsyncAsHook(async () => {
-    if (!talerPayUri) throw Error("Missing pay uri");
-    const payStatus = await wxApi.preparePay(talerPayUri);
-    const balance = await wxApi.getBalance();
-    return { payStatus, balance };
-  }, [NotificationType.CoinWithdrawn]);
+  const hook = useAsyncAsHook2(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(() => {
-    const payStatus =
-      hook && !hook.hasError ? hook.response.payStatus : undefined;
+    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 &&
@@ -122,74 +168,139 @@ export function PayPage({
         }, 3000);
       }
     }
-  }, []);
-
-  if (!hook) {
-    return <Loading />;
-  }
+  }, [hookResponse]);
 
-  if (hook.hasError) {
-    return (
-      <LoadingError
-        title={<i18n.Translate>Could not load pay status</i18n.Translate>}
-        error={hook}
-      />
-    );
+  if (!hook || hook.hasError) {
+    return {
+      status: "loading",
+      hook,
+    };
   }
+  const { payStatus } = hook.response;
+  const amount = Amounts.parseOrThrow(payStatus.amountRaw);
 
   const foundBalance = hook.response.balance.balances.find(
-    (b) =>
-      Amounts.parseOrThrow(b.available).currency ===
-      Amounts.parseOrThrow(hook.response.payStatus.amountRaw).currency,
+    (b) => Amounts.parseOrThrow(b.available).currency === amount.currency,
   );
   const foundAmount = foundBalance
     ? Amounts.parseOrThrow(foundBalance.available)
     : undefined;
 
-  const onClick = async (): Promise<void> => {
+  async function doPayment(): Promise<void> {
     try {
-      const res = await doPayment(hook.response.payStatus);
+      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 redirect to ${fu}`);
+        }
+      }
       setPayResult(res);
     } catch (e) {
-      console.error(e);
-      if (e instanceof Error) {
-        setPayErrMsg(e.message);
+      if (e instanceof TalerError) {
+        setPayErrMsg(e);
       }
     }
+  }
+
+  const payDisabled =
+    payErrMsg ||
+    !foundAmount ||
+    payStatus.status === PreparePayResultType.InsufficientBalance;
+
+  const payHandler: ButtonHandler = {
+    onClick: payDisabled ? undefined : doPayment,
+    error: payErrMsg,
+  };
+
+  let totalFees = Amounts.getZero(amount.currency);
+  if (payStatus.status === PreparePayResultType.PaymentPossible) {
+    const amountEffective: AmountJson = Amounts.parseOrThrow(
+      payStatus.amountEffective,
+    );
+    totalFees = Amounts.sub(amountEffective, amount).amount;
+  }
+
+  if (!payResult) {
+    return {
+      status: "ready",
+      hook: undefined,
+      uri: hook.response.uri,
+      amount,
+      totalFees,
+      balance: foundAmount,
+      payHandler,
+      payStatus: hook.response.payStatus,
+      payResult,
+    };
+  }
+
+  return {
+    status: "confirmed",
+    hook: undefined,
+    uri: hook.response.uri,
+    amount,
+    totalFees,
+    balance: foundAmount,
+    payStatus: hook.response.payStatus,
+    payResult,
+    payHandler: {},
   };
+}
+
+export function PayPage({
+  talerPayUri,
+  goToWalletManualWithdraw,
+  goBack,
+}: Props): VNode {
+  const { i18n } = useTranslationContext();
+
+  const state = useComponentState(talerPayUri, wxApi);
 
+  if (state.status === "loading") {
+    if (!state.hook) return <Loading />;
+    return (
+      <LoadingError
+        title={<i18n.Translate>Could not load pay status</i18n.Translate>}
+        error={state.hook}
+      />
+    );
+  }
   return (
-    <PaymentRequestView
-      uri={talerPayUri!}
-      payStatus={hook.response.payStatus}
-      payResult={payResult}
-      onClick={onClick}
+    <View
+      state={state}
+      goBack={goBack}
       goToWalletManualWithdraw={goToWalletManualWithdraw}
-      balance={foundAmount}
     />
   );
 }
 
-export interface PaymentRequestViewProps {
-  payStatus: PreparePayResult;
-  payResult?: ConfirmPayResult;
-  onClick: () => void;
-  payErrMsg?: string;
-  uri: string;
-  goToWalletManualWithdraw: (s: string) => void;
-  balance: AmountJson | undefined;
-}
-export function PaymentRequestView({
-  uri,
-  payStatus,
-  payResult,
-  onClick,
+export function View({
+  state,
+  goBack,
   goToWalletManualWithdraw,
-  balance,
-}: PaymentRequestViewProps): VNode {
+}: {
+  state: Ready | Confirmed;
+  goToWalletManualWithdraw: (currency?: string) => void;
+  goBack: () => void;
+}): VNode {
   const { i18n } = useTranslationContext();
-  let totalFees: AmountJson = Amounts.getZero(payStatus.amountRaw);
-  const contractTerms: ContractTerms = payStatus.contractTerms;
+  const contractTerms: ContractTerms = state.payStatus.contractTerms;
 
   if (!contractTerms) {
     return (
@@ -203,124 +314,6 @@ export function PaymentRequestView({
     );
   }
 
-  const amountRaw = Amounts.parseOrThrow(payStatus.amountRaw);
-  if (payStatus.status === PreparePayResultType.PaymentPossible) {
-    const amountEffective: AmountJson = Amounts.parseOrThrow(
-      payStatus.amountEffective,
-    );
-    totalFees = Amounts.sub(amountEffective, amountRaw).amount;
-  }
-
-  function Alternative(): VNode {
-    const [showQR, setShowQR] = useState<boolean>(false);
-    const privateUri =
-      payStatus.status !== PreparePayResultType.AlreadyConfirmed
-        ? `${uri}&n=${payStatus.noncePriv}`
-        : uri;
-    if (!uri) return <Fragment />;
-    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(): VNode {
-    if (payResult) {
-      if (payResult.type === ConfirmPayResultType.Pending) {
-        return (
-          <section>
-            <div>
-              <p>
-                <i18n.Translate>Processing</i18n.Translate>...
-              </p>
-            </div>
-          </section>
-        );
-      }
-      return <Fragment />;
-    }
-    if (payStatus.status === PreparePayResultType.PaymentPossible) {
-      return (
-        <Fragment>
-          <section>
-            <ButtonSuccess upperCased onClick={onClick}>
-              <i18n.Translate>
-                Pay {<Amount value={payStatus.amountEffective} />}
-              </i18n.Translate>
-            </ButtonSuccess>
-          </section>
-          <Alternative />
-        </Fragment>
-      );
-    }
-    if (payStatus.status === PreparePayResultType.InsufficientBalance) {
-      return (
-        <Fragment>
-          <section>
-            {balance ? (
-              <WarningBox>
-                <i18n.Translate>
-                  Your balance of {<Amount value={balance} />} is not enough to
-                  pay for this purchase
-                </i18n.Translate>
-              </WarningBox>
-            ) : (
-              <WarningBox>
-                <i18n.Translate>
-                  Your balance is not enough to pay for this purchase.
-                </i18n.Translate>
-              </WarningBox>
-            )}
-          </section>
-          <section>
-            <ButtonSuccess
-              upperCased
-              onClick={() => goToWalletManualWithdraw(amountRaw.currency)}
-            >
-              <i18n.Translate>Withdraw digital cash</i18n.Translate>
-            </ButtonSuccess>
-          </section>
-          <Alternative />
-        </Fragment>
-      );
-    }
-    if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
-      return (
-        <Fragment>
-          <section>
-            {payStatus.paid && contractTerms.fulfillment_message && (
-              <Part
-                title={<i18n.Translate>Merchant message</i18n.Translate>}
-                text={contractTerms.fulfillment_message}
-                kind="neutral"
-              />
-            )}
-          </section>
-          {!payStatus.paid && <Alternative />}
-        </Fragment>
-      );
-    }
-    return <span />;
-  }
-
   return (
     <WalletAction>
       <LogoHeader />
@@ -328,70 +321,31 @@ export function PaymentRequestView({
       <SubTitle>
         <i18n.Translate>Digital cash payment</i18n.Translate>
       </SubTitle>
-      {payStatus.status === PreparePayResultType.AlreadyConfirmed &&
-        (payStatus.paid ? (
-          payStatus.contractTerms.fulfillment_url ? (
-            <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>
-          ) : (
-            <SuccessBox>
-              <i18n.Translate>Already paid</i18n.Translate>
-            </SuccessBox>
-          )
-        ) : (
-          <WarningBox>
-            <i18n.Translate>Already claimed</i18n.Translate>
-          </WarningBox>
-        ))}
-      {payResult && payResult.type === ConfirmPayResultType.Done && (
-        <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>
-      )}
+
+      <ShowImportantMessage state={state} />
+
       <section>
-        {payStatus.status !== PreparePayResultType.InsufficientBalance &&
-          Amounts.isNonZero(totalFees) && (
+        {state.payStatus.status !== PreparePayResultType.InsufficientBalance &&
+          Amounts.isNonZero(state.totalFees) && (
             <Part
               big
               title={<i18n.Translate>Total to pay</i18n.Translate>}
-              text={<Amount value={payStatus.amountEffective} />}
+              text={<Amount value={state.payStatus.amountEffective} />}
               kind="negative"
             />
           )}
         <Part
           big
           title={<i18n.Translate>Purchase amount</i18n.Translate>}
-          text={<Amount value={payStatus.amountRaw} />}
+          text={<Amount value={state.payStatus.amountRaw} />}
           kind="neutral"
         />
-        {Amounts.isNonZero(totalFees) && (
+        {Amounts.isNonZero(state.totalFees) && (
           <Fragment>
             <Part
               big
               title={<i18n.Translate>Fee</i18n.Translate>}
-              text={<Amount value={totalFees} />}
+              text={<Amount value={state.totalFees} />}
               kind="negative"
             />
           </Fragment>
@@ -417,9 +371,12 @@ export function PaymentRequestView({
           <ProductList products={contractTerms.products} />
         )}
       </section>
-      <ButtonsSection />
+      <ButtonsSection
+        state={state}
+        goToWalletManualWithdraw={goToWalletManualWithdraw}
+      />
       <section>
-        <Link upperCased>
+        <Link upperCased onClick={goBack}>
           <i18n.Translate>Cancel</i18n.Translate>
         </Link>
       </section>
@@ -495,3 +452,189 @@ function ProductList({ products }: { products: Product[] 
}): VNode {
     </Fragment>
   );
 }
+
+function ShowImportantMessage({ state }: { state: Ready | Confirmed }): 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: 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: Ready | Confirmed;
+  goToWalletManualWithdraw: (currency: string) => void;
+}): VNode {
+  const { i18n } = useTranslationContext();
+  if (state.status === "ready") {
+    const { payStatus } = state;
+    if (payStatus.status === PreparePayResultType.PaymentPossible) {
+      return (
+        <Fragment>
+          <section>
+            <ButtonSuccess upperCased onClick={state.payHandler.onClick}>
+              <i18n.Translate>
+                Pay {<Amount value={payStatus.amountEffective} />}
+              </i18n.Translate>
+            </ButtonSuccess>
+          </section>
+          <PayWithMobile state={state} />
+        </Fragment>
+      );
+    }
+    if (payStatus.status === PreparePayResultType.InsufficientBalance) {
+      return (
+        <Fragment>
+          <section>
+            {state.balance ? (
+              <WarningBox>
+                <i18n.Translate>
+                  Your balance of {<Amount value={state.balance} />} is not
+                  enough to pay for this purchase
+                </i18n.Translate>
+              </WarningBox>
+            ) : (
+              <WarningBox>
+                <i18n.Translate>
+                  Your balance is not enough to pay for this purchase.
+                </i18n.Translate>
+              </WarningBox>
+            )}
+          </section>
+          <section>
+            <ButtonSuccess
+              upperCased
+              onClick={() => goToWalletManualWithdraw(state.amount.currency)}
+            >
+              <i18n.Translate>Withdraw digital cash</i18n.Translate>
+            </ButtonSuccess>
+          </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 />;
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts 
b/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts
index 2a297c4b..0301e321 100644
--- a/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts
+++ b/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts
@@ -149,7 +149,7 @@ describe("Withdraw CTA states", () => {
       expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"))
       expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"))
 
-      expect(state.doWithdrawal.disabled).false
+      expect(state.doWithdrawal.onClick).not.undefined
       expect(state.mustAcceptFirst).false
 
     }
@@ -213,7 +213,7 @@ describe("Withdraw CTA states", () => {
       expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"))
       expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"))
 
-      expect(state.doWithdrawal.disabled).true
+      expect(state.doWithdrawal.onClick).undefined
       expect(state.mustAcceptFirst).true
 
       // accept TOS
@@ -238,7 +238,7 @@ describe("Withdraw CTA states", () => {
       expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"))
       expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"))
 
-      expect(state.doWithdrawal.disabled).false
+      expect(state.doWithdrawal.onClick).not.undefined
       expect(state.mustAcceptFirst).true
 
     }
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx 
b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx
index 64059f72..2293d650 100644
--- a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx
+++ b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx
@@ -119,7 +119,7 @@ export function useComponentState(
   const uriHookDep =
     !uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response
       ? undefined
-      : uriInfoHook;
+      : uriInfoHook.response;
 
   const { amount, thisExchange, thisCurrencyExchanges } = useMemo(() => {
     if (!uriHookDep)
@@ -129,7 +129,7 @@ export function useComponentState(
         thisCurrencyExchanges: [],
       };
 
-    const { uriInfo, knownExchanges } = uriHookDep.response;
+    const { uriInfo, knownExchanges } = uriHookDep;
 
     const amount = uriInfo ? Amounts.parseOrThrow(uriInfo.amount) : undefined;
     const thisCurrencyExchanges =
@@ -324,9 +324,11 @@ export function useComponentState(
     withdrawalFee,
     chosenAmount: amount,
     doWithdrawal: {
-      onClick: doWithdrawAndCheckError,
+      onClick:
+        doingWithdraw || (mustAcceptFirst && !reviewed)
+          ? undefined
+          : doWithdrawAndCheckError,
       error: withdrawError,
-      disabled: doingWithdraw || (mustAcceptFirst && !reviewed),
     },
     tosProps: !termsState
       ? undefined
@@ -427,7 +429,7 @@ export function View({ state }: { state: Success }): VNode {
             (state.mustAcceptFirst && state.tosProps.reviewed)) && (
             <ButtonSuccess
               upperCased
-              disabled={state.doWithdrawal.disabled}
+              disabled={!state.doWithdrawal.onClick}
               onClick={state.doWithdrawal.onClick}
             >
               <i18n.Translate>Confirm withdrawal</i18n.Translate>
@@ -436,7 +438,7 @@ export function View({ state }: { state: Success }): VNode {
           {state.tosProps.terms.status === "notfound" && (
             <ButtonWarning
               upperCased
-              disabled={state.doWithdrawal.disabled}
+              disabled={!state.doWithdrawal.onClick}
               onClick={state.doWithdrawal.onClick}
             >
               <i18n.Translate>Withdraw anyway</i18n.Translate>
diff --git a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts 
b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts
index e592073d..d03455ff 100644
--- a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts
+++ b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts
@@ -39,7 +39,12 @@ export interface HookOperationalError {
   details: TalerErrorDetail;
 }
 
+interface WithRetry {
+  retry: () => void;
+}
+
 export type HookResponse<T> = HookOk<T> | HookError | undefined;
+export type HookResponseWithRetry<T> = ((HookOk<T> | HookError) & WithRetry) | 
undefined;
 
 export function useAsyncAsHook<T>(
   fn: () => Promise<T | false>,
@@ -84,3 +89,45 @@ export function useAsyncAsHook<T>(
   }, [args]);
   return result;
 }
+
+export function useAsyncAsHook2<T>(
+  fn: () => Promise<T | false>,
+  deps?: any[],
+): HookResponseWithRetry<T> {
+
+  const [result, setHookResponse] = useState<HookResponse<T>>(undefined);
+
+  const args = useMemo(() => ({
+    fn
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }), deps || [])
+
+  async function doAsync(): Promise<void> {
+    try {
+      const response = await args.fn();
+      if (response === false) return;
+      setHookResponse({ hasError: false, response });
+    } catch (e) {
+      if (e instanceof TalerError) {
+        setHookResponse({
+          hasError: true,
+          operational: true,
+          details: e.errorDetail,
+        });
+      } else if (e instanceof Error) {
+        setHookResponse({
+          hasError: true,
+          operational: false,
+          message: e.message,
+        });
+      }
+    }
+  }
+
+  useEffect(() => {
+    doAsync();
+  }, [args]);
+
+  if (!result) return undefined;
+  return { ...result, retry: doAsync };
+}
diff --git a/packages/taler-wallet-webextension/src/test-utils.ts 
b/packages/taler-wallet-webextension/src/test-utils.ts
index f10e49ac..eceda616 100644
--- a/packages/taler-wallet-webextension/src/test-utils.ts
+++ b/packages/taler-wallet-webextension/src/test-utils.ts
@@ -64,7 +64,6 @@ export function renderNodeOrBrowser(Component: any, args: 
any): void {
 
 interface Mounted<T> {
   unmount: () => void;
-  getLastResult: () => T | null;
   getLastResultOrThrow: () => T;
   assertNoPendingUpdate: () => void;
   waitNextUpdate: (s?: string) => Promise<void>;
@@ -76,15 +75,23 @@ export function mountHook<T>(callback: () => T, Context?: 
({ children }: { child
   // const result: { current: T | null } = {
   //   current: null
   // }
-  let lastResult: T | null = null;
+  let lastResult: T | Error | null = null;
 
   const listener: Array<() => void> = []
 
   // component that's going to hold the hook
   function Component(): VNode {
-    const hookResult = callback()
-    // save the hook result
-    lastResult = hookResult
+
+    try {
+      lastResult = callback()
+    } catch (e) {
+      if (e instanceof Error) {
+        lastResult = e
+      } else {
+        lastResult = new Error(`mounting the hook throw an exception: ${e}`)
+      }
+    }
+
     // notify to everyone waiting for an update and clean the queue
     listener.splice(0, listener.length).forEach(cb => cb())
     return create(Fragment, {})
@@ -123,7 +130,7 @@ export function mountHook<T>(callback: () => T, Context?: 
({ children }: { child
     }
   }
 
-  function getLastResult(): T | null {
+  function getLastResult(): T | Error | null {
     const copy = lastResult
     lastResult = null
     return copy;
@@ -131,6 +138,7 @@ export function mountHook<T>(callback: () => T, Context?: 
({ children }: { child
 
   function getLastResultOrThrow(): T {
     const r = getLastResult()
+    if (r instanceof Error) throw r;
     if (!r) throw Error('there was no last result')
     return r;
   }
@@ -143,14 +151,18 @@ export function mountHook<T>(callback: () => T, Context?: 
({ children }: { child
 
       listener.push(() => {
         clearTimeout(tid)
-        rej(Error(`Expecting no pending result but the hook get updated. Check 
the dependencies of the hooks.`))
+        rej(Error(`Expecting no pending result but the hook got updated. 
+        If the update was not intended you need to check the hook dependencies 
+        (or dependencies of the internal state) but otherwise make 
+        sure to consume the result before ending the test.`))
       })
     })
 
     const r = getLastResult()
-    if (r) throw Error('There are still pending results. This may happen 
because the hook did a new update but the test didn\'t get the result using 
getLastResult');
+    if (r) throw Error(`There are still pending results.
+    This may happen because the hook did a new update but the test didn't 
consume the result using getLastResult`);
   }
   return {
-    unmount, getLastResult, getLastResultOrThrow, waitNextUpdate, 
assertNoPendingUpdate
+    unmount, getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate
   }
 }
diff --git 
a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx 
b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx
index b9d39891..0440c50a 100644
--- a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx
@@ -62,8 +62,7 @@ export interface TextFieldHandler {
 }
 
 export interface ButtonHandler {
-  onClick: () => Promise<void>;
-  disabled?: boolean;
+  onClick?: () => Promise<void>;
   error?: TalerError;
 }
 

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]