payments test case

This commit is contained in:
Sebastian 2022-04-21 14:23:53 -03:00
parent 8e29f91a56
commit 64acf8e2b1
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
8 changed files with 1154 additions and 391 deletions

View File

@ -19,9 +19,13 @@
* @author Sebastian Javier Marchano (sebasjm) * @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 { createExample } from "../test-utils.js";
import { PaymentRequestView as TestedComponent } from "./Pay.js"; import { View as TestedComponent } from "./Pay.js";
export default { export default {
title: "cta/pay", title: "cta/pay",
@ -30,175 +34,323 @@ export default {
}; };
export const NoBalance = createExample(TestedComponent, { export const NoBalance = createExample(TestedComponent, {
payStatus: { state: {
status: PreparePayResultType.InsufficientBalance, status: "ready",
noncePriv: "", hook: undefined,
proposalId: "proposal1234", amount: Amounts.parseOrThrow("USD:10"),
contractTerms: { balance: undefined,
merchant: { payHandler: {
name: "someone", onClick: async () => {
null;
}, },
summary: "some beers", },
amount: "USD:10", totalFees: Amounts.parseOrThrow("USD:0"),
} as Partial<ContractTerms> as any, payResult: undefined,
amountRaw: "USD:10", 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, { export const NoEnoughBalance = createExample(TestedComponent, {
payStatus: { state: {
status: PreparePayResultType.InsufficientBalance, status: "ready",
noncePriv: "", hook: undefined,
proposalId: "proposal1234", amount: Amounts.parseOrThrow("USD:10"),
contractTerms: { balance: {
merchant: { currency: "USD",
name: "someone", fraction: 40000000,
value: 9,
},
payHandler: {
onClick: async () => {
null;
}, },
summary: "some beers", },
amount: "USD:10", totalFees: Amounts.parseOrThrow("USD:0"),
} as Partial<ContractTerms> as any, payResult: undefined,
amountRaw: "USD:10", uri: "",
}, payStatus: {
balance: { status: PreparePayResultType.InsufficientBalance,
currency: "USD", noncePriv: "",
fraction: 40000000, proposalId: "proposal1234",
value: 9, 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, { export const PaymentPossible = createExample(TestedComponent, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", state: {
payStatus: { status: "ready",
status: PreparePayResultType.PaymentPossible, hook: undefined,
amountEffective: "USD:10", amount: Amounts.parseOrThrow("USD:10"),
amountRaw: "USD:10", balance: {
noncePriv: "", currency: "USD",
contractTerms: { fraction: 40000000,
nonce: "123213123", value: 11,
merchant: { },
name: "someone", payHandler: {
onClick: async () => {
null;
}, },
amount: "USD:10", },
summary: "some beers", totalFees: Amounts.parseOrThrow("USD:0"),
} as Partial<ContractTerms> as any, payResult: undefined,
contractTermsHash: "123456", uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
proposalId: "proposal1234", 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, { export const PaymentPossibleWithFee = createExample(TestedComponent, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", state: {
payStatus: { status: "ready",
status: PreparePayResultType.PaymentPossible, hook: undefined,
amountEffective: "USD:10.20", amount: Amounts.parseOrThrow("USD:10"),
amountRaw: "USD:10", balance: {
noncePriv: "", currency: "USD",
contractTerms: { fraction: 40000000,
nonce: "123213123", value: 11,
merchant: { },
name: "someone", payHandler: {
onClick: async () => {
null;
}, },
amount: "USD:10", },
summary: "some beers", totalFees: Amounts.parseOrThrow("USD:0.20"),
} as Partial<ContractTerms> as any, payResult: undefined,
contractTermsHash: "123456", uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
proposalId: "proposal1234", 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"; import beer from "../../static-dev/beer.png";
export const TicketWithAProductList = createExample(TestedComponent, { export const TicketWithAProductList = createExample(TestedComponent, {
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", state: {
payStatus: { status: "ready",
status: PreparePayResultType.PaymentPossible, hook: undefined,
amountEffective: "USD:10", amount: Amounts.parseOrThrow("USD:10"),
amountRaw: "USD:10", balance: {
noncePriv: "", currency: "USD",
contractTerms: { fraction: 40000000,
nonce: "123213123", value: 11,
merchant: { },
name: "someone", payHandler: {
onClick: async () => {
null;
}, },
amount: "USD:10", },
products: [ totalFees: Amounts.parseOrThrow("USD:0.20"),
{ payResult: undefined,
description: "ten beers", uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
price: "USD:1", payStatus: {
quantity: 10, status: PreparePayResultType.PaymentPossible,
image: beer, amountEffective: "USD:10.20",
amountRaw: "USD:10",
noncePriv: "",
contractTerms: {
nonce: "123213123",
merchant: {
name: "someone",
}, },
{ amount: "USD:10",
description: "beer without image", summary: "some beers",
price: "USD:1", products: [
quantity: 10, {
}, description: "ten beers",
{ price: "USD:1",
description: "one brown beer", quantity: 10,
price: "USD:2", image: beer,
quantity: 1, },
image: beer, {
}, description: "beer without image",
], price: "USD:1",
summary: "some beers", quantity: 10,
} as Partial<ContractTerms> as any, },
contractTermsHash: "123456", {
proposalId: "proposal1234", 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, { export const AlreadyConfirmedByOther = createExample(TestedComponent, {
payStatus: { state: {
status: PreparePayResultType.AlreadyConfirmed, status: "ready",
amountEffective: "USD:10", hook: undefined,
amountRaw: "USD:10", amount: Amounts.parseOrThrow("USD:10"),
contractTerms: { balance: {
merchant: { currency: "USD",
name: "someone", fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
}, },
summary: "some beers", },
amount: "USD:10", totalFees: Amounts.parseOrThrow("USD:0.20"),
} as Partial<ContractTerms> as any, payResult: undefined,
contractTermsHash: "123456", uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
proposalId: "proposal1234", payStatus: {
paid: false, 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, { export const AlreadyPaidWithoutFulfillment = createExample(TestedComponent, {
payStatus: { state: {
status: PreparePayResultType.AlreadyConfirmed, status: "ready",
amountEffective: "USD:10", hook: undefined,
amountRaw: "USD:10", amount: Amounts.parseOrThrow("USD:10"),
contractTerms: { balance: {
merchant: { currency: "USD",
name: "someone", fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
}, },
summary: "some beers", },
amount: "USD:10", totalFees: Amounts.parseOrThrow("USD:0.20"),
} as Partial<ContractTerms> as any, payResult: undefined,
contractTermsHash: "123456", uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
proposalId: "proposal1234", payStatus: {
paid: true, 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, { export const AlreadyPaidWithFulfillment = createExample(TestedComponent, {
payStatus: { state: {
status: PreparePayResultType.AlreadyConfirmed, status: "ready",
amountEffective: "USD:10", hook: undefined,
amountRaw: "USD:10", amount: Amounts.parseOrThrow("USD:10"),
contractTerms: { balance: {
merchant: { currency: "USD",
name: "someone", fraction: 40000000,
value: 11,
},
payHandler: {
onClick: async () => {
null;
}, },
fulfillment_message: },
"congratulations! you are looking at the fulfillment message! ", totalFees: Amounts.parseOrThrow("USD:0.20"),
summary: "some beers", payResult: undefined,
amount: "USD:10", uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
} as Partial<ContractTerms> as any, payStatus: {
contractTermsHash: "123456", status: PreparePayResultType.AlreadyConfirmed,
proposalId: "proposal1234", amountEffective: "USD:10",
paid: true, 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,
}); });

View File

@ -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()
});
});

View File

@ -27,9 +27,7 @@
import { import {
AmountJson, AmountJson,
AmountLike,
Amounts, Amounts,
AmountString,
ConfirmPayResult, ConfirmPayResult,
ConfirmPayResultDone, ConfirmPayResultDone,
ConfirmPayResultType, ConfirmPayResultType,
@ -38,12 +36,14 @@ import {
PreparePayResult, PreparePayResult,
PreparePayResultType, PreparePayResultType,
Product, Product,
TalerErrorCode,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { TalerError } from "@gnu-taler/taler-wallet-core"; import { TalerError } from "@gnu-taler/taler-wallet-core";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Amount } from "../components/Amount.js"; import { Amount } from "../components/Amount.js";
import { ErrorMessage } from "../components/ErrorMessage.js"; import { ErrorMessage } from "../components/ErrorMessage.js";
import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js";
import { Loading } from "../components/Loading.js"; import { Loading } from "../components/Loading.js";
import { LoadingError } from "../components/LoadingError.js"; import { LoadingError } from "../components/LoadingError.js";
import { LogoHeader } from "../components/LogoHeader.js"; import { LogoHeader } from "../components/LogoHeader.js";
@ -60,7 +60,12 @@ import {
WarningBox, WarningBox,
} from "../components/styled/index.js"; } from "../components/styled/index.js";
import { useTranslationContext } from "../context/translation.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"; import * as wxApi from "../wxApi.js";
interface Props { interface Props {
@ -69,47 +74,88 @@ interface Props {
goBack: () => void; goBack: () => void;
} }
const doPayment = async ( async function doPayment(
payStatus: PreparePayResult, payStatus: PreparePayResult,
): Promise<ConfirmPayResultDone> => { api: typeof wxApi,
): Promise<ConfirmPayResultDone> {
if (payStatus.status !== "payment-possible") { 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 proposalId = payStatus.proposalId;
const res = await wxApi.confirmPay(proposalId, undefined); const res = await api.confirmPay(proposalId, undefined);
if (res.type !== ConfirmPayResultType.Done) { 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; const fu = res.contractTerms.fulfillment_url;
if (fu) { if (fu) {
document.location.href = fu; document.location.href = fu;
} }
return res; return res;
}; }
export function PayPage({ type State = Loading | Ready | Confirmed;
talerPayUri, interface Loading {
goToWalletManualWithdraw, status: "loading";
goBack, hook: HookError | undefined;
}: Props): VNode { }
const { i18n } = useTranslationContext(); 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>( const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>(
undefined, undefined,
); );
const [payErrMsg, setPayErrMsg] = useState<TalerError | string | undefined>( const [payErrMsg, setPayErrMsg] = useState<TalerError | undefined>(undefined);
undefined,
);
const hook = useAsyncAsHook(async () => { const hook = useAsyncAsHook2(async () => {
if (!talerPayUri) throw Error("Missing pay uri"); if (!talerPayUri) throw Error("ERROR_NO-URI-FOR-PAYMENT");
const payStatus = await wxApi.preparePay(talerPayUri); const payStatus = await api.preparePay(talerPayUri);
const balance = await wxApi.getBalance(); const balance = await api.getBalance();
return { payStatus, balance }; return { payStatus, balance, uri: talerPayUri };
}, [NotificationType.CoinWithdrawn]); });
useEffect(() => { useEffect(() => {
const payStatus = api.onUpdateNotification([NotificationType.CoinWithdrawn], () => {
hook && !hook.hasError ? hook.response.payStatus : undefined; hook?.retry();
});
});
const hookResponse = !hook || hook.hasError ? undefined : hook.response;
useEffect(() => {
if (!hookResponse) return;
const { payStatus } = hookResponse;
if ( if (
payStatus && payStatus &&
payStatus.status === PreparePayResultType.AlreadyConfirmed && payStatus.status === PreparePayResultType.AlreadyConfirmed &&
@ -122,74 +168,139 @@ export function PayPage({
}, 3000); }, 3000);
} }
} }
}, []); }, [hookResponse]);
if (!hook) { if (!hook || hook.hasError) {
return <Loading />; return {
} status: "loading",
hook,
if (hook.hasError) { };
return (
<LoadingError
title={<i18n.Translate>Could not load pay status</i18n.Translate>}
error={hook}
/>
);
} }
const { payStatus } = hook.response;
const amount = Amounts.parseOrThrow(payStatus.amountRaw);
const foundBalance = hook.response.balance.balances.find( const foundBalance = hook.response.balance.balances.find(
(b) => (b) => Amounts.parseOrThrow(b.available).currency === amount.currency,
Amounts.parseOrThrow(b.available).currency ===
Amounts.parseOrThrow(hook.response.payStatus.amountRaw).currency,
); );
const foundAmount = foundBalance const foundAmount = foundBalance
? Amounts.parseOrThrow(foundBalance.available) ? Amounts.parseOrThrow(foundBalance.available)
: undefined; : undefined;
const onClick = async (): Promise<void> => { async function doPayment(): Promise<void> {
try { 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); setPayResult(res);
} catch (e) { } catch (e) {
console.error(e); if (e instanceof TalerError) {
if (e instanceof Error) { setPayErrMsg(e);
setPayErrMsg(e.message);
} }
} }
}
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 ( return (
<PaymentRequestView <View
uri={talerPayUri!} state={state}
payStatus={hook.response.payStatus} goBack={goBack}
payResult={payResult}
onClick={onClick}
goToWalletManualWithdraw={goToWalletManualWithdraw} goToWalletManualWithdraw={goToWalletManualWithdraw}
balance={foundAmount}
/> />
); );
} }
export interface PaymentRequestViewProps { export function View({
payStatus: PreparePayResult; state,
payResult?: ConfirmPayResult; goBack,
onClick: () => void;
payErrMsg?: string;
uri: string;
goToWalletManualWithdraw: (s: string) => void;
balance: AmountJson | undefined;
}
export function PaymentRequestView({
uri,
payStatus,
payResult,
onClick,
goToWalletManualWithdraw, goToWalletManualWithdraw,
balance, }: {
}: PaymentRequestViewProps): VNode { state: Ready | Confirmed;
goToWalletManualWithdraw: (currency?: string) => void;
goBack: () => void;
}): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
let totalFees: AmountJson = Amounts.getZero(payStatus.amountRaw); const contractTerms: ContractTerms = state.payStatus.contractTerms;
const contractTerms: ContractTerms = payStatus.contractTerms;
if (!contractTerms) { if (!contractTerms) {
return ( 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 ( return (
<WalletAction> <WalletAction>
<LogoHeader /> <LogoHeader />
@ -328,70 +321,31 @@ export function PaymentRequestView({
<SubTitle> <SubTitle>
<i18n.Translate>Digital cash payment</i18n.Translate> <i18n.Translate>Digital cash payment</i18n.Translate>
</SubTitle> </SubTitle>
{payStatus.status === PreparePayResultType.AlreadyConfirmed &&
(payStatus.paid ? ( <ShowImportantMessage state={state} />
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>
)}
<section> <section>
{payStatus.status !== PreparePayResultType.InsufficientBalance && {state.payStatus.status !== PreparePayResultType.InsufficientBalance &&
Amounts.isNonZero(totalFees) && ( Amounts.isNonZero(state.totalFees) && (
<Part <Part
big big
title={<i18n.Translate>Total to pay</i18n.Translate>} title={<i18n.Translate>Total to pay</i18n.Translate>}
text={<Amount value={payStatus.amountEffective} />} text={<Amount value={state.payStatus.amountEffective} />}
kind="negative" kind="negative"
/> />
)} )}
<Part <Part
big big
title={<i18n.Translate>Purchase amount</i18n.Translate>} title={<i18n.Translate>Purchase amount</i18n.Translate>}
text={<Amount value={payStatus.amountRaw} />} text={<Amount value={state.payStatus.amountRaw} />}
kind="neutral" kind="neutral"
/> />
{Amounts.isNonZero(totalFees) && ( {Amounts.isNonZero(state.totalFees) && (
<Fragment> <Fragment>
<Part <Part
big big
title={<i18n.Translate>Fee</i18n.Translate>} title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={totalFees} />} text={<Amount value={state.totalFees} />}
kind="negative" kind="negative"
/> />
</Fragment> </Fragment>
@ -417,9 +371,12 @@ export function PaymentRequestView({
<ProductList products={contractTerms.products} /> <ProductList products={contractTerms.products} />
)} )}
</section> </section>
<ButtonsSection /> <ButtonsSection
state={state}
goToWalletManualWithdraw={goToWalletManualWithdraw}
/>
<section> <section>
<Link upperCased> <Link upperCased onClick={goBack}>
<i18n.Translate>Cancel</i18n.Translate> <i18n.Translate>Cancel</i18n.Translate>
</Link> </Link>
</section> </section>
@ -495,3 +452,189 @@ function ProductList({ products }: { products: Product[] }): VNode {
</Fragment> </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 />;
}

View File

@ -149,7 +149,7 @@ describe("Withdraw CTA states", () => {
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")) expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"))
expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")) expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"))
expect(state.doWithdrawal.disabled).false expect(state.doWithdrawal.onClick).not.undefined
expect(state.mustAcceptFirst).false expect(state.mustAcceptFirst).false
} }
@ -213,7 +213,7 @@ describe("Withdraw CTA states", () => {
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")) expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"))
expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")) expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"))
expect(state.doWithdrawal.disabled).true expect(state.doWithdrawal.onClick).undefined
expect(state.mustAcceptFirst).true expect(state.mustAcceptFirst).true
// accept TOS // accept TOS
@ -238,7 +238,7 @@ describe("Withdraw CTA states", () => {
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")) expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"))
expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")) expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"))
expect(state.doWithdrawal.disabled).false expect(state.doWithdrawal.onClick).not.undefined
expect(state.mustAcceptFirst).true expect(state.mustAcceptFirst).true
} }

View File

@ -119,7 +119,7 @@ export function useComponentState(
const uriHookDep = const uriHookDep =
!uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response !uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response
? undefined ? undefined
: uriInfoHook; : uriInfoHook.response;
const { amount, thisExchange, thisCurrencyExchanges } = useMemo(() => { const { amount, thisExchange, thisCurrencyExchanges } = useMemo(() => {
if (!uriHookDep) if (!uriHookDep)
@ -129,7 +129,7 @@ export function useComponentState(
thisCurrencyExchanges: [], thisCurrencyExchanges: [],
}; };
const { uriInfo, knownExchanges } = uriHookDep.response; const { uriInfo, knownExchanges } = uriHookDep;
const amount = uriInfo ? Amounts.parseOrThrow(uriInfo.amount) : undefined; const amount = uriInfo ? Amounts.parseOrThrow(uriInfo.amount) : undefined;
const thisCurrencyExchanges = const thisCurrencyExchanges =
@ -324,9 +324,11 @@ export function useComponentState(
withdrawalFee, withdrawalFee,
chosenAmount: amount, chosenAmount: amount,
doWithdrawal: { doWithdrawal: {
onClick: doWithdrawAndCheckError, onClick:
doingWithdraw || (mustAcceptFirst && !reviewed)
? undefined
: doWithdrawAndCheckError,
error: withdrawError, error: withdrawError,
disabled: doingWithdraw || (mustAcceptFirst && !reviewed),
}, },
tosProps: !termsState tosProps: !termsState
? undefined ? undefined
@ -427,7 +429,7 @@ export function View({ state }: { state: Success }): VNode {
(state.mustAcceptFirst && state.tosProps.reviewed)) && ( (state.mustAcceptFirst && state.tosProps.reviewed)) && (
<ButtonSuccess <ButtonSuccess
upperCased upperCased
disabled={state.doWithdrawal.disabled} disabled={!state.doWithdrawal.onClick}
onClick={state.doWithdrawal.onClick} onClick={state.doWithdrawal.onClick}
> >
<i18n.Translate>Confirm withdrawal</i18n.Translate> <i18n.Translate>Confirm withdrawal</i18n.Translate>
@ -436,7 +438,7 @@ export function View({ state }: { state: Success }): VNode {
{state.tosProps.terms.status === "notfound" && ( {state.tosProps.terms.status === "notfound" && (
<ButtonWarning <ButtonWarning
upperCased upperCased
disabled={state.doWithdrawal.disabled} disabled={!state.doWithdrawal.onClick}
onClick={state.doWithdrawal.onClick} onClick={state.doWithdrawal.onClick}
> >
<i18n.Translate>Withdraw anyway</i18n.Translate> <i18n.Translate>Withdraw anyway</i18n.Translate>

View File

@ -39,7 +39,12 @@ export interface HookOperationalError {
details: TalerErrorDetail; details: TalerErrorDetail;
} }
interface WithRetry {
retry: () => void;
}
export type HookResponse<T> = HookOk<T> | HookError | undefined; export type HookResponse<T> = HookOk<T> | HookError | undefined;
export type HookResponseWithRetry<T> = ((HookOk<T> | HookError) & WithRetry) | undefined;
export function useAsyncAsHook<T>( export function useAsyncAsHook<T>(
fn: () => Promise<T | false>, fn: () => Promise<T | false>,
@ -84,3 +89,45 @@ export function useAsyncAsHook<T>(
}, [args]); }, [args]);
return result; 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 };
}

View File

@ -64,7 +64,6 @@ export function renderNodeOrBrowser(Component: any, args: any): void {
interface Mounted<T> { interface Mounted<T> {
unmount: () => void; unmount: () => void;
getLastResult: () => T | null;
getLastResultOrThrow: () => T; getLastResultOrThrow: () => T;
assertNoPendingUpdate: () => void; assertNoPendingUpdate: () => void;
waitNextUpdate: (s?: string) => Promise<void>; waitNextUpdate: (s?: string) => Promise<void>;
@ -76,15 +75,23 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
// const result: { current: T | null } = { // const result: { current: T | null } = {
// current: null // current: null
// } // }
let lastResult: T | null = null; let lastResult: T | Error | null = null;
const listener: Array<() => void> = [] const listener: Array<() => void> = []
// component that's going to hold the hook // component that's going to hold the hook
function Component(): VNode { function Component(): VNode {
const hookResult = callback()
// save the hook result try {
lastResult = hookResult 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 // notify to everyone waiting for an update and clean the queue
listener.splice(0, listener.length).forEach(cb => cb()) listener.splice(0, listener.length).forEach(cb => cb())
return create(Fragment, {}) 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 const copy = lastResult
lastResult = null lastResult = null
return copy; return copy;
@ -131,6 +138,7 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
function getLastResultOrThrow(): T { function getLastResultOrThrow(): T {
const r = getLastResult() const r = getLastResult()
if (r instanceof Error) throw r;
if (!r) throw Error('there was no last result') if (!r) throw Error('there was no last result')
return r; return r;
} }
@ -143,14 +151,18 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
listener.push(() => { listener.push(() => {
clearTimeout(tid) 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() 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 { return {
unmount, getLastResult, getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate unmount, getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate
} }
} }

View File

@ -62,8 +62,7 @@ export interface TextFieldHandler {
} }
export interface ButtonHandler { export interface ButtonHandler {
onClick: () => Promise<void>; onClick?: () => Promise<void>;
disabled?: boolean;
error?: TalerError; error?: TalerError;
} }