implement aborting and getting refunds from failed payments

This commit is contained in:
Florian Dold 2018-01-29 16:41:17 +01:00
parent c8c03e381e
commit 1a66e232a5
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
13 changed files with 494 additions and 168 deletions

View File

@ -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;
}

View File

@ -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 ""

View File

@ -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
#, c-format
msgid "Confirm payment"
msgstr ""
#: 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 ""

View File

@ -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
#, c-format
msgid "Confirm payment"
msgstr ""
#: 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 ""

View File

@ -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
#, c-format
msgid "Confirm payment"
msgstr ""
#: 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 ""

View File

@ -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": [

View File

@ -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
#, c-format
msgid "Confirm payment"
msgstr ""
#: 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 ""

View File

@ -475,42 +475,117 @@ 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;
/**
* Contract terms hash to identify the contract that this
* refund is for.
*/
h_contract_terms: string;
/**
* Public key of the coin being refunded.
*/
@Checkable.String
coin_pub: string;
/**
* Refund transaction ID between merchant and exchange.
*/
@Checkable.Number
rtransaction_id: number;
/**
* Public key of the merchant.
*/
merchant_pub: string;
/**
* 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;
/**
* SHA-512 hash of the contact of the merchant with the customer.
*/
h_contract_terms: string;
/**
* coin's public key, both ECDHE and EdDSA.
*/
coin_pub: string;
/**
* 64-bit transaction id of the refund transaction between merchant and customer
*/
rtransaction_id: number;
/**
* EdDSA public key of the merchant.
*/
merchant_pub: string;
/**
* 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;
}

View File

@ -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

View File

@ -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;
};
}
/**

View File

@ -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}/>
</>
);
}
}

View File

@ -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 });
}

View File

@ -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