gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[GNUnet-SVN] [taler-wallet-webex] 02/02: implement refunds


From: gnunet
Subject: [GNUnet-SVN] [taler-wallet-webex] 02/02: implement refunds
Date: Sun, 27 Aug 2017 03:56:32 +0200

This is an automated email from the git hooks/post-receive script.

dold pushed a commit to branch master
in repository wallet-webex.

commit 8697efd2c8751717a3a3fcaf72feb7c49ebfec02
Author: Florian Dold <address@hidden>
AuthorDate: Sun Aug 27 03:56:19 2017 +0200

    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(-)

diff --git a/node_modules/.bin/tsc b/node_modules/.bin/tsc
index abecd812..0863208a 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 1d314276..f8f8f1a0 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 6b320d5a..fef3468b 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 02d54328..9dc26627 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": "address@hidden",
-        "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": "address@hidden",
-  "_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": "address@hidden"
-  },
-  "_npmVersion": "2.15.0",
-  "_phantomChildren": {},
-  "_requested": {
-    "raw": "address@hidden",
-    "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": "address@hidden",
-  "_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": "address@hidden",
     "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": "address@hidden"
-    }
-  ],
-  "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');\ncons [...]
-  "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 edf933ac..ee47da7c 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": "address@hidden",
-        "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": "address@hidden",
-  "_inCache": true,
-  "_location": "/resolve-from",
-  "_nodeVersion": "4.2.1",
-  "_npmUser": {
-    "name": "sindresorhus",
-    "email": "address@hidden"
-  },
-  "_npmVersion": "2.14.7",
-  "_phantomChildren": {},
-  "_requested": {
-    "raw": "address@hidden",
-    "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": "address@hidden",
-  "_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": "address@hidden",
     "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": "address@hidden"
-    }
-  ],
-  "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\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 b3ef137f..03108f76 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 b05d7d18..1db6e62d 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 dffff86e..ee1ac260 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<T>(store: Store<T>, key: any, f: (v: T) => T): QueryRoot {
+  mutate<T>(store: Store<T>, 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 9031b19b..aabf4ffc 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 68d70b0b..b892e2e4 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<TransactionRecord> {
+  class PurchasesStore extends Store<PurchaseRecord> {
     constructor() {
-      super("transactions", {keyPath: "contractTermsHash"});
+      super("purchases", {keyPath: "contractTermsHash"});
     }
 
-    fulfillmentUrlIndex = new Index<string, TransactionRecord>(this, 
"fulfillment_url", "contractTerms.fulfillment_url");
-    orderIdIndex = new Index<string, TransactionRecord>(this, "order_id", 
"contractTerms.order_id");
+    fulfillmentUrlIndex = new Index<string, PurchaseRecord>(this, 
"fulfillment_url", "contractTerms.fulfillment_url");
+    orderIdIndex = new Index<string, PurchaseRecord>(this, "order_id", 
"contractTerms.order_id");
   }
 
   class DenominationsStore extends Store<DenominationRecord> {
@@ -568,7 +557,7 @@ export namespace Stores {
   export const proposals = new ProposalsStore();
   export const refresh = new Store<RefreshSessionRecord>("refresh", {keyPath: 
"meltCoinPub"});
   export const reserves = new Store<ReserveRecord>("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<QueryPaymentResult> {
     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<any> {
     const doPaymentSucceeded = async() => {
-      const t = await this.q().get<TransactionRecord>(Stores.transactions,
+      const t = await this.q().get<PurchaseRecord>(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<void> {
+    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<void> {
+    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<PurchaseRecord|undefined> {
+    return this.q().get(Stores.purchases, contractTermsHash);
+  }
 }
diff --git a/src/webex/messages.ts b/src/webex/messages.ts
index 397e8876..7de28b9e 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 51abdb0e..da4657a9 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<any> {
+async function downloadContract(url: string, nonce: string): Promise<any> {
   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<any> {
+  // 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 00000000..f97dc9d6
--- /dev/null
+++ b/src/webex/pages/refund.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+  <meta charset="UTF-8">
+  <title>Taler Wallet: Refund Status</title>
+
+  <link rel="stylesheet" type="text/css" href="../style/wallet.css">
+
+  <link rel="icon" href="/img/icon.png">
+
+  <script src="/dist/page-common-bundle.js"></script>
+  <script src="/dist/refund-bundle.js"></script>
+
+  <body>
+    <div id="container"></div>
+  </body>
+</html>
diff --git a/src/webex/pages/refund.tsx b/src/webex/pages/refund.tsx
new file mode 100644
index 00000000..b9506bf2
--- /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 <http://www.gnu.org/licenses/>
+ */
+
+
+/**
+ * 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 <p>No refunds</p>;
+  }
+
+  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 (
+    <div>
+      <p>Refund fully received: <AmountDisplay amount={amountDone} /> (refund 
fees: <AmountDisplay amount={feesDone} />)</p>
+      <p>Refund incoming: <AmountDisplay amount={amountPending} /> (refund 
fees: <AmountDisplay amount={feesPending} />)</p>
+    </div>
+  );
+};
+
+class RefundStatusView extends React.Component<RefundStatusViewProps, 
RefundStatusViewState> {
+
+  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 <span>No purchase with contract terms hash 
{this.props.contractTermsHash} found</span>;
+      } else {
+        return <span>...</span>;
+      }
+    }
+    const merchantName = purchase.contractTerms.merchant.name || "(unknown)";
+    const summary = purchase.contractTerms.summary || 
purchase.contractTerms.order_id;
+    return (
+      <div id="main">
+        <h1>Refund Status</h1>
+        <p>Status of purchase <strong>{summary}</strong> from merchant 
<strong>{merchantName}</strong> (order id 
{purchase.contractTerms.order_id}).</p>
+        <p>Total amount: <AmountDisplay amount={purchase.contractTerms.amount} 
/></p>
+        {purchase.finished ? <RefundDetail purchase={purchase} /> : 
<p>Purchase not completed.</p>}
+      </div>
+    );
+  }
+
+  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(<RefundStatusView contractTermsHash={contractTermsHash} />, 
container);
+}
+
+document.addEventListener("DOMContentLoaded", () => main());
diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx
index 51f9019e..fe964e68 100644
--- a/src/webex/renderHtml.tsx
+++ b/src/webex/renderHtml.tsx
@@ -73,6 +73,8 @@ export function renderAmount(amount: AmountJson) {
   return <span>{x}&nbsp;{amount.currency}</span>;
 }
 
+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 306406a1..1423da53 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<void> {
   return callBackend("log-and-display-error", args);
 }
@@ -329,3 +337,11 @@ export function logAndDisplayError(args: any): 
Promise<void> {
 export function getReport(reportUid: string): Promise<void> {
   return callBackend("get-report", { reportUid });
 }
+
+export function acceptRefund(refundData: any): Promise<number> {
+  return callBackend("accept-refund", refundData);
+}
+
+export function getPurchase(contractTermsHash: string): 
Promise<PurchaseRecord> {
+  return callBackend("get-purchase", { contractTermsHash });
+}
diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts
index 353961ff..0d1c2d8c 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 89a4a5ae..af586dc5 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 1c767f3f..3b845856 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -490,6 +490,13 @@ address@hidden:
   version "1.6.0"
   resolved 
"https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e";
 
address@hidden:
+  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"
+
 address@hidden:
   version "6.22.0"
   resolved 
"https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.22.0.tgz#027620bee567a88c32561574e7fd0801d33118e4";
@@ -1523,7 +1530,7 @@ address@hidden:
   version "1.0.1"
   resolved 
"https://registry.yarnpkg.com/debug-log/-/debug-log-1.0.1.tgz#2307632d4c04382b8df8a32f70b895046d52745f";
 
address@hidden, address@hidden, address@hidden, address@hidden:
address@hidden, address@hidden, address@hidden, address@hidden, address@hidden:
   version "2.6.8"
   resolved 
"https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc";
   dependencies:
@@ -2091,6 +2098,12 @@ address@hidden:
   version "2.0.1"
   resolved 
"https://registry.yarnpkg.com/fn-name/-/fn-name-2.0.1.tgz#5214d7537a4d06a4a301c0cc262feb84188002e7";
 
address@hidden:
+  version "1.2.4"
+  resolved 
"https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.2.4.tgz#355e8f4d16876b43f577b0d5ce2668b9723214ea";
+  dependencies:
+    debug "^2.4.5"
+
 address@hidden, address@hidden:
   version "1.0.2"
   resolved 
"https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80";

-- 
To stop receiving notification emails like this one, please contact
address@hidden



reply via email to

[Prev in Thread] Current Thread [Next in Thread]