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