summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/dbTypes.ts46
-rw-r--r--src/i18n/de.po29
-rw-r--r--src/i18n/en-US.po25
-rw-r--r--src/i18n/fr.po25
-rw-r--r--src/i18n/it.po25
-rw-r--r--src/i18n/strings.ts30
-rw-r--r--src/i18n/taler-wallet-webex.pot25
-rw-r--r--src/talerTypes.ts89
-rw-r--r--src/wallet.ts176
-rw-r--r--src/webex/messages.ts6
-rw-r--r--src/webex/pages/confirm-contract.tsx129
-rw-r--r--src/webex/wxApi.ts23
-rw-r--r--src/webex/wxBackend.ts8
13 files changed, 481 insertions, 155 deletions
diff --git a/src/dbTypes.ts b/src/dbTypes.ts
index 035c100a9..6c467ce74 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 1a003c17d..398fdfab0 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 3d3fd4332..68faa6bae 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 08f4a9d0c..93077fb33 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 08f4a9d0c..93077fb33 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 9e78abc39..072bd9532 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 08f4a9d0c..93077fb33 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 d593c3d34..611d667c5 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 {
+@Checkable.Class()
+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.
+ */
+@Checkable.Class()
+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 8167556f8..34b2388e3 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 9a7dc8fd4..45cac6a9f 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 7fe6b9600..f41dba069 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 a1b0380b9..ee1ca23ba 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 98b543d28..a778cc986 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