From 64acf8e2b1083de6f78b7d21dd2701af2fee1911 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 21 Apr 2022 14:23:53 -0300 Subject: payments test case --- .../src/cta/Pay.stories.tsx | 418 +++++++++----- .../taler-wallet-webextension/src/cta/Pay.test.ts | 408 ++++++++++++++ packages/taler-wallet-webextension/src/cta/Pay.tsx | 619 +++++++++++++-------- .../src/cta/Withdraw.test.ts | 6 +- .../taler-wallet-webextension/src/cta/Withdraw.tsx | 14 +- 5 files changed, 1085 insertions(+), 380 deletions(-) create mode 100644 packages/taler-wallet-webextension/src/cta/Pay.test.ts (limited to 'packages/taler-wallet-webextension/src/cta') 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 + */ + +/** + * + * @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 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), + getBalance: async () => ({ + balances: [] + } as Partial), + } as Partial 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), + getBalance: async () => ({ + balances: [{ + available: 'USD:5' + }] + } as Partial), + } as Partial 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), + getBalance: async () => ({ + balances: [{ + available: 'USD:15' + }] + } as Partial), + } as Partial 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), + getBalance: async () => ({ + balances: [{ + available: 'USD:15' + }] + } as Partial), + } as Partial 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), + getBalance: async () => ({ + balances: [{ + available: 'USD:15' + }] + } as Partial), + confirmPay: async () => ({ + type: ConfirmPayResultType.Done, + contractTerms: {} + } as Partial), + } as Partial 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), + getBalance: async () => ({ + balances: [{ + available: 'USD:15' + }] + } as Partial), + confirmPay: async () => ({ + type: ConfirmPayResultType.Pending, + lastError: { code: 1 }, + } as Partial), + } as Partial 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), + getBalance: async () => ({ + balances: [{ + available: Amounts.stringify(availableBalance) + }] + } as Partial), + } as Partial 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 => { + api: typeof wxApi, +): Promise { 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( undefined, ); - const [payErrMsg, setPayErrMsg] = useState( - undefined, - ); + const [payErrMsg, setPayErrMsg] = useState(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 ; - } + }, [hookResponse]); - if (hook.hasError) { - return ( - Could not load pay status} - 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 => { + async function doPayment(): Promise { 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 ; + return ( + Could not load pay status} + error={state.hook} + /> + ); + } return ( - ); } -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(false); - const privateUri = - payStatus.status !== PreparePayResultType.AlreadyConfirmed - ? `${uri}&n=${payStatus.noncePriv}` - : uri; - if (!uri) return ; - return ( -
- setShowQR((qr) => !qr)}> - {!showQR ? ( - Pay with a mobile phone - ) : ( - Hide QR - )} - - {showQR && ( -
- - - Scan the QR code or - - click here - - -
- )} -
- ); - } - - function ButtonsSection(): VNode { - if (payResult) { - if (payResult.type === ConfirmPayResultType.Pending) { - return ( -
-
-

- Processing... -

-
-
- ); - } - return ; - } - if (payStatus.status === PreparePayResultType.PaymentPossible) { - return ( - -
- - - Pay {} - - -
- -
- ); - } - if (payStatus.status === PreparePayResultType.InsufficientBalance) { - return ( - -
- {balance ? ( - - - Your balance of {} is not enough to - pay for this purchase - - - ) : ( - - - Your balance is not enough to pay for this purchase. - - - )} -
-
- goToWalletManualWithdraw(amountRaw.currency)} - > - Withdraw digital cash - -
- -
- ); - } - if (payStatus.status === PreparePayResultType.AlreadyConfirmed) { - return ( - -
- {payStatus.paid && contractTerms.fulfillment_message && ( - Merchant message} - text={contractTerms.fulfillment_message} - kind="neutral" - /> - )} -
- {!payStatus.paid && } -
- ); - } - return ; - } - return ( @@ -328,70 +321,31 @@ export function PaymentRequestView({ Digital cash payment - {payStatus.status === PreparePayResultType.AlreadyConfirmed && - (payStatus.paid ? ( - payStatus.contractTerms.fulfillment_url ? ( - - - Already paid, you are going to be redirected to{" "} - - {payStatus.contractTerms.fulfillment_url} - - - - ) : ( - - Already paid - - ) - ) : ( - - Already claimed - - ))} - {payResult && payResult.type === ConfirmPayResultType.Done && ( - -

- Payment complete -

-

- {!payResult.contractTerms.fulfillment_message ? ( - payResult.contractTerms.fulfillment_url ? ( - - You are going to be redirected to $ - {payResult.contractTerms.fulfillment_url} - - ) : ( - You can close this page. - ) - ) : ( - payResult.contractTerms.fulfillment_message - )} -

-
- )} + + +
- {payStatus.status !== PreparePayResultType.InsufficientBalance && - Amounts.isNonZero(totalFees) && ( + {state.payStatus.status !== PreparePayResultType.InsufficientBalance && + Amounts.isNonZero(state.totalFees) && ( Total to pay} - text={} + text={} kind="negative" /> )} Purchase amount} - text={} + text={} kind="neutral" /> - {Amounts.isNonZero(totalFees) && ( + {Amounts.isNonZero(state.totalFees) && ( Fee} - text={} + text={} kind="negative" /> @@ -417,9 +371,12 @@ export function PaymentRequestView({ )}
- +
- + Cancel
@@ -495,3 +452,189 @@ function ProductList({ products }: { products: Product[] }): VNode {
); } + +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 ( + + + Already paid, you are going to be redirected to{" "} + + {payStatus.contractTerms.fulfillment_url} + + + + ); + } + return ( + + Already paid + + ); + } + return ( + + Already claimed + + ); + } + + if (state.status == "confirmed") { + const { payResult, payHandler } = state; + if (payHandler.error) { + return ; + } + if (payResult.type === ConfirmPayResultType.Done) { + return ( + +

+ Payment complete +

+

+ {!payResult.contractTerms.fulfillment_message ? ( + payResult.contractTerms.fulfillment_url ? ( + + You are going to be redirected to $ + {payResult.contractTerms.fulfillment_url} + + ) : ( + You can close this page. + ) + ) : ( + payResult.contractTerms.fulfillment_message + )} +

+
+ ); + } + } + return ; +} + +function PayWithMobile({ state }: { state: Ready }): VNode { + const { i18n } = useTranslationContext(); + + const [showQR, setShowQR] = useState(false); + + const privateUri = + state.payStatus.status !== PreparePayResultType.AlreadyConfirmed + ? `${state.uri}&n=${state.payStatus.noncePriv}` + : state.uri; + return ( +
+ setShowQR((qr) => !qr)}> + {!showQR ? ( + Pay with a mobile phone + ) : ( + Hide QR + )} + + {showQR && ( +
+ + + Scan the QR code or + + click here + + +
+ )} +
+ ); +} + +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 ( + +
+ + + Pay {} + + +
+ +
+ ); + } + if (payStatus.status === PreparePayResultType.InsufficientBalance) { + return ( + +
+ {state.balance ? ( + + + Your balance of {} is not + enough to pay for this purchase + + + ) : ( + + + Your balance is not enough to pay for this purchase. + + + )} +
+
+ goToWalletManualWithdraw(state.amount.currency)} + > + Withdraw digital cash + +
+ +
+ ); + } + if (payStatus.status === PreparePayResultType.AlreadyConfirmed) { + return ( + +
+ {payStatus.paid && + state.payStatus.contractTerms.fulfillment_message && ( + Merchant message} + text={state.payStatus.contractTerms.fulfillment_message} + kind="neutral" + /> + )} +
+ {!payStatus.paid && } +
+ ); + } + } + + if (state.status === "confirmed") { + if (state.payResult.type === ConfirmPayResultType.Pending) { + return ( +
+
+

+ Processing... +

+
+
+ ); + } + } + + return ; +} 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)) && ( Confirm withdrawal @@ -436,7 +438,7 @@ export function View({ state }: { state: Success }): VNode { {state.tosProps.terms.status === "notfound" && ( Withdraw anyway -- cgit v1.2.3