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 /src/webex | |
parent | c8c03e381e252dc3a73a2c35bb1cd2ee24eeaabb (diff) | |
download | wallet-core-1a66e232a55dff8c889e5554f637f4d4e475179c.tar.xz |
implement aborting and getting refunds from failed payments
Diffstat (limited to 'src/webex')
-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 |
4 files changed, 132 insertions, 34 deletions
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 |