aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2022-04-21 14:23:53 -0300
committerSebastian <sebasjm@gmail.com>2022-04-21 14:23:53 -0300
commit64acf8e2b1083de6f78b7d21dd2701af2fee1911 (patch)
tree8f48df244fa2299ea68711a3aeb91da955dc8040
parent8e29f91a56af962404c9a30868d6c3a21997ba57 (diff)
payments test case
-rw-r--r--packages/taler-wallet-webextension/src/cta/Pay.stories.tsx418
-rw-r--r--packages/taler-wallet-webextension/src/cta/Pay.test.ts408
-rw-r--r--packages/taler-wallet-webextension/src/cta/Pay.tsx619
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw.test.ts6
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw.tsx14
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts47
-rw-r--r--packages/taler-wallet-webextension/src/test-utils.ts30
-rw-r--r--packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx3
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 7dbb7723d..3656bbbd4 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 000000000..4c0fe45ca
--- /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 f2661308c..0d5d57378 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 2a297c4bb..0301e321c 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 64059f721..2293d6508 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 e592073dd..d03455ff7 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 f10e49ac4..eceda616f 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 b9d398915..0440c50a9 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;
}