diff options
author | Florian Dold <florian.dold@gmail.com> | 2018-01-29 16:41:17 +0100 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2018-01-29 16:41:17 +0100 |
commit | 1a66e232a55dff8c889e5554f637f4d4e475179c (patch) | |
tree | e02390f0edfecf5e925d44a71c62056060819886 | |
parent | c8c03e381e252dc3a73a2c35bb1cd2ee24eeaabb (diff) |
implement aborting and getting refunds from failed payments
-rw-r--r-- | src/dbTypes.ts | 46 | ||||
-rw-r--r-- | src/i18n/de.po | 29 | ||||
-rw-r--r-- | src/i18n/en-US.po | 25 | ||||
-rw-r--r-- | src/i18n/fr.po | 25 | ||||
-rw-r--r-- | src/i18n/it.po | 25 | ||||
-rw-r--r-- | src/i18n/strings.ts | 30 | ||||
-rw-r--r-- | src/i18n/taler-wallet-webex.pot | 25 | ||||
-rw-r--r-- | src/talerTypes.ts | 89 | ||||
-rw-r--r-- | src/wallet.ts | 176 | ||||
-rw-r--r-- | src/webex/messages.ts | 6 | ||||
-rw-r--r-- | src/webex/pages/confirm-contract.tsx | 129 | ||||
-rw-r--r-- | src/webex/wxApi.ts | 23 | ||||
-rw-r--r-- | src/webex/wxBackend.ts | 8 |
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 |