diff --git a/packages/taler-integrationtests/package.json b/packages/taler-integrationtests/package.json index 713852370..ba2f112ef 100644 --- a/packages/taler-integrationtests/package.json +++ b/packages/taler-integrationtests/package.json @@ -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 ", "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" } } diff --git a/packages/taler-integrationtests/scenario b/packages/taler-integrationtests/scenario index a0050258e..9bef68ffa 100755 --- a/packages/taler-integrationtests/scenario +++ b/packages/taler-integrationtests/scenario @@ -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}' diff --git a/packages/taler-integrationtests/src/harness.ts b/packages/taler-integrationtests/src/harness.ts index 027869d15..2507d12f7 100644 --- a/packages/taler-integrationtests/src/harness.ts +++ b/packages/taler-integrationtests/src/harness.ts @@ -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 { +export async function delayMs(ms: number): Promise { 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, diff --git a/packages/taler-integrationtests/src/test-refund-incremental.ts b/packages/taler-integrationtests/src/test-refund-incremental.ts new file mode 100644 index 000000000..29685dd3e --- /dev/null +++ b/packages/taler-integrationtests/src/test-refund-incremental.ts @@ -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 + */ + +/** + * 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(); +}); diff --git a/packages/taler-integrationtests/src/test-refund.ts b/packages/taler-integrationtests/src/test-refund.ts new file mode 100644 index 000000000..c2f152f53 --- /dev/null +++ b/packages/taler-integrationtests/src/test-refund.ts @@ -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 + */ + +/** + * 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(); +}); diff --git a/packages/taler-integrationtests/testrunner b/packages/taler-integrationtests/testrunner index 6476d73fa..03cb15b3c 100755 --- a/packages/taler-integrationtests/testrunner +++ b/packages/taler-integrationtests/testrunner @@ -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}' diff --git a/packages/taler-integrationtests/tsconfig.json b/packages/taler-integrationtests/tsconfig.json index 9fa8001a8..2fe0853d4 100644 --- a/packages/taler-integrationtests/tsconfig.json +++ b/packages/taler-integrationtests/tsconfig.json @@ -24,9 +24,6 @@ "typeRoots": ["./node_modules/@types"] }, "references": [ - { - "path": "../idb-bridge/", - }, { "path": "../taler-wallet-core" } diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts index 9792d2268..2b6ee97ae 100644 --- a/packages/taler-wallet-core/src/operations/refund.ts +++ b/packages/taler-wallet-core/src/operations/refund.ts @@ -203,7 +203,7 @@ async function acceptRefunds( refunds: MerchantCoinRefundStatus[], reason: RefundReason, ): Promise { - 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 { - 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, diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 2d66b5e9d..8de204d49 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -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(); + + 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) { diff --git a/packages/taler-wallet-core/src/types/transactions.ts b/packages/taler-wallet-core/src/types/transactions.ts index de378f51a..fe5580f85 100644 --- a/packages/taler-wallet-core/src/types/transactions.ts +++ b/packages/taler-wallet-core/src/types/transactions.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8ff6bb22..16143c47e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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