From 21c176a69ee04c4d59baedb79017f6c42ece22d6 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Fri, 25 Aug 2017 18:08:37 +0200 Subject: add rudimentary error reporting in a new tab --- src/logging.ts | 45 +++++++++++++++++++++++++++++++-- src/webex/messages.ts | 8 ++++++ src/webex/pages/error.tsx | 63 ++++++++++++++++++++++++++++++++++------------- src/webex/wxApi.ts | 8 ++++++ src/webex/wxBackend.ts | 9 +++++++ 5 files changed, 114 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/logging.ts b/src/logging.ts index a589c8091..2c559e8d9 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -208,6 +208,44 @@ export async function recordException(msg: string, e: any): Promise { return record("error", e.toString(), stack, frame.file, frame.line, frame.column); } + +/** + * Cache for reports. Also used when something is so broken that we can't even + * access the database. + */ +const reportCache: { [reportId: string]: any } = {}; + + +/** + * Get a UUID that does not use cryptographically secure randomness. + * Formatted as RFC4122 version 4 UUID. + */ +function getInsecureUuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + + +/** + * Store a report and return a unique identifier to retrieve it later. + */ +export async function storeReport(report: any): Promise { + const uid = getInsecureUuid(); + reportCache[uid] = report; + return uid; +} + + +/** + * Retrieve a report by its unique identifier. + */ +export async function getReport(reportUid: string): Promise { + return reportCache[reportUid]; +} + + /** * Record a log entry in the database. */ @@ -218,6 +256,8 @@ export async function record(level: Level, line?: number, col?: number): Promise { if (typeof indexedDB === "undefined") { + console.log("can't access DB for logging in this context"); + console.log("log was", { level, msg, detail, source, line, col }); return; } @@ -257,7 +297,7 @@ export async function record(level: Level, } } -const loggingDbVersion = 1; +const loggingDbVersion = 2; const logsStore: Store = new Store("logs"); @@ -283,7 +323,8 @@ export function openLoggingDb(): Promise { console.error(e); } } - resDb.createObjectStore("logs", {keyPath: "id", autoIncrement: true}); + resDb.createObjectStore("logs", { keyPath: "id", autoIncrement: true }); + resDb.createObjectStore("reports", { keyPath: "uid", autoIncrement: false }); }; }); } diff --git a/src/webex/messages.ts b/src/webex/messages.ts index d7ecd06a1..397e8876e 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -176,6 +176,14 @@ export interface MessageMap { request: { }; response: void; }; + "log-and-display-error": { + request: any; + response: void; + }; + "get-report": { + request: { reportUid: string }; + response: void; + }; } /** diff --git a/src/webex/pages/error.tsx b/src/webex/pages/error.tsx index e86b6cf4c..3f3940d72 100644 --- a/src/webex/pages/error.tsx +++ b/src/webex/pages/error.tsx @@ -22,40 +22,69 @@ * @author Florian Dold */ + import * as React from "react"; import * as ReactDOM from "react-dom"; import URI = require("urijs"); +import * as wxApi from "../wxApi"; + interface ErrorProps { - message: string; + report: any; } class ErrorView extends React.Component { render(): JSX.Element { - return ( -
- An error occurred: {this.props.message} -
- ); + const report = this.props.report; + if (!report) { + return ( +
+

Error Report Not Found

+

This page is supposed to display an error reported by the GNU Taler wallet, + but the corresponding error report can't be found.

+

Maybe the error occured before the browser was restarted or the wallet was reloaded.

+
+ ); + } + switch (report.name) { + default: + return ( +
+

Unknown Error

+ The GNU Taler wallet reported an unknown error. Here are the details: +
+              {JSON.stringify(report, null, " ")}
+            
+
+ ); + } } } async function main() { - try { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); + const url = new URI(document.location.href); + const query: any = URI.parseQuery(url.query()); - const message: string = query.message || "unknown error"; + const container = document.getElementById("container"); + if (!container) { + console.error("fatal: can't mount component, countainer missing"); + return; + } - ReactDOM.render(, document.getElementById( - "container")!); + // report that we'll render, either looked up from the + // logging module or synthesized here for fixed/fatal errors + let report; - } catch (e) { - // TODO: provide more context information, maybe factor it out into a - // TODO:generic error reporting function or component. - document.body.innerText = `Fatal error: "${e.message}".`; - console.error(`got error "${e.message}"`, e); + const reportUid: string = query.reportUid; + if (!reportUid) { + report = { + name: "missing-error", + }; + } else { + report = await wxApi.getReport(reportUid); } + + ReactDOM.render(, container); } document.addEventListener("DOMContentLoaded", () => main()); diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index 1371e27e4..306406a1a 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -321,3 +321,11 @@ export function getSenderWireInfos(): Promise { export function returnCoins(args: { amount: AmountJson, exchange: string, senderWire: object }): Promise { return callBackend("return-coins", args); } + +export function logAndDisplayError(args: any): Promise { + return callBackend("log-and-display-error", args); +} + +export function getReport(reportUid: string): Promise { + return callBackend("get-report", { reportUid }); +} diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 974bcb3c2..353961ff0 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -303,6 +303,15 @@ function handleMessage(sender: MessageSender, } return resp; } + case "log-and-display-error": + logging.storeReport(detail).then((reportUid) => { + chrome.tabs.create({ + url: chrome.extension.getURL(`/src/webex/pages/error.html?reportUid=${reportUid}`), + }); + }); + return; + case "get-report": + return logging.getReport(detail.reportUid); default: // Exhaustiveness check. // See https://www.typescriptlang.org/docs/handbook/advanced-types.html -- cgit v1.2.3 From 8697efd2c8751717a3a3fcaf72feb7c49ebfec02 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 27 Aug 2017 03:56:19 +0200 Subject: implement refunds --- node_modules/.bin/tsc | 2 +- node_modules/.bin/tsserver | 2 +- node_modules/.bin/uglifyjs | 2 +- .../handlebars/node_modules/uglify-js/bin/uglifyjs | 0 node_modules/nyc/node_modules/md5-hex/package.json | 97 ++--------- .../nyc/node_modules/resolve-from/package.json | 89 ++-------- package.json | 3 +- src/crypto/cryptoWorker.ts | 2 +- src/query.ts | 9 +- src/types.ts | 32 +++- src/wallet.ts | 155 +++++++++++++---- src/webex/messages.ts | 8 + src/webex/notify.ts | 186 ++++++++------------- src/webex/pages/refund.html | 18 ++ src/webex/pages/refund.tsx | 138 +++++++++++++++ src/webex/renderHtml.tsx | 2 + src/webex/wxApi.ts | 16 ++ src/webex/wxBackend.ts | 22 ++- webpack.config.js | 1 + yarn.lock | 15 +- 20 files changed, 480 insertions(+), 319 deletions(-) mode change 100755 => 100644 node_modules/handlebars/node_modules/uglify-js/bin/uglifyjs create mode 100644 src/webex/pages/refund.html create mode 100644 src/webex/pages/refund.tsx (limited to 'src') diff --git a/node_modules/.bin/tsc b/node_modules/.bin/tsc index abecd8127..0863208a6 120000 --- a/node_modules/.bin/tsc +++ b/node_modules/.bin/tsc @@ -1 +1 @@ -../typedoc/node_modules/typescript/bin/tsc \ No newline at end of file +../typescript/bin/tsc \ No newline at end of file diff --git a/node_modules/.bin/tsserver b/node_modules/.bin/tsserver index 1d314276d..f8f8f1a0c 120000 --- a/node_modules/.bin/tsserver +++ b/node_modules/.bin/tsserver @@ -1 +1 @@ -../typedoc/node_modules/typescript/bin/tsserver \ No newline at end of file +../typescript/bin/tsserver \ No newline at end of file diff --git a/node_modules/.bin/uglifyjs b/node_modules/.bin/uglifyjs index 6b320d5a3..fef3468b6 120000 --- a/node_modules/.bin/uglifyjs +++ b/node_modules/.bin/uglifyjs @@ -1 +1 @@ -../handlebars/node_modules/uglify-js/bin/uglifyjs \ No newline at end of file +../uglify-js/bin/uglifyjs \ No newline at end of file diff --git a/node_modules/handlebars/node_modules/uglify-js/bin/uglifyjs b/node_modules/handlebars/node_modules/uglify-js/bin/uglifyjs old mode 100755 new mode 100644 diff --git a/node_modules/nyc/node_modules/md5-hex/package.json b/node_modules/nyc/node_modules/md5-hex/package.json index 02d54328a..9dc26627f 100644 --- a/node_modules/nyc/node_modules/md5-hex/package.json +++ b/node_modules/nyc/node_modules/md5-hex/package.json @@ -1,82 +1,25 @@ { - "_args": [ - [ - { - "raw": "md5-hex@^1.2.0", - "scope": null, - "escapedName": "md5-hex", - "name": "md5-hex", - "rawSpec": "^1.2.0", - "spec": ">=1.2.0 <2.0.0", - "type": "range" - }, - "/Users/benjamincoe/bcoe/nyc" - ] - ], - "_from": "md5-hex@>=1.2.0 <2.0.0", - "_id": "md5-hex@1.3.0", - "_inCache": true, - "_location": "/md5-hex", - "_nodeVersion": "4.4.2", - "_npmOperationalInternal": { - "host": "packages-12-west.internal.npmjs.com", - "tmp": "tmp/md5-hex-1.3.0.tgz_1460471196734_0.9732175024691969" - }, - "_npmUser": { - "name": "sindresorhus", - "email": "sindresorhus@gmail.com" - }, - "_npmVersion": "2.15.0", - "_phantomChildren": {}, - "_requested": { - "raw": "md5-hex@^1.2.0", - "scope": null, - "escapedName": "md5-hex", - "name": "md5-hex", - "rawSpec": "^1.2.0", - "spec": ">=1.2.0 <2.0.0", - "type": "range" - }, - "_requiredBy": [ - "/", - "/caching-transform" - ], - "_resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-1.3.0.tgz", - "_shasum": "d2c4afe983c4370662179b8cad145219135046c4", - "_shrinkwrap": null, - "_spec": "md5-hex@^1.2.0", - "_where": "/Users/benjamincoe/bcoe/nyc", + "name": "md5-hex", + "version": "1.3.0", + "description": "Create a MD5 hash with hex encoding", + "license": "MIT", + "repository": "sindresorhus/md5-hex", "author": { "name": "Sindre Sorhus", "email": "sindresorhus@gmail.com", "url": "sindresorhus.com" }, "browser": "browser.js", - "bugs": { - "url": "https://github.com/sindresorhus/md5-hex/issues" - }, - "dependencies": { - "md5-o-matic": "^0.1.1" - }, - "description": "Create a MD5 hash with hex encoding", - "devDependencies": { - "ava": "*", - "xo": "*" - }, - "directories": {}, - "dist": { - "shasum": "d2c4afe983c4370662179b8cad145219135046c4", - "tarball": "https://registry.npmjs.org/md5-hex/-/md5-hex-1.3.0.tgz" - }, "engines": { "node": ">=0.10.0" }, + "scripts": { + "test": "xo && ava" + }, "files": [ "index.js", "browser.js" ], - "gitHead": "273d9c659a29e4cd53512f526282afd5ac1c1413", - "homepage": "https://github.com/sindresorhus/md5-hex#readme", "keywords": [ "hash", "crypto", @@ -86,23 +29,11 @@ "browser", "browserify" ], - "license": "MIT", - "maintainers": [ - { - "name": "sindresorhus", - "email": "sindresorhus@gmail.com" - } - ], - "name": "md5-hex", - "optionalDependencies": {}, - "readme": "# md5-hex [![Build Status](https://travis-ci.org/sindresorhus/md5-hex.svg?branch=master)](https://travis-ci.org/sindresorhus/md5-hex)\n\n> Create a MD5 hash with hex encoding\n\n*Please don't use MD5 hashes for anything sensitive!*\n\nCheckout [`hasha`](https://github.com/sindresorhus/hasha) if you need something more flexible.\n\n\n## Install\n\n```\n$ npm install --save md5-hex\n```\n\n\n## Usage\n\n```js\nconst fs = require('fs');\nconst md5Hex = require('md5-hex');\nconst buffer = fs.readFileSync('unicorn.png');\n\nmd5Hex(buffer);\n//=> '1abcb33beeb811dca15f0ac3e47b88d9'\n```\n\n\n## API\n\n### md5Hex(input)\n\n#### input\n\nType: `buffer` `string` `array[string|buffer]`\n\nPrefer buffers as they're faster to hash, but strings can be useful for small things.\n\nPass an array instead of concatenating strings and/or buffers. The output is the same, but arrays do not incur the overhead of concatenation.\n\n\n## License\n\nMIT © [Sindre Sorhus](https://sindresorhus.com)\n", - "readmeFilename": "readme.md", - "repository": { - "type": "git", - "url": "git+https://github.com/sindresorhus/md5-hex.git" - }, - "scripts": { - "test": "xo && ava" + "dependencies": { + "md5-o-matic": "^0.1.1" }, - "version": "1.3.0" + "devDependencies": { + "ava": "*", + "xo": "*" + } } diff --git a/node_modules/nyc/node_modules/resolve-from/package.json b/node_modules/nyc/node_modules/resolve-from/package.json index edf933ac5..ee47da7c1 100644 --- a/node_modules/nyc/node_modules/resolve-from/package.json +++ b/node_modules/nyc/node_modules/resolve-from/package.json @@ -1,73 +1,23 @@ { - "_args": [ - [ - { - "raw": "resolve-from@^2.0.0", - "scope": null, - "escapedName": "resolve-from", - "name": "resolve-from", - "rawSpec": "^2.0.0", - "spec": ">=2.0.0 <3.0.0", - "type": "range" - }, - "/Users/benjamincoe/bcoe/nyc" - ] - ], - "_from": "resolve-from@>=2.0.0 <3.0.0", - "_id": "resolve-from@2.0.0", - "_inCache": true, - "_location": "/resolve-from", - "_nodeVersion": "4.2.1", - "_npmUser": { - "name": "sindresorhus", - "email": "sindresorhus@gmail.com" - }, - "_npmVersion": "2.14.7", - "_phantomChildren": {}, - "_requested": { - "raw": "resolve-from@^2.0.0", - "scope": null, - "escapedName": "resolve-from", - "name": "resolve-from", - "rawSpec": "^2.0.0", - "spec": ">=2.0.0 <3.0.0", - "type": "range" - }, - "_requiredBy": [ - "/" - ], - "_resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", - "_shasum": "9480ab20e94ffa1d9e80a804c7ea147611966b57", - "_shrinkwrap": null, - "_spec": "resolve-from@^2.0.0", - "_where": "/Users/benjamincoe/bcoe/nyc", + "name": "resolve-from", + "version": "2.0.0", + "description": "Resolve the path of a module like require.resolve() but from a given path", + "license": "MIT", + "repository": "sindresorhus/resolve-from", "author": { "name": "Sindre Sorhus", "email": "sindresorhus@gmail.com", "url": "sindresorhus.com" }, - "bugs": { - "url": "https://github.com/sindresorhus/resolve-from/issues" - }, - "dependencies": {}, - "description": "Resolve the path of a module like require.resolve() but from a given path", - "devDependencies": { - "ava": "*", - "xo": "*" - }, - "directories": {}, - "dist": { - "shasum": "9480ab20e94ffa1d9e80a804c7ea147611966b57", - "tarball": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz" - }, "engines": { "node": ">=0.10.0" }, + "scripts": { + "test": "xo && ava" + }, "files": [ "index.js" ], - "gitHead": "583e0f8df06e1bc4d1c96d8d4f2484c745f522c3", - "homepage": "https://github.com/sindresorhus/resolve-from#readme", "keywords": [ "require", "resolve", @@ -77,23 +27,8 @@ "like", "path" ], - "license": "MIT", - "maintainers": [ - { - "name": "sindresorhus", - "email": "sindresorhus@gmail.com" - } - ], - "name": "resolve-from", - "optionalDependencies": {}, - "readme": "# resolve-from [![Build Status](https://travis-ci.org/sindresorhus/resolve-from.svg?branch=master)](https://travis-ci.org/sindresorhus/resolve-from)\n\n> Resolve the path of a module like [`require.resolve()`](http://nodejs.org/api/globals.html#globals_require_resolve) but from a given path\n\nUnlike `require.resolve()` it returns `null` instead of throwing when the module can't be found.\n\n\n## Install\n\n```\n$ npm install --save resolve-from\n```\n\n\n## Usage\n\n```js\nconst resolveFrom = require('resolve-from');\n\n// there's a file at `./foo/bar.js`\n\nresolveFrom('foo', './bar');\n//=> '/Users/sindresorhus/dev/test/foo/bar.js'\n```\n\n\n## API\n\n### resolveFrom(fromDir, moduleId)\n\n#### fromDir\n\nType: `string`\n\nDirectory to resolve from.\n\n#### moduleId\n\nType: `string`\n\nWhat you would use in `require()`.\n\n\n## Tip\n\nCreate a partial using a bound function if you want to require from the same `fromDir` multiple times:\n\n```js\nconst resolveFromFoo = resolveFrom.bind(null, 'foo');\n\nresolveFromFoo('./bar');\nresolveFromFoo('./baz');\n```\n\n\n## License\n\nMIT © [Sindre Sorhus](http://sindresorhus.com)\n", - "readmeFilename": "readme.md", - "repository": { - "type": "git", - "url": "git+https://github.com/sindresorhus/resolve-from.git" - }, - "scripts": { - "test": "xo && ava" - }, - "version": "2.0.0" + "devDependencies": { + "ava": "*", + "xo": "*" + } } diff --git a/package.json b/package.json index b3ef137f0..03108f765 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ }, "dependencies": { "@types/react": "^16.0.2", - "@types/react-dom": "^15.5.2" + "@types/react-dom": "^15.5.2", + "axios": "^0.16.2" } } diff --git a/src/crypto/cryptoWorker.ts b/src/crypto/cryptoWorker.ts index b05d7d184..1db6e62d5 100644 --- a/src/crypto/cryptoWorker.ts +++ b/src/crypto/cryptoWorker.ts @@ -271,7 +271,7 @@ namespace RpcFunctions { const newAmount = new native.Amount(cd.coin.currentAmount); newAmount.sub(coinSpend); cd.coin.currentAmount = newAmount.toJson(); - cd.coin.status = CoinStatus.TransactionPending; + cd.coin.status = CoinStatus.PurchasePending; const d = new native.DepositRequestPS({ amount_with_fee: coinSpend.toNbo(), diff --git a/src/query.ts b/src/query.ts index dffff86eb..ee1ac2603 100644 --- a/src/query.ts +++ b/src/query.ts @@ -658,13 +658,13 @@ export class QueryRoot { /** * Get, modify and store an element inside a transaction. */ - mutate(store: Store, key: any, f: (v: T) => T): QueryRoot { + mutate(store: Store, key: any, f: (v: T|undefined) => T|undefined): QueryRoot { this.checkFinished(); const doPut = (tx: IDBTransaction) => { const reqGet = tx.objectStore(store.name).get(key); reqGet.onsuccess = () => { const r = reqGet.result; - let m: T; + let m: T|undefined; try { m = f(r); } catch (e) { @@ -674,8 +674,9 @@ export class QueryRoot { } throw e; } - - tx.objectStore(store.name).put(m); + if (m !== undefined && m !== null) { + tx.objectStore(store.name).put(m); + } }; }; this.scheduleFinish(); diff --git a/src/types.ts b/src/types.ts index 9031b19b7..aabf4ffc0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -798,9 +798,9 @@ export enum CoinStatus { */ Fresh, /** - * Currently planned to be sent to a merchant for a transaction. + * Currently planned to be sent to a merchant for a purchase. */ - TransactionPending, + PurchasePending, /** * Used for a completed transaction and now dirty. */ @@ -1662,3 +1662,31 @@ export class ReturnCoinsRequest { */ static checked: (obj: any) => ReturnCoinsRequest; } + + +export interface RefundPermission { + refund_amount: AmountJson; + refund_fee: AmountJson; + h_contract_terms: string; + coin_pub: string; + rtransaction_id: number; + merchant_pub: string; + merchant_sig: string; +} + + +export interface PurchaseRecord { + contractTermsHash: string; + contractTerms: ContractTerms; + payReq: PayReq; + merchantSig: string; + + /** + * The purchase isn't active anymore, it's either successfully paid or + * refunded/aborted. + */ + finished: boolean; + + refundsPending: { [refundSig: string]: RefundPermission }; + refundsDone: { [refundSig: string]: RefundPermission }; +} diff --git a/src/wallet.ts b/src/wallet.ts index 68d70b0bb..b892e2e4b 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -82,6 +82,8 @@ import { WalletBalanceEntry, WireFee, WireInfo, + RefundPermission, + PurchaseRecord, } from "./types"; import URI = require("urijs"); @@ -241,19 +243,6 @@ class WireDetailJson { } -interface TransactionRecord { - contractTermsHash: string; - contractTerms: ContractTerms; - payReq: PayReq; - merchantSig: string; - - /** - * The transaction isn't active anymore, it's either successfully paid - * or refunded/aborted. - */ - finished: boolean; -} - /** * Badge that shows activity for the wallet. @@ -516,13 +505,13 @@ export namespace Stores { } } - class TransactionsStore extends Store { + class PurchasesStore extends Store { constructor() { - super("transactions", {keyPath: "contractTermsHash"}); + super("purchases", {keyPath: "contractTermsHash"}); } - fulfillmentUrlIndex = new Index(this, "fulfillment_url", "contractTerms.fulfillment_url"); - orderIdIndex = new Index(this, "order_id", "contractTerms.order_id"); + fulfillmentUrlIndex = new Index(this, "fulfillment_url", "contractTerms.fulfillment_url"); + orderIdIndex = new Index(this, "order_id", "contractTerms.order_id"); } class DenominationsStore extends Store { @@ -568,7 +557,7 @@ export namespace Stores { export const proposals = new ProposalsStore(); export const refresh = new Store("refresh", {keyPath: "meltCoinPub"}); export const reserves = new Store("reserves", {keyPath: "reserve_pub"}); - export const transactions = new TransactionsStore(); + export const purchases = new PurchasesStore(); } /* tslint:enable:completed-docs */ @@ -909,12 +898,14 @@ export class Wallet { merchant_pub: proposal.contractTerms.merchant_pub, order_id: proposal.contractTerms.order_id, }; - const t: TransactionRecord = { + const t: PurchaseRecord = { contractTerms: proposal.contractTerms, contractTermsHash: proposal.contractTermsHash, finished: false, merchantSig: proposal.merchantSig, payReq, + refundsDone: {}, + refundsPending: {}, }; const historyEntry: HistoryRecord = { @@ -931,7 +922,7 @@ export class Wallet { }; await this.q() - .put(Stores.transactions, t) + .put(Stores.purchases, t) .put(Stores.history, historyEntry) .putAll(Stores.coins, payCoinInfo.map((pci) => pci.updatedCoin)) .finish(); @@ -972,9 +963,9 @@ export class Wallet { throw Error(`proposal with id ${proposalId} not found`); } - const transaction = await this.q().get(Stores.transactions, proposal.contractTermsHash); + const purchase = await this.q().get(Stores.purchases, proposal.contractTermsHash); - if (transaction) { + if (purchase) { // Already payed ... return "paid"; } @@ -1017,8 +1008,8 @@ export class Wallet { } // First check if we already payed for it. - const transaction = await this.q().get(Stores.transactions, proposal.contractTermsHash); - if (transaction) { + const purchase = await this.q().get(Stores.purchases, proposal.contractTermsHash); + if (purchase) { return "paid"; } @@ -1049,7 +1040,7 @@ export class Wallet { async queryPayment(url: string): Promise { console.log("query for payment", url); - const t = await this.q().getIndexed(Stores.transactions.fulfillmentUrlIndex, url); + const t = await this.q().getIndexed(Stores.purchases.fulfillmentUrlIndex, url); if (!t) { console.log("query for payment failed"); @@ -1890,7 +1881,7 @@ export class Wallet { return balance; } - function collectPayments(t: TransactionRecord, balance: WalletBalance) { + function collectPayments(t: PurchaseRecord, balance: WalletBalance) { if (t.finished) { return balance; } @@ -1934,7 +1925,7 @@ export class Wallet { .reduce(collectPendingWithdraw, balance); tx.iter(Stores.reserves) .reduce(collectPaybacks, balance); - tx.iter(Stores.transactions) + tx.iter(Stores.purchases) .reduce(collectPayments, balance); await tx.finish(); return balance; @@ -2282,7 +2273,7 @@ export class Wallet { async paymentSucceeded(contractTermsHash: string, merchantSig: string): Promise { const doPaymentSucceeded = async() => { - const t = await this.q().get(Stores.transactions, + const t = await this.q().get(Stores.purchases, contractTermsHash); if (!t) { console.error("contract not found"); @@ -2309,7 +2300,7 @@ export class Wallet { await this.q() .putAll(Stores.coins, modifiedCoins) - .put(Stores.transactions, t) + .put(Stores.purchases, t) .finish(); for (const c of t.payReq.coins) { this.refresh(c.coin_pub); @@ -2560,4 +2551,110 @@ export class Wallet { await this.q().put(Stores.coinsReturns, currentCrr); } } + + async acceptRefund(refundPermissions: RefundPermission[]): Promise { + if (!refundPermissions.length) { + console.warn("got empty refund list"); + return; + } + const hc = refundPermissions[0].h_contract_terms; + if (!hc) { + throw Error("h_contract_terms missing in refund permission"); + } + const m = refundPermissions[0].merchant_pub; + if (!hc) { + throw Error("merchant_pub missing in refund permission"); + } + for (const perm of refundPermissions) { + if (perm.h_contract_terms !== hc) { + throw Error("h_contract_terms different in refund permission"); + } + if (perm.merchant_pub !== m) { + throw Error("merchant_pub different in refund permission"); + } + } + + /** + * Add refund to purchase if not already added. + */ + function f(t: PurchaseRecord|undefined): PurchaseRecord|undefined { + if (!t) { + console.error("purchase not found, not adding refunds"); + return; + } + + for (const perm of refundPermissions) { + if (!t.refundsPending[perm.merchant_sig] && !t.refundsDone[perm.merchant_sig]) { + t.refundsPending[perm.merchant_sig] = perm; + } + } + return t; + } + + // Add the refund permissions to the purchase within a DB transaction + await this.q().mutate(Stores.purchases, hc, f).finish(); + this.notifier.notify(); + + // Start submitting it but don't wait for it here. + this.submitRefunds(hc); + } + + async submitRefunds(contractTermsHash: string): Promise { + const purchase = await this.q().get(Stores.purchases, contractTermsHash); + if (!purchase) { + console.error("not submitting refunds, contract terms not found:", contractTermsHash); + return; + } + const pendingKeys = Object.keys(purchase.refundsPending); + if (pendingKeys.length === 0) { + return; + } + for (const pk of pendingKeys) { + const perm = purchase.refundsPending[pk]; + console.log("sending refund permission", perm); + const reqUrl = (new URI("refund")).absoluteTo(purchase.payReq.exchange); + const resp = await this.http.postJson(reqUrl.href(), perm); + if (resp.status !== 200) { + console.error("refund failed", resp); + continue; + } + + // Transactionally mark successful refunds as done + const transformPurchase = (t: PurchaseRecord|undefined): PurchaseRecord|undefined => { + if (!t) { + console.warn("purchase not found, not updating refund"); + return; + } + if (t.refundsPending[pk]) { + t.refundsDone[pk] = t.refundsPending[pk]; + delete t.refundsPending[pk]; + } + return t; + }; + const transformCoin = (c: CoinRecord|undefined): CoinRecord|undefined => { + if (!c) { + console.warn("coin not found, can't apply refund"); + return; + } + c.status = CoinStatus.Dirty; + c.currentAmount = Amounts.add(c.currentAmount, perm.refund_amount).amount; + c.currentAmount = Amounts.sub(c.currentAmount, perm.refund_fee).amount; + + return c; + }; + + + await this.q() + .mutate(Stores.purchases, contractTermsHash, transformPurchase) + .mutate(Stores.coins, perm.coin_pub, transformCoin) + .finish(); + this.refresh(perm.coin_pub); + } + + this.notifier.notify(); + } + + async getPurchase(contractTermsHash: string): Promise { + return this.q().get(Stores.purchases, contractTermsHash); + } } diff --git a/src/webex/messages.ts b/src/webex/messages.ts index 397e8876e..7de28b9e9 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -184,6 +184,14 @@ export interface MessageMap { request: { reportUid: string }; response: void; }; + "accept-refund": { + request: any; + response: void; + }; + "get-purchase": { + request: any; + response: void; + } } /** diff --git a/src/webex/notify.ts b/src/webex/notify.ts index 51abdb0e0..da4657a96 100644 --- a/src/webex/notify.ts +++ b/src/webex/notify.ts @@ -30,6 +30,8 @@ import wxApi = require("./wxApi"); import { QueryPaymentResult } from "../types"; +import axios from 'axios'; + declare var cloneInto: any; let logVerbose: boolean = false; @@ -98,85 +100,38 @@ function setStyles(installed: boolean) { } -function handlePaymentResponse(maybeFoundResponse: QueryPaymentResult) { +async function handlePaymentResponse(maybeFoundResponse: QueryPaymentResult) { if (!maybeFoundResponse.found) { console.log("pay-failed", {hint: "payment not found in the wallet"}); return; } const walletResp = maybeFoundResponse; - /** - * Handle a failed payment. - * - * Try to notify the wallet first, before we show a potentially - * synchronous error message (such as an alert) or leave the page. - */ - async function handleFailedPayment(r: XMLHttpRequest) { - let timeoutHandle: number|null = null; - function err() { - // FIXME: proper error reporting! - console.log("pay-failed", {status: r.status, response: r.responseText}); - } - function onTimeout() { - timeoutHandle = null; - err(); - } - timeoutHandle = window.setTimeout(onTimeout, 200); - - await wxApi.paymentFailed(walletResp.contractTermsHash); - if (timeoutHandle !== null) { - clearTimeout(timeoutHandle); - timeoutHandle = null; - } - err(); - } logVerbose && console.log("handling taler-notify-payment: ", walletResp); - // Payment timeout in ms. - let timeout_ms = 1000; - // Current request. - let r: XMLHttpRequest|null; - let timeoutHandle: number|null = null; - function sendPay() { - r = new XMLHttpRequest(); - r.open("post", walletResp.contractTerms.pay_url); - r.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); - r.send(JSON.stringify(walletResp.payReq)); - r.onload = async () => { - if (!r) { - return; - } - switch (r.status) { - case 200: - const merchantResp = JSON.parse(r.responseText); - logVerbose && console.log("got success from pay_url"); - await wxApi.paymentSucceeded(walletResp.contractTermsHash, merchantResp.sig); - const nextUrl = walletResp.contractTerms.fulfillment_url; - logVerbose && console.log("taler-payment-succeeded done, going to", nextUrl); - window.location.href = nextUrl; - window.location.reload(true); - break; - default: - handleFailedPayment(r); - break; - } - r = null; - if (timeoutHandle !== null) { - clearTimeout(timeoutHandle!); - timeoutHandle = null; - } - }; - function retry() { - if (r) { - r.abort(); - r = null; - } - timeout_ms = Math.min(timeout_ms * 2, 10 * 1000); - logVerbose && console.log("sendPay timed out, retrying in ", timeout_ms, "ms"); - sendPay(); + let resp; + try { + const config = { + timeout: 5000, /* 5 seconds */ + headers: { "Content-Type": "application/json;charset=UTF-8" }, + validateStatus: (s: number) => s == 200, } - timeoutHandle = window.setTimeout(retry, timeout_ms); + resp = await axios.post(walletResp.contractTerms.pay_url, walletResp.payReq, config); + } catch (e) { + // Gives the user the option to retry / abort and refresh + wxApi.logAndDisplayError({ + name: "pay-post-failed", + message: e.message, + response: e.response, + }); + throw e; } - sendPay(); + const merchantResp = resp.data; + logVerbose && console.log("got success from pay_url"); + await wxApi.paymentSucceeded(walletResp.contractTermsHash, merchantResp.sig); + const nextUrl = walletResp.contractTerms.fulfillment_url; + logVerbose && console.log("taler-payment-succeeded done, going to", nextUrl); + window.location.href = nextUrl; + window.location.reload(true); } @@ -233,53 +188,24 @@ function init() { type HandlerFn = (detail: any, sendResponse: (msg: any) => void) => void; -function downloadContract(url: string, nonce: string): Promise { +async function downloadContract(url: string, nonce: string): Promise { const parsed_url = new URI(url); url = parsed_url.setQuery({nonce}).href(); - // FIXME: include and check nonce! - return new Promise((resolve, reject) => { - const contract_request = new XMLHttpRequest(); - console.log("downloading contract from '" + url + "'"); - contract_request.open("GET", url, true); - contract_request.onload = (e) => { - if (contract_request.readyState === 4) { - if (contract_request.status === 200) { - console.log("response text:", - contract_request.responseText); - const contract_wrapper = JSON.parse(contract_request.responseText); - if (!contract_wrapper) { - console.error("response text was invalid json"); - const detail = { - body: contract_request.responseText, - hint: "invalid json", - status: contract_request.status, - }; - reject(detail); - return; - } - resolve(contract_wrapper); - } else { - const detail = { - body: contract_request.responseText, - hint: "contract download failed", - status: contract_request.status, - }; - reject(detail); - return; - } - } - }; - contract_request.onerror = (e) => { - const detail = { - body: contract_request.responseText, - hint: "contract download failed", - status: contract_request.status, - }; - reject(detail); - return; - }; - contract_request.send(); - }); + console.log("downloading contract from '" + url + "'"); + let resp; + try { + resp = await axios.get(url, { validateStatus: (s) => s == 200 }); + } catch (e) { + wxApi.logAndDisplayError({ + name: "contract-download-failed", + message: e.message, + response: e.response, + sameTab: true, + }); + throw e; + } + console.log("got response", resp); + return resp.data; } async function processProposal(proposal: any) { @@ -328,8 +254,38 @@ async function processProposal(proposal: any) { document.location.replace(target); } + +/** + * Handle a payment request (coming either from an HTTP 402 or + * the JS wallet API). + */ function talerPay(msg: any): Promise { + // Use a promise directly instead of of an async + // function since some paths never resolve the promise. return new Promise(async(resolve, reject) => { + if (msg.refund_url) { + console.log("processing refund"); + let resp; + try { + const config = { + validateStatus: (s: number) => s == 200, + } + resp = await axios.get(msg.refund_url, config); + } catch (e) { + wxApi.logAndDisplayError({ + name: "refund-download-failed", + message: e.message, + response: e.response, + sameTab: true, + }); + throw e; + } + await wxApi.acceptRefund(resp.data); + const hc = resp.data.refund_permissions[0].h_contract_terms; + document.location.href = chrome.extension.getURL(`/src/webex/pages/refund.html?contractTermsHash=${hc}`); + return; + } + // current URL without fragment const url = new URI(document.location.href).fragment("").href(); const res = await wxApi.queryPayment(url); diff --git a/src/webex/pages/refund.html b/src/webex/pages/refund.html new file mode 100644 index 000000000..f97dc9d6c --- /dev/null +++ b/src/webex/pages/refund.html @@ -0,0 +1,18 @@ + + + + + + Taler Wallet: Refund Status + + + + + + + + + +
+ + diff --git a/src/webex/pages/refund.tsx b/src/webex/pages/refund.tsx new file mode 100644 index 000000000..b9506bf29 --- /dev/null +++ b/src/webex/pages/refund.tsx @@ -0,0 +1,138 @@ +/* + This file is part of TALER + (C) 2015-2016 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see + */ + + +/** + * Page that shows refund status for purchases. + * + * @author Florian Dold + */ + + +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import URI = require("urijs"); + +import * as wxApi from "../wxApi"; +import * as types from "../../types"; + +import { AmountDisplay } from "../renderHtml"; + +interface RefundStatusViewProps { + contractTermsHash: string; +} + +interface RefundStatusViewState { + purchase?: types.PurchaseRecord; + gotResult: boolean; +} + + +const RefundDetail = ({purchase}: {purchase: types.PurchaseRecord}) => { + const pendingKeys = Object.keys(purchase.refundsPending); + const doneKeys = Object.keys(purchase.refundsDone); + if (pendingKeys.length == 0 && doneKeys.length == 0) { + return

No refunds

; + } + + const currency = { ...purchase.refundsDone, ...purchase.refundsPending }[([...pendingKeys, ...doneKeys][0])].refund_amount.currency; + if (!currency) { + throw Error("invariant"); + } + + let amountPending = types.Amounts.getZero(currency); + let feesPending = types.Amounts.getZero(currency) + for (let k of pendingKeys) { + amountPending = types.Amounts.add(amountPending, purchase.refundsPending[k].refund_amount).amount; + feesPending = types.Amounts.add(feesPending, purchase.refundsPending[k].refund_fee).amount; + } + let amountDone = types.Amounts.getZero(currency); + let feesDone = types.Amounts.getZero(currency); + for (let k of doneKeys) { + amountDone = types.Amounts.add(amountDone, purchase.refundsDone[k].refund_amount).amount; + feesDone = types.Amounts.add(feesDone, purchase.refundsDone[k].refund_fee).amount; + } + + return ( +
+

Refund fully received: (refund fees: )

+

Refund incoming: (refund fees: )

+
+ ); +}; + +class RefundStatusView extends React.Component { + + constructor(props: RefundStatusViewProps) { + super(props); + this.state = { gotResult: false }; + } + + componentDidMount() { + this.update(); + const port = chrome.runtime.connect(); + port.onMessage.addListener((msg: any) => { + if (msg.notify) { + console.log("got notified"); + this.update(); + } + }); + } + + render(): JSX.Element { + const purchase = this.state.purchase; + if (!purchase) { + if (this.state.gotResult) { + return No purchase with contract terms hash {this.props.contractTermsHash} found; + } else { + return ...; + } + } + const merchantName = purchase.contractTerms.merchant.name || "(unknown)"; + const summary = purchase.contractTerms.summary || purchase.contractTerms.order_id; + return ( +
+

Refund Status

+

Status of purchase {summary} from merchant {merchantName} (order id {purchase.contractTerms.order_id}).

+

Total amount:

+ {purchase.finished ? :

Purchase not completed.

} +
+ ); + } + + async update() { + const purchase = await wxApi.getPurchase(this.props.contractTermsHash); + console.log("got purchase", purchase); + this.setState({ purchase, gotResult: true }); + } +} + + +async function main() { + const url = new URI(document.location.href); + const query: any = URI.parseQuery(url.query()); + + const container = document.getElementById("container"); + if (!container) { + console.error("fatal: can't mount component, countainer missing"); + return; + } + + const contractTermsHash = query.contractTermsHash || "(none)"; + ReactDOM.render(, container); +} + +document.addEventListener("DOMContentLoaded", () => main()); diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx index 51f9019ef..fe964e68a 100644 --- a/src/webex/renderHtml.tsx +++ b/src/webex/renderHtml.tsx @@ -73,6 +73,8 @@ export function renderAmount(amount: AmountJson) { return {x} {amount.currency}; } +export const AmountDisplay = ({amount}: {amount: AmountJson}) => renderAmount(amount); + /** * Abbreviate a string to a given length, and show the full diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index 306406a1a..1423da53b 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -31,6 +31,7 @@ import { DenominationRecord, ExchangeRecord, PreCoinRecord, + PurchaseRecord, QueryPaymentResult, ReserveCreationInfo, ReserveRecord, @@ -322,6 +323,13 @@ export function returnCoins(args: { amount: AmountJson, exchange: string, sender return callBackend("return-coins", args); } + +/** + * Record an error report and display it in a tabl. + * + * If sameTab is set, the error report will be opened in the current tab, + * otherwise in a new tab. + */ export function logAndDisplayError(args: any): Promise { return callBackend("log-and-display-error", args); } @@ -329,3 +337,11 @@ export function logAndDisplayError(args: any): Promise { export function getReport(reportUid: string): Promise { return callBackend("get-report", { reportUid }); } + +export function acceptRefund(refundData: any): Promise { + return callBackend("accept-refund", refundData); +} + +export function getPurchase(contractTermsHash: string): Promise { + return callBackend("get-purchase", { contractTermsHash }); +} diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 353961ff0..0d1c2d8ca 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -305,13 +305,24 @@ function handleMessage(sender: MessageSender, } case "log-and-display-error": logging.storeReport(detail).then((reportUid) => { - chrome.tabs.create({ - url: chrome.extension.getURL(`/src/webex/pages/error.html?reportUid=${reportUid}`), - }); + const url = chrome.extension.getURL(`/src/webex/pages/error.html?reportUid=${reportUid}`); + if (detail.sameTab && sender && sender.tab && sender.tab.id) { + chrome.tabs.update(detail.tabId, { url }); + } else { + chrome.tabs.create({ url }); + } }); return; case "get-report": return logging.getReport(detail.reportUid); + case "accept-refund": + return needsWallet().acceptRefund(detail.refund_permissions); + case "get-purchase": + const contractTermsHash = detail.contractTermsHash; + if (!contractTermsHash) { + throw Error("contractTermsHash missing"); + } + return needsWallet().getPurchase(contractTermsHash); default: // Exhaustiveness check. // See https://www.typescriptlang.org/docs/handbook/advanced-types.html @@ -380,6 +391,9 @@ class ChromeNotifier implements Notifier { /** * Mapping from tab ID to payment information (if any). + * + * Used to pass information from an intercepted HTTP header to the content + * script on the page. */ const paymentRequestCookies: { [n: number]: any } = {}; @@ -401,6 +415,7 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri const fields = { contract_url: headers["x-taler-contract-url"], offer_url: headers["x-taler-offer-url"], + refund_url: headers["x-taler-refund-url"], }; const talerHeaderFound = Object.keys(fields).filter((x: any) => (fields as any)[x]).length !== 0; @@ -415,6 +430,7 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri const payDetail = { contract_url: fields.contract_url, offer_url: fields.offer_url, + refund_url: fields.refund_url, }; console.log("got pay detail", payDetail); diff --git a/webpack.config.js b/webpack.config.js index 89a4a5aee..af586dc53 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -76,6 +76,7 @@ module.exports = function (env) { "popup": "./src/webex/pages/popup.tsx", "reset-required": "./src/webex/pages/reset-required.tsx", "return-coins": "./src/webex/pages/return-coins.tsx", + "refund": "./src/webex/pages/refund.tsx", "show-db": "./src/webex/pages/show-db.ts", "tree": "./src/webex/pages/tree.tsx", }, diff --git a/yarn.lock b/yarn.lock index 1c767f3ff..3b8458563 100644 --- a/yarn.lock +++ b/yarn.lock @@ -490,6 +490,13 @@ aws4@^1.2.1: version "1.6.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" +axios@^0.16.2: + version "0.16.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.16.2.tgz#ba4f92f17167dfbab40983785454b9ac149c3c6d" + dependencies: + follow-redirects "^1.2.3" + is-buffer "^1.1.5" + babel-code-frame@^6.22.0: version "6.22.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.22.0.tgz#027620bee567a88c32561574e7fd0801d33118e4" @@ -1523,7 +1530,7 @@ debug-log@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debug-log/-/debug-log-1.0.1.tgz#2307632d4c04382b8df8a32f70b895046d52745f" -debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.3: +debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.4.5, debug@^2.6.3: version "2.6.8" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc" dependencies: @@ -2091,6 +2098,12 @@ fn-name@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/fn-name/-/fn-name-2.0.1.tgz#5214d7537a4d06a4a301c0cc262feb84188002e7" +follow-redirects@^1.2.3: + version "1.2.4" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.2.4.tgz#355e8f4d16876b43f577b0d5ce2668b9723214ea" + dependencies: + debug "^2.4.5" + for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" -- cgit v1.2.3 From 24181bdf20e0d23ec5ec5d2eaa08ae1cfb905f0f Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 27 Aug 2017 04:19:11 +0200 Subject: better error report / retry prompt for failed payments --- src/webex/notify.ts | 1 + src/webex/pages/confirm-create-reserve.tsx | 36 +---------------------- src/webex/pages/error.tsx | 46 ++++++++++++++++++++++++++---- src/webex/renderHtml.tsx | 37 ++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 41 deletions(-) (limited to 'src') diff --git a/src/webex/notify.ts b/src/webex/notify.ts index da4657a96..5e024d619 100644 --- a/src/webex/notify.ts +++ b/src/webex/notify.ts @@ -120,6 +120,7 @@ async function handlePaymentResponse(maybeFoundResponse: QueryPaymentResult) { // Gives the user the option to retry / abort and refresh wxApi.logAndDisplayError({ name: "pay-post-failed", + contractTerms: walletResp.contractTerms, message: e.message, response: e.response, }); diff --git a/src/webex/pages/confirm-create-reserve.tsx b/src/webex/pages/confirm-create-reserve.tsx index 4e3b6748f..f957364c4 100644 --- a/src/webex/pages/confirm-create-reserve.tsx +++ b/src/webex/pages/confirm-create-reserve.tsx @@ -41,7 +41,7 @@ import { getReserveCreationInfo, } from "../wxApi"; -import {renderAmount} from "../renderHtml"; +import {Collapsible, renderAmount} from "../renderHtml"; import * as React from "react"; import * as ReactDOM from "react-dom"; @@ -80,40 +80,6 @@ class EventTrigger { } -interface CollapsibleState { - collapsed: boolean; -} - -interface CollapsibleProps { - initiallyCollapsed: boolean; - title: string; -} - -class Collapsible extends React.Component { - constructor(props: CollapsibleProps) { - super(props); - this.state = { collapsed: props.initiallyCollapsed }; - } - render() { - const doOpen = (e: any) => { - this.setState({collapsed: false}); - e.preventDefault(); - }; - const doClose = (e: any) => { - this.setState({collapsed: true}); - e.preventDefault(); - }; - if (this.state.collapsed) { - return

{this.props.title}

; - } - return ( -
-

{this.props.title}

- {this.props.children} -
- ); - } -} function renderAuditorDetails(rci: ReserveCreationInfo|null) { console.log("rci", rci); diff --git a/src/webex/pages/error.tsx b/src/webex/pages/error.tsx index 3f3940d72..2edef5e5b 100644 --- a/src/webex/pages/error.tsx +++ b/src/webex/pages/error.tsx @@ -29,6 +29,8 @@ import URI = require("urijs"); import * as wxApi from "../wxApi"; +import { Collapsible } from "../renderHtml"; + interface ErrorProps { report: any; } @@ -38,7 +40,7 @@ class ErrorView extends React.Component { const report = this.props.report; if (!report) { return ( -
+

Error Report Not Found

This page is supposed to display an error reported by the GNU Taler wallet, but the corresponding error report can't be found.

@@ -46,15 +48,47 @@ class ErrorView extends React.Component {
); } - switch (report.name) { - default: + try { + switch (report.name) { + case "pay-post-failed": { + const summary = report.contractTerms.summary || report.contractTerms.order_id; + return ( +
+

Failed to send payment

+

Failed to send payment for {summary} to merchant {report.contractTerms.merchant.name}.

+

You can retry the payment. If this problem persists, + please contact the mechant with the error details below.

+ +
+                  {JSON.stringify(report, null, " ")}
+                
+
+
+ ); + } + default: + return ( +
+

Unknown Error

+ The GNU Taler wallet reported an unknown error. Here are the details: +
+                {JSON.stringify(report, null, " ")}
+              
+
+ ); + } + } catch (e) { return ( -
-

Unknown Error

- The GNU Taler wallet reported an unknown error. Here are the details: +
+

Error

+ The GNU Taler wallet reported an error. Here are the details:
               {JSON.stringify(report, null, " ")}
             
+ A detailed error report could not be generated: +
+              {e.toString()}
+            
); } diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx index fe964e68a..2a5b50533 100644 --- a/src/webex/renderHtml.tsx +++ b/src/webex/renderHtml.tsx @@ -91,3 +91,40 @@ export function abbrev(s: string, n: number = 5) { ); } + + + +interface CollapsibleState { + collapsed: boolean; +} + +interface CollapsibleProps { + initiallyCollapsed: boolean; + title: string; +} + +export class Collapsible extends React.Component { + constructor(props: CollapsibleProps) { + super(props); + this.state = { collapsed: props.initiallyCollapsed }; + } + render() { + const doOpen = (e: any) => { + this.setState({collapsed: false}); + e.preventDefault(); + }; + const doClose = (e: any) => { + this.setState({collapsed: true}); + e.preventDefault(); + }; + if (this.state.collapsed) { + return

{this.props.title}

; + } + return ( +
+

{this.props.title}

+ {this.props.children} +
+ ); + } +} -- cgit v1.2.3 From ccc6d822424be9b257e63b0d71f2d46f2523fe3e Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 27 Aug 2017 04:35:24 +0200 Subject: canonicalize account info JSON when collecting them --- src/wallet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/wallet.ts b/src/wallet.ts index b892e2e4b..72c8a70f8 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -2413,7 +2413,7 @@ export class Wallet { const senderWiresSet = new Set(); await this.q().iter(Stores.reserves).map((x) => { if (x.senderWire) { - senderWiresSet.add(JSON.stringify(x.senderWire)); + senderWiresSet.add(canonicalJson(x.senderWire)); } }).run(); const senderWires = Array.from(senderWiresSet).map((x) => JSON.parse(x)); -- cgit v1.2.3 From 63914ab53b18ec29269c2c3fe4e01ac9b36330e5 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 27 Aug 2017 05:42:46 +0200 Subject: make sure that refreshing works after refund --- src/query.ts | 18 ++++++++++++++++-- src/types.ts | 5 +++++ src/wallet.ts | 37 +++++++++++++++++++++---------------- 3 files changed, 42 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/query.ts b/src/query.ts index ee1ac2603..d7689f2bc 100644 --- a/src/query.ts +++ b/src/query.ts @@ -547,9 +547,18 @@ export class QueryRoot { private finished: boolean = false; + private keys: { [keyName: string]: IDBValidKey } = {}; + constructor(public db: IDBDatabase) { } + /** + * Get a named key that was created during the query. + */ + key(keyName: string): IDBValidKey|undefined { + return this.keys[keyName]; + } + private checkFinished() { if (this.finished) { throw Error("Can't add work to query after it was started"); @@ -627,10 +636,15 @@ export class QueryRoot { * Overrides if an existing object with the same key exists * in the store. */ - put(store: Store, val: T): QueryRoot { + put(store: Store, val: T, keyName?: string): QueryRoot { this.checkFinished(); const doPut = (tx: IDBTransaction) => { - tx.objectStore(store.name).put(val); + const req = tx.objectStore(store.name).put(val); + if (keyName) { + req.onsuccess = () => { + this.keys[keyName] = req.result; + }; + } }; this.scheduleFinish(); this.addWork(doPut, store.name, true); diff --git a/src/types.ts b/src/types.ts index aabf4ffc0..9492d1a75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -759,6 +759,11 @@ export interface RefreshSessionRecord { * Is this session finished? */ finished: boolean; + + /** + * Record ID when retrieved from the DB. + */ + id?: number; } diff --git a/src/wallet.ts b/src/wallet.ts index 72c8a70f8..e7a36f7b7 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -555,7 +555,7 @@ export namespace Stores { export const nonces = new NonceStore(); export const precoins = new Store("precoins", {keyPath: "coinPub"}); export const proposals = new ProposalsStore(); - export const refresh = new Store("refresh", {keyPath: "meltCoinPub"}); + export const refresh = new Store("refresh", {keyPath: "id", autoIncrement: true}); export const reserves = new Store("reserves", {keyPath: "reserve_pub"}); export const purchases = new PurchasesStore(); } @@ -1836,7 +1836,7 @@ export class Wallet { if (c.suspended) { return balance; } - if (!(c.status === CoinStatus.Dirty || c.status === CoinStatus.Fresh)) { + if (!(c.status === CoinStatus.Fresh)) { return balance; } console.log("collecting balance"); @@ -1999,25 +1999,30 @@ export class Wallet { // Store refresh session and subtract refreshed amount from // coin in the same transaction. - await this.q() - .put(Stores.refresh, refreshSession) - .mutate(Stores.coins, coin.coinPub, mutateCoin) - .finish(); + const query = this.q(); + query.put(Stores.refresh, refreshSession, "refreshKey") + .mutate(Stores.coins, coin.coinPub, mutateCoin); + await query.finish(); + + const key = query.key("refreshKey"); + if (!key || typeof key !== "number") { + throw Error("insert failed"); + } + + refreshSession.id = key; return refreshSession; } async refresh(oldCoinPub: string): Promise { - let refreshSession: RefreshSessionRecord|undefined; - const oldSession = await this.q().get(Stores.refresh, oldCoinPub); - if (oldSession) { - console.log("got old session for", oldCoinPub); - console.log(oldSession); - refreshSession = oldSession; - } else { - refreshSession = await this.createRefreshSession(oldCoinPub); + + const oldRefreshSessions = await this.q().iter(Stores.refresh).toArray(); + for (const session of oldRefreshSessions) { + console.log("got old session for", oldCoinPub, session); + this.continueRefreshSession(session); } + let refreshSession = await this.createRefreshSession(oldCoinPub); if (!refreshSession) { // refreshing not necessary console.log("not refreshing", oldCoinPub); @@ -2031,9 +2036,8 @@ export class Wallet { return; } if (typeof refreshSession.norevealIndex !== "number") { - const coinPub = refreshSession.meltCoinPub; await this.refreshMelt(refreshSession); - const r = await this.q().get(Stores.refresh, coinPub); + const r = await this.q().get(Stores.refresh, refreshSession.id); if (!r) { throw Error("refresh session does not exist anymore"); } @@ -2441,6 +2445,7 @@ export class Wallet { console.error(`Exchange ${req.exchange} not known to the wallet`); return; } + console.log("selecting coins for return:", req); const cds = await this.getCoinsForReturn(req.exchange, req.amount); console.log(cds); -- cgit v1.2.3 From b47522c11bf5c426f998254f9dc61f0018229b44 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 27 Aug 2017 05:57:39 +0200 Subject: proper rounding for amount operations --- src/types.ts | 4 ++-- src/wallet.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/types.ts b/src/types.ts index 9492d1a75..d016b7fea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1312,7 +1312,7 @@ export namespace Amounts { } value = value + x.value + Math.floor((fraction + x.fraction) / fractionalBase); - fraction = (fraction + x.fraction) % fractionalBase; + fraction = Math.floor((fraction + x.fraction) % fractionalBase); if (value > Number.MAX_SAFE_INTEGER) { return { amount: getMaxAmount(currency), saturated: true }; } @@ -1440,7 +1440,7 @@ export namespace Amounts { export function fromFloat(floatVal: number, currency: string) { return { currency, - fraction: (floatVal - Math.floor(floatVal)) * fractionalBase, + fraction: Math.floor((floatVal - Math.floor(floatVal)) * fractionalBase), value: Math.floor(floatVal), }; } diff --git a/src/wallet.ts b/src/wallet.ts index e7a36f7b7..3d095fc06 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -413,6 +413,8 @@ export function selectPayCoins(cds: CoinWithDenom[], paymentAmount: AmountJson, denom.feeDeposit).amount) >= 0; isBelowFee = Amounts.cmp(accFee, depositFeeLimit) <= 0; + console.log("coin selection", { coversAmount, isBelowFee, accFee, accAmount, paymentAmount }); + if ((coversAmount && isBelowFee) || coversAmountWithFee) { return cdsResult; } @@ -759,6 +761,8 @@ export class Wallet { cds.push({coin, denom}); } + console.log("coin return: selecting from possible coins", { cds, amount } ); + return selectPayCoins(cds, amount, amount); } -- cgit v1.2.3 From 43575b591921e994761fd531bcfe95239119b695 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 27 Aug 2017 06:47:13 +0200 Subject: show error in create reserve dialog --- src/webex/pages/confirm-create-reserve.tsx | 11 ++++------- src/webex/wxBackend.ts | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/webex/pages/confirm-create-reserve.tsx b/src/webex/pages/confirm-create-reserve.tsx index f957364c4..7d543860f 100644 --- a/src/webex/pages/confirm-create-reserve.tsx +++ b/src/webex/pages/confirm-create-reserve.tsx @@ -371,7 +371,7 @@ class ExchangeSelection extends ImplicitStateComponent { if (this.statusString()) { return (

- {i18n.str`A problem occured, see below. ${this.statusString()}`} + {this.statusString()}

); } @@ -515,12 +515,9 @@ class ExchangeSelection extends ImplicitStateComponent { console.dir(r); } catch (e) { console.log("get exchange info rejected", e); - if (e.hasOwnProperty("httpStatus")) { - this.statusString(`Error: request failed with status ${e.httpStatus}`); - } else if (e.hasOwnProperty("errorResponse")) { - const resp = e.errorResponse; - this.statusString(`Error: ${resp.error} (${resp.hint})`); - } + this.statusString(`Error: ${e.message}`); + // Re-try every 5 seconds as long as there is a problem + setTimeout(() => this.statusString() ? this.forceReserveUpdate() : undefined, 5000); } } diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 0d1c2d8ca..2f249af44 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -352,7 +352,7 @@ async function dispatch(req: any, sender: any, sendResponse: any): Promise try { sendResponse({ error: "exception", - hint: e.message, + message: e.message, stack, }); } catch (e) { -- cgit v1.2.3