tip and refund stories and test

This commit is contained in:
Sebastian 2022-05-02 19:21:34 -03:00
parent e5c9f588e4
commit 939729004a
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
11 changed files with 981 additions and 285 deletions

View File

@ -24,35 +24,13 @@
* Imports.
*/
import {
AmountJson,
Amounts,
amountToPretty,
ConfirmPayResult,
ConfirmPayResultType,
ContractTerms,
NotificationType,
PreparePayResult,
PreparePayResultType,
} 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 { ErrorTalerOperation } from "../components/ErrorTalerOperation.js";
import { Loading } from "../components/Loading.js";
import { LoadingError } from "../components/LoadingError.js";
import { LogoHeader } from "../components/LogoHeader.js";
import { Part } from "../components/Part.js";
import {
ErrorBox,
SubTitle,
SuccessBox,
WalletAction,
WarningBox,
} from "../components/styled/index.js";
import { SubTitle, WalletAction } from "../components/styled/index.js";
import { useTranslationContext } from "../context/translation.js";
import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import * as wxApi from "../wxApi.js";
import { HookError } from "../hooks/useAsyncAsHook.js";
interface Props {
talerDepositUri?: string;
@ -102,7 +80,7 @@ export function View({ state }: ViewProps): VNode {
<LogoHeader />
<SubTitle>
<i18n.Translate>Digital cash deposit</i18n.Translate>
<i18n.Translate>Digital cash refund</i18n.Translate>
</SubTitle>
</WalletAction>
);

View File

@ -32,7 +32,7 @@ type Subs = {
[key in NotificationType]?: VoidFunction
}
class SubsHandler {
export class SubsHandler {
private subs: Subs = {};
constructor() {

View File

@ -353,7 +353,7 @@ export function View({
);
}
function ProductList({ products }: { products: Product[] }): VNode {
export function ProductList({ products }: { products: Product[] }): VNode {
const { i18n } = useTranslationContext();
return (
<Fragment>

View File

@ -19,7 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { OrderShortInfo } from "@gnu-taler/taler-util";
import { Amounts } from "@gnu-taler/taler-util";
import { createExample } from "../test-utils.js";
import { View as TestedComponent } from "./Refund.js";
@ -30,46 +30,70 @@ export default {
};
export const Complete = createExample(TestedComponent, {
applyResult: {
amountEffectivePaid: "USD:10",
amountRefundGone: "USD:0",
amountRefundGranted: "USD:2",
contractTermsHash: "QWEASDZXC",
info: {
summary: "tasty cold beer",
contractTermsHash: "QWEASDZXC",
} as Partial<OrderShortInfo> as any,
pendingAtExchange: false,
proposalId: "proposal123",
},
});
export const Partial = createExample(TestedComponent, {
applyResult: {
amountEffectivePaid: "USD:10",
amountRefundGone: "USD:1",
amountRefundGranted: "USD:2",
contractTermsHash: "QWEASDZXC",
info: {
summary: "tasty cold beer",
contractTermsHash: "QWEASDZXC",
} as Partial<OrderShortInfo> as any,
pendingAtExchange: false,
proposalId: "proposal123",
state: {
status: "completed",
amount: Amounts.parseOrThrow("USD:1"),
hook: undefined,
merchantName: "the merchant",
products: undefined,
},
});
export const InProgress = createExample(TestedComponent, {
applyResult: {
amountEffectivePaid: "USD:10",
amountRefundGone: "USD:1",
amountRefundGranted: "USD:2",
contractTermsHash: "QWEASDZXC",
info: {
summary: "tasty cold beer",
contractTermsHash: "QWEASDZXC",
} as Partial<OrderShortInfo> as any,
pendingAtExchange: true,
proposalId: "proposal123",
state: {
status: "in-progress",
hook: undefined,
amount: Amounts.parseOrThrow("USD:1"),
merchantName: "the merchant",
products: undefined,
progress: 0.5,
},
});
export const Ready = createExample(TestedComponent, {
state: {
status: "ready",
hook: undefined,
accept: {},
ignore: {},
amount: Amounts.parseOrThrow("USD:1"),
merchantName: "the merchant",
products: [],
orderId: "abcdef",
},
});
import beer from "../../static-dev/beer.png";
export const WithAProductList = createExample(TestedComponent, {
state: {
status: "ready",
hook: undefined,
accept: {},
ignore: {},
amount: Amounts.parseOrThrow("USD:1"),
merchantName: "the merchant",
products: [
{
description: "beer",
image: beer,
quantity: 2,
},
{
description: "t-shirt",
price: "EUR:1",
quantity: 5,
},
],
orderId: "abcdef",
},
});
export const Ignored = createExample(TestedComponent, {
state: {
status: "ignored",
hook: undefined,
merchantName: "the merchant",
},
});

View File

@ -0,0 +1,243 @@
/*
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 { Amounts, NotificationType, PrepareRefundResult } from "@gnu-taler/taler-util";
import { expect } from "chai";
import { mountHook } from "../test-utils.js";
import { SubsHandler } from "./Pay.test.js";
import { useComponentState } from "./Refund.jsx";
// onUpdateNotification: subscriptions.saveSubscription,
describe("Refund CTA states", () => {
it("should tell the user that the URI is missing", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState(undefined, {
prepareRefund: async () => ({}),
applyRefund: async () => ({}),
onUpdateNotification: async () => ({})
} 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) expect.fail();
if (!hook.hasError) expect.fail();
if (hook.operational) expect.fail();
expect(hook.message).eq("ERROR_NO-URI-FOR-REFUND");
}
await assertNoPendingUpdate()
});
it("should be ready after loading", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("taler://refund/asdasdas", {
prepareRefund: async () => ({
total: 0,
applied: 0,
failed: 0,
amountEffectivePaid: 'EUR:2',
info: {
contractTermsHash: '123',
merchant: {
name: 'the merchant name'
},
orderId: 'orderId1',
summary: 'the sumary'
}
} as PrepareRefundResult as any),
applyRefund: async () => ({}),
onUpdateNotification: async () => ({})
} as any),
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== 'ready') expect.fail();
if (state.hook) expect.fail();
expect(state.accept.onClick).not.undefined;
expect(state.ignore.onClick).not.undefined;
expect(state.merchantName).eq('the merchant name');
expect(state.orderId).eq('orderId1');
expect(state.products).undefined;
}
await assertNoPendingUpdate()
});
it("should be ignored after clicking the ignore button", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("taler://refund/asdasdas", {
prepareRefund: async () => ({
total: 0,
applied: 0,
failed: 0,
amountEffectivePaid: 'EUR:2',
info: {
contractTermsHash: '123',
merchant: {
name: 'the merchant name'
},
orderId: 'orderId1',
summary: 'the sumary'
}
} as PrepareRefundResult as any),
applyRefund: async () => ({}),
onUpdateNotification: async () => ({})
} as any),
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== 'ready') expect.fail();
if (state.hook) expect.fail();
expect(state.accept.onClick).not.undefined;
expect(state.merchantName).eq('the merchant name');
expect(state.orderId).eq('orderId1');
expect(state.products).undefined;
if (state.ignore.onClick === undefined) expect.fail();
state.ignore.onClick()
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== 'ignored') expect.fail();
if (state.hook) expect.fail();
expect(state.merchantName).eq('the merchant name');
}
await assertNoPendingUpdate()
});
it("should be in progress when doing refresh", async () => {
let numApplied = 1;
const subscriptions = new SubsHandler();
function notifyMelt(): void {
numApplied++;
subscriptions.notifyEvent(NotificationType.RefreshMelted)
}
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("taler://refund/asdasdas", {
prepareRefund: async () => ({
total: 3,
applied: numApplied,
failed: 0,
amountEffectivePaid: 'EUR:2',
info: {
contractTermsHash: '123',
merchant: {
name: 'the merchant name'
},
orderId: 'orderId1',
summary: 'the sumary'
}
} as PrepareRefundResult as any),
applyRefund: async () => ({}),
onUpdateNotification: subscriptions.saveSubscription,
} as any),
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== 'in-progress') expect.fail();
if (state.hook) expect.fail();
expect(state.merchantName).eq('the merchant name');
expect(state.products).undefined;
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2"))
expect(state.progress).closeTo(1 / 3, 0.01)
notifyMelt()
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== 'in-progress') expect.fail();
if (state.hook) expect.fail();
expect(state.merchantName).eq('the merchant name');
expect(state.products).undefined;
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2"))
expect(state.progress).closeTo(2 / 3, 0.01)
notifyMelt()
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== 'completed') expect.fail();
if (state.hook) expect.fail();
expect(state.merchantName).eq('the merchant name');
expect(state.products).undefined;
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2"))
}
await assertNoPendingUpdate()
});
});

View File

@ -21,83 +21,282 @@
*/
import {
amountFractionalBase,
AmountJson,
Amounts,
ApplyRefundResponse,
NotificationType,
Product,
} from "@gnu-taler/taler-util";
import { h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
import { SubTitle, Title } from "../components/styled/index.js";
import { Amount } from "../components/Amount.js";
import { Loading } from "../components/Loading.js";
import { LoadingError } from "../components/LoadingError.js";
import { LogoHeader } from "../components/LogoHeader.js";
import { Part } from "../components/Part.js";
import {
Button,
ButtonSuccess,
SubTitle,
WalletAction,
} from "../components/styled/index.js";
import { useTranslationContext } from "../context/translation.js";
import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { ButtonHandler } from "../mui/handlers.js";
import * as wxApi from "../wxApi.js";
import { ProductList } from "./Pay.js";
interface Props {
talerRefundUri?: string;
}
export interface ViewProps {
applyResult: ApplyRefundResponse;
state: State;
}
export function View({ applyResult }: ViewProps): VNode {
export function View({ state }: ViewProps): VNode {
const { i18n } = useTranslationContext();
return (
<section class="main">
<Title>GNU Taler Wallet</Title>
<article class="fade">
if (state.status === "loading") {
if (!state.hook) {
return <Loading />;
}
return (
<LoadingError
title={<i18n.Translate>Could not load refund status</i18n.Translate>}
error={state.hook}
/>
);
}
if (state.status === "ignored") {
return (
<WalletAction>
<LogoHeader />
<SubTitle>
<i18n.Translate>Refund Status</i18n.Translate>
<i18n.Translate>Digital cash refund</i18n.Translate>
</SubTitle>
<section>
<p>
<i18n.Translate>You&apos;ve ignored the tip.</i18n.Translate>
</p>
</section>
</WalletAction>
);
}
if (state.status === "in-progress") {
return (
<WalletAction>
<LogoHeader />
<SubTitle>
<i18n.Translate>Digital cash refund</i18n.Translate>
</SubTitle>
<section>
<p>
<i18n.Translate>The refund is in progress.</i18n.Translate>
</p>
</section>
<section>
<Part
big
title={<i18n.Translate>Total to refund</i18n.Translate>}
text={<Amount value={state.amount} />}
kind="negative"
/>
</section>
{state.products && state.products.length ? (
<section>
<ProductList products={state.products} />
</section>
) : undefined}
<section>
<ProgressBar value={state.progress} />
</section>
</WalletAction>
);
}
if (state.status === "completed") {
return (
<WalletAction>
<LogoHeader />
<SubTitle>
<i18n.Translate>Digital cash refund</i18n.Translate>
</SubTitle>
<section>
<p>
<i18n.Translate>this refund is already accepted.</i18n.Translate>
</p>
</section>
</WalletAction>
);
}
return (
<WalletAction>
<LogoHeader />
<SubTitle>
<i18n.Translate>Digital cash refund</i18n.Translate>
</SubTitle>
<section>
<p>
<i18n.Translate>
The product <em>{applyResult.info.summary}</em> has received a total
effective refund of{" "}
The merchant &quot;<b>{state.merchantName}</b>&quot; is offering you
a refund.
</i18n.Translate>
<AmountView amount={applyResult.amountRefundGranted} />.
</p>
{applyResult.pendingAtExchange ? (
<p>
<i18n.Translate>
Refund processing is still in progress.
</i18n.Translate>
</p>
) : null}
{!Amounts.isZero(applyResult.amountRefundGone) ? (
<p>
<i18n.Translate>
The refund amount of{" "}
<AmountView amount={applyResult.amountRefundGone} /> could not be
applied.
</i18n.Translate>
</p>
) : null}
</article>
</section>
</section>
<section>
<Part
big
title={<i18n.Translate>Total to refund</i18n.Translate>}
text={<Amount value={state.amount} />}
kind="negative"
/>
</section>
{state.products && state.products.length ? (
<section>
<ProductList products={state.products} />
</section>
) : undefined}
<section>
<ButtonSuccess onClick={state.accept.onClick}>
<i18n.Translate>Confirm refund</i18n.Translate>
</ButtonSuccess>
<Button onClick={state.ignore.onClick}>
<i18n.Translate>Ignore</i18n.Translate>
</Button>
</section>
</WalletAction>
);
}
export function RefundPage({ talerRefundUri }: Props): VNode {
const [applyResult, setApplyResult] = useState<
ApplyRefundResponse | undefined
>(undefined);
const { i18n } = useTranslationContext();
const [errMsg, setErrMsg] = useState<string | undefined>(undefined);
type State = Loading | Ready | Ignored | InProgress | Completed;
interface Loading {
status: "loading";
hook: HookError | undefined;
}
interface Ready {
status: "ready";
hook: undefined;
merchantName: string;
products: Product[] | undefined;
amount: AmountJson;
accept: ButtonHandler;
ignore: ButtonHandler;
orderId: string;
}
interface Ignored {
status: "ignored";
hook: undefined;
merchantName: string;
}
interface InProgress {
status: "in-progress";
hook: undefined;
merchantName: string;
products: Product[] | undefined;
amount: AmountJson;
progress: number;
}
interface Completed {
status: "completed";
hook: undefined;
merchantName: string;
products: Product[] | undefined;
amount: AmountJson;
}
export function useComponentState(
talerRefundUri: string | undefined,
api: typeof wxApi,
): State {
const [ignored, setIgnored] = useState(false);
const info = useAsyncAsHook(async () => {
if (!talerRefundUri) throw Error("ERROR_NO-URI-FOR-REFUND");
const refund = await api.prepareRefund({ talerRefundUri });
return { refund, uri: talerRefundUri };
});
useEffect(() => {
if (!talerRefundUri) return;
const doFetch = async (): Promise<void> => {
try {
const result = await wxApi.applyRefund(talerRefundUri);
setApplyResult(result);
} catch (e) {
if (e instanceof Error) {
setErrMsg(e.message);
console.log("err message", e.message);
}
}
};
doFetch();
}, [talerRefundUri]);
api.onUpdateNotification([NotificationType.RefreshMelted], () => {
info?.retry();
});
});
console.log("rendering");
if (!info || info.hasError) {
return {
status: "loading",
hook: info,
};
}
const { refund, uri } = info.response;
const doAccept = async (): Promise<void> => {
await api.applyRefund(uri);
info.retry();
};
const doIgnore = async (): Promise<void> => {
setIgnored(true);
};
if (ignored) {
return {
status: "ignored",
hook: undefined,
merchantName: info.response.refund.info.merchant.name,
};
}
const pending = refund.total > refund.applied + refund.failed;
const completed = refund.total > 0 && refund.applied === refund.total;
if (pending) {
return {
status: "in-progress",
hook: undefined,
amount: Amounts.parseOrThrow(info.response.refund.amountEffectivePaid),
merchantName: info.response.refund.info.merchant.name,
products: info.response.refund.info.products,
progress: (refund.applied + refund.failed) / refund.total,
};
}
if (completed) {
return {
status: "completed",
hook: undefined,
amount: Amounts.parseOrThrow(info.response.refund.amountEffectivePaid),
merchantName: info.response.refund.info.merchant.name,
products: info.response.refund.info.products,
};
}
return {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow(info.response.refund.amountEffectivePaid),
merchantName: info.response.refund.info.merchant.name,
products: info.response.refund.info.products,
orderId: info.response.refund.info.orderId,
accept: {
onClick: doAccept,
},
ignore: {
onClick: doIgnore,
},
};
}
export function RefundPage({ talerRefundUri }: Props): VNode {
const { i18n } = useTranslationContext();
const state = useComponentState(talerRefundUri, wxApi);
if (!talerRefundUri) {
return (
@ -107,43 +306,26 @@ export function RefundPage({ talerRefundUri }: Props): VNode {
);
}
if (errMsg) {
return (
<span>
<i18n.Translate>Error: {errMsg}</i18n.Translate>
</span>
);
}
if (!applyResult) {
return (
<span>
<i18n.Translate>Updating refund status</i18n.Translate>
</span>
);
}
return <View applyResult={applyResult} />;
return <View state={state} />;
}
export function renderAmount(amount: AmountJson | string): VNode {
let a;
if (typeof amount === "string") {
a = Amounts.parse(amount);
} else {
a = amount;
}
if (!a) {
return <span>(invalid amount)</span>;
}
const x = a.value + a.fraction / amountFractionalBase;
function ProgressBar({ value }: { value: number }): VNode {
return (
<span>
{x}&nbsp;{a.currency}
</span>
<div
style={{
width: 400,
height: 20,
backgroundColor: "white",
border: "solid black 1px",
}}
>
<div
style={{
width: `${value * 100}%`,
height: "100%",
backgroundColor: "lightgreen",
}}
></div>
</div>
);
}
function AmountView({ amount }: { amount: AmountJson | string }): VNode {
return renderAmount(amount);
}

View File

@ -19,7 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { TalerProtocolTimestamp } from "@gnu-taler/taler-util";
import { Amounts } from "@gnu-taler/taler-util";
import { createExample } from "../test-utils.js";
import { View as TestedComponent } from "./Tip.js";
@ -30,25 +30,23 @@ export default {
};
export const Accepted = createExample(TestedComponent, {
prepareTipResult: {
accepted: true,
merchantBaseUrl: "",
state: {
status: "accepted",
hook: undefined,
amount: Amounts.parseOrThrow("EUR:1"),
exchangeBaseUrl: "",
expirationTimestamp: TalerProtocolTimestamp.fromSeconds(1),
tipAmountEffective: "USD:10",
tipAmountRaw: "USD:5",
walletTipId: "id",
merchantBaseUrl: "",
},
});
export const NotYetAccepted = createExample(TestedComponent, {
prepareTipResult: {
accepted: false,
export const Ready = createExample(TestedComponent, {
state: {
status: "ready",
hook: undefined,
amount: Amounts.parseOrThrow("EUR:1"),
merchantBaseUrl: "http://merchant.url/",
exchangeBaseUrl: "http://exchange.url/",
expirationTimestamp: TalerProtocolTimestamp.fromSeconds(1),
tipAmountEffective: "USD:10",
tipAmountRaw: "USD:5",
walletTipId: "id",
accept: {},
ignore: {},
},
});

View File

@ -0,0 +1,192 @@
/*
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 { Amounts, PrepareTipResult } from "@gnu-taler/taler-util";
import { expect } from "chai";
import { mountHook } from "../test-utils.js";
import { useComponentState } from "./Tip.jsx";
describe("Tip CTA states", () => {
it("should tell the user that the URI is missing", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState(undefined, {
prepareTip: async () => ({}),
acceptTip: async () => ({})
} 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) expect.fail();
if (!hook.hasError) expect.fail();
if (hook.operational) expect.fail();
expect(hook.message).eq("ERROR_NO-URI-FOR-TIP");
}
await assertNoPendingUpdate()
});
it("should be ready for accepting the tip", async () => {
let tipAccepted = false;
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("taler://tip/asd", {
prepareTip: async () => ({
accepted: tipAccepted,
exchangeBaseUrl: "exchange url",
merchantBaseUrl: "merchant url",
tipAmountEffective: "EUR:1",
walletTipId: "tip_id",
} as PrepareTipResult as any),
acceptTip: async () => {
tipAccepted = true
}
} as any),
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== "ready") expect.fail()
if (state.hook) expect.fail();
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1"));
expect(state.merchantBaseUrl).eq("merchant url");
expect(state.exchangeBaseUrl).eq("exchange url");
if (state.accept.onClick === undefined) expect.fail();
state.accept.onClick();
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== "accepted") expect.fail()
if (state.hook) expect.fail();
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1"));
expect(state.merchantBaseUrl).eq("merchant url");
expect(state.exchangeBaseUrl).eq("exchange url");
}
await assertNoPendingUpdate()
});
it("should be ignored after clicking the ignore button", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("taler://tip/asd", {
prepareTip: async () => ({
exchangeBaseUrl: "exchange url",
merchantBaseUrl: "merchant url",
tipAmountEffective: "EUR:1",
walletTipId: "tip_id",
} as PrepareTipResult as any),
acceptTip: async () => ({})
} as any),
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== "ready") expect.fail()
if (state.hook) expect.fail();
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1"));
expect(state.merchantBaseUrl).eq("merchant url");
expect(state.exchangeBaseUrl).eq("exchange url");
if (state.ignore.onClick === undefined) expect.fail();
state.ignore.onClick();
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== "ignored") expect.fail()
if (state.hook) expect.fail();
}
await assertNoPendingUpdate()
});
it("should render accepted if the tip has been used previously", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("taler://tip/asd", {
prepareTip: async () => ({
accepted: true,
exchangeBaseUrl: "exchange url",
merchantBaseUrl: "merchant url",
tipAmountEffective: "EUR:1",
walletTipId: "tip_id",
} as PrepareTipResult as any),
acceptTip: async () => ({})
} as any),
);
{
const { status, hook } = getLastResultOrThrow()
expect(status).equals('loading')
expect(hook).undefined;
}
await waitNextUpdate()
{
const state = getLastResultOrThrow()
if (state.status !== "accepted") expect.fail()
if (state.hook) expect.fail();
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1"));
expect(state.merchantBaseUrl).eq("merchant url");
expect(state.exchangeBaseUrl).eq("exchange url");
}
await assertNoPendingUpdate()
});
});

View File

@ -20,98 +20,210 @@
* @author sebasjm
*/
import {
amountFractionalBase,
AmountJson,
Amounts,
PrepareTipResult,
} from "@gnu-taler/taler-util";
import { AmountJson, Amounts, PrepareTipResult } from "@gnu-taler/taler-util";
import { h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
import { Amount } from "../components/Amount.js";
import { Loading } from "../components/Loading.js";
import { Title } from "../components/styled/index.js";
import { LoadingError } from "../components/LoadingError.js";
import { LogoHeader } from "../components/LogoHeader.js";
import { Part } from "../components/Part.js";
import {
Button,
ButtonSuccess,
SubTitle,
WalletAction,
} from "../components/styled/index.js";
import { useTranslationContext } from "../context/translation.js";
import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { ButtonHandler } from "../mui/handlers.js";
import * as wxApi from "../wxApi.js";
interface Props {
talerTipUri?: string;
}
export interface ViewProps {
prepareTipResult: PrepareTipResult;
onAccept: () => void;
onIgnore: () => void;
type State = Loading | Ready | Accepted | Ignored;
interface Loading {
status: "loading";
hook: HookError | undefined;
}
export function View({
prepareTipResult,
onAccept,
onIgnore,
}: ViewProps): VNode {
interface Ignored {
status: "ignored";
hook: undefined;
}
interface Accepted {
status: "accepted";
hook: undefined;
merchantBaseUrl: string;
amount: AmountJson;
exchangeBaseUrl: string;
}
interface Ready {
status: "ready";
hook: undefined;
merchantBaseUrl: string;
amount: AmountJson;
exchangeBaseUrl: string;
accept: ButtonHandler;
ignore: ButtonHandler;
}
export function useComponentState(
talerTipUri: string | undefined,
api: typeof wxApi,
): State {
const [tipIgnored, setTipIgnored] = useState(false);
const tipInfo = useAsyncAsHook(async () => {
if (!talerTipUri) throw Error("ERROR_NO-URI-FOR-TIP");
const tip = await api.prepareTip({ talerTipUri });
return { tip };
});
if (!tipInfo || tipInfo.hasError) {
return {
status: "loading",
hook: tipInfo,
};
}
const { tip } = tipInfo.response;
const doAccept = async (): Promise<void> => {
await api.acceptTip({ walletTipId: tip.walletTipId });
tipInfo.retry();
};
const doIgnore = async (): Promise<void> => {
setTipIgnored(true);
};
if (tipIgnored) {
return {
status: "ignored",
hook: undefined,
};
}
if (tip.accepted) {
return {
status: "accepted",
hook: undefined,
merchantBaseUrl: tip.merchantBaseUrl,
exchangeBaseUrl: tip.exchangeBaseUrl,
amount: Amounts.parseOrThrow(tip.tipAmountEffective),
};
}
return {
status: "ready",
hook: undefined,
merchantBaseUrl: tip.merchantBaseUrl,
exchangeBaseUrl: tip.exchangeBaseUrl,
accept: {
onClick: doAccept,
},
ignore: {
onClick: doIgnore,
},
amount: Amounts.parseOrThrow(tip.tipAmountEffective),
};
}
export function View({ state }: { state: State }): VNode {
const { i18n } = useTranslationContext();
if (state.status === "loading") {
if (!state.hook) {
return <Loading />;
}
return (
<LoadingError
title={<i18n.Translate>Could not load tip status</i18n.Translate>}
error={state.hook}
/>
);
}
if (state.status === "ignored") {
return (
<WalletAction>
<LogoHeader />
<SubTitle>
<i18n.Translate>Digital cash tip</i18n.Translate>
</SubTitle>
<span>
<i18n.Translate>You&apos;ve ignored the tip.</i18n.Translate>
</span>
</WalletAction>
);
}
if (state.status === "accepted") {
return (
<WalletAction>
<LogoHeader />
<SubTitle>
<i18n.Translate>Digital cash tip</i18n.Translate>
</SubTitle>
<section>
<i18n.Translate>
Tip from <code>{state.merchantBaseUrl}</code> accepted. Check your
transactions list for more details.
</i18n.Translate>
</section>
</WalletAction>
);
}
return (
<section class="main">
<Title>GNU Taler Wallet</Title>
<article class="fade">
{prepareTipResult.accepted ? (
<span>
<i18n.Translate>
Tip from <code>{prepareTipResult.merchantBaseUrl}</code> accepted.
Check your transactions list for more details.
</i18n.Translate>
</span>
) : (
<div>
<p>
<i18n.Translate>
The merchant <code>{prepareTipResult.merchantBaseUrl}</code> is
offering you a tip of{" "}
<strong>
<AmountView amount={prepareTipResult.tipAmountEffective} />
</strong>{" "}
via the exchange <code>{prepareTipResult.exchangeBaseUrl}</code>
</i18n.Translate>
</p>
<button onClick={onAccept}>
<i18n.Translate>Accept tip</i18n.Translate>
</button>
<button onClick={onIgnore}>
<i18n.Translate>Ignore</i18n.Translate>
</button>
</div>
)}
</article>
</section>
<WalletAction>
<LogoHeader />
<SubTitle>
<i18n.Translate>Digital cash tip</i18n.Translate>
</SubTitle>
<section>
<p>
<i18n.Translate>The merchant is offering you a tip</i18n.Translate>
</p>
<Part
title={<i18n.Translate>Amount</i18n.Translate>}
text={<Amount value={state.amount} />}
kind="positive"
big
/>
<Part
title={<i18n.Translate>Merchant URL</i18n.Translate>}
text={state.merchantBaseUrl}
kind="neutral"
/>
<Part
title={<i18n.Translate>Exchange</i18n.Translate>}
text={state.exchangeBaseUrl}
kind="neutral"
/>
</section>
<section>
<ButtonSuccess onClick={state.accept.onClick}>
<i18n.Translate>Accept tip</i18n.Translate>
</ButtonSuccess>
<Button onClick={state.ignore.onClick}>
<i18n.Translate>Ignore</i18n.Translate>
</Button>
</section>
</WalletAction>
);
}
export function TipPage({ talerTipUri }: Props): VNode {
const { i18n } = useTranslationContext();
const [updateCounter, setUpdateCounter] = useState<number>(0);
const [prepareTipResult, setPrepareTipResult] = useState<
PrepareTipResult | undefined
>(undefined);
const [tipIgnored, setTipIgnored] = useState(false);
useEffect(() => {
if (!talerTipUri) return;
const doFetch = async (): Promise<void> => {
const p = await wxApi.prepareTip({ talerTipUri });
setPrepareTipResult(p);
};
doFetch();
}, [talerTipUri, updateCounter]);
const doAccept = async (): Promise<void> => {
if (!prepareTipResult) {
return;
}
await wxApi.acceptTip({ walletTipId: prepareTipResult?.walletTipId });
setUpdateCounter(updateCounter + 1);
};
const doIgnore = (): void => {
setTipIgnored(true);
};
const state = useComponentState(talerTipUri, wxApi);
if (!talerTipUri) {
return (
@ -121,45 +233,5 @@ export function TipPage({ talerTipUri }: Props): VNode {
);
}
if (tipIgnored) {
return (
<span>
<i18n.Translate>You&apos;ve ignored the tip.</i18n.Translate>
</span>
);
}
if (!prepareTipResult) {
return <Loading />;
}
return (
<View
prepareTipResult={prepareTipResult}
onAccept={doAccept}
onIgnore={doIgnore}
/>
);
}
function renderAmount(amount: AmountJson | string): VNode {
let a;
if (typeof amount === "string") {
a = Amounts.parse(amount);
} else {
a = amount;
}
if (!a) {
return <span>(invalid amount)</span>;
}
const x = a.value + a.fraction / amountFractionalBase;
return (
<span>
{x}&nbsp;{a.currency}
</span>
);
}
function AmountView({ amount }: { amount: AmountJson | string }): VNode {
return renderAmount(amount);
return <View state={state} />;
}

View File

@ -515,13 +515,13 @@ export function TransactionView({
<Part
big
title={<i18n.Translate>Total tip</i18n.Translate>}
text={<Amount value={transaction.amountEffective} />}
text={<Amount value={transaction.amountRaw} />}
kind="positive"
/>
<Part
big
title={<i18n.Translate>Received amount</i18n.Translate>}
text={<Amount value={transaction.amountRaw} />}
text={<Amount value={transaction.amountEffective} />}
kind="neutral"
/>
<Part

View File

@ -44,6 +44,8 @@ import {
KnownBankAccounts,
NotificationType,
PreparePayResult,
PrepareRefundRequest,
PrepareRefundResult,
PrepareTipRequest,
PrepareTipResult,
RetryTransactionRequest,
@ -405,6 +407,11 @@ export function addExchange(req: AddExchangeRequest): Promise<void> {
return callBackend("addExchange", req);
}
export function prepareRefund(req: PrepareRefundRequest): Promise<PrepareRefundResult> {
return callBackend("prepareRefund", req);
}
export function prepareTip(req: PrepareTipRequest): Promise<PrepareTipResult> {
return callBackend("prepareTip", req);
}