deposit from payto

This commit is contained in:
Sebastian 2022-05-03 00:16:03 -03:00
parent 939729004a
commit dc842eab6b
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
11 changed files with 435 additions and 19 deletions

View File

@ -1070,6 +1070,23 @@ export const codecForGetFeeForDeposit = (): Codec<GetFeeForDepositRequest> =>
.property("depositPaytoUri", codecForString())
.build("GetFeeForDepositRequest");
export interface PrepareDepositRequest {
depositPaytoUri: string;
amount: AmountString;
}
export const codecForPrepareDepositRequest =
(): Codec<PrepareDepositRequest> =>
buildCodecForObject<PrepareDepositRequest>()
.property("amount", codecForAmountString())
.property("depositPaytoUri", codecForString())
.build("PrepareDepositRequest");
export interface PrepareDepositResponse {
totalDepositCost: AmountJson;
effectiveDepositAmount: AmountJson;
}
export const codecForCreateDepositGroupRequest =
(): Codec<CreateDepositGroupRequest> =>
buildCodecForObject<CreateDepositGroupRequest>()

View File

@ -35,6 +35,8 @@ import {
Logger,
NotificationType,
parsePaytoUri,
PrepareDepositRequest,
PrepareDepositResponse,
TalerErrorDetail,
TalerProtocolTimestamp,
TrackDepositGroupRequest,
@ -367,6 +369,108 @@ export async function getFeeForDeposit(
);
}
export async function prepareDepositGroup(
ws: InternalWalletState,
req: PrepareDepositRequest,
): Promise<PrepareDepositResponse> {
const p = parsePaytoUri(req.depositPaytoUri);
if (!p) {
throw Error("invalid payto URI");
}
const amount = Amounts.parseOrThrow(req.amount);
const exchangeInfos: { url: string; master_pub: string }[] = [];
await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
}))
.runReadOnly(async (tx) => {
const allExchanges = await tx.exchanges.iter().toArray();
for (const e of allExchanges) {
const details = await getExchangeDetails(tx, e.baseUrl);
if (!details || amount.currency !== details.currency) {
continue;
}
exchangeInfos.push({
master_pub: details.masterPublicKey,
url: e.baseUrl,
});
}
});
const now = AbsoluteTime.now();
const nowRounded = AbsoluteTime.toTimestamp(now);
const contractTerms: ContractTerms = {
auditors: [],
exchanges: exchangeInfos,
amount: req.amount,
max_fee: Amounts.stringify(amount),
max_wire_fee: Amounts.stringify(amount),
wire_method: p.targetType,
timestamp: nowRounded,
merchant_base_url: "",
summary: "",
nonce: "",
wire_transfer_deadline: nowRounded,
order_id: "",
h_wire: "",
pay_deadline: AbsoluteTime.toTimestamp(
AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
),
merchant: {
name: "(wallet)",
},
merchant_pub: "",
refund_deadline: TalerProtocolTimestamp.zero(),
};
const { h: contractTermsHash } = await ws.cryptoApi.hashString({
str: canonicalJson(contractTerms),
});
const contractData = extractContractData(
contractTerms,
contractTermsHash,
"",
);
const candidates = await getCandidatePayCoins(ws, {
allowedAuditors: contractData.allowedAuditors,
allowedExchanges: contractData.allowedExchanges,
amount: contractData.amount,
maxDepositFee: contractData.maxDepositFee,
maxWireFee: contractData.maxWireFee,
timestamp: contractData.timestamp,
wireFeeAmortization: contractData.wireFeeAmortization,
wireMethod: contractData.wireMethod,
});
const payCoinSel = selectPayCoins({
candidates,
contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
wireFeeLimit: contractData.maxWireFee,
prevPayCoins: [],
});
if (!payCoinSel) {
throw Error("insufficient funds");
}
const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel);
const effectiveDepositAmount = await getEffectiveDepositAmount(
ws,
p.targetType,
payCoinSel,
);
return { totalDepositCost, effectiveDepositAmount }
}
export async function createDepositGroup(
ws: InternalWalletState,
req: CreateDepositGroupRequest,

View File

@ -46,6 +46,7 @@ import {
codecForImportDbRequest,
codecForIntegrationTestArgs,
codecForListKnownBankAccounts,
codecForPrepareDepositRequest,
codecForPreparePayRequest, codecForPrepareRefundRequest, codecForPrepareTipRequest,
codecForRetryTransactionRequest,
codecForSetCoinSuspendedRequest,
@ -114,6 +115,7 @@ import { getBalances } from "./operations/balance.js";
import {
createDepositGroup,
getFeeForDeposit,
prepareDepositGroup,
processDepositGroup,
trackDepositGroup
} from "./operations/deposits.js";
@ -944,6 +946,10 @@ async function dispatchRequestInternal(
const req = codecForGetFeeForDeposit().decode(payload);
return await getFeeForDeposit(ws, req);
}
case "prepareDeposit": {
const req = codecForPrepareDepositRequest().decode(payload);
return await prepareDepositGroup(ws, req);
}
case "createDepositGroup": {
const req = codecForCreateDepositGroupRequest().decode(payload);
return await createDepositGroup(ws, req);

View File

@ -63,6 +63,7 @@ export enum Pages {
cta_refund = "/cta/refund",
cta_tips = "/cta/tip",
cta_withdraw = "/cta/withdraw",
cta_deposit = "/cta/deposit",
}
export function PopupNavBar({ path = "" }: { path?: string }): VNode {

View File

@ -13,7 +13,8 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { h, VNode } from "preact";
import { PaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util";
import { Fragment, h, VNode } from "preact";
import { ExtraLargeText, LargeText, SmallLightText } from "./styled/index.js";
export type Kind = "positive" | "negative" | "neutral";
@ -39,3 +40,43 @@ export function Part({ text, title, kind, big }: Props): VNode {
</div>
);
}
interface PropsPayto {
payto: PaytoUri;
kind: Kind;
big?: boolean;
}
export function PartPayto({ payto, kind, big }: PropsPayto): VNode {
const Text = big ? ExtraLargeText : LargeText;
let text: string | undefined = undefined;
let title = "";
if (payto.isKnown) {
if (payto.targetType === "x-taler-bank") {
text = payto.account;
title = "Bank account";
} else if (payto.targetType === "bitcoin") {
text = payto.targetPath;
title = "Bitcoin addr";
} else if (payto.targetType === "iban") {
text = payto.targetPath;
title = "IBAN";
}
}
if (!text) {
text = stringifyPaytoUri(payto);
title = "Payto URI";
}
return (
<div style={{ margin: "1em" }}>
<SmallLightText style={{ margin: ".5em" }}>{title}</SmallLightText>
<Text
style={{
color:
kind == "positive" ? "green" : kind == "negative" ? "red" : "black",
}}
>
{text}
</Text>
</div>
);
}

View File

@ -19,7 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { ContractTerms, PreparePayResultType } from "@gnu-taler/taler-util";
import { Amounts } from "@gnu-taler/taler-util";
import { createExample } from "../test-utils.js";
import { View as TestedComponent } from "./Deposit.js";
@ -29,6 +29,13 @@ export default {
argTypes: {},
};
export const Simple = createExample(TestedComponent, {
state: { status: "ready" },
export const Ready = createExample(TestedComponent, {
state: {
status: "ready",
confirm: {},
cost: Amounts.parseOrThrow("EUR:1.2"),
effective: Amounts.parseOrThrow("EUR:1"),
fee: Amounts.parseOrThrow("EUR:0.2"),
hook: undefined,
},
});

View File

@ -0,0 +1,92 @@
/*
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, PrepareDepositResponse } from "@gnu-taler/taler-util";
import { expect } from "chai";
import { mountHook } from "../test-utils.js";
import { useComponentState } from "./Deposit.jsx";
describe("Deposit CTA states", () => {
it("should tell the user that the URI is missing", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState(undefined, 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-DEPOSIT");
}
await assertNoPendingUpdate()
});
it("should be ready after loading", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("payto://refund/asdasdas", "EUR:1", {
prepareDeposit: async () => ({
effectiveDepositAmount: Amounts.parseOrThrow("EUR:1"),
totalDepositCost: Amounts.parseOrThrow("EUR:1.2")
} as PrepareDepositResponse as any),
createDepositGroup: 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.confirm.onClick).not.undefined;
expect(state.cost).deep.eq(Amounts.parseOrThrow("EUR:1.2"));
expect(state.fee).deep.eq(Amounts.parseOrThrow("EUR:0.2"));
expect(state.effective).deep.eq(Amounts.parseOrThrow("EUR:1"));
}
await assertNoPendingUpdate()
});
});

View File

@ -24,48 +24,120 @@
* Imports.
*/
import {
AmountJson,
Amounts,
AmountString,
CreateDepositGroupResponse,
} from "@gnu-taler/taler-util";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
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 { SubTitle, WalletAction } from "../components/styled/index.js";
import { Part } from "../components/Part.js";
import {
ButtonSuccess,
SubTitle,
WalletAction,
} from "../components/styled/index.js";
import { useTranslationContext } from "../context/translation.js";
import { HookError } from "../hooks/useAsyncAsHook.js";
import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { ButtonHandler } from "../mui/handlers.js";
import * as wxApi from "../wxApi.js";
interface Props {
talerDepositUri?: string;
amount: AmountString;
goBack: () => void;
}
type State = Loading | Ready;
type State = Loading | Ready | Completed;
interface Loading {
status: "loading";
hook: HookError | undefined;
}
interface Ready {
status: "ready";
hook: undefined;
fee: AmountJson;
cost: AmountJson;
effective: AmountJson;
confirm: ButtonHandler;
}
interface Completed {
status: "completed";
hook: undefined;
}
function useComponentState(uri: string | undefined): State {
export function useComponentState(
talerDepositUri: string | undefined,
amountStr: AmountString | undefined,
api: typeof wxApi,
): State {
const [result, setResult] = useState<CreateDepositGroupResponse | undefined>(
undefined,
);
const info = useAsyncAsHook(async () => {
if (!talerDepositUri) throw Error("ERROR_NO-URI-FOR-DEPOSIT");
if (!amountStr) throw Error("ERROR_NO-AMOUNT-FOR-DEPOSIT");
const amount = Amounts.parse(amountStr);
if (!amount) throw Error("ERROR_INVALID-AMOUNT-FOR-DEPOSIT");
const deposit = await api.prepareDeposit(
talerDepositUri,
Amounts.stringify(amount),
);
return { deposit, uri: talerDepositUri, amount };
});
if (!info || info.hasError) {
return {
status: "loading",
hook: info,
};
}
const { deposit, uri, amount } = info.response;
async function doDeposit(): Promise<void> {
const resp = await api.createDepositGroup(uri, Amounts.stringify(amount));
setResult(resp);
}
if (result !== undefined) {
return {
status: "completed",
hook: undefined,
};
}
return {
status: "loading",
status: "ready",
hook: undefined,
confirm: {
onClick: doDeposit,
},
fee: Amounts.sub(deposit.totalDepositCost, deposit.effectiveDepositAmount)
.amount,
cost: deposit.totalDepositCost,
effective: deposit.effectiveDepositAmount,
};
}
export function DepositPage({ talerDepositUri, goBack }: Props): VNode {
export function DepositPage({ talerDepositUri, amount, goBack }: Props): VNode {
const { i18n } = useTranslationContext();
const state = useComponentState(talerDepositUri);
if (state.status === "loading") {
if (!state.hook) return <Loading />;
const state = useComponentState(talerDepositUri, amount, wxApi);
if (!talerDepositUri) {
return (
<LoadingError
title={<i18n.Translate>Could not load pay status</i18n.Translate>}
error={state.hook}
/>
<span>
<i18n.Translate>missing taler deposit uri</i18n.Translate>
</span>
);
}
return <View state={state} />;
}
@ -75,13 +147,71 @@ export interface ViewProps {
export function View({ state }: ViewProps): VNode {
const { i18n } = useTranslationContext();
if (state.status === "loading") {
if (!state.hook) return <Loading />;
return (
<LoadingError
title={<i18n.Translate>Could not load deposit status</i18n.Translate>}
error={state.hook}
/>
);
}
if (state.status === "completed") {
return (
<WalletAction>
<LogoHeader />
<SubTitle>
<i18n.Translate>Digital cash deposit</i18n.Translate>
</SubTitle>
<section>
<p>
<i18n.Translate>deposit completed</i18n.Translate>
</p>
</section>
</WalletAction>
);
}
return (
<WalletAction>
<LogoHeader />
<SubTitle>
<i18n.Translate>Digital cash refund</i18n.Translate>
<i18n.Translate>Digital cash deposit</i18n.Translate>
</SubTitle>
<section>
{Amounts.isNonZero(state.cost) && (
<Part
big
title={<i18n.Translate>Cost</i18n.Translate>}
text={<Amount value={state.cost} />}
kind="negative"
/>
)}
{Amounts.isNonZero(state.fee) && (
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={state.fee} />}
kind="negative"
/>
)}
<Part
big
title={<i18n.Translate>To be received</i18n.Translate>}
text={<Amount value={state.effective} />}
kind="positive"
/>
</section>
<section>
<ButtonSuccess upperCased onClick={state.confirm.onClick}>
<i18n.Translate>
Deposit {<Amount value={state.effective} />}
</i18n.Translate>
</ButtonSuccess>
</section>
</WalletAction>
);
}

View File

@ -38,6 +38,7 @@ import { PayPage } from "../cta/Pay.js";
import { RefundPage } from "../cta/Refund.js";
import { TipPage } from "../cta/Tip.js";
import { WithdrawPage } from "../cta/Withdraw.js";
import { DepositPage as DepositPageCTA } from "../cta/Deposit.js";
import { Pages, WalletNavBar } from "../NavigationBar.js";
import { DeveloperPage } from "./DeveloperPage.js";
import { BackupPage } from "./BackupPage.js";
@ -232,6 +233,7 @@ export function Application(): VNode {
<Route path={Pages.cta_refund} component={RefundPage} />
<Route path={Pages.cta_tips} component={TipPage} />
<Route path={Pages.cta_withdraw} component={WithdrawPage} />
<Route path={Pages.cta_deposit} component={DepositPageCTA} />
{/**
* NOT FOUND

View File

@ -19,6 +19,7 @@ import {
Amounts,
NotificationType,
parsePaytoUri,
parsePayUri,
Transaction,
TransactionType,
WithdrawalType,
@ -32,13 +33,14 @@ import { BankDetailsByPaytoType } from "../components/BankDetailsByPaytoType.js"
import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js";
import { Loading } from "../components/Loading.js";
import { LoadingError } from "../components/LoadingError.js";
import { Part } from "../components/Part.js";
import { Part, PartPayto } from "../components/Part.js";
import {
Button,
ButtonDestructive,
ButtonPrimary,
CenteredDialog,
InfoBox,
LargeText,
ListOfProducts,
Overlay,
RowBorderGray,
@ -428,6 +430,7 @@ export function TransactionView({
Amounts.parseOrThrow(transaction.amountEffective),
Amounts.parseOrThrow(transaction.amountRaw),
).amount;
const payto = parsePaytoUri(transaction.targetPaytoUri);
return (
<TransactionTemplate>
<SubTitle>
@ -456,6 +459,7 @@ export function TransactionView({
text={<Amount value={fee} />}
kind="negative"
/>
{payto && <PartPayto big payto={payto} kind="neutral" />}
</TransactionTemplate>
);
}

View File

@ -43,6 +43,8 @@ import {
GetWithdrawalDetailsForUriRequest,
KnownBankAccounts,
NotificationType,
PrepareDepositRequest,
PrepareDepositResponse,
PreparePayResult,
PrepareRefundRequest,
PrepareRefundResult,
@ -160,6 +162,16 @@ export function getFeeForDeposit(
} as GetFeeForDepositRequest);
}
export function prepareDeposit(
depositPaytoUri: string,
amount: AmountString,
): Promise<PrepareDepositResponse> {
return callBackend("prepareDeposit", {
depositPaytoUri,
amount,
} as PrepareDepositRequest);
}
export function createDepositGroup(
depositPaytoUri: string,
amount: AmountString,