From 1a66e232a55dff8c889e5554f637f4d4e475179c Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 29 Jan 2018 16:41:17 +0100 Subject: 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(-) (limited to 'src') 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,45 +475,120 @@ 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. */ 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 { - 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 { 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 { 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 { - 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 { + 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 { + 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 { + async getFullRefundFees(refundPermissions: MerchantRefundPermission[]): Promise { if (refundPermissions.length === 0) { throw Error("no refunds given"); } @@ -2829,6 +2857,54 @@ export class Wallet { } + async abortFailedPayment(contractTermsHash: string): Promise { + 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 { 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 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.ComponentError: either contractUrl or proposalId must be given; @@ -272,18 +302,72 @@ class ContractPrompt extends React.Component +
The following items are included:
    {c.products.map( (p: any, i: number) => (
  • {p.description}: {renderAmount(p.price)}
  • )) }
- +
); } + + const ConfirmButton = () => ( + + ); + + const WorkingButton = () => ( +
+