simplify refunds a bit, show in transaction history, add integration tests

This commit is contained in:
Florian Dold 2020-08-10 16:48:38 +05:30
parent 5f8714091a
commit 66d76a3591
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
11 changed files with 373 additions and 155 deletions

View File

@ -4,40 +4,19 @@
"description": "Integration tests and fault injection for GNU Taler components",
"main": "index.js",
"scripts": {
"compile": "tsc",
"test": "tsc && ava"
"compile": "tsc -b"
},
"author": "Florian Dold <dold@taler.net>",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@ava/typescript": "^1.1.1",
"ava": "^3.11.1",
"esm": "^3.2.25",
"source-map-support": "^0.5.19",
"ts-node": "^8.10.2"
"ts-node": "^8.10.2",
"typescript": "^3.9.7"
},
"dependencies": {
"axios": "^0.19.2",
"taler-wallet-core": "workspace:*",
"tslib": "^2.0.0",
"typescript": "^3.9.7"
},
"ava": {
"require": [
"esm"
],
"files": [
"src/**/test-*"
],
"typescript": {
"extensions": [
"js",
"ts",
"tsx"
],
"rewritePaths": {
"src/": "lib/"
}
}
"tslib": "^2.0.0"
}
}

View File

@ -17,7 +17,7 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
cd $DIR
./node_modules/.bin/tsc
./node_modules/.bin/tsc -b
export ESM_OPTIONS='{"sourceMap": true}'

View File

@ -50,7 +50,7 @@ import { EddsaKeyPair } from "taler-wallet-core/lib/crypto/talerCrypto";
const exec = util.promisify(require("child_process").exec);
async function delay(ms: number): Promise<void> {
export async function delayMs(ms: number): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(), ms);
});
@ -410,7 +410,7 @@ async function pingProc(
return;
} catch (e) {
console.log(`service ${serviceName} not ready:`, e.toString());
await delay(1000);
await delayMs(1000);
}
if (!proc || proc.proc.exitCode !== null) {
throw Error(`service process ${serviceName} stopped unexpectedly`);
@ -951,16 +951,41 @@ export class MerchantService {
}
async queryPrivateOrderStatus(instanceName: string, orderId: string) {
let url;
if (instanceName === "default") {
url = `http://localhost:${this.merchantConfig.httpPort}/private/orders/${orderId}`;
} else {
url = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/private/orders/${orderId}`;
}
const resp = await axios.get(url);
const reqUrl = new URL(
`private/orders/${orderId}`,
this.makeInstanceBaseUrl(instanceName),
);
const resp = await axios.get(reqUrl.href);
return codecForMerchantOrderPrivateStatusResponse().decode(resp.data);
}
makeInstanceBaseUrl(instanceName: string): string {
if (instanceName === "default") {
return `http://localhost:${this.merchantConfig.httpPort}/`;
} else {
return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`;
}
}
async giveRefund(r: {
instance: string;
orderId: string;
amount: string;
justification: string;
}): Promise<{ talerRefundUri: string }> {
const reqUrl = new URL(
`private/orders/${r.orderId}/refund`,
this.makeInstanceBaseUrl(r.instance),
);
const resp = await axios.post(reqUrl.href, {
refund: r.amount,
reason: r.justification,
});
return {
talerRefundUri: resp.data.taler_refund_uri,
}
}
async createOrder(
instanceName: string,
req: PostOrderRequest,

View File

@ -0,0 +1,126 @@
/*
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, delayMs } 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,
} = await createSimpleTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
// Set up order.
const orderResp = await merchant.createOrder("default", {
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_url: "taler://fulfillment-success/thx",
},
});
let orderStatus = await merchant.queryPrivateOrderStatus(
"default",
orderResp.order_id,
);
t.assertTrue(orderStatus.order_status === "unpaid")
// Make wallet pay for the order
const r1 = await wallet.apiRequest("preparePay", {
talerPayUri: orderStatus.taler_pay_uri,
});
t.assertTrue(r1.type === "response");
const r2 = await wallet.apiRequest("confirmPay", {
// FIXME: should be validated, don't cast!
proposalId: (r1.result as any).proposalId,
});
t.assertTrue(r2.type === "response");
// Check if payment was successful.
orderStatus = await merchant.queryPrivateOrderStatus(
"default",
orderResp.order_id,
);
t.assertTrue(orderStatus.order_status === "paid");
let ref = await merchant.giveRefund({
amount: "TESTKUDOS:2.5",
instance: "default",
justification: "foo",
orderId: orderResp.order_id,
});
console.log("first refund increase response", ref);
// Wait at least a second, because otherwise the increased
// refund will be grouped with the previous one.
await delayMs(1.2);
ref = await merchant.giveRefund({
amount: "TESTKUDOS:5",
instance: "default",
justification: "bar",
orderId: orderResp.order_id,
});
console.log("second refund increase response", ref);
let r = await wallet.apiRequest("applyRefund", {
talerRefundUri: ref.talerRefundUri,
});
console.log(r);
orderStatus = await merchant.queryPrivateOrderStatus(
"default",
orderResp.order_id,
);
t.assertTrue(orderStatus.order_status === "paid");
t.assertAmountEquals(orderStatus.refund_amount, "TESTKUDOS:5");
console.log(JSON.stringify(orderStatus, undefined, 2));
await wallet.runUntilDone();
r = await wallet.apiRequest("getBalances", {});
console.log(JSON.stringify(r, undefined, 2));
r = await wallet.apiRequest("getTransactions", {});
console.log(JSON.stringify(r, undefined, 2));
await t.shutdown();
});

View File

@ -0,0 +1,102 @@
/*
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 } 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,
} = await createSimpleTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
// Set up order.
const orderResp = await merchant.createOrder("default", {
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_url: "taler://fulfillment-success/thx",
},
});
let orderStatus = await merchant.queryPrivateOrderStatus(
"default",
orderResp.order_id,
);
t.assertTrue(orderStatus.order_status === "unpaid")
// Make wallet pay for the order
const r1 = await wallet.apiRequest("preparePay", {
talerPayUri: orderStatus.taler_pay_uri,
});
t.assertTrue(r1.type === "response");
const r2 = await wallet.apiRequest("confirmPay", {
// FIXME: should be validated, don't cast!
proposalId: (r1.result as any).proposalId,
});
t.assertTrue(r2.type === "response");
// Check if payment was successful.
orderStatus = await merchant.queryPrivateOrderStatus(
"default",
orderResp.order_id,
);
t.assertTrue(orderStatus.order_status === "paid");
const ref = await merchant.giveRefund({
amount: "TESTKUDOS:5",
instance: "default",
justification: "foo",
orderId: orderResp.order_id,
});
console.log(ref);
let r = await wallet.apiRequest("applyRefund", {
talerRefundUri: ref.talerRefundUri,
});
console.log(r);
await wallet.runUntilDone();
r = await wallet.apiRequest("getBalances", {});
console.log(JSON.stringify(r, undefined, 2));
r = await wallet.apiRequest("getTransactions", {});
console.log(JSON.stringify(r, undefined, 2));
await t.shutdown();
});

View File

@ -17,7 +17,7 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
cd $DIR
./node_modules/.bin/tsc
./node_modules/.bin/tsc -b
export ESM_OPTIONS='{"sourceMap": true}'

View File

@ -24,9 +24,6 @@
"typeRoots": ["./node_modules/@types"]
},
"references": [
{
"path": "../idb-bridge/",
},
{
"path": "../taler-wallet-core"
}

View File

@ -203,7 +203,7 @@ async function acceptRefunds(
refunds: MerchantCoinRefundStatus[],
reason: RefundReason,
): Promise<void> {
console.log("handling refunds", refunds);
logger.trace("handling refunds", refunds);
const now = getTimestampNow();
await ws.db.runWithWriteTransaction(
@ -302,37 +302,6 @@ async function acceptRefunds(
});
}
async function startRefundQuery(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
const success = await ws.db.runWithWriteTransaction(
[Stores.purchases],
async (tx) => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
logger.error("no purchase found for refund URL");
return false;
}
p.refundStatusRequested = true;
p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo();
await tx.put(Stores.purchases, p);
return true;
},
);
if (!success) {
return;
}
ws.notify({
type: NotificationType.RefundStarted,
});
await processPurchaseQueryRefund(ws, proposalId);
}
/**
* Accept a refund, return the contract hash for the contract
* that was involved in the refund.
@ -360,8 +329,31 @@ export async function applyRefund(
);
}
const proposalId = purchase.proposalId;
logger.info("processing purchase for refund");
await startRefundQuery(ws, purchase.proposalId);
const success = await ws.db.runWithWriteTransaction(
[Stores.purchases],
async (tx) => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
logger.error("no purchase found for refund URL");
return false;
}
p.refundStatusRequested = true;
p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo();
await tx.put(Stores.purchases, p);
return true;
},
);
if (success) {
ws.notify({
type: NotificationType.RefundStarted,
});
await processPurchaseQueryRefund(ws, proposalId);
}
return {
contractTermsHash: purchase.contractData.contractTermsHash,
@ -422,7 +414,7 @@ async function processPurchaseQueryRefundImpl(
const request = await ws.http.get(requestUrl.href);
console.log("got json", JSON.stringify(await request.json(), undefined, 2));
logger.trace("got json", JSON.stringify(await request.json(), undefined, 2));
const refundResponse = await readSuccessResponseJsonOrThrow(
request,

View File

@ -18,7 +18,12 @@
* Imports.
*/
import { InternalWalletState } from "./state";
import { Stores, WithdrawalSourceType } from "../types/dbTypes";
import {
Stores,
WithdrawalSourceType,
WalletRefundItem,
RefundState,
} from "../types/dbTypes";
import { Amounts, AmountJson } from "../util/amounts";
import { timestampCmp } from "../util/time";
import {
@ -29,8 +34,10 @@ import {
PaymentStatus,
WithdrawalType,
WithdrawalDetails,
PaymentShortInfo,
} from "../types/transactions";
import { getFundingPaytoUris } from "./reserves";
import { ResultLevel } from "idb-bridge";
/**
* Create an event ID from the type and the primary key for the event.
@ -224,6 +231,18 @@ export async function getTransactions(
if (!proposal) {
return;
}
const info: PaymentShortInfo = {
fulfillmentUrl: pr.contractData.fulfillmentUrl,
merchant: pr.contractData.merchant,
orderId: pr.contractData.orderId,
products: pr.contractData.products,
summary: pr.contractData.summary,
summary_i18n: pr.contractData.summaryI18n,
};
const paymentTransactionId = makeEventId(
TransactionType.Payment,
pr.proposalId,
);
transactions.push({
type: TransactionType.Payment,
amountRaw: Amounts.stringify(pr.contractData.amount),
@ -233,15 +252,62 @@ export async function getTransactions(
: PaymentStatus.Accepted,
pending: !pr.timestampFirstSuccessfulPay,
timestamp: pr.timestampAccept,
transactionId: makeEventId(TransactionType.Payment, pr.proposalId),
info: {
fulfillmentUrl: pr.contractData.fulfillmentUrl,
merchant: pr.contractData.merchant,
orderId: pr.contractData.orderId,
products: pr.contractData.products,
summary: pr.contractData.summary,
summary_i18n: pr.contractData.summaryI18n,
},
transactionId: paymentTransactionId,
info: info,
});
const refundGroupKeys = new Set<string>();
for (const rk of Object.keys(pr.refunds)) {
const refund = pr.refunds[rk];
const groupKey = `${refund.executionTime.t_ms}`;
refundGroupKeys.add(groupKey);
}
refundGroupKeys.forEach((groupKey: string) => {
const refundTransactionId = makeEventId(
TransactionType.Payment,
pr.proposalId,
groupKey,
);
let r0: WalletRefundItem | undefined;
let amountEffective = Amounts.getZero(
pr.contractData.amount.currency,
);
let amountRaw = Amounts.getZero(pr.contractData.amount.currency);
for (const rk of Object.keys(pr.refunds)) {
const refund = pr.refunds[rk];
if (!r0) {
r0 = refund;
}
if (refund.type === RefundState.Applied) {
amountEffective = Amounts.add(
amountEffective,
refund.refundAmount,
).amount;
amountRaw = Amounts.add(
amountRaw,
Amounts.sub(
refund.refundAmount,
refund.refundFee,
refund.totalRefreshCostBound,
).amount,
).amount;
}
}
if (!r0) {
throw Error("invariant violated");
}
transactions.push({
type: TransactionType.Refund,
info,
refundedTransactionId: paymentTransactionId,
transactionId: refundTransactionId,
timestamp: r0.executionTime,
amountEffective: Amounts.stringify(amountEffective),
amountRaw: Amounts.stringify(amountRaw),
pending: false,
});
});
// for (const rg of pr.refundGroups) {

View File

@ -218,7 +218,7 @@ export interface TransactionPayment extends TransactionCommon {
amountEffective: AmountString;
}
interface PaymentShortInfo {
export interface PaymentShortInfo {
/**
* Order ID, uniquely identifies the order within a merchant instance
*/
@ -259,9 +259,6 @@ interface TransactionRefund extends TransactionCommon {
// Additional information about the refunded payment
info: PaymentShortInfo;
// Part of the refund that couldn't be applied because the refund permissions were expired
amountInvalid: AmountString;
// Amount that has been refunded by the merchant
amountRaw: AmountString;

View File

@ -36,16 +36,12 @@ importers:
axios: 0.19.2
taler-wallet-core: 'link:../taler-wallet-core'
tslib: 2.0.0
typescript: 3.9.7
devDependencies:
'@ava/typescript': 1.1.1
ava: 3.11.1
esm: 3.2.25
source-map-support: 0.5.19
ts-node: 8.10.2_typescript@3.9.7
typescript: 3.9.7
specifiers:
'@ava/typescript': ^1.1.1
ava: ^3.11.1
axios: ^0.19.2
esm: ^3.2.25
source-map-support: ^0.5.19
@ -1123,69 +1119,6 @@ packages:
hasBin: true
resolution:
integrity: sha512-y5U8BGeSRjs/OypsC4CJxr+L1KtLKU5kUyHr5hcghXn7HNr2f4LE/4gvl0Q5lNkLX1obdRW1oODphNdU/glwmA==
/ava/3.11.1:
dependencies:
'@concordance/react': 2.0.0
acorn: 7.3.1
acorn-walk: 7.2.0
ansi-styles: 4.2.1
arrgv: 1.0.2
arrify: 2.0.1
callsites: 3.1.0
chalk: 4.1.0
chokidar: 3.4.1
chunkd: 2.0.1
ci-info: 2.0.0
ci-parallel-vars: 1.0.1
clean-yaml-object: 0.1.0
cli-cursor: 3.1.0
cli-truncate: 2.1.0
code-excerpt: 3.0.0
common-path-prefix: 3.0.0
concordance: 5.0.0
convert-source-map: 1.7.0
currently-unhandled: 0.4.1
debug: 4.1.1
del: 5.1.0
emittery: 0.7.1
equal-length: 1.0.1
figures: 3.2.0
globby: 11.0.1
ignore-by-default: 2.0.0
import-local: 3.0.2
indent-string: 4.0.0
is-error: 2.2.2
is-plain-object: 4.1.1
is-promise: 4.0.0
lodash: 4.17.19
matcher: 3.0.0
md5-hex: 3.0.1
mem: 6.1.0
ms: 2.1.2
ora: 4.0.5
p-map: 4.0.0
picomatch: 2.2.2
pkg-conf: 3.1.0
plur: 4.0.0
pretty-ms: 7.0.0
read-pkg: 5.2.0
resolve-cwd: 3.0.0
slash: 3.0.0
source-map-support: 0.5.19
stack-utils: 2.0.2
strip-ansi: 6.0.0
supertap: 1.0.0
temp-dir: 2.0.0
trim-off-newlines: 1.0.1
update-notifier: 4.1.0
write-file-atomic: 3.0.3
yargs: 15.4.1
dev: true
engines:
node: '>=10.18.0 <11 || >=12.14.0 <12.17.0 || >=12.17.0 <13 || >=14.0.0'
hasBin: true
resolution:
integrity: sha512-yGPD0msa5Qronw7GHDNlLaB7oU5zryYtXeuvny40YV6TMskSghqK7Ky3NisM/sr+aqI3DY7sfmORx8dIWQgMoQ==
/axe-core/3.5.5:
dev: true
engines:
@ -4757,6 +4690,7 @@ packages:
resolution:
integrity: sha512-/OyrHCJ8jtzu+QZ+771YaxQ9s4g5Z3XsQE3Ma7q+BL392xxBn4UMvvCdVnqKC2T/dz03/VXSLVKOP3lHmDdc/w==
/typescript/3.9.7:
dev: true
engines:
node: '>=4.2.0'
hasBin: true