gnunet-svn
[Top][All Lists]
Advanced

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

[GNUnet-SVN] [taler-wallet-webex] 03/03: implement aborting and getting


From: gnunet
Subject: [GNUnet-SVN] [taler-wallet-webex] 03/03: implement aborting and getting refunds from failed payments
Date: Mon, 29 Jan 2018 16:41:31 +0100

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

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

commit 1a66e232a55dff8c889e5554f637f4d4e475179c
Author: Florian Dold <address@hidden>
AuthorDate: Mon Jan 29 16:41:17 2018 +0100

    implement aborting and getting refunds from failed payments
---
 src/dbTypes.ts                       |  46 ++++++++-
 src/i18n/de.po                       |  29 +++---
 src/i18n/en-US.po                    |  25 +++--
 src/i18n/fr.po                       |  25 +++--
 src/i18n/it.po                       |  25 +++--
 src/i18n/strings.ts                  |  30 ++++--
 src/i18n/taler-wallet-webex.pot      |  25 +++--
 src/talerTypes.ts                    |  89 ++++++++++++++++--
 src/wallet.ts                        | 176 +++++++++++++++++++++++++----------
 src/webex/messages.ts                |   6 +-
 src/webex/pages/confirm-contract.tsx | 129 +++++++++++++++++++------
 src/webex/wxApi.ts                   |  23 ++++-
 src/webex/wxBackend.ts               |   8 +-
 13 files changed, 481 insertions(+), 155 deletions(-)

diff --git a/src/dbTypes.ts b/src/dbTypes.ts
index 035c100a..6c467ce7 100644
--- a/src/dbTypes.ts
+++ b/src/dbTypes.ts
@@ -31,8 +31,8 @@ import {
   CoinPaySig,
   ContractTerms,
   Denomination,
+  MerchantRefundPermission,
   PayReq,
-  RefundPermission,
   TipResponse,
   WireDetail,
 } from "./talerTypes";
@@ -762,9 +762,25 @@ export interface WireFee {
  * the customer accepts a proposal.  Includes refund status if applicable.
  */
 export interface PurchaseRecord {
+  /**
+   * Hash of the contract terms.
+   */
   contractTermsHash: string;
+
+  /**
+   * Contract terms we got from the merchant.
+   */
   contractTerms: ContractTerms;
+
+  /**
+   * The payment request, ready to be send to the merchant's
+   * /pay URL.
+   */
   payReq: PayReq;
+
+  /**
+   * Signature from the merchant over the contract terms.
+   */
   merchantSig: string;
 
   /**
@@ -773,8 +789,15 @@ export interface PurchaseRecord {
    */
   finished: boolean;
 
-  refundsPending: { [refundSig: string]: RefundPermission };
-  refundsDone: { [refundSig: string]: RefundPermission };
+  /**
+   * Pending refunds for the purchase.
+   */
+  refundsPending: { [refundSig: string]: MerchantRefundPermission };
+
+  /**
+   * Submitted refunds for the purchase.
+   */
+  refundsDone: { [refundSig: string]: MerchantRefundPermission };
 
   /**
    * When was the purchase made?
@@ -788,8 +811,25 @@ export interface PurchaseRecord {
    */
   timestamp_refund: number;
 
+  /**
+   * Last session id that we submitted to /pay (if any).
+   */
   lastSessionSig: string | undefined;
+
+  /**
+   * Last session signature that we submitted to /pay (if any).
+   */
   lastSessionId: string | undefined;
+
+  /**
+   * An abort (with refund) was requested for this (incomplete!) purchase.
+   */
+  abortRequested: boolean;
+
+  /**
+   * The abort (with refund) was completed for this (incomplete!) purchase.
+   */
+  abortDone: boolean;
 }
 
 
diff --git a/src/i18n/de.po b/src/i18n/de.po
index 1a003c17..398fdfab 100644
--- a/src/i18n/de.po
+++ b/src/i18n/de.po
@@ -27,28 +27,28 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: src/webex/pages/confirm-contract.tsx:73
+#: src/webex/pages/confirm-contract.tsx:74
 #, c-format
 msgid "show more details\n"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:87
+#: src/webex/pages/confirm-contract.tsx:88
 #, c-format
 msgid "Accepted exchanges:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:92
+#: src/webex/pages/confirm-contract.tsx:93
 #, c-format
 msgid "Exchanges in the wallet:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:200
+#: src/webex/pages/confirm-contract.tsx:211
 #, c-format
 msgid "You have insufficient funds of the requested currency in your wallet."
 msgstr ""
 
 #. tslint:disable-next-line:max-line-length
-#: src/webex/pages/confirm-contract.tsx:202
+#: src/webex/pages/confirm-contract.tsx:213
 #, c-format
 msgid ""
 "You do not have any funds from an exchange that is accepted by this "
@@ -56,16 +56,21 @@ msgid ""
 "wallet."
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:280
-#, c-format
-msgid "The merchant%1$s offers you to purchase:\n"
-msgstr ""
-
-#: src/webex/pages/confirm-contract.tsx:301
+#: src/webex/pages/confirm-contract.tsx:305
 #, fuzzy, c-format
 msgid "Confirm payment"
 msgstr "Bezahlung bestätigen"
 
+#: src/webex/pages/confirm-contract.tsx:314
+#, c-format
+msgid "Submitting payment"
+msgstr ""
+
+#: src/webex/pages/confirm-contract.tsx:349
+#, c-format
+msgid "The merchant%1$s offers you to purchase:\n"
+msgstr ""
+
 #: src/webex/pages/confirm-create-reserve.tsx:126
 #, c-format
 msgid "Select"
@@ -154,7 +159,7 @@ msgstr ""
 
 #. #-#-#-#-#  - (PACKAGE VERSION)  #-#-#-#-#
 #. TODO:generic error reporting function or component.
-#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:155
+#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:153
 #, c-format
 msgid "Fatal error: \"%1$s\"."
 msgstr ""
diff --git a/src/i18n/en-US.po b/src/i18n/en-US.po
index 3d3fd433..68faa6ba 100644
--- a/src/i18n/en-US.po
+++ b/src/i18n/en-US.po
@@ -27,28 +27,28 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: src/webex/pages/confirm-contract.tsx:73
+#: src/webex/pages/confirm-contract.tsx:74
 #, c-format
 msgid "show more details\n"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:87
+#: src/webex/pages/confirm-contract.tsx:88
 #, c-format
 msgid "Accepted exchanges:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:92
+#: src/webex/pages/confirm-contract.tsx:93
 #, c-format
 msgid "Exchanges in the wallet:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:200
+#: src/webex/pages/confirm-contract.tsx:211
 #, c-format
 msgid "You have insufficient funds of the requested currency in your wallet."
 msgstr ""
 
 #. tslint:disable-next-line:max-line-length
-#: src/webex/pages/confirm-contract.tsx:202
+#: src/webex/pages/confirm-contract.tsx:213
 #, c-format
 msgid ""
 "You do not have any funds from an exchange that is accepted by this "
@@ -56,14 +56,19 @@ msgid ""
 "wallet."
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:280
+#: src/webex/pages/confirm-contract.tsx:305
 #, c-format
-msgid "The merchant%1$s offers you to purchase:\n"
+msgid "Confirm payment"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:301
+#: src/webex/pages/confirm-contract.tsx:314
 #, c-format
-msgid "Confirm payment"
+msgid "Submitting payment"
+msgstr ""
+
+#: src/webex/pages/confirm-contract.tsx:349
+#, c-format
+msgid "The merchant%1$s offers you to purchase:\n"
 msgstr ""
 
 #: src/webex/pages/confirm-create-reserve.tsx:126
@@ -154,7 +159,7 @@ msgstr ""
 
 #. #-#-#-#-#  - (PACKAGE VERSION)  #-#-#-#-#
 #. TODO:generic error reporting function or component.
-#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:155
+#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:153
 #, c-format
 msgid "Fatal error: \"%1$s\"."
 msgstr ""
diff --git a/src/i18n/fr.po b/src/i18n/fr.po
index 08f4a9d0..93077fb3 100644
--- a/src/i18n/fr.po
+++ b/src/i18n/fr.po
@@ -27,28 +27,28 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: src/webex/pages/confirm-contract.tsx:73
+#: src/webex/pages/confirm-contract.tsx:74
 #, c-format
 msgid "show more details\n"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:87
+#: src/webex/pages/confirm-contract.tsx:88
 #, c-format
 msgid "Accepted exchanges:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:92
+#: src/webex/pages/confirm-contract.tsx:93
 #, c-format
 msgid "Exchanges in the wallet:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:200
+#: src/webex/pages/confirm-contract.tsx:211
 #, c-format
 msgid "You have insufficient funds of the requested currency in your wallet."
 msgstr ""
 
 #. tslint:disable-next-line:max-line-length
-#: src/webex/pages/confirm-contract.tsx:202
+#: src/webex/pages/confirm-contract.tsx:213
 #, c-format
 msgid ""
 "You do not have any funds from an exchange that is accepted by this "
@@ -56,14 +56,19 @@ msgid ""
 "wallet."
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:280
+#: src/webex/pages/confirm-contract.tsx:305
 #, c-format
-msgid "The merchant%1$s offers you to purchase:\n"
+msgid "Confirm payment"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:301
+#: src/webex/pages/confirm-contract.tsx:314
 #, c-format
-msgid "Confirm payment"
+msgid "Submitting payment"
+msgstr ""
+
+#: src/webex/pages/confirm-contract.tsx:349
+#, c-format
+msgid "The merchant%1$s offers you to purchase:\n"
 msgstr ""
 
 #: src/webex/pages/confirm-create-reserve.tsx:126
@@ -154,7 +159,7 @@ msgstr ""
 
 #. #-#-#-#-#  - (PACKAGE VERSION)  #-#-#-#-#
 #. TODO:generic error reporting function or component.
-#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:155
+#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:153
 #, c-format
 msgid "Fatal error: \"%1$s\"."
 msgstr ""
diff --git a/src/i18n/it.po b/src/i18n/it.po
index 08f4a9d0..93077fb3 100644
--- a/src/i18n/it.po
+++ b/src/i18n/it.po
@@ -27,28 +27,28 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: src/webex/pages/confirm-contract.tsx:73
+#: src/webex/pages/confirm-contract.tsx:74
 #, c-format
 msgid "show more details\n"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:87
+#: src/webex/pages/confirm-contract.tsx:88
 #, c-format
 msgid "Accepted exchanges:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:92
+#: src/webex/pages/confirm-contract.tsx:93
 #, c-format
 msgid "Exchanges in the wallet:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:200
+#: src/webex/pages/confirm-contract.tsx:211
 #, c-format
 msgid "You have insufficient funds of the requested currency in your wallet."
 msgstr ""
 
 #. tslint:disable-next-line:max-line-length
-#: src/webex/pages/confirm-contract.tsx:202
+#: src/webex/pages/confirm-contract.tsx:213
 #, c-format
 msgid ""
 "You do not have any funds from an exchange that is accepted by this "
@@ -56,14 +56,19 @@ msgid ""
 "wallet."
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:280
+#: src/webex/pages/confirm-contract.tsx:305
 #, c-format
-msgid "The merchant%1$s offers you to purchase:\n"
+msgid "Confirm payment"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:301
+#: src/webex/pages/confirm-contract.tsx:314
 #, c-format
-msgid "Confirm payment"
+msgid "Submitting payment"
+msgstr ""
+
+#: src/webex/pages/confirm-contract.tsx:349
+#, c-format
+msgid "The merchant%1$s offers you to purchase:\n"
 msgstr ""
 
 #: src/webex/pages/confirm-create-reserve.tsx:126
@@ -154,7 +159,7 @@ msgstr ""
 
 #. #-#-#-#-#  - (PACKAGE VERSION)  #-#-#-#-#
 #. TODO:generic error reporting function or component.
-#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:155
+#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:153
 #, c-format
 msgid "Fatal error: \"%1$s\"."
 msgstr ""
diff --git a/src/i18n/strings.ts b/src/i18n/strings.ts
index 9e78abc3..072bd953 100644
--- a/src/i18n/strings.ts
+++ b/src/i18n/strings.ts
@@ -39,12 +39,15 @@ strings['de'] = {
       "You do not have any funds from an exchange that is accepted by this 
merchant. None of the exchanges accepted by the merchant is known to your 
wallet.": [
         ""
       ],
-      "The merchant%1$s offers you to purchase:\n": [
-        ""
-      ],
       "Confirm payment": [
         "Bezahlung bestätigen"
       ],
+      "Submitting payment": [
+        ""
+      ],
+      "The merchant%1$s offers you to purchase:\n": [
+        ""
+      ],
       "Select": [
         ""
       ],
@@ -228,10 +231,13 @@ strings['en-US'] = {
       "You do not have any funds from an exchange that is accepted by this 
merchant. None of the exchanges accepted by the merchant is known to your 
wallet.": [
         ""
       ],
-      "The merchant%1$s offers you to purchase:\n": [
+      "Confirm payment": [
         ""
       ],
-      "Confirm payment": [
+      "Submitting payment": [
+        ""
+      ],
+      "The merchant%1$s offers you to purchase:\n": [
         ""
       ],
       "Select": [
@@ -417,10 +423,13 @@ strings['fr'] = {
       "You do not have any funds from an exchange that is accepted by this 
merchant. None of the exchanges accepted by the merchant is known to your 
wallet.": [
         ""
       ],
-      "The merchant%1$s offers you to purchase:\n": [
+      "Confirm payment": [
         ""
       ],
-      "Confirm payment": [
+      "Submitting payment": [
+        ""
+      ],
+      "The merchant%1$s offers you to purchase:\n": [
         ""
       ],
       "Select": [
@@ -606,10 +615,13 @@ strings['it'] = {
       "You do not have any funds from an exchange that is accepted by this 
merchant. None of the exchanges accepted by the merchant is known to your 
wallet.": [
         ""
       ],
-      "The merchant%1$s offers you to purchase:\n": [
+      "Confirm payment": [
         ""
       ],
-      "Confirm payment": [
+      "Submitting payment": [
+        ""
+      ],
+      "The merchant%1$s offers you to purchase:\n": [
         ""
       ],
       "Select": [
diff --git a/src/i18n/taler-wallet-webex.pot b/src/i18n/taler-wallet-webex.pot
index 08f4a9d0..93077fb3 100644
--- a/src/i18n/taler-wallet-webex.pot
+++ b/src/i18n/taler-wallet-webex.pot
@@ -27,28 +27,28 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: src/webex/pages/confirm-contract.tsx:73
+#: src/webex/pages/confirm-contract.tsx:74
 #, c-format
 msgid "show more details\n"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:87
+#: src/webex/pages/confirm-contract.tsx:88
 #, c-format
 msgid "Accepted exchanges:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:92
+#: src/webex/pages/confirm-contract.tsx:93
 #, c-format
 msgid "Exchanges in the wallet:"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:200
+#: src/webex/pages/confirm-contract.tsx:211
 #, c-format
 msgid "You have insufficient funds of the requested currency in your wallet."
 msgstr ""
 
 #. tslint:disable-next-line:max-line-length
-#: src/webex/pages/confirm-contract.tsx:202
+#: src/webex/pages/confirm-contract.tsx:213
 #, c-format
 msgid ""
 "You do not have any funds from an exchange that is accepted by this "
@@ -56,14 +56,19 @@ msgid ""
 "wallet."
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:280
+#: src/webex/pages/confirm-contract.tsx:305
 #, c-format
-msgid "The merchant%1$s offers you to purchase:\n"
+msgid "Confirm payment"
 msgstr ""
 
-#: src/webex/pages/confirm-contract.tsx:301
+#: src/webex/pages/confirm-contract.tsx:314
 #, c-format
-msgid "Confirm payment"
+msgid "Submitting payment"
+msgstr ""
+
+#: src/webex/pages/confirm-contract.tsx:349
+#, c-format
+msgid "The merchant%1$s offers you to purchase:\n"
 msgstr ""
 
 #: src/webex/pages/confirm-create-reserve.tsx:126
@@ -154,7 +159,7 @@ msgstr ""
 
 #. #-#-#-#-#  - (PACKAGE VERSION)  #-#-#-#-#
 #. TODO:generic error reporting function or component.
-#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:155
+#: src/webex/pages/confirm-create-reserve.tsx:505 src/webex/pages/tip.tsx:153
 #, c-format
 msgid "Fatal error: \"%1$s\"."
 msgstr ""
diff --git a/src/talerTypes.ts b/src/talerTypes.ts
index d593c3d3..611d667c 100644
--- a/src/talerTypes.ts
+++ b/src/talerTypes.ts
@@ -475,46 +475,121 @@ export interface PayReq {
 /**
  * Refund permission in the format that the merchant gives it to us.
  */
-export interface RefundPermission {
address@hidden()
+export class MerchantRefundPermission {
   /**
    * Amount to be refunded.
    */
+  @Checkable.Value(() => AmountJson)
   refund_amount: AmountJson;
 
   /**
    * Fee for the refund.
    */
+  @Checkable.Value(() => AmountJson)
+  refund_fee: AmountJson;
+
+  /**
+   * Public key of the coin being refunded.
+   */
+  @Checkable.String
+  coin_pub: string;
+
+  /**
+   * Refund transaction ID between merchant and exchange.
+   */
+  @Checkable.Number
+  rtransaction_id: number;
+
+  /**
+   * Signature made by the merchant over the refund permission.
+   */
+  @Checkable.String
+  merchant_sig: string;
+
+  /**
+   * Create a MerchantRefundPermission from untyped JSON.
+   */
+  static checked: (obj: any) => MerchantRefundPermission;
+}
+
+
+/**
+ * Refund request sent to the exchange.
+ */
+export interface RefundRequest {
+  /**
+   * Amount to be refunded, can be a fraction of the
+   * coin's total deposit value (including deposit fee);
+   * must be larger than the refund fee.
+   */
+  refund_amount: AmountJson;
+
+  /**
+   * Refund fee associated with the given coin.
+   * must be smaller than the refund amount.
+   */
   refund_fee: AmountJson;
 
   /**
-   * Contract terms hash to identify the contract that this
-   * refund is for.
+   * SHA-512 hash of the contact of the merchant with the customer.
    */
   h_contract_terms: string;
 
   /**
-   * Public key of the coin being refunded.
+   * coin's public key, both ECDHE and EdDSA.
    */
   coin_pub: string;
 
   /**
-   * Refund transaction ID between merchant and exchange.
+   * 64-bit transaction id of the refund transaction between merchant and 
customer
    */
   rtransaction_id: number;
 
   /**
-   * Public key of the merchant.
+   * EdDSA public key of the merchant.
    */
   merchant_pub: string;
 
   /**
-   * Signature made by the merchant over the refund permission.
+   * EdDSA signature of the merchant affirming the refund.
    */
   merchant_sig: string;
 }
 
 
 /**
+ * Response for a refund pickup or a /pay in abort mode.
+ */
address@hidden()
+export class MerchantRefundResponse {
+  /**
+   * Public key of the merchant
+   */
+  @Checkable.String
+  merchant_pub: string;
+
+  /**
+   * Contract terms hash of the contract that
+   * is being refunded.
+   */
+  @Checkable.String
+  h_contract_terms: string;
+
+  /**
+   * The signed refund permissions, to be sent to the exchange.
+   */
+  @Checkable.List(Checkable.Value(() => MerchantRefundPermission))
+  refund_permissions: MerchantRefundPermission[];
+
+  /**
+   * Create a MerchantRefundReponse from untyped JSON.
+   */
+  static checked: (obj: any) => MerchantRefundResponse;
+}
+
+
+/**
  * Planchet detail sent to the merchant.
  */
 export interface TipPlanchetDetail {
diff --git a/src/wallet.ts b/src/wallet.ts
index 8167556f..34b2388e 100644
--- a/src/wallet.ts
+++ b/src/wallet.ts
@@ -76,10 +76,12 @@ import {
   Denomination,
   ExchangeHandle,
   KeysJson,
+  MerchantRefundPermission,
+  MerchantRefundResponse,
   PayReq,
   PaybackConfirmation,
   Proposal,
-  RefundPermission,
+  RefundRequest,
   TipPlanchetDetail,
   TipResponse,
   TipToken,
@@ -648,6 +650,8 @@ export class Wallet {
       order_id: proposal.contractTerms.order_id,
     };
     const t: PurchaseRecord = {
+      abortDone: false,
+      abortRequested: false,
       contractTerms: proposal.contractTerms,
       contractTermsHash: proposal.contractTermsHash,
       finished: false,
@@ -676,7 +680,6 @@ export class Wallet {
    * Returns an id for it to retrieve it later.
    */
   async downloadProposal(url: string): Promise<number> {
-
     const oldProposal = await this.q().getIndexed(Stores.proposals.urlIndex, 
url);
     if (oldProposal) {
       return oldProposal.id!;
@@ -716,13 +719,37 @@ export class Wallet {
     return id;
   }
 
+
+  async refundFailedPay(proposalId: number) {
+    console.log(`refunding failed payment with proposal id ${proposalId}`);
+    const proposal: ProposalDownloadRecord|undefined = await 
this.q().get(Stores.proposals, proposalId);
+
+    if (!proposal) {
+      throw Error(`proposal with id ${proposalId} not found`);
+    }
+
+    const purchase = await this.q().get(Stores.purchases, 
proposal.contractTermsHash);
+    if (!purchase) {
+      throw Error("purchase not found for proposal");
+    }
+
+    if (purchase.finished) {
+      throw Error("can't auto-refund finished purchase");
+    }
+  }
+
+
   async submitPay(contractTermsHash: string, sessionId: string | undefined): 
Promise<ConfirmPayResult> {
     const purchase = await this.q().get(Stores.purchases, contractTermsHash);
     if (!purchase) {
       throw Error("Purchase not found: " + contractTermsHash);
     }
+    if (purchase.abortRequested) {
+      throw Error("not submitting payment for aborted purchase");
+    }
     let resp;
     const payReq = { ...purchase.payReq, session_id: sessionId };
+
     try {
       const config = {
         headers: { "Content-Type": "application/json;charset=UTF-8" },
@@ -737,14 +764,6 @@ export class Wallet {
     }
     const merchantResp = resp.data;
     console.log("got success from pay_url");
-    const fu = new URI(purchase.contractTerms.fulfillment_url);
-    fu.addSearch("order_id", purchase.contractTerms.order_id);
-    if (merchantResp.session_sig) {
-      purchase.lastSessionSig = merchantResp.session_sig;
-      purchase.lastSessionId = sessionId;
-      fu.addSearch("session_sig", merchantResp.session_sig);
-      await this.q().put(Stores.purchases, purchase).finish();
-    }
 
     const merchantPub = purchase.contractTerms.merchant_pub;
     const valid: boolean = await (
@@ -767,6 +786,14 @@ export class Wallet {
       modifiedCoins.push(c);
     }
 
+    const fu = new URI(purchase.contractTerms.fulfillment_url);
+    fu.addSearch("order_id", purchase.contractTerms.order_id);
+    if (merchantResp.session_sig) {
+      purchase.lastSessionSig = merchantResp.session_sig;
+      purchase.lastSessionId = sessionId;
+      fu.addSearch("session_sig", merchantResp.session_sig);
+    }
+
     await this.q()
               .putAll(Stores.coins, modifiedCoins)
               .put(Stores.purchases, purchase)
@@ -782,8 +809,7 @@ export class Wallet {
 
 
   /**
-   * Add a contract to the wallet and sign coins,
-   * but do not send them yet.
+   * Add a contract to the wallet and sign coins, and send them.
    */
   async confirmPay(proposalId: number, sessionId: string | undefined): 
Promise<ConfirmPayResult> {
     console.log(`executing confirmPay with proposalId ${proposalId} and 
sessionId ${sessionId}`);
@@ -860,6 +886,7 @@ export class Wallet {
     return sp;
   }
 
+
   /**
    * Check if payment for an offer is possible, or if the offer has already
    * been payed for.
@@ -1295,6 +1322,7 @@ export class Wallet {
     return wiJson;
   }
 
+
   async getPossibleDenoms(exchangeBaseUrl: string) {
     return (
       this.q().iterIndex(Stores.denominations.exchangeBaseUrlIndex,
@@ -2522,46 +2550,13 @@ export class Wallet {
     }
   }
 
-  /**
-   * Accept a refund, return the contract hash for the contract
-   * that was involved in the refund.
-   */
-  async acceptRefund(refundUrl: string): Promise<string> {
-    console.log("processing refund");
-    let resp;
-    try {
-      const config = {
-        validateStatus: (s: number) => s === 200,
-      };
-      resp = await axios.get(refundUrl, config);
-    } catch (e) {
-      console.log("error downloading refund permission", e);
-      throw e;
-    }
-
-    // FIXME: validate schema
-    const refundPermissions = resp.data.refund_permissions;
+  async acceptRefundResponse(refundResponse: MerchantRefundResponse): 
Promise<string> {
+    const refundPermissions = refundResponse.refund_permissions;
 
     if (!refundPermissions.length) {
       console.warn("got empty refund list");
       throw Error("empty refund");
     }
-    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.
@@ -2582,6 +2577,8 @@ export class Wallet {
       return t;
     }
 
+    const hc = refundResponse.h_contract_terms;
+
     // Add the refund permissions to the purchase within a DB transaction
     await this.q().mutate(Stores.purchases, hc, f).finish();
     this.notifier.notify();
@@ -2589,7 +2586,29 @@ export class Wallet {
     // Start submitting it but don't wait for it here.
     this.submitRefunds(hc);
 
-    return refundPermissions[0].h_contract_terms;
+    return hc;
+  }
+
+
+  /**
+   * Accept a refund, return the contract hash for the contract
+   * that was involved in the refund.
+   */
+  async acceptRefund(refundUrl: string): Promise<string> {
+    console.log("processing refund");
+    let resp;
+    try {
+      const config = {
+        validateStatus: (s: number) => s === 200,
+      };
+      resp = await axios.get(refundUrl, config);
+    } catch (e) {
+      console.log("error downloading refund permission", e);
+      throw e;
+    }
+
+    const refundResponse = MerchantRefundResponse.checked(resp.data);
+    return this.acceptRefundResponse(refundResponse);
   }
 
 
@@ -2605,11 +2624,20 @@ export class Wallet {
     }
     for (const pk of pendingKeys) {
       const perm = purchase.refundsPending[pk];
+      const req: RefundRequest = {
+        coin_pub: perm.coin_pub,
+        h_contract_terms: purchase.contractTermsHash,
+        merchant_pub: purchase.contractTerms.merchant_pub,
+        merchant_sig: perm.merchant_sig,
+        refund_amount: perm.refund_amount,
+        refund_fee: perm.refund_fee,
+        rtransaction_id: perm.rtransaction_id,
+      };
       console.log("sending refund permission", perm);
       // FIXME: not correct once we support multiple exchanges per payment
       const exchangeUrl = purchase.payReq.coins[0].exchange_url;
       const reqUrl = (new URI("refund")).absoluteTo(exchangeUrl);
-      const resp = await this.http.postJson(reqUrl.href(), perm);
+      const resp = await this.http.postJson(reqUrl.href(), req);
       if (resp.status !== 200) {
         console.error("refund failed", resp);
         continue;
@@ -2654,7 +2682,7 @@ export class Wallet {
     return this.q().get(Stores.purchases, contractTermsHash);
   }
 
-  async getFullRefundFees(refundPermissions: RefundPermission[]): 
Promise<AmountJson> {
+  async getFullRefundFees(refundPermissions: MerchantRefundPermission[]): 
Promise<AmountJson> {
     if (refundPermissions.length === 0) {
       throw Error("no refunds given");
     }
@@ -2829,6 +2857,54 @@ export class Wallet {
   }
 
 
+  async abortFailedPayment(contractTermsHash: string): Promise<void> {
+    const purchase = await this.q().get(Stores.purchases, contractTermsHash);
+    if (!purchase) {
+      throw Error("Purchase not found, unable to abort with refund");
+    }
+    if (purchase.finished) {
+      throw Error("Purchase already finished, not aborting");
+    }
+    if (purchase.abortDone) {
+      console.warn("abort requested on already aborted purchase");
+      return;
+    }
+
+    purchase.abortRequested = true;
+
+    // From now on, we can't retry payment anymore,
+    // so mark this in the DB in case the /pay abort
+    // does not complete on the first try.
+    await this.q().put(Stores.purchases, purchase);
+
+    let resp;
+
+    const abortReq = { ...purchase.payReq, mode: "abort-refund" };
+
+    try {
+      const config = {
+        headers: { "Content-Type": "application/json;charset=UTF-8" },
+        timeout: 5000, /* 5 seconds */
+        validateStatus: (s: number) => s === 200,
+      };
+      resp = await axios.post(purchase.contractTerms.pay_url, abortReq, 
config);
+    } catch (e) {
+      // Gives the user the option to retry / abort and refresh
+      console.log("aborting payment failed", e);
+      throw e;
+    }
+
+    const refundResponse = MerchantRefundResponse.checked(resp.data);
+    await this.acceptRefundResponse(refundResponse);
+
+    const markAbortDone = (p: PurchaseRecord) => {
+      p.abortDone = true;
+      return p;
+    };
+    await this.q().mutate(Stores.purchases, purchase.contractTermsHash, 
markAbortDone);
+  }
+
+
   /**
    * Synchronously get the paid URL for a resource from the plain fulfillment
    * URL.  Returns undefined if the fulfillment URL is not a resource that was
diff --git a/src/webex/messages.ts b/src/webex/messages.ts
index 9a7dc8fd..45cac6a9 100644
--- a/src/webex/messages.ts
+++ b/src/webex/messages.ts
@@ -170,7 +170,7 @@ export interface MessageMap {
     response: dbTypes.PurchaseRecord;
   };
   "get-full-refund-fees": {
-    request: { refundPermissions: talerTypes.RefundPermission[] };
+    request: { refundPermissions: talerTypes.MerchantRefundPermission[] };
     response: AmountJson;
   };
   "accept-tip": {
@@ -201,6 +201,10 @@ export interface MessageMap {
     request: { refundUrl: string }
     response: string;
   };
+  "abort-failed-payment": {
+    request: { contractTermsHash: string }
+    response: void;
+  };
 }
 
 /**
diff --git a/src/webex/pages/confirm-contract.tsx 
b/src/webex/pages/confirm-contract.tsx
index 7fe6b960..f41dba06 100644
--- a/src/webex/pages/confirm-contract.tsx
+++ b/src/webex/pages/confirm-contract.tsx
@@ -40,6 +40,7 @@ import * as wxApi from "../wxApi";
 import * as React from "react";
 import * as ReactDOM from "react-dom";
 import URI = require("urijs");
+import { WalletApiError } from "../wxApi";
 
 
 interface DetailState {
@@ -111,7 +112,8 @@ interface ContractPromptProps {
 interface ContractPromptState {
   proposalId: number | undefined;
   proposal: ProposalDownloadRecord | undefined;
-  error: string |  null;
+  checkPayError: string | undefined;
+  confirmPayError: object | undefined;
   payDisabled: boolean;
   alreadyPaid: boolean;
   exchanges: ExchangeRecord[] | undefined;
@@ -124,21 +126,30 @@ interface ContractPromptState {
   payStatus?: CheckPayResult;
   replaying: boolean;
   payInProgress: boolean;
+  payAttempt: number;
+  working: boolean;
+  abortDone: boolean;
+  abortStarted: boolean;
 }
 
 class ContractPrompt extends React.Component<ContractPromptProps, 
ContractPromptState> {
   constructor(props: ContractPromptProps) {
     super(props);
     this.state = {
+      abortDone: false,
+      abortStarted: false,
       alreadyPaid: false,
-      error: null,
+      checkPayError: undefined,
+      confirmPayError: undefined,
       exchanges: undefined,
       holdCheck: false,
+      payAttempt: 0,
       payDisabled: true,
       payInProgress: false,
       proposal: undefined,
       proposalId: props.proposalId,
       replaying: false,
+      working: false,
     };
   }
 
@@ -154,7 +165,7 @@ class ContractPrompt extends 
React.Component<ContractPromptProps, ContractPrompt
     if (this.props.resourceUrl) {
       const p = await 
wxApi.queryPaymentByFulfillmentUrl(this.props.resourceUrl);
       console.log("query for resource url", this.props.resourceUrl, "result", 
p);
-      if (p) {
+      if (p && p.finished) {
         if (p.lastSessionSig === undefined || p.lastSessionSig === 
this.props.sessionId) {
           const nextUrl = new URI(p.contractTerms.fulfillment_url);
           nextUrl.addSearch("order_id", p.contractTerms.order_id);
@@ -166,6 +177,8 @@ class ContractPrompt extends 
React.Component<ContractPromptProps, ContractPrompt
         } else {
           // We're in a new session
           this.setState({ replaying: true });
+          // FIXME:  This could also go wrong.  However the payment
+          // was already successful once, so we can just retry and not refund 
it.
           const payResult = await wxApi.submitPay(p.contractTermsHash, 
this.props.sessionId);
           console.log("payResult", payResult);
           location.replace(payResult.nextUrl);
@@ -206,24 +219,24 @@ class ContractPrompt extends 
React.Component<ContractPromptProps, ContractPrompt
         const acceptedExchangePubs = 
this.state.proposal.contractTerms.exchanges.map((e) => e.master_pub);
         const ex = this.state.exchanges.find((e) => 
acceptedExchangePubs.indexOf(e.masterPublicKey) >= 0);
         if (ex) {
-          this.setState({ error: msgInsufficient });
+          this.setState({ checkPayError: msgInsufficient });
         } else {
-          this.setState({ error: msgNoMatch });
+          this.setState({ checkPayError: msgNoMatch });
         }
       } else {
-        this.setState({ error: msgInsufficient });
+        this.setState({ checkPayError: msgInsufficient });
       }
       this.setState({ payDisabled: true });
     } else if (payStatus.status === "paid") {
-      this.setState({ alreadyPaid: true, payDisabled: false, error: null, 
payStatus });
+      this.setState({ alreadyPaid: true, payDisabled: false, checkPayError: 
undefined, payStatus });
     } else {
-      this.setState({ payDisabled: false, error: null, payStatus });
+      this.setState({ payDisabled: false, checkPayError: undefined, payStatus 
});
     }
   }
 
   async doPayment() {
     const proposal = this.state.proposal;
-    this.setState({holdCheck: true});
+    this.setState({ holdCheck: true, payAttempt: this.state.payAttempt + 1});
     if (!proposal) {
       return;
     }
@@ -234,11 +247,17 @@ class ContractPrompt extends 
React.Component<ContractPromptProps, ContractPrompt
     }
     console.log("confirmPay with", proposalId, "and", this.props.sessionId);
     let payResult;
+    this.setState({ working: true });
     try {
       payResult = await wxApi.confirmPay(proposalId, this.props.sessionId);
     } catch (e) {
-
+      if (!(e instanceof WalletApiError)) {
+        throw e;
+      }
+      this.setState({ confirmPayError: e.detail });
       return;
+    } finally {
+      this.setState({ working: false });
     }
     console.log("payResult", payResult);
     document.location.href = payResult.nextUrl;
@@ -246,6 +265,17 @@ class ContractPrompt extends 
React.Component<ContractPromptProps, ContractPrompt
   }
 
 
+  async abortPayment() {
+    const proposal = this.state.proposal;
+    this.setState({ holdCheck: true, abortStarted: true });
+    if (!proposal) {
+      return;
+    }
+    wxApi.abortFailedPayment(proposal.contractTermsHash);
+    this.setState({ abortDone: true });
+  }
+
+
   render() {
     if (this.props.contractUrl === undefined && this.props.proposalId === 
undefined) {
       return <span>Error: either contractUrl or proposalId must be 
given</span>;
@@ -272,18 +302,72 @@ class ContractPrompt extends 
React.Component<ContractPromptProps, ContractPrompt
     let products = null;
     if (c.products.length) {
       products = (
-        <>
+        <div>
           <span>The following items are included:</span>
           <ul>
             {c.products.map(
               (p: any, i: number) => (<li key={i}>{p.description}: 
{renderAmount(p.price)}</li>))
             }
           </ul>
-      </>
+      </div>
       );
     }
+
+    const ConfirmButton = () => (
+      <button className="pure-button button-success"
+              disabled={this.state.payDisabled}
+              onClick={() => this.doPayment()}>
+        {i18n.str`Confirm payment`}
+      </button>
+    );
+
+    const WorkingButton = () => (
+      <div>
+      <button className="pure-button button-success"
+              disabled={this.state.payDisabled}
+              onClick={() => this.doPayment()}>
+        <span><object className="svg-icon svg-baseline" 
data="/img/spinner-bars.svg" /> </span>
+        {i18n.str`Submitting payment`}
+      </button>
+      </div>
+    );
+
+    const ConfirmPayDialog = () => (
+      <div>
+        {this.state.working ? WorkingButton() : ConfirmButton()}
+        <div>
+          {(this.state.alreadyPaid
+            ? <p className="okaybox">
+                You already paid for this, clicking "Confirm payment" will not 
cost money again.
+              </p>
+            : <p />)}
+          {(this.state.checkPayError ? <p 
className="errorbox">{this.state.checkPayError}</p> : <p />)}
+        </div>
+        <Details exchanges={this.state.exchanges} contractTerms={c} 
collapsed={!this.state.checkPayError}/>
+      </div>
+    );
+
+    const PayErrorDialog = () => (
+      <div>
+        <p>There was an error paying (attempt #{this.state.payAttempt}):</p>
+        <pre>{JSON.stringify(this.state.confirmPayError)}</pre>
+        { this.state.abortStarted
+        ? <span>Aborting payment ...</span>
+        : this.state.abortDone
+        ? <span>Payment aborted!</span>
+        : <>
+            <button className="pure-button" onClick={() => this.doPayment()}>
+              Retry Payment
+            </button>
+            <button className="pure-button" onClick={() => 
this.abortPayment()}>
+              Abort Payment
+            </button>
+          </>
+        }
+      </div>
+    );
+
     return (
-      <>
         <div>
           <i18n.Translate wrap="p">
             The merchant <span>{merchantName}</span> {" "}
@@ -302,22 +386,11 @@ class ContractPrompt extends 
React.Component<ContractPromptProps, ContractPrompt
             :
             <p>The total price is <span>{amount}</span>.</p>
           }
+          { this.state.confirmPayError
+            ? PayErrorDialog()
+            : ConfirmPayDialog()
+          }
         </div>
-        <button className="pure-button button-success"
-                disabled={this.state.payDisabled}
-                onClick={() => this.doPayment()}>
-          {i18n.str`Confirm payment`}
-        </button>
-        <div>
-          {(this.state.alreadyPaid
-            ? <p className="okaybox">
-                You already paid for this, clicking "Confirm payment" will not 
cost money again.
-              </p>
-            : <p />)}
-          {(this.state.error ? <p className="errorbox">{this.state.error}</p> 
: <p />)}
-        </div>
-        <Details exchanges={this.state.exchanges} contractTerms={c} 
collapsed={!this.state.error}/>
-      </>
     );
   }
 }
diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts
index a1b0380b..ee1ca23b 100644
--- a/src/webex/wxApi.ts
+++ b/src/webex/wxApi.ts
@@ -43,7 +43,7 @@ import {
 } from "../walletTypes";
 
 import {
-  RefundPermission,
+  MerchantRefundPermission,
   TipToken,
 } from "../talerTypes";
 
@@ -72,14 +72,22 @@ export interface UpgradeResponse {
 }
 
 
+export class WalletApiError extends Error {
+  constructor(message: string, public detail: any) {
+    super(message);
+  }
+}
+
+
 async function callBackend<T extends MessageType>(
   type: T,
   detail: MessageMap[T]["request"],
 ): Promise<MessageMap[T]["response"]> {
   return new Promise<MessageMap[T]["response"]>((resolve, reject) => {
     chrome.runtime.sendMessage({ type, detail }, (resp) => {
-      if (resp && resp.error) {
-        reject(resp);
+      if (typeof resp === "object" && resp && resp.error) {
+        const e = new WalletApiError(resp.error.message, resp);
+        reject(e);
       } else {
         resolve(resp);
       }
@@ -327,7 +335,7 @@ export function getPurchase(contractTermsHash: string): 
Promise<PurchaseRecord>
  * Get the refund fees for a refund permission, including
  * subsequent refresh and unrefreshable coins.
  */
-export function getFullRefundFees(args: { refundPermissions: 
RefundPermission[] }): Promise<AmountJson> {
+export function getFullRefundFees(args: { refundPermissions: 
MerchantRefundPermission[] }): Promise<AmountJson> {
   return callBackend("get-full-refund-fees", { refundPermissions: 
args.refundPermissions });
 }
 
@@ -374,3 +382,10 @@ export function downloadProposal(url: string): 
Promise<number> {
 export function acceptRefund(refundUrl: string): Promise<string> {
   return callBackend("accept-refund", { refundUrl });
 }
+
+/**
+ * Abort a failed payment and try to get a refund.
+ */
+export function abortFailedPayment(contractTermsHash: string) {
+  return callBackend("abort-failed-payment", { contractTermsHash });
+}
diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts
index 98b543d2..a778cc98 100644
--- a/src/webex/wxBackend.ts
+++ b/src/webex/wxBackend.ts
@@ -308,6 +308,12 @@ function handleMessage(sender: MessageSender,
     case "download-proposal": {
       return needsWallet().downloadProposal(detail.url);
     }
+    case "abort-failed-payment": {
+      if (!detail.contractTermsHash) {
+        throw Error("contracTermsHash not given");
+      }
+      return needsWallet().abortFailedPayment(detail.contractTermsHash);
+    }
     case "taler-pay": {
       const senderUrl = sender.url;
       if (!senderUrl) {
@@ -514,7 +520,7 @@ function handleHttpPayment(headerList: 
chrome.webRequest.HttpHeader[], url: stri
     console.log("processing refund");
     const uri = new 
URI(chrome.extension.getURL("/src/webex/pages/refund.html"));
     uri.query({ refundUrl: fields.refund_url });
-    return { redirectUrl: uri.href };
+    return { redirectUrl: uri.href() };
   }
 
   // We need to do some asynchronous operation, we can't directly redirect

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



reply via email to

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