diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src')
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; } |