payments test case
This commit is contained in:
parent
8e29f91a56
commit
64acf8e2b1
@ -19,9 +19,13 @@
|
||||
* @author Sebastian Javier Marchano (sebasjm)
|
||||
*/
|
||||
|
||||
import { ContractTerms, PreparePayResultType } from "@gnu-taler/taler-util";
|
||||
import {
|
||||
Amounts,
|
||||
ContractTerms,
|
||||
PreparePayResultType,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { createExample } from "../test-utils.js";
|
||||
import { PaymentRequestView as TestedComponent } from "./Pay.js";
|
||||
import { View as TestedComponent } from "./Pay.js";
|
||||
|
||||
export default {
|
||||
title: "cta/pay",
|
||||
@ -30,175 +34,323 @@ export default {
|
||||
};
|
||||
|
||||
export const NoBalance = createExample(TestedComponent, {
|
||||
payStatus: {
|
||||
status: PreparePayResultType.InsufficientBalance,
|
||||
noncePriv: "",
|
||||
proposalId: "proposal1234",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
state: {
|
||||
status: "ready",
|
||||
hook: undefined,
|
||||
amount: Amounts.parseOrThrow("USD:10"),
|
||||
balance: undefined,
|
||||
payHandler: {
|
||||
onClick: async () => {
|
||||
null;
|
||||
},
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
amountRaw: "USD:10",
|
||||
},
|
||||
totalFees: Amounts.parseOrThrow("USD:0"),
|
||||
payResult: undefined,
|
||||
uri: "",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.InsufficientBalance,
|
||||
noncePriv: "",
|
||||
proposalId: "proposal1234",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
},
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
amountRaw: "USD:10",
|
||||
},
|
||||
},
|
||||
goBack: () => null,
|
||||
goToWalletManualWithdraw: () => null,
|
||||
});
|
||||
|
||||
export const NoEnoughBalance = createExample(TestedComponent, {
|
||||
payStatus: {
|
||||
status: PreparePayResultType.InsufficientBalance,
|
||||
noncePriv: "",
|
||||
proposalId: "proposal1234",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
state: {
|
||||
status: "ready",
|
||||
hook: undefined,
|
||||
amount: Amounts.parseOrThrow("USD:10"),
|
||||
balance: {
|
||||
currency: "USD",
|
||||
fraction: 40000000,
|
||||
value: 9,
|
||||
},
|
||||
payHandler: {
|
||||
onClick: async () => {
|
||||
null;
|
||||
},
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
amountRaw: "USD:10",
|
||||
},
|
||||
balance: {
|
||||
currency: "USD",
|
||||
fraction: 40000000,
|
||||
value: 9,
|
||||
},
|
||||
totalFees: Amounts.parseOrThrow("USD:0"),
|
||||
payResult: undefined,
|
||||
uri: "",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.InsufficientBalance,
|
||||
noncePriv: "",
|
||||
proposalId: "proposal1234",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
},
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
amountRaw: "USD:10",
|
||||
},
|
||||
},
|
||||
goBack: () => null,
|
||||
goToWalletManualWithdraw: () => null,
|
||||
});
|
||||
|
||||
export const PaymentPossible = createExample(TestedComponent, {
|
||||
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.PaymentPossible,
|
||||
amountEffective: "USD:10",
|
||||
amountRaw: "USD:10",
|
||||
noncePriv: "",
|
||||
contractTerms: {
|
||||
nonce: "123213123",
|
||||
merchant: {
|
||||
name: "someone",
|
||||
state: {
|
||||
status: "ready",
|
||||
hook: undefined,
|
||||
amount: Amounts.parseOrThrow("USD:10"),
|
||||
balance: {
|
||||
currency: "USD",
|
||||
fraction: 40000000,
|
||||
value: 11,
|
||||
},
|
||||
payHandler: {
|
||||
onClick: async () => {
|
||||
null;
|
||||
},
|
||||
amount: "USD:10",
|
||||
summary: "some beers",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
},
|
||||
totalFees: Amounts.parseOrThrow("USD:0"),
|
||||
payResult: undefined,
|
||||
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.PaymentPossible,
|
||||
amountEffective: "USD:10",
|
||||
amountRaw: "USD:10",
|
||||
noncePriv: "",
|
||||
contractTerms: {
|
||||
nonce: "123213123",
|
||||
merchant: {
|
||||
name: "someone",
|
||||
},
|
||||
amount: "USD:10",
|
||||
summary: "some beers",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
},
|
||||
},
|
||||
goBack: () => null,
|
||||
goToWalletManualWithdraw: () => null,
|
||||
});
|
||||
|
||||
export const PaymentPossibleWithFee = createExample(TestedComponent, {
|
||||
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.PaymentPossible,
|
||||
amountEffective: "USD:10.20",
|
||||
amountRaw: "USD:10",
|
||||
noncePriv: "",
|
||||
contractTerms: {
|
||||
nonce: "123213123",
|
||||
merchant: {
|
||||
name: "someone",
|
||||
state: {
|
||||
status: "ready",
|
||||
hook: undefined,
|
||||
amount: Amounts.parseOrThrow("USD:10"),
|
||||
balance: {
|
||||
currency: "USD",
|
||||
fraction: 40000000,
|
||||
value: 11,
|
||||
},
|
||||
payHandler: {
|
||||
onClick: async () => {
|
||||
null;
|
||||
},
|
||||
amount: "USD:10",
|
||||
summary: "some beers",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
},
|
||||
totalFees: Amounts.parseOrThrow("USD:0.20"),
|
||||
payResult: undefined,
|
||||
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.PaymentPossible,
|
||||
amountEffective: "USD:10.20",
|
||||
amountRaw: "USD:10",
|
||||
noncePriv: "",
|
||||
contractTerms: {
|
||||
nonce: "123213123",
|
||||
merchant: {
|
||||
name: "someone",
|
||||
},
|
||||
amount: "USD:10",
|
||||
summary: "some beers",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
},
|
||||
},
|
||||
goBack: () => null,
|
||||
goToWalletManualWithdraw: () => null,
|
||||
});
|
||||
|
||||
import beer from "../../static-dev/beer.png";
|
||||
|
||||
export const TicketWithAProductList = createExample(TestedComponent, {
|
||||
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.PaymentPossible,
|
||||
amountEffective: "USD:10",
|
||||
amountRaw: "USD:10",
|
||||
noncePriv: "",
|
||||
contractTerms: {
|
||||
nonce: "123213123",
|
||||
merchant: {
|
||||
name: "someone",
|
||||
state: {
|
||||
status: "ready",
|
||||
hook: undefined,
|
||||
amount: Amounts.parseOrThrow("USD:10"),
|
||||
balance: {
|
||||
currency: "USD",
|
||||
fraction: 40000000,
|
||||
value: 11,
|
||||
},
|
||||
payHandler: {
|
||||
onClick: async () => {
|
||||
null;
|
||||
},
|
||||
amount: "USD:10",
|
||||
products: [
|
||||
{
|
||||
description: "ten beers",
|
||||
price: "USD:1",
|
||||
quantity: 10,
|
||||
image: beer,
|
||||
},
|
||||
totalFees: Amounts.parseOrThrow("USD:0.20"),
|
||||
payResult: undefined,
|
||||
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.PaymentPossible,
|
||||
amountEffective: "USD:10.20",
|
||||
amountRaw: "USD:10",
|
||||
noncePriv: "",
|
||||
contractTerms: {
|
||||
nonce: "123213123",
|
||||
merchant: {
|
||||
name: "someone",
|
||||
},
|
||||
{
|
||||
description: "beer without image",
|
||||
price: "USD:1",
|
||||
quantity: 10,
|
||||
},
|
||||
{
|
||||
description: "one brown beer",
|
||||
price: "USD:2",
|
||||
quantity: 1,
|
||||
image: beer,
|
||||
},
|
||||
],
|
||||
summary: "some beers",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
amount: "USD:10",
|
||||
summary: "some beers",
|
||||
products: [
|
||||
{
|
||||
description: "ten beers",
|
||||
price: "USD:1",
|
||||
quantity: 10,
|
||||
image: beer,
|
||||
},
|
||||
{
|
||||
description: "beer without image",
|
||||
price: "USD:1",
|
||||
quantity: 10,
|
||||
},
|
||||
{
|
||||
description: "one brown beer",
|
||||
price: "USD:2",
|
||||
quantity: 1,
|
||||
image: beer,
|
||||
},
|
||||
],
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
},
|
||||
},
|
||||
goBack: () => null,
|
||||
goToWalletManualWithdraw: () => null,
|
||||
});
|
||||
|
||||
export const AlreadyConfirmedByOther = createExample(TestedComponent, {
|
||||
payStatus: {
|
||||
status: PreparePayResultType.AlreadyConfirmed,
|
||||
amountEffective: "USD:10",
|
||||
amountRaw: "USD:10",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
state: {
|
||||
status: "ready",
|
||||
hook: undefined,
|
||||
amount: Amounts.parseOrThrow("USD:10"),
|
||||
balance: {
|
||||
currency: "USD",
|
||||
fraction: 40000000,
|
||||
value: 11,
|
||||
},
|
||||
payHandler: {
|
||||
onClick: async () => {
|
||||
null;
|
||||
},
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
paid: false,
|
||||
},
|
||||
totalFees: Amounts.parseOrThrow("USD:0.20"),
|
||||
payResult: undefined,
|
||||
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.AlreadyConfirmed,
|
||||
amountEffective: "USD:10",
|
||||
amountRaw: "USD:10",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
},
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
paid: false,
|
||||
},
|
||||
},
|
||||
goBack: () => null,
|
||||
goToWalletManualWithdraw: () => null,
|
||||
});
|
||||
|
||||
export const AlreadyPaidWithoutFulfillment = createExample(TestedComponent, {
|
||||
payStatus: {
|
||||
status: PreparePayResultType.AlreadyConfirmed,
|
||||
amountEffective: "USD:10",
|
||||
amountRaw: "USD:10",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
state: {
|
||||
status: "ready",
|
||||
hook: undefined,
|
||||
amount: Amounts.parseOrThrow("USD:10"),
|
||||
balance: {
|
||||
currency: "USD",
|
||||
fraction: 40000000,
|
||||
value: 11,
|
||||
},
|
||||
payHandler: {
|
||||
onClick: async () => {
|
||||
null;
|
||||
},
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
paid: true,
|
||||
},
|
||||
totalFees: Amounts.parseOrThrow("USD:0.20"),
|
||||
payResult: undefined,
|
||||
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.AlreadyConfirmed,
|
||||
amountEffective: "USD:10",
|
||||
amountRaw: "USD:10",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
},
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
paid: true,
|
||||
},
|
||||
},
|
||||
goBack: () => null,
|
||||
goToWalletManualWithdraw: () => null,
|
||||
});
|
||||
|
||||
export const AlreadyPaidWithFulfillment = createExample(TestedComponent, {
|
||||
payStatus: {
|
||||
status: PreparePayResultType.AlreadyConfirmed,
|
||||
amountEffective: "USD:10",
|
||||
amountRaw: "USD:10",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
state: {
|
||||
status: "ready",
|
||||
hook: undefined,
|
||||
amount: Amounts.parseOrThrow("USD:10"),
|
||||
balance: {
|
||||
currency: "USD",
|
||||
fraction: 40000000,
|
||||
value: 11,
|
||||
},
|
||||
payHandler: {
|
||||
onClick: async () => {
|
||||
null;
|
||||
},
|
||||
fulfillment_message:
|
||||
"congratulations! you are looking at the fulfillment message! ",
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
paid: true,
|
||||
},
|
||||
totalFees: Amounts.parseOrThrow("USD:0.20"),
|
||||
payResult: undefined,
|
||||
uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
|
||||
payStatus: {
|
||||
status: PreparePayResultType.AlreadyConfirmed,
|
||||
amountEffective: "USD:10",
|
||||
amountRaw: "USD:10",
|
||||
contractTerms: {
|
||||
merchant: {
|
||||
name: "someone",
|
||||
},
|
||||
fulfillment_message:
|
||||
"congratulations! you are looking at the fulfillment message! ",
|
||||
summary: "some beers",
|
||||
amount: "USD:10",
|
||||
} as Partial<ContractTerms> as any,
|
||||
contractTermsHash: "123456",
|
||||
proposalId: "proposal1234",
|
||||
paid: true,
|
||||
},
|
||||
},
|
||||
goBack: () => null,
|
||||
goToWalletManualWithdraw: () => null,
|
||||
});
|
||||
|
408
packages/taler-wallet-webextension/src/cta/Pay.test.ts
Normal file
408
packages/taler-wallet-webextension/src/cta/Pay.test.ts
Normal 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()
|
||||
});
|
||||
|
||||
|
||||
});
|
@ -27,9 +27,7 @@
|
||||
|
||||
import {
|
||||
AmountJson,
|
||||
AmountLike,
|
||||
Amounts,
|
||||
AmountString,
|
||||
ConfirmPayResult,
|
||||
ConfirmPayResultDone,
|
||||
ConfirmPayResultType,
|
||||
@ -38,12 +36,14 @@ import {
|
||||
PreparePayResult,
|
||||
PreparePayResultType,
|
||||
Product,
|
||||
TalerErrorCode,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { TalerError } from "@gnu-taler/taler-wallet-core";
|
||||
import { Fragment, h, VNode } from "preact";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { Amount } from "../components/Amount.js";
|
||||
import { ErrorMessage } from "../components/ErrorMessage.js";
|
||||
import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js";
|
||||
import { Loading } from "../components/Loading.js";
|
||||
import { LoadingError } from "../components/LoadingError.js";
|
||||
import { LogoHeader } from "../components/LogoHeader.js";
|
||||
@ -60,7 +60,12 @@ import {
|
||||
WarningBox,
|
||||
} from "../components/styled/index.js";
|
||||
import { useTranslationContext } from "../context/translation.js";
|
||||
import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
|
||||
import {
|
||||
HookError,
|
||||
useAsyncAsHook,
|
||||
useAsyncAsHook2,
|
||||
} from "../hooks/useAsyncAsHook.js";
|
||||
import { ButtonHandler } from "../wallet/CreateManualWithdraw.js";
|
||||
import * as wxApi from "../wxApi.js";
|
||||
|
||||
interface Props {
|
||||
@ -69,47 +74,88 @@ interface Props {
|
||||
goBack: () => void;
|
||||
}
|
||||
|
||||
const doPayment = async (
|
||||
async function doPayment(
|
||||
payStatus: PreparePayResult,
|
||||
): Promise<ConfirmPayResultDone> => {
|
||||
api: typeof wxApi,
|
||||
): Promise<ConfirmPayResultDone> {
|
||||
if (payStatus.status !== "payment-possible") {
|
||||
throw Error(`invalid state: ${payStatus.status}`);
|
||||
throw TalerError.fromUncheckedDetail({
|
||||
code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
|
||||
hint: `payment is not possible: ${payStatus.status}`,
|
||||
});
|
||||
}
|
||||
const proposalId = payStatus.proposalId;
|
||||
const res = await wxApi.confirmPay(proposalId, undefined);
|
||||
const res = await api.confirmPay(proposalId, undefined);
|
||||
if (res.type !== ConfirmPayResultType.Done) {
|
||||
throw Error("payment pending");
|
||||
throw TalerError.fromUncheckedDetail({
|
||||
code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
|
||||
hint: `could not confirm payment`,
|
||||
payResult: res,
|
||||
});
|
||||
}
|
||||
const fu = res.contractTerms.fulfillment_url;
|
||||
if (fu) {
|
||||
document.location.href = fu;
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
export function PayPage({
|
||||
talerPayUri,
|
||||
goToWalletManualWithdraw,
|
||||
goBack,
|
||||
}: Props): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
type State = Loading | Ready | Confirmed;
|
||||
interface Loading {
|
||||
status: "loading";
|
||||
hook: HookError | undefined;
|
||||
}
|
||||
interface Ready {
|
||||
status: "ready";
|
||||
hook: undefined;
|
||||
uri: string;
|
||||
amount: AmountJson;
|
||||
totalFees: AmountJson;
|
||||
payStatus: PreparePayResult;
|
||||
balance: AmountJson | undefined;
|
||||
payHandler: ButtonHandler;
|
||||
payResult: undefined;
|
||||
}
|
||||
|
||||
interface Confirmed {
|
||||
status: "confirmed";
|
||||
hook: undefined;
|
||||
uri: string;
|
||||
amount: AmountJson;
|
||||
totalFees: AmountJson;
|
||||
payStatus: PreparePayResult;
|
||||
balance: AmountJson | undefined;
|
||||
payResult: ConfirmPayResult;
|
||||
payHandler: ButtonHandler;
|
||||
}
|
||||
|
||||
export function useComponentState(
|
||||
talerPayUri: string | undefined,
|
||||
api: typeof wxApi,
|
||||
): State {
|
||||
const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [payErrMsg, setPayErrMsg] = useState<TalerError | string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [payErrMsg, setPayErrMsg] = useState<TalerError | undefined>(undefined);
|
||||
|
||||
const hook = useAsyncAsHook(async () => {
|
||||
if (!talerPayUri) throw Error("Missing pay uri");
|
||||
const payStatus = await wxApi.preparePay(talerPayUri);
|
||||
const balance = await wxApi.getBalance();
|
||||
return { payStatus, balance };
|
||||
}, [NotificationType.CoinWithdrawn]);
|
||||
const hook = useAsyncAsHook2(async () => {
|
||||
if (!talerPayUri) throw Error("ERROR_NO-URI-FOR-PAYMENT");
|
||||
const payStatus = await api.preparePay(talerPayUri);
|
||||
const balance = await api.getBalance();
|
||||
return { payStatus, balance, uri: talerPayUri };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const payStatus =
|
||||
hook && !hook.hasError ? hook.response.payStatus : undefined;
|
||||
api.onUpdateNotification([NotificationType.CoinWithdrawn], () => {
|
||||
hook?.retry();
|
||||
});
|
||||
});
|
||||
|
||||
const hookResponse = !hook || hook.hasError ? undefined : hook.response;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hookResponse) return;
|
||||
const { payStatus } = hookResponse;
|
||||
if (
|
||||
payStatus &&
|
||||
payStatus.status === PreparePayResultType.AlreadyConfirmed &&
|
||||
@ -122,74 +168,139 @@ export function PayPage({
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [hookResponse]);
|
||||
|
||||
if (!hook) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (hook.hasError) {
|
||||
return (
|
||||
<LoadingError
|
||||
title={<i18n.Translate>Could not load pay status</i18n.Translate>}
|
||||
error={hook}
|
||||
/>
|
||||
);
|
||||
if (!hook || hook.hasError) {
|
||||
return {
|
||||
status: "loading",
|
||||
hook,
|
||||
};
|
||||
}
|
||||
const { payStatus } = hook.response;
|
||||
const amount = Amounts.parseOrThrow(payStatus.amountRaw);
|
||||
|
||||
const foundBalance = hook.response.balance.balances.find(
|
||||
(b) =>
|
||||
Amounts.parseOrThrow(b.available).currency ===
|
||||
Amounts.parseOrThrow(hook.response.payStatus.amountRaw).currency,
|
||||
(b) => Amounts.parseOrThrow(b.available).currency === amount.currency,
|
||||
);
|
||||
const foundAmount = foundBalance
|
||||
? Amounts.parseOrThrow(foundBalance.available)
|
||||
: undefined;
|
||||
|
||||
const onClick = async (): Promise<void> => {
|
||||
async function doPayment(): Promise<void> {
|
||||
try {
|
||||
const res = await doPayment(hook.response.payStatus);
|
||||
if (payStatus.status !== "payment-possible") {
|
||||
throw TalerError.fromUncheckedDetail({
|
||||
code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
|
||||
hint: `payment is not possible: ${payStatus.status}`,
|
||||
});
|
||||
}
|
||||
const res = await api.confirmPay(payStatus.proposalId, undefined);
|
||||
if (res.type !== ConfirmPayResultType.Done) {
|
||||
throw TalerError.fromUncheckedDetail({
|
||||
code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
|
||||
hint: `could not confirm payment`,
|
||||
payResult: res,
|
||||
});
|
||||
}
|
||||
const fu = res.contractTerms.fulfillment_url;
|
||||
if (fu) {
|
||||
if (typeof window !== "undefined") {
|
||||
document.location.href = fu;
|
||||
} else {
|
||||
console.log(`should redirect to ${fu}`);
|
||||
}
|
||||
}
|
||||
setPayResult(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error) {
|
||||
setPayErrMsg(e.message);
|
||||
if (e instanceof TalerError) {
|
||||
setPayErrMsg(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payDisabled =
|
||||
payErrMsg ||
|
||||
!foundAmount ||
|
||||
payStatus.status === PreparePayResultType.InsufficientBalance;
|
||||
|
||||
const payHandler: ButtonHandler = {
|
||||
onClick: payDisabled ? undefined : doPayment,
|
||||
error: payErrMsg,
|
||||
};
|
||||
|
||||
let totalFees = Amounts.getZero(amount.currency);
|
||||
if (payStatus.status === PreparePayResultType.PaymentPossible) {
|
||||
const amountEffective: AmountJson = Amounts.parseOrThrow(
|
||||
payStatus.amountEffective,
|
||||
);
|
||||
totalFees = Amounts.sub(amountEffective, amount).amount;
|
||||
}
|
||||
|
||||
if (!payResult) {
|
||||
return {
|
||||
status: "ready",
|
||||
hook: undefined,
|
||||
uri: hook.response.uri,
|
||||
amount,
|
||||
totalFees,
|
||||
balance: foundAmount,
|
||||
payHandler,
|
||||
payStatus: hook.response.payStatus,
|
||||
payResult,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "confirmed",
|
||||
hook: undefined,
|
||||
uri: hook.response.uri,
|
||||
amount,
|
||||
totalFees,
|
||||
balance: foundAmount,
|
||||
payStatus: hook.response.payStatus,
|
||||
payResult,
|
||||
payHandler: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function PayPage({
|
||||
talerPayUri,
|
||||
goToWalletManualWithdraw,
|
||||
goBack,
|
||||
}: Props): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
|
||||
const state = useComponentState(talerPayUri, wxApi);
|
||||
|
||||
if (state.status === "loading") {
|
||||
if (!state.hook) return <Loading />;
|
||||
return (
|
||||
<LoadingError
|
||||
title={<i18n.Translate>Could not load pay status</i18n.Translate>}
|
||||
error={state.hook}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PaymentRequestView
|
||||
uri={talerPayUri!}
|
||||
payStatus={hook.response.payStatus}
|
||||
payResult={payResult}
|
||||
onClick={onClick}
|
||||
<View
|
||||
state={state}
|
||||
goBack={goBack}
|
||||
goToWalletManualWithdraw={goToWalletManualWithdraw}
|
||||
balance={foundAmount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PaymentRequestViewProps {
|
||||
payStatus: PreparePayResult;
|
||||
payResult?: ConfirmPayResult;
|
||||
onClick: () => void;
|
||||
payErrMsg?: string;
|
||||
uri: string;
|
||||
goToWalletManualWithdraw: (s: string) => void;
|
||||
balance: AmountJson | undefined;
|
||||
}
|
||||
export function PaymentRequestView({
|
||||
uri,
|
||||
payStatus,
|
||||
payResult,
|
||||
onClick,
|
||||
export function View({
|
||||
state,
|
||||
goBack,
|
||||
goToWalletManualWithdraw,
|
||||
balance,
|
||||
}: PaymentRequestViewProps): VNode {
|
||||
}: {
|
||||
state: Ready | Confirmed;
|
||||
goToWalletManualWithdraw: (currency?: string) => void;
|
||||
goBack: () => void;
|
||||
}): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
let totalFees: AmountJson = Amounts.getZero(payStatus.amountRaw);
|
||||
const contractTerms: ContractTerms = payStatus.contractTerms;
|
||||
const contractTerms: ContractTerms = state.payStatus.contractTerms;
|
||||
|
||||
if (!contractTerms) {
|
||||
return (
|
||||
@ -203,124 +314,6 @@ export function PaymentRequestView({
|
||||
);
|
||||
}
|
||||
|
||||
const amountRaw = Amounts.parseOrThrow(payStatus.amountRaw);
|
||||
if (payStatus.status === PreparePayResultType.PaymentPossible) {
|
||||
const amountEffective: AmountJson = Amounts.parseOrThrow(
|
||||
payStatus.amountEffective,
|
||||
);
|
||||
totalFees = Amounts.sub(amountEffective, amountRaw).amount;
|
||||
}
|
||||
|
||||
function Alternative(): VNode {
|
||||
const [showQR, setShowQR] = useState<boolean>(false);
|
||||
const privateUri =
|
||||
payStatus.status !== PreparePayResultType.AlreadyConfirmed
|
||||
? `${uri}&n=${payStatus.noncePriv}`
|
||||
: uri;
|
||||
if (!uri) return <Fragment />;
|
||||
return (
|
||||
<section>
|
||||
<LinkSuccess upperCased onClick={() => setShowQR((qr) => !qr)}>
|
||||
{!showQR ? (
|
||||
<i18n.Translate>Pay with a mobile phone</i18n.Translate>
|
||||
) : (
|
||||
<i18n.Translate>Hide QR</i18n.Translate>
|
||||
)}
|
||||
</LinkSuccess>
|
||||
{showQR && (
|
||||
<div>
|
||||
<QR text={privateUri} />
|
||||
<i18n.Translate>
|
||||
Scan the QR code or
|
||||
<a href={privateUri}>
|
||||
<i18n.Translate>click here</i18n.Translate>
|
||||
</a>
|
||||
</i18n.Translate>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonsSection(): VNode {
|
||||
if (payResult) {
|
||||
if (payResult.type === ConfirmPayResultType.Pending) {
|
||||
return (
|
||||
<section>
|
||||
<div>
|
||||
<p>
|
||||
<i18n.Translate>Processing</i18n.Translate>...
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return <Fragment />;
|
||||
}
|
||||
if (payStatus.status === PreparePayResultType.PaymentPossible) {
|
||||
return (
|
||||
<Fragment>
|
||||
<section>
|
||||
<ButtonSuccess upperCased onClick={onClick}>
|
||||
<i18n.Translate>
|
||||
Pay {<Amount value={payStatus.amountEffective} />}
|
||||
</i18n.Translate>
|
||||
</ButtonSuccess>
|
||||
</section>
|
||||
<Alternative />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
if (payStatus.status === PreparePayResultType.InsufficientBalance) {
|
||||
return (
|
||||
<Fragment>
|
||||
<section>
|
||||
{balance ? (
|
||||
<WarningBox>
|
||||
<i18n.Translate>
|
||||
Your balance of {<Amount value={balance} />} is not enough to
|
||||
pay for this purchase
|
||||
</i18n.Translate>
|
||||
</WarningBox>
|
||||
) : (
|
||||
<WarningBox>
|
||||
<i18n.Translate>
|
||||
Your balance is not enough to pay for this purchase.
|
||||
</i18n.Translate>
|
||||
</WarningBox>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<ButtonSuccess
|
||||
upperCased
|
||||
onClick={() => goToWalletManualWithdraw(amountRaw.currency)}
|
||||
>
|
||||
<i18n.Translate>Withdraw digital cash</i18n.Translate>
|
||||
</ButtonSuccess>
|
||||
</section>
|
||||
<Alternative />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
|
||||
return (
|
||||
<Fragment>
|
||||
<section>
|
||||
{payStatus.paid && contractTerms.fulfillment_message && (
|
||||
<Part
|
||||
title={<i18n.Translate>Merchant message</i18n.Translate>}
|
||||
text={contractTerms.fulfillment_message}
|
||||
kind="neutral"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
{!payStatus.paid && <Alternative />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return <span />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WalletAction>
|
||||
<LogoHeader />
|
||||
@ -328,70 +321,31 @@ export function PaymentRequestView({
|
||||
<SubTitle>
|
||||
<i18n.Translate>Digital cash payment</i18n.Translate>
|
||||
</SubTitle>
|
||||
{payStatus.status === PreparePayResultType.AlreadyConfirmed &&
|
||||
(payStatus.paid ? (
|
||||
payStatus.contractTerms.fulfillment_url ? (
|
||||
<SuccessBox>
|
||||
<i18n.Translate>
|
||||
Already paid, you are going to be redirected to{" "}
|
||||
<a href={payStatus.contractTerms.fulfillment_url}>
|
||||
{payStatus.contractTerms.fulfillment_url}
|
||||
</a>
|
||||
</i18n.Translate>
|
||||
</SuccessBox>
|
||||
) : (
|
||||
<SuccessBox>
|
||||
<i18n.Translate>Already paid</i18n.Translate>
|
||||
</SuccessBox>
|
||||
)
|
||||
) : (
|
||||
<WarningBox>
|
||||
<i18n.Translate>Already claimed</i18n.Translate>
|
||||
</WarningBox>
|
||||
))}
|
||||
{payResult && payResult.type === ConfirmPayResultType.Done && (
|
||||
<SuccessBox>
|
||||
<h3>
|
||||
<i18n.Translate>Payment complete</i18n.Translate>
|
||||
</h3>
|
||||
<p>
|
||||
{!payResult.contractTerms.fulfillment_message ? (
|
||||
payResult.contractTerms.fulfillment_url ? (
|
||||
<i18n.Translate>
|
||||
You are going to be redirected to $
|
||||
{payResult.contractTerms.fulfillment_url}
|
||||
</i18n.Translate>
|
||||
) : (
|
||||
<i18n.Translate>You can close this page.</i18n.Translate>
|
||||
)
|
||||
) : (
|
||||
payResult.contractTerms.fulfillment_message
|
||||
)}
|
||||
</p>
|
||||
</SuccessBox>
|
||||
)}
|
||||
|
||||
<ShowImportantMessage state={state} />
|
||||
|
||||
<section>
|
||||
{payStatus.status !== PreparePayResultType.InsufficientBalance &&
|
||||
Amounts.isNonZero(totalFees) && (
|
||||
{state.payStatus.status !== PreparePayResultType.InsufficientBalance &&
|
||||
Amounts.isNonZero(state.totalFees) && (
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Total to pay</i18n.Translate>}
|
||||
text={<Amount value={payStatus.amountEffective} />}
|
||||
text={<Amount value={state.payStatus.amountEffective} />}
|
||||
kind="negative"
|
||||
/>
|
||||
)}
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Purchase amount</i18n.Translate>}
|
||||
text={<Amount value={payStatus.amountRaw} />}
|
||||
text={<Amount value={state.payStatus.amountRaw} />}
|
||||
kind="neutral"
|
||||
/>
|
||||
{Amounts.isNonZero(totalFees) && (
|
||||
{Amounts.isNonZero(state.totalFees) && (
|
||||
<Fragment>
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Fee</i18n.Translate>}
|
||||
text={<Amount value={totalFees} />}
|
||||
text={<Amount value={state.totalFees} />}
|
||||
kind="negative"
|
||||
/>
|
||||
</Fragment>
|
||||
@ -417,9 +371,12 @@ export function PaymentRequestView({
|
||||
<ProductList products={contractTerms.products} />
|
||||
)}
|
||||
</section>
|
||||
<ButtonsSection />
|
||||
<ButtonsSection
|
||||
state={state}
|
||||
goToWalletManualWithdraw={goToWalletManualWithdraw}
|
||||
/>
|
||||
<section>
|
||||
<Link upperCased>
|
||||
<Link upperCased onClick={goBack}>
|
||||
<i18n.Translate>Cancel</i18n.Translate>
|
||||
</Link>
|
||||
</section>
|
||||
@ -495,3 +452,189 @@ function ProductList({ products }: { products: Product[] }): VNode {
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowImportantMessage({ state }: { state: Ready | Confirmed }): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
const { payStatus } = state;
|
||||
if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
|
||||
if (payStatus.paid) {
|
||||
if (payStatus.contractTerms.fulfillment_url) {
|
||||
return (
|
||||
<SuccessBox>
|
||||
<i18n.Translate>
|
||||
Already paid, you are going to be redirected to{" "}
|
||||
<a href={payStatus.contractTerms.fulfillment_url}>
|
||||
{payStatus.contractTerms.fulfillment_url}
|
||||
</a>
|
||||
</i18n.Translate>
|
||||
</SuccessBox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SuccessBox>
|
||||
<i18n.Translate>Already paid</i18n.Translate>
|
||||
</SuccessBox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WarningBox>
|
||||
<i18n.Translate>Already claimed</i18n.Translate>
|
||||
</WarningBox>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == "confirmed") {
|
||||
const { payResult, payHandler } = state;
|
||||
if (payHandler.error) {
|
||||
return <ErrorTalerOperation error={payHandler.error.errorDetail} />;
|
||||
}
|
||||
if (payResult.type === ConfirmPayResultType.Done) {
|
||||
return (
|
||||
<SuccessBox>
|
||||
<h3>
|
||||
<i18n.Translate>Payment complete</i18n.Translate>
|
||||
</h3>
|
||||
<p>
|
||||
{!payResult.contractTerms.fulfillment_message ? (
|
||||
payResult.contractTerms.fulfillment_url ? (
|
||||
<i18n.Translate>
|
||||
You are going to be redirected to $
|
||||
{payResult.contractTerms.fulfillment_url}
|
||||
</i18n.Translate>
|
||||
) : (
|
||||
<i18n.Translate>You can close this page.</i18n.Translate>
|
||||
)
|
||||
) : (
|
||||
payResult.contractTerms.fulfillment_message
|
||||
)}
|
||||
</p>
|
||||
</SuccessBox>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
function PayWithMobile({ state }: { state: Ready }): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
|
||||
const [showQR, setShowQR] = useState<boolean>(false);
|
||||
|
||||
const privateUri =
|
||||
state.payStatus.status !== PreparePayResultType.AlreadyConfirmed
|
||||
? `${state.uri}&n=${state.payStatus.noncePriv}`
|
||||
: state.uri;
|
||||
return (
|
||||
<section>
|
||||
<LinkSuccess upperCased onClick={() => setShowQR((qr) => !qr)}>
|
||||
{!showQR ? (
|
||||
<i18n.Translate>Pay with a mobile phone</i18n.Translate>
|
||||
) : (
|
||||
<i18n.Translate>Hide QR</i18n.Translate>
|
||||
)}
|
||||
</LinkSuccess>
|
||||
{showQR && (
|
||||
<div>
|
||||
<QR text={privateUri} />
|
||||
<i18n.Translate>
|
||||
Scan the QR code or
|
||||
<a href={privateUri}>
|
||||
<i18n.Translate>click here</i18n.Translate>
|
||||
</a>
|
||||
</i18n.Translate>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonsSection({
|
||||
state,
|
||||
goToWalletManualWithdraw,
|
||||
}: {
|
||||
state: Ready | Confirmed;
|
||||
goToWalletManualWithdraw: (currency: string) => void;
|
||||
}): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
if (state.status === "ready") {
|
||||
const { payStatus } = state;
|
||||
if (payStatus.status === PreparePayResultType.PaymentPossible) {
|
||||
return (
|
||||
<Fragment>
|
||||
<section>
|
||||
<ButtonSuccess upperCased onClick={state.payHandler.onClick}>
|
||||
<i18n.Translate>
|
||||
Pay {<Amount value={payStatus.amountEffective} />}
|
||||
</i18n.Translate>
|
||||
</ButtonSuccess>
|
||||
</section>
|
||||
<PayWithMobile state={state} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
if (payStatus.status === PreparePayResultType.InsufficientBalance) {
|
||||
return (
|
||||
<Fragment>
|
||||
<section>
|
||||
{state.balance ? (
|
||||
<WarningBox>
|
||||
<i18n.Translate>
|
||||
Your balance of {<Amount value={state.balance} />} is not
|
||||
enough to pay for this purchase
|
||||
</i18n.Translate>
|
||||
</WarningBox>
|
||||
) : (
|
||||
<WarningBox>
|
||||
<i18n.Translate>
|
||||
Your balance is not enough to pay for this purchase.
|
||||
</i18n.Translate>
|
||||
</WarningBox>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<ButtonSuccess
|
||||
upperCased
|
||||
onClick={() => goToWalletManualWithdraw(state.amount.currency)}
|
||||
>
|
||||
<i18n.Translate>Withdraw digital cash</i18n.Translate>
|
||||
</ButtonSuccess>
|
||||
</section>
|
||||
<PayWithMobile state={state} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
|
||||
return (
|
||||
<Fragment>
|
||||
<section>
|
||||
{payStatus.paid &&
|
||||
state.payStatus.contractTerms.fulfillment_message && (
|
||||
<Part
|
||||
title={<i18n.Translate>Merchant message</i18n.Translate>}
|
||||
text={state.payStatus.contractTerms.fulfillment_message}
|
||||
kind="neutral"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
{!payStatus.paid && <PayWithMobile state={state} />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status === "confirmed") {
|
||||
if (state.payResult.type === ConfirmPayResultType.Pending) {
|
||||
return (
|
||||
<section>
|
||||
<div>
|
||||
<p>
|
||||
<i18n.Translate>Processing</i18n.Translate>...
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Fragment />;
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
}
|
||||
|
@ -119,7 +119,7 @@ export function useComponentState(
|
||||
const uriHookDep =
|
||||
!uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response
|
||||
? undefined
|
||||
: uriInfoHook;
|
||||
: uriInfoHook.response;
|
||||
|
||||
const { amount, thisExchange, thisCurrencyExchanges } = useMemo(() => {
|
||||
if (!uriHookDep)
|
||||
@ -129,7 +129,7 @@ export function useComponentState(
|
||||
thisCurrencyExchanges: [],
|
||||
};
|
||||
|
||||
const { uriInfo, knownExchanges } = uriHookDep.response;
|
||||
const { uriInfo, knownExchanges } = uriHookDep;
|
||||
|
||||
const amount = uriInfo ? Amounts.parseOrThrow(uriInfo.amount) : undefined;
|
||||
const thisCurrencyExchanges =
|
||||
@ -324,9 +324,11 @@ export function useComponentState(
|
||||
withdrawalFee,
|
||||
chosenAmount: amount,
|
||||
doWithdrawal: {
|
||||
onClick: doWithdrawAndCheckError,
|
||||
onClick:
|
||||
doingWithdraw || (mustAcceptFirst && !reviewed)
|
||||
? undefined
|
||||
: doWithdrawAndCheckError,
|
||||
error: withdrawError,
|
||||
disabled: doingWithdraw || (mustAcceptFirst && !reviewed),
|
||||
},
|
||||
tosProps: !termsState
|
||||
? undefined
|
||||
@ -427,7 +429,7 @@ export function View({ state }: { state: Success }): VNode {
|
||||
(state.mustAcceptFirst && state.tosProps.reviewed)) && (
|
||||
<ButtonSuccess
|
||||
upperCased
|
||||
disabled={state.doWithdrawal.disabled}
|
||||
disabled={!state.doWithdrawal.onClick}
|
||||
onClick={state.doWithdrawal.onClick}
|
||||
>
|
||||
<i18n.Translate>Confirm withdrawal</i18n.Translate>
|
||||
@ -436,7 +438,7 @@ export function View({ state }: { state: Success }): VNode {
|
||||
{state.tosProps.terms.status === "notfound" && (
|
||||
<ButtonWarning
|
||||
upperCased
|
||||
disabled={state.doWithdrawal.disabled}
|
||||
disabled={!state.doWithdrawal.onClick}
|
||||
onClick={state.doWithdrawal.onClick}
|
||||
>
|
||||
<i18n.Translate>Withdraw anyway</i18n.Translate>
|
||||
|
@ -39,7 +39,12 @@ export interface HookOperationalError {
|
||||
details: TalerErrorDetail;
|
||||
}
|
||||
|
||||
interface WithRetry {
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
export type HookResponse<T> = HookOk<T> | HookError | undefined;
|
||||
export type HookResponseWithRetry<T> = ((HookOk<T> | HookError) & WithRetry) | undefined;
|
||||
|
||||
export function useAsyncAsHook<T>(
|
||||
fn: () => Promise<T | false>,
|
||||
@ -84,3 +89,45 @@ export function useAsyncAsHook<T>(
|
||||
}, [args]);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useAsyncAsHook2<T>(
|
||||
fn: () => Promise<T | false>,
|
||||
deps?: any[],
|
||||
): HookResponseWithRetry<T> {
|
||||
|
||||
const [result, setHookResponse] = useState<HookResponse<T>>(undefined);
|
||||
|
||||
const args = useMemo(() => ({
|
||||
fn
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), deps || [])
|
||||
|
||||
async function doAsync(): Promise<void> {
|
||||
try {
|
||||
const response = await args.fn();
|
||||
if (response === false) return;
|
||||
setHookResponse({ hasError: false, response });
|
||||
} catch (e) {
|
||||
if (e instanceof TalerError) {
|
||||
setHookResponse({
|
||||
hasError: true,
|
||||
operational: true,
|
||||
details: e.errorDetail,
|
||||
});
|
||||
} else if (e instanceof Error) {
|
||||
setHookResponse({
|
||||
hasError: true,
|
||||
operational: false,
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
doAsync();
|
||||
}, [args]);
|
||||
|
||||
if (!result) return undefined;
|
||||
return { ...result, retry: doAsync };
|
||||
}
|
||||
|
@ -64,7 +64,6 @@ export function renderNodeOrBrowser(Component: any, args: any): void {
|
||||
|
||||
interface Mounted<T> {
|
||||
unmount: () => void;
|
||||
getLastResult: () => T | null;
|
||||
getLastResultOrThrow: () => T;
|
||||
assertNoPendingUpdate: () => void;
|
||||
waitNextUpdate: (s?: string) => Promise<void>;
|
||||
@ -76,15 +75,23 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
|
||||
// const result: { current: T | null } = {
|
||||
// current: null
|
||||
// }
|
||||
let lastResult: T | null = null;
|
||||
let lastResult: T | Error | null = null;
|
||||
|
||||
const listener: Array<() => void> = []
|
||||
|
||||
// component that's going to hold the hook
|
||||
function Component(): VNode {
|
||||
const hookResult = callback()
|
||||
// save the hook result
|
||||
lastResult = hookResult
|
||||
|
||||
try {
|
||||
lastResult = callback()
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
lastResult = e
|
||||
} else {
|
||||
lastResult = new Error(`mounting the hook throw an exception: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
// notify to everyone waiting for an update and clean the queue
|
||||
listener.splice(0, listener.length).forEach(cb => cb())
|
||||
return create(Fragment, {})
|
||||
@ -123,7 +130,7 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
|
||||
}
|
||||
}
|
||||
|
||||
function getLastResult(): T | null {
|
||||
function getLastResult(): T | Error | null {
|
||||
const copy = lastResult
|
||||
lastResult = null
|
||||
return copy;
|
||||
@ -131,6 +138,7 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
|
||||
|
||||
function getLastResultOrThrow(): T {
|
||||
const r = getLastResult()
|
||||
if (r instanceof Error) throw r;
|
||||
if (!r) throw Error('there was no last result')
|
||||
return r;
|
||||
}
|
||||
@ -143,14 +151,18 @@ export function mountHook<T>(callback: () => T, Context?: ({ children }: { child
|
||||
|
||||
listener.push(() => {
|
||||
clearTimeout(tid)
|
||||
rej(Error(`Expecting no pending result but the hook get updated. Check the dependencies of the hooks.`))
|
||||
rej(Error(`Expecting no pending result but the hook got updated.
|
||||
If the update was not intended you need to check the hook dependencies
|
||||
(or dependencies of the internal state) but otherwise make
|
||||
sure to consume the result before ending the test.`))
|
||||
})
|
||||
})
|
||||
|
||||
const r = getLastResult()
|
||||
if (r) throw Error('There are still pending results. This may happen because the hook did a new update but the test didn\'t get the result using getLastResult');
|
||||
if (r) throw Error(`There are still pending results.
|
||||
This may happen because the hook did a new update but the test didn't consume the result using getLastResult`);
|
||||
}
|
||||
return {
|
||||
unmount, getLastResult, getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate
|
||||
unmount, getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate
|
||||
}
|
||||
}
|
||||
|
@ -62,8 +62,7 @@ export interface TextFieldHandler {
|
||||
}
|
||||
|
||||
export interface ButtonHandler {
|
||||
onClick: () => Promise<void>;
|
||||
disabled?: boolean;
|
||||
onClick?: () => Promise<void>;
|
||||
error?: TalerError;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user