tipping API and integration test

This commit is contained in:
Florian Dold 2020-09-08 17:40:47 +05:30
parent be77ee284a
commit b063382d25
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
13 changed files with 331 additions and 92 deletions

View File

@ -72,6 +72,10 @@ import {
CoinDumpJson,
ForceExchangeUpdateRequest,
ForceRefreshRequest,
PrepareTipResult,
PrepareTipRequest,
codecForPrepareTipResult,
AcceptTipRequest,
} from "taler-wallet-core";
import { URL } from "url";
import axios, { AxiosError } from "axios";
@ -81,6 +85,9 @@ import {
PostOrderRequest,
PostOrderResponse,
MerchantOrderPrivateStatusResponse,
TippingReserveStatus,
TipCreateConfirmation,
TipCreateRequest,
} from "./merchantApiTypes";
import { ApplyRefundResponse } from "taler-wallet-core";
import { PendingOperationsResponse } from "taler-wallet-core";
@ -1216,10 +1223,46 @@ export namespace MerchantPrivateApi {
};
}
export async function createTippingReserve(merchantService: MerchantServiceInterface,
export async function createTippingReserve(
merchantService: MerchantServiceInterface,
instance: string,
req: CreateMerchantTippingReserveRequest,
): Promise<CreateMerchantTippingReserveConfirmation> {}
): Promise<CreateMerchantTippingReserveConfirmation> {
const reqUrl = new URL(
`private/reserves`,
merchantService.makeInstanceBaseUrl(instance),
);
const resp = await axios.post(reqUrl.href, req);
// FIXME: validate
return resp.data;
}
export async function queryTippingReserves(
merchantService: MerchantServiceInterface,
instance: string,
): Promise<TippingReserveStatus> {
const reqUrl = new URL(
`private/reserves`,
merchantService.makeInstanceBaseUrl(instance),
);
const resp = await axios.get(reqUrl.href);
// FIXME: validate
return resp.data;
}
export async function giveTip(
merchantService: MerchantServiceInterface,
instance: string,
req: TipCreateRequest,
): Promise<TipCreateConfirmation> {
const reqUrl = new URL(
`private/tips`,
merchantService.makeInstanceBaseUrl(instance),
);
const resp = await axios.post(reqUrl.href, req);
// FIXME: validate
return resp.data;
}
}
export interface CreateMerchantTippingReserveRequest {
@ -1238,7 +1281,7 @@ export interface CreateMerchantTippingReserveConfirmation {
reserve_pub: string;
// Wire account of the exchange where to transfer the funds
payto_url: string;
payto_uri: string;
}
export class MerchantService implements MerchantServiceInterface {
@ -1594,6 +1637,22 @@ export class WalletCli {
throw new OperationFailedError(resp.error);
}
async prepareTip(req: PrepareTipRequest): Promise<PrepareTipResult> {
const resp = await this.apiRequest("prepareTip", req);
if (resp.type === "response") {
return codecForPrepareTipResult().decode(resp.result);
}
throw new OperationFailedError(resp.error);
}
async acceptTip(req: AcceptTipRequest): Promise<void> {
const resp = await this.apiRequest("acceptTip", req);
if (resp.type === "response") {
return;
}
throw new OperationFailedError(resp.error);
}
async dumpCoins(): Promise<CoinDumpJson> {
const resp = await this.apiRequest("dumpCoins", {});
if (resp.type === "response") {

View File

@ -223,3 +223,63 @@ export interface TransactionWireReport {
// Public key of the coin for which we got the exchange error.
coin_pub: CoinPublicKeyString;
}
export interface TippingReserveStatus {
// Array of all known reserves (possibly empty!)
reserves: ReserveStatusEntry[];
}
export interface ReserveStatusEntry {
// Public key of the reserve
reserve_pub: string;
// Timestamp when it was established
creation_time: Timestamp;
// Timestamp when it expires
expiration_time: Timestamp;
// Initial amount as per reserve creation call
merchant_initial_amount: AmountString;
// Initial amount as per exchange, 0 if exchange did
// not confirm reserve creation yet.
exchange_initial_amount: AmountString;
// Amount picked up so far.
pickup_amount: AmountString;
// Amount approved for tips that exceeds the pickup_amount.
committed_amount: AmountString;
// Is this reserve active (false if it was deleted but not purged)
active: boolean;
}
export interface TipCreateConfirmation {
// Unique tip identifier for the tip that was created.
tip_id: string;
// taler://tip URI for the tip
taler_tip_uri: string;
// URL that will directly trigger processing
// the tip when the browser is redirected to it
tip_status_url: string;
// when does the tip expire
tip_expiration: Timestamp;
}
export interface TipCreateRequest {
// Amount that the customer should be tipped
amount: AmountString;
// Justification for giving the tip
justification: string;
// URL that the user should be directed to after tipping,
// will be included in the tip_token.
next_url: string;
}

View File

@ -0,0 +1,106 @@
/*
This file is part of GNU Taler
(C) 2020 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/>
*/
/**
* Imports.
*/
import {
runTest,
GlobalTestState,
MerchantPrivateApi,
BankAccessApi,
BankApi,
} from "./harness";
import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
/**
* Run test for basic, bank-integrated withdrawal.
*/
runTest(async (t: GlobalTestState) => {
// Set up test environment
const {
wallet,
bank,
exchange,
merchant,
exchangeBankAccount,
} = await createSimpleTestkudosEnvironment(t);
const mbu = await BankApi.createRandomBankUser(bank);
const tipReserveResp = await MerchantPrivateApi.createTippingReserve(
merchant,
"default",
{
exchange_url: exchange.baseUrl,
initial_balance: "TESTKUDOS:10",
wire_method: "x-taler-bank",
},
);
console.log("tipReserveResp:", tipReserveResp);
t.assertDeepEqual(
tipReserveResp.payto_uri,
exchangeBankAccount.accountPaytoUri,
);
await BankApi.adminAddIncoming(bank, {
amount: "TESTKUDOS:10",
debitAccountPayto: mbu.accountPaytoUri,
exchangeBankAccount,
reservePub: tipReserveResp.reserve_pub,
});
await exchange.runWirewatchOnce();
await merchant.stop();
await merchant.start();
await merchant.pingUntilAvailable();
const r = await MerchantPrivateApi.queryTippingReserves(merchant, "default");
console.log("tipping reserves:", JSON.stringify(r, undefined, 2));
t.assertTrue(r.reserves.length === 1);
t.assertDeepEqual(
r.reserves[0].exchange_initial_amount,
r.reserves[0].merchant_initial_amount,
);
const tip = await MerchantPrivateApi.giveTip(merchant, "default", {
amount: "TESTKUDOS:5",
justification: "why not?",
next_url: "https://example.com/after-tip",
});
console.log("created tip", tip);
const ptr = await wallet.prepareTip({
talerTipUri: tip.taler_tip_uri,
});
console.log(ptr);
await wallet.acceptTip({
walletTipId: ptr.walletTipId,
});
await wallet.runUntilDone();
const bal = await wallet.getBalances();
console.log(bal);
});

View File

@ -288,9 +288,9 @@ walletCli
break;
case TalerUriType.TalerTip:
{
const res = await wallet.getTipStatus(uri);
const res = await wallet.prepareTip(uri);
console.log("tip status", res);
await wallet.acceptTip(res.tipId);
await wallet.acceptTip(res.walletTipId);
}
break;
case TalerUriType.TalerRefund:

View File

@ -8,7 +8,7 @@ import { IDBFactory, IDBDatabase } from "idb-bridge";
* with each major change. When incrementing the major version,
* the wallet should import data from the previous version.
*/
const TALER_DB_NAME = "taler-walletdb-v10";
const TALER_DB_NAME = "taler-walletdb-v11";
/**
* Current database minor version, should be incremented

View File

@ -368,7 +368,7 @@ async function gatherTipPending(
type: PendingOperationType.TipPickup,
givesLifeness: true,
merchantBaseUrl: tip.merchantBaseUrl,
tipId: tip.tipId,
tipId: tip.walletTipId,
merchantTipId: tip.merchantTipId,
});
}

View File

@ -16,7 +16,7 @@
import { InternalWalletState } from "./state";
import { parseTipUri } from "../util/taleruri";
import { TipStatus, TalerErrorDetails } from "../types/walletTypes";
import { PrepareTipResult, TalerErrorDetails } from "../types/walletTypes";
import {
TipPlanchetDetail,
codecForTipPickupGetResponse,
@ -46,20 +46,23 @@ import { getTimestampNow } from "../util/time";
import { readSuccessResponseJsonOrThrow } from "../util/http";
import { URL } from "../util/url";
import { Logger } from "../util/logging";
import { checkDbInvariant } from "../util/invariants";
const logger = new Logger("operations/tip.ts");
export async function getTipStatus(
export async function prepareTip(
ws: InternalWalletState,
talerTipUri: string,
): Promise<TipStatus> {
): Promise<PrepareTipResult> {
const res = parseTipUri(talerTipUri);
if (!res) {
throw Error("invalid taler://tip URI");
}
const tipStatusUrl = new URL("tip-pickup", res.merchantBaseUrl);
tipStatusUrl.searchParams.set("tip_id", res.merchantTipId);
const tipStatusUrl = new URL(
`tips/${res.merchantTipId}`,
res.merchantBaseUrl,
);
logger.trace("checking tip status from", tipStatusUrl.href);
const merchantResp = await ws.http.get(tipStatusUrl.href);
const tipPickupStatus = await readSuccessResponseJsonOrThrow(
@ -68,7 +71,7 @@ export async function getTipStatus(
);
logger.trace(`status ${tipPickupStatus}`);
const amount = Amounts.parseOrThrow(tipPickupStatus.amount);
const amount = Amounts.parseOrThrow(tipPickupStatus.tip_amount);
const merchantOrigin = new URL(res.merchantBaseUrl).origin;
@ -85,7 +88,7 @@ export async function getTipStatus(
amount,
);
const tipId = encodeCrock(getRandomBytes(32));
const walletTipId = encodeCrock(getRandomBytes(32));
const selectedDenoms = await selectWithdrawalDenoms(
ws,
tipPickupStatus.exchange_url,
@ -93,11 +96,11 @@ export async function getTipStatus(
);
tipRecord = {
tipId,
walletTipId: walletTipId,
acceptedTimestamp: undefined,
rejectedTimestamp: undefined,
amount,
deadline: tipPickupStatus.stamp_expire,
deadline: tipPickupStatus.expiration,
exchangeUrl: tipPickupStatus.exchange_url,
merchantBaseUrl: res.merchantBaseUrl,
nextUrl: undefined,
@ -117,18 +120,13 @@ export async function getTipStatus(
await ws.db.put(Stores.tips, tipRecord);
}
const tipStatus: TipStatus = {
const tipStatus: PrepareTipResult = {
accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
amount: Amounts.parseOrThrow(tipPickupStatus.amount),
amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left),
exchangeUrl: tipPickupStatus.exchange_url,
nextUrl: tipPickupStatus.extra.next_url,
merchantOrigin: merchantOrigin,
merchantTipId: res.merchantTipId,
expirationTimestamp: tipPickupStatus.stamp_expire,
timestamp: tipPickupStatus.stamp_created,
totalFees: tipRecord.totalFees,
tipId: tipRecord.tipId,
amount: Amounts.stringify(tipPickupStatus.tip_amount),
exchangeBaseUrl: tipPickupStatus.exchange_url,
expirationTimestamp: tipPickupStatus.expiration,
totalFees: Amounts.stringify(tipRecord.totalFees),
walletTipId: tipRecord.walletTipId,
};
return tipStatus;
@ -152,7 +150,9 @@ async function incrementTipRetry(
t.lastError = err;
await tx.put(Stores.tips, t);
});
ws.notify({ type: NotificationType.TipOperationError });
if (err) {
ws.notify({ type: NotificationType.TipOperationError, error: err });
}
}
export async function processTip(
@ -225,15 +225,8 @@ async function processTipImpl(
}
tipRecord = await ws.db.get(Stores.tips, tipId);
if (!tipRecord) {
throw Error("tip not in database");
}
if (!tipRecord.planchets) {
throw Error("invariant violated");
}
logger.trace("got planchets for tip!");
checkDbInvariant(!!tipRecord, "tip record should be in database");
checkDbInvariant(!!tipRecord.planchets, "tip record should have planchets");
// Planchets in the form that the merchant expects
const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map((p) => ({
@ -241,23 +234,17 @@ async function processTipImpl(
denom_pub_hash: p.denomPubHash,
}));
let merchantResp;
const tipStatusUrl = new URL(
`/tips/${tipRecord.merchantTipId}/pickup`,
tipRecord.merchantBaseUrl,
);
const tipStatusUrl = new URL("tip-pickup", tipRecord.merchantBaseUrl);
try {
const req = { planchets: planchetsDetail, tip_id: tipRecord.merchantTipId };
merchantResp = await ws.http.postJson(tipStatusUrl.href, req);
if (merchantResp.status !== 200) {
throw Error(`unexpected status ${merchantResp.status} for tip-pickup`);
}
logger.trace("got merchant resp:", merchantResp);
} catch (e) {
logger.warn("tipping failed", e);
throw e;
}
const response = codecForTipResponse().decode(await merchantResp.json());
const req = { planchets: planchetsDetail };
const merchantResp = await ws.http.postJson(tipStatusUrl.href, req);
const response = await readSuccessResponseJsonOrThrow(
merchantResp,
codecForTipResponse(),
);
if (response.reserve_sigs.length !== tipRecord.planchets.length) {
throw Error("number of tip responses does not match requested planchets");
@ -293,7 +280,7 @@ async function processTipImpl(
exchangeBaseUrl: tipRecord.exchangeUrl,
source: {
type: WithdrawalSourceType.Tip,
tipId: tipRecord.tipId,
tipId: tipRecord.walletTipId,
},
timestampStart: getTimestampNow(),
withdrawalGroupId: withdrawalGroupId,

View File

@ -986,7 +986,7 @@ export interface TipRecord {
/**
* Tip ID chosen by the wallet.
*/
tipId: string;
walletTipId: string;
/**
* The merchant's identifier for this tip.
@ -1760,7 +1760,7 @@ class ReserveHistoryStore extends Store<ReserveHistoryRecord> {
class TipsStore extends Store<TipRecord> {
constructor() {
super("tips", { keyPath: "tipId" });
super("tips", { keyPath: "walletTipId" });
}
}

View File

@ -186,6 +186,7 @@ export interface ProposalOperationErrorNotification {
export interface TipOperationErrorNotification {
type: NotificationType.TipOperationError;
error: TalerErrorDetails;
}
export interface WithdrawOperationErrorNotification {

View File

@ -773,17 +773,11 @@ export class WithdrawOperationStatusResponse {
* Response from the merchant.
*/
export class TipPickupGetResponse {
extra: any;
amount: string;
amount_left: string;
tip_amount: string;
exchange_url: string;
stamp_expire: Timestamp;
stamp_created: Timestamp;
expiration: Timestamp;
}
export class WithdrawResponse {
@ -1261,12 +1255,9 @@ export const codecForWithdrawOperationStatusResponse = (): Codec<
export const codecForTipPickupGetResponse = (): Codec<TipPickupGetResponse> =>
buildCodecForObject<TipPickupGetResponse>()
.property("extra", codecForAny())
.property("amount", codecForString())
.property("amount_left", codecForString())
.property("tip_amount", codecForString())
.property("exchange_url", codecForString())
.property("stamp_expire", codecForTimestamp)
.property("stamp_created", codecForTimestamp)
.property("expiration", codecForTimestamp)
.build("TipPickupGetResponse");
export const codecForRecoupConfirmation = (): Codec<RecoupConfirmation> =>

View File

@ -38,7 +38,7 @@ import {
ExchangeWireInfo,
DenominationSelectionInfo,
} from "./dbTypes";
import { Timestamp } from "../util/time";
import { Timestamp, codecForTimestamp } from "../util/time";
import {
buildCodecForObject,
codecForString,
@ -348,23 +348,33 @@ export class ReturnCoinsRequest {
static checked: (obj: any) => ReturnCoinsRequest;
}
/**
* Status of processing a tip.
*/
export interface TipStatus {
export interface PrepareTipResult {
/**
* Unique ID for the tip assigned by the wallet.
* Typically different from the merchant-generated tip ID.
*/
walletTipId: string;
/**
* Has the tip already been accepted?
*/
accepted: boolean;
amount: AmountJson;
amountLeft: AmountJson;
nextUrl: string;
exchangeUrl: string;
tipId: string;
merchantTipId: string;
merchantOrigin: string;
amount: AmountString;
totalFees: AmountString;
exchangeBaseUrl: string;
expirationTimestamp: Timestamp;
timestamp: Timestamp;
totalFees: AmountJson;
}
export const codecForPrepareTipResult = (): Codec<PrepareTipResult> =>
buildCodecForObject<PrepareTipResult>()
.property("accepted", codecForBoolean())
.property("amount", codecForAmountString())
.property("totalFees", codecForAmountString())
.property("exchangeBaseUrl", codecForString())
.property("expirationTimestamp", codecForTimestamp)
.property("walletTipId", codecForString())
.build("PrepareTipResult");
export interface BenchmarkResult {
time: { [s: string]: number };
repetitions: number;
@ -903,3 +913,21 @@ export const codecForForceRefreshRequest = (): Codec<ForceRefreshRequest> =>
buildCodecForObject<ForceRefreshRequest>()
.property("coinPubList", codecForList(codecForString()))
.build("ForceRefreshRequest");
export interface PrepareTipRequest {
talerTipUri: string;
}
export const codecForPrepareTipRequest = (): Codec<PrepareTipRequest> =>
buildCodecForObject<PrepareTipRequest>()
.property("talerTipUri", codecForString())
.build("PrepareTipRequest");
export interface AcceptTipRequest {
walletTipId: string;
}
export const codecForAcceptTipRequest = (): Codec<AcceptTipRequest> =>
buildCodecForObject<AcceptTipRequest>()
.property("walletTipId", codecForString())
.build("AcceptTipRequest");

View File

@ -59,7 +59,6 @@ import {
ConfirmPayResult,
ReturnCoinsRequest,
SenderWireInfos,
TipStatus,
PreparePayResult,
AcceptWithdrawalResponse,
PurchaseDetails,
@ -93,6 +92,9 @@ import {
codecForSetCoinSuspendedRequest,
codecForForceExchangeUpdateRequest,
codecForForceRefreshRequest,
PrepareTipResult,
codecForPrepareTipRequest,
codecForAcceptTipRequest,
} from "./types/walletTypes";
import { Logger } from "./util/logging";
@ -121,7 +123,7 @@ import {
import { processWithdrawGroup } from "./operations/withdraw";
import { getPendingOperations } from "./operations/pending";
import { getBalances } from "./operations/balance";
import { acceptTip, getTipStatus, processTip } from "./operations/tip";
import { acceptTip, prepareTip, processTip } from "./operations/tip";
import { TimerGroup } from "./util/timer";
import { AsyncCondition } from "./util/promiseUtils";
import { AsyncOpMemoSingle } from "./util/asyncMemo";
@ -769,8 +771,8 @@ export class Wallet {
}
}
async getTipStatus(talerTipUri: string): Promise<TipStatus> {
return getTipStatus(this.ws, talerTipUri);
async prepareTip(talerTipUri: string): Promise<PrepareTipResult> {
return prepareTip(this.ws, talerTipUri);
}
async abortFailedPayment(contractTermsHash: string): Promise<void> {
@ -1096,6 +1098,15 @@ export class Wallet {
refreshGroupId,
};
}
case "prepareTip": {
const req = codecForPrepareTipRequest().decode(payload);
return await this.prepareTip(req.talerTipUri);
}
case "acceptTip": {
const req = codecForAcceptTipRequest().decode(payload);
await this.acceptTip(req.walletTipId);
return {};
}
}
throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,

View File

@ -22,12 +22,8 @@
* Imports.
*/
import {
AmountJson,
ConfirmPayResult,
BalancesResponse,
PurchaseDetails,
TipStatus,
BenchmarkResult,
PreparePayResult,
AcceptWithdrawalResponse,
WalletDiagnostics,