2019-12-02 00:42:40 +01:00
|
|
|
/*
|
|
|
|
This file is part of GNU Taler
|
|
|
|
(C) 2019 GNUnet e.V.
|
|
|
|
|
|
|
|
GNU Taler is free software; you can redistribute it and/or modify it under the
|
|
|
|
terms of the GNU General Public License as published by the Free Software
|
|
|
|
Foundation; either version 3, or (at your option) any later version.
|
|
|
|
|
|
|
|
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
|
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
|
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
|
|
|
|
|
|
You should have received a copy of the GNU General Public License along with
|
|
|
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { AmountJson } from "../util/amounts";
|
|
|
|
import {
|
|
|
|
Auditor,
|
|
|
|
ExchangeHandle,
|
|
|
|
MerchantRefundResponse,
|
|
|
|
PayReq,
|
|
|
|
Proposal,
|
|
|
|
ContractTerms,
|
2019-12-03 14:40:05 +01:00
|
|
|
MerchantRefundPermission,
|
|
|
|
RefundRequest,
|
2019-12-02 00:42:40 +01:00
|
|
|
} from "../talerTypes";
|
|
|
|
import {
|
|
|
|
Timestamp,
|
|
|
|
CoinSelectionResult,
|
|
|
|
CoinWithDenom,
|
|
|
|
PayCoinInfo,
|
|
|
|
getTimestampNow,
|
|
|
|
PreparePayResult,
|
|
|
|
ConfirmPayResult,
|
|
|
|
} from "../walletTypes";
|
|
|
|
import {
|
|
|
|
oneShotIter,
|
|
|
|
oneShotIterIndex,
|
|
|
|
oneShotGet,
|
|
|
|
runWithWriteTransaction,
|
|
|
|
oneShotPut,
|
|
|
|
oneShotGetIndexed,
|
2019-12-03 14:40:05 +01:00
|
|
|
oneShotMutate,
|
2019-12-02 00:42:40 +01:00
|
|
|
} from "../util/query";
|
|
|
|
import {
|
|
|
|
Stores,
|
|
|
|
CoinStatus,
|
|
|
|
DenominationRecord,
|
|
|
|
ProposalRecord,
|
|
|
|
PurchaseRecord,
|
|
|
|
CoinRecord,
|
|
|
|
ProposalStatus,
|
|
|
|
} from "../dbTypes";
|
|
|
|
import * as Amounts from "../util/amounts";
|
|
|
|
import {
|
|
|
|
amountToPretty,
|
|
|
|
strcmp,
|
|
|
|
extractTalerStamp,
|
|
|
|
canonicalJson,
|
2019-12-03 00:52:15 +01:00
|
|
|
extractTalerStampOrThrow,
|
2019-12-02 00:42:40 +01:00
|
|
|
} from "../util/helpers";
|
|
|
|
import { Logger } from "../util/logging";
|
|
|
|
import { InternalWalletState } from "./state";
|
2019-12-03 14:40:05 +01:00
|
|
|
import { parsePayUri, parseRefundUri } from "../util/taleruri";
|
2019-12-02 00:42:40 +01:00
|
|
|
import { getTotalRefreshCost, refresh } from "./refresh";
|
|
|
|
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
|
|
|
|
|
|
|
|
export interface SpeculativePayData {
|
|
|
|
payCoinInfo: PayCoinInfo;
|
|
|
|
exchangeUrl: string;
|
|
|
|
orderDownloadId: string;
|
|
|
|
proposal: ProposalRecord;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface CoinsForPaymentArgs {
|
|
|
|
allowedAuditors: Auditor[];
|
|
|
|
allowedExchanges: ExchangeHandle[];
|
|
|
|
depositFeeLimit: AmountJson;
|
|
|
|
paymentAmount: AmountJson;
|
|
|
|
wireFeeAmortization: number;
|
|
|
|
wireFeeLimit: AmountJson;
|
|
|
|
wireFeeTime: Timestamp;
|
|
|
|
wireMethod: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface SelectPayCoinsResult {
|
|
|
|
cds: CoinWithDenom[];
|
|
|
|
totalFees: AmountJson;
|
|
|
|
}
|
|
|
|
|
|
|
|
const logger = new Logger("pay.ts");
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Select coins for a payment under the merchant's constraints.
|
|
|
|
*
|
|
|
|
* @param denoms all available denoms, used to compute refresh fees
|
|
|
|
*/
|
|
|
|
export function selectPayCoins(
|
|
|
|
denoms: DenominationRecord[],
|
|
|
|
cds: CoinWithDenom[],
|
|
|
|
paymentAmount: AmountJson,
|
|
|
|
depositFeeLimit: AmountJson,
|
|
|
|
): SelectPayCoinsResult | undefined {
|
|
|
|
if (cds.length === 0) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
// Sort by ascending deposit fee and denomPub if deposit fee is the same
|
|
|
|
// (to guarantee deterministic results)
|
|
|
|
cds.sort(
|
|
|
|
(o1, o2) =>
|
|
|
|
Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) ||
|
|
|
|
strcmp(o1.denom.denomPub, o2.denom.denomPub),
|
|
|
|
);
|
|
|
|
const currency = cds[0].denom.value.currency;
|
|
|
|
const cdsResult: CoinWithDenom[] = [];
|
|
|
|
let accDepositFee: AmountJson = Amounts.getZero(currency);
|
|
|
|
let accAmount: AmountJson = Amounts.getZero(currency);
|
|
|
|
for (const { coin, denom } of cds) {
|
|
|
|
if (coin.suspended) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
if (coin.status !== CoinStatus.Fresh) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
cdsResult.push({ coin, denom });
|
|
|
|
accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount;
|
|
|
|
let leftAmount = Amounts.sub(
|
|
|
|
coin.currentAmount,
|
|
|
|
Amounts.sub(paymentAmount, accAmount).amount,
|
|
|
|
).amount;
|
|
|
|
accAmount = Amounts.add(coin.currentAmount, accAmount).amount;
|
|
|
|
const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0;
|
|
|
|
const coversAmountWithFee =
|
|
|
|
Amounts.cmp(
|
|
|
|
accAmount,
|
|
|
|
Amounts.add(paymentAmount, denom.feeDeposit).amount,
|
|
|
|
) >= 0;
|
|
|
|
const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0;
|
|
|
|
|
|
|
|
logger.trace("candidate coin selection", {
|
|
|
|
coversAmount,
|
|
|
|
isBelowFee,
|
|
|
|
accDepositFee,
|
|
|
|
accAmount,
|
|
|
|
paymentAmount,
|
|
|
|
});
|
|
|
|
|
|
|
|
if ((coversAmount && isBelowFee) || coversAmountWithFee) {
|
|
|
|
const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit)
|
|
|
|
.amount;
|
|
|
|
leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount;
|
|
|
|
logger.trace("deposit fee to cover", amountToPretty(depositFeeToCover));
|
|
|
|
let totalFees: AmountJson = Amounts.getZero(currency);
|
|
|
|
if (coversAmountWithFee && !isBelowFee) {
|
|
|
|
// these are the fees the customer has to pay
|
|
|
|
// because the merchant doesn't cover them
|
|
|
|
totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount;
|
|
|
|
}
|
|
|
|
totalFees = Amounts.add(
|
|
|
|
totalFees,
|
|
|
|
getTotalRefreshCost(denoms, denom, leftAmount),
|
|
|
|
).amount;
|
|
|
|
return { cds: cdsResult, totalFees };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get exchanges and associated coins that are still spendable, but only
|
|
|
|
* if the sum the coins' remaining value covers the payment amount and fees.
|
|
|
|
*/
|
|
|
|
async function getCoinsForPayment(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
args: CoinsForPaymentArgs,
|
|
|
|
): Promise<CoinSelectionResult | undefined> {
|
|
|
|
const {
|
|
|
|
allowedAuditors,
|
|
|
|
allowedExchanges,
|
|
|
|
depositFeeLimit,
|
|
|
|
paymentAmount,
|
|
|
|
wireFeeAmortization,
|
|
|
|
wireFeeLimit,
|
|
|
|
wireFeeTime,
|
|
|
|
wireMethod,
|
|
|
|
} = args;
|
|
|
|
|
|
|
|
let remainingAmount = paymentAmount;
|
|
|
|
|
|
|
|
const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray();
|
|
|
|
|
|
|
|
for (const exchange of exchanges) {
|
|
|
|
let isOkay: boolean = false;
|
|
|
|
const exchangeDetails = exchange.details;
|
|
|
|
if (!exchangeDetails) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
const exchangeFees = exchange.wireInfo;
|
|
|
|
if (!exchangeFees) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// is the exchange explicitly allowed?
|
|
|
|
for (const allowedExchange of allowedExchanges) {
|
|
|
|
if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) {
|
|
|
|
isOkay = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// is the exchange allowed because of one of its auditors?
|
|
|
|
if (!isOkay) {
|
|
|
|
for (const allowedAuditor of allowedAuditors) {
|
|
|
|
for (const auditor of exchangeDetails.auditors) {
|
|
|
|
if (auditor.auditor_pub === allowedAuditor.auditor_pub) {
|
|
|
|
isOkay = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (isOkay) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isOkay) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
const coins = await oneShotIterIndex(
|
|
|
|
ws.db,
|
|
|
|
Stores.coins.exchangeBaseUrlIndex,
|
|
|
|
exchange.baseUrl,
|
|
|
|
).toArray();
|
|
|
|
|
|
|
|
const denoms = await oneShotIterIndex(
|
|
|
|
ws.db,
|
|
|
|
Stores.denominations.exchangeBaseUrlIndex,
|
|
|
|
exchange.baseUrl,
|
|
|
|
).toArray();
|
|
|
|
|
|
|
|
if (!coins || coins.length === 0) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Denomination of the first coin, we assume that all other
|
|
|
|
// coins have the same currency
|
|
|
|
const firstDenom = await oneShotGet(ws.db, Stores.denominations, [
|
|
|
|
exchange.baseUrl,
|
|
|
|
coins[0].denomPub,
|
|
|
|
]);
|
|
|
|
if (!firstDenom) {
|
|
|
|
throw Error("db inconsistent");
|
|
|
|
}
|
|
|
|
const currency = firstDenom.value.currency;
|
|
|
|
const cds: CoinWithDenom[] = [];
|
|
|
|
for (const coin of coins) {
|
|
|
|
const denom = await oneShotGet(ws.db, Stores.denominations, [
|
|
|
|
exchange.baseUrl,
|
|
|
|
coin.denomPub,
|
|
|
|
]);
|
|
|
|
if (!denom) {
|
|
|
|
throw Error("db inconsistent");
|
|
|
|
}
|
|
|
|
if (denom.value.currency !== currency) {
|
|
|
|
console.warn(
|
|
|
|
`same pubkey for different currencies at exchange ${exchange.baseUrl}`,
|
|
|
|
);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
if (coin.suspended) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
if (coin.status !== CoinStatus.Fresh) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
cds.push({ coin, denom });
|
|
|
|
}
|
|
|
|
|
|
|
|
let totalFees = Amounts.getZero(currency);
|
|
|
|
let wireFee: AmountJson | undefined;
|
|
|
|
for (const fee of exchangeFees.feesForType[wireMethod] || []) {
|
|
|
|
if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) {
|
|
|
|
wireFee = fee.wireFee;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (wireFee) {
|
|
|
|
const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization);
|
|
|
|
if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) {
|
|
|
|
totalFees = Amounts.add(amortizedWireFee, totalFees).amount;
|
|
|
|
remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit);
|
|
|
|
|
|
|
|
if (res) {
|
|
|
|
totalFees = Amounts.add(totalFees, res.totalFees).amount;
|
|
|
|
return {
|
|
|
|
cds: res.cds,
|
|
|
|
exchangeUrl: exchange.baseUrl,
|
|
|
|
totalAmount: remainingAmount,
|
|
|
|
totalFees,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Record all information that is necessary to
|
|
|
|
* pay for a proposal in the wallet's database.
|
|
|
|
*/
|
|
|
|
async function recordConfirmPay(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposal: ProposalRecord,
|
|
|
|
payCoinInfo: PayCoinInfo,
|
|
|
|
chosenExchange: string,
|
|
|
|
): Promise<PurchaseRecord> {
|
2019-12-03 00:52:15 +01:00
|
|
|
const d = proposal.download;
|
|
|
|
if (!d) {
|
|
|
|
throw Error("proposal is in invalid state");
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
const payReq: PayReq = {
|
|
|
|
coins: payCoinInfo.sigs,
|
2019-12-03 00:52:15 +01:00
|
|
|
merchant_pub: d.contractTerms.merchant_pub,
|
2019-12-02 00:42:40 +01:00
|
|
|
mode: "pay",
|
2019-12-03 00:52:15 +01:00
|
|
|
order_id: d.contractTerms.order_id,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
const t: PurchaseRecord = {
|
|
|
|
abortDone: false,
|
|
|
|
abortRequested: false,
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms: d.contractTerms,
|
|
|
|
contractTermsHash: d.contractTermsHash,
|
2019-12-02 00:42:40 +01:00
|
|
|
finished: false,
|
|
|
|
lastSessionId: undefined,
|
2019-12-03 00:52:15 +01:00
|
|
|
merchantSig: d.merchantSig,
|
2019-12-02 00:42:40 +01:00
|
|
|
payReq,
|
|
|
|
refundsDone: {},
|
|
|
|
refundsPending: {},
|
|
|
|
timestamp: getTimestampNow(),
|
|
|
|
timestamp_refund: undefined,
|
2019-12-03 00:52:15 +01:00
|
|
|
proposalId: proposal.proposalId,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
await runWithWriteTransaction(
|
|
|
|
ws.db,
|
2019-12-03 00:52:15 +01:00
|
|
|
[Stores.coins, Stores.purchases, Stores.proposals],
|
2019-12-02 00:42:40 +01:00
|
|
|
async tx => {
|
2019-12-03 00:52:15 +01:00
|
|
|
const p = await tx.get(Stores.proposals, proposal.proposalId);
|
|
|
|
if (p) {
|
|
|
|
p.proposalStatus = ProposalStatus.ACCEPTED;
|
|
|
|
await tx.put(Stores.proposals, p);
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
await tx.put(Stores.purchases, t);
|
|
|
|
for (let c of payCoinInfo.updatedCoins) {
|
|
|
|
await tx.put(Stores.coins, c);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
ws.badge.showNotification();
|
|
|
|
ws.notifier.notify();
|
|
|
|
return t;
|
|
|
|
}
|
|
|
|
|
|
|
|
function getNextUrl(contractTerms: ContractTerms): string {
|
2019-12-02 17:35:47 +01:00
|
|
|
const f = contractTerms.fulfillment_url;
|
|
|
|
if (f.startsWith("http://") || f.startsWith("https://")) {
|
2019-12-03 00:52:15 +01:00
|
|
|
const fu = new URL(contractTerms.fulfillment_url);
|
2019-12-02 17:35:47 +01:00
|
|
|
fu.searchParams.set("order_id", contractTerms.order_id);
|
|
|
|
return fu.href;
|
|
|
|
} else {
|
|
|
|
return f;
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export async function abortFailedPayment(
|
|
|
|
ws: InternalWalletState,
|
2019-12-03 00:52:15 +01:00
|
|
|
proposalId: string,
|
2019-12-02 00:42:40 +01:00
|
|
|
): Promise<void> {
|
2019-12-03 00:52:15 +01:00
|
|
|
const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
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 oneShotPut(ws.db, Stores.purchases, purchase);
|
|
|
|
|
|
|
|
let resp;
|
|
|
|
|
|
|
|
const abortReq = { ...purchase.payReq, mode: "abort-refund" };
|
|
|
|
|
|
|
|
const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href;
|
|
|
|
|
|
|
|
try {
|
|
|
|
resp = await ws.http.postJson(payUrl, abortReq);
|
|
|
|
} 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.responseJson);
|
|
|
|
await acceptRefundResponse(ws, refundResponse);
|
|
|
|
|
|
|
|
await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => {
|
2019-12-03 00:52:15 +01:00
|
|
|
const p = await tx.get(Stores.purchases, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!p) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
p.abortDone = true;
|
|
|
|
await tx.put(Stores.purchases, p);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
export async function processDownloadProposal(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
): Promise<void> {
|
|
|
|
const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
|
|
|
|
if (!proposal) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const parsed_url = new URL(proposal.url);
|
|
|
|
parsed_url.searchParams.set("nonce", proposal.noncePub);
|
|
|
|
const urlWithNonce = parsed_url.href;
|
|
|
|
console.log("downloading contract from '" + urlWithNonce + "'");
|
|
|
|
let resp;
|
|
|
|
try {
|
|
|
|
resp = await ws.http.get(urlWithNonce);
|
|
|
|
} catch (e) {
|
|
|
|
console.log("contract download failed", e);
|
|
|
|
throw e;
|
|
|
|
}
|
|
|
|
|
|
|
|
const proposalResp = Proposal.checked(resp.responseJson);
|
|
|
|
|
|
|
|
const contractTermsHash = await ws.cryptoApi.hashString(
|
|
|
|
canonicalJson(proposalResp.contract_terms),
|
|
|
|
);
|
|
|
|
|
|
|
|
const fulfillmentUrl = proposalResp.contract_terms.fulfillment_url;
|
|
|
|
|
|
|
|
await runWithWriteTransaction(
|
|
|
|
ws.db,
|
|
|
|
[Stores.proposals, Stores.purchases],
|
|
|
|
async tx => {
|
|
|
|
const p = await tx.get(Stores.proposals, proposalId);
|
|
|
|
if (!p) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
fulfillmentUrl.startsWith("http://") ||
|
|
|
|
fulfillmentUrl.startsWith("https://")
|
|
|
|
) {
|
|
|
|
const differentPurchase = await tx.getIndexed(
|
|
|
|
Stores.purchases.fulfillmentUrlIndex,
|
|
|
|
fulfillmentUrl,
|
|
|
|
);
|
|
|
|
if (differentPurchase) {
|
|
|
|
p.proposalStatus = ProposalStatus.REPURCHASE;
|
|
|
|
p.repurchaseProposalId = differentPurchase.proposalId;
|
|
|
|
await tx.put(Stores.proposals, p);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
p.download = {
|
|
|
|
contractTerms: proposalResp.contract_terms,
|
|
|
|
merchantSig: proposalResp.sig,
|
|
|
|
contractTermsHash,
|
|
|
|
};
|
|
|
|
p.proposalStatus = ProposalStatus.PROPOSED;
|
|
|
|
await tx.put(Stores.proposals, p);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
ws.notifier.notify();
|
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
/**
|
|
|
|
* Download a proposal and store it in the database.
|
|
|
|
* Returns an id for it to retrieve it later.
|
|
|
|
*
|
|
|
|
* @param sessionId Current session ID, if the proposal is being
|
|
|
|
* downloaded in the context of a session ID.
|
|
|
|
*/
|
2019-12-03 00:52:15 +01:00
|
|
|
async function startDownloadProposal(
|
2019-12-02 00:42:40 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
url: string,
|
|
|
|
sessionId?: string,
|
|
|
|
): Promise<string> {
|
|
|
|
const oldProposal = await oneShotGetIndexed(
|
|
|
|
ws.db,
|
|
|
|
Stores.proposals.urlIndex,
|
|
|
|
url,
|
|
|
|
);
|
|
|
|
if (oldProposal) {
|
2019-12-03 00:52:15 +01:00
|
|
|
await processDownloadProposal(ws, oldProposal.proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
return oldProposal.proposalId;
|
|
|
|
}
|
|
|
|
|
|
|
|
const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
|
|
|
|
const proposalId = encodeCrock(getRandomBytes(32));
|
|
|
|
|
|
|
|
const proposalRecord: ProposalRecord = {
|
2019-12-03 00:52:15 +01:00
|
|
|
download: undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
noncePriv: priv,
|
2019-12-03 00:52:15 +01:00
|
|
|
noncePub: pub,
|
2019-12-02 00:42:40 +01:00
|
|
|
timestamp: getTimestampNow(),
|
|
|
|
url,
|
|
|
|
downloadSessionId: sessionId,
|
|
|
|
proposalId: proposalId,
|
2019-12-03 00:52:15 +01:00
|
|
|
proposalStatus: ProposalStatus.DOWNLOADING,
|
|
|
|
repurchaseProposalId: undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
await oneShotPut(ws.db, Stores.proposals, proposalRecord);
|
|
|
|
await processDownloadProposal(ws, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
return proposalId;
|
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
export async function submitPay(
|
2019-12-02 00:42:40 +01:00
|
|
|
ws: InternalWalletState,
|
2019-12-03 00:52:15 +01:00
|
|
|
proposalId: string,
|
2019-12-02 00:42:40 +01:00
|
|
|
sessionId: string | undefined,
|
|
|
|
): Promise<ConfirmPayResult> {
|
2019-12-03 00:52:15 +01:00
|
|
|
const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!purchase) {
|
2019-12-03 00:52:15 +01:00
|
|
|
throw Error("Purchase not found: " + proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
if (purchase.abortRequested) {
|
|
|
|
throw Error("not submitting payment for aborted purchase");
|
|
|
|
}
|
|
|
|
let resp;
|
|
|
|
const payReq = { ...purchase.payReq, session_id: sessionId };
|
|
|
|
|
|
|
|
const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href;
|
|
|
|
|
|
|
|
try {
|
|
|
|
resp = await ws.http.postJson(payUrl, payReq);
|
|
|
|
} catch (e) {
|
|
|
|
// Gives the user the option to retry / abort and refresh
|
|
|
|
console.log("payment failed", e);
|
|
|
|
throw e;
|
|
|
|
}
|
|
|
|
const merchantResp = resp.responseJson;
|
|
|
|
console.log("got success from pay URL");
|
|
|
|
|
|
|
|
const merchantPub = purchase.contractTerms.merchant_pub;
|
|
|
|
const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
|
|
|
|
merchantResp.sig,
|
2019-12-03 00:52:15 +01:00
|
|
|
purchase.contractTermsHash,
|
2019-12-02 00:42:40 +01:00
|
|
|
merchantPub,
|
|
|
|
);
|
|
|
|
if (!valid) {
|
|
|
|
console.error("merchant payment signature invalid");
|
|
|
|
// FIXME: properly display error
|
|
|
|
throw Error("merchant payment signature invalid");
|
|
|
|
}
|
|
|
|
purchase.finished = true;
|
|
|
|
const modifiedCoins: CoinRecord[] = [];
|
|
|
|
for (const pc of purchase.payReq.coins) {
|
|
|
|
const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub);
|
|
|
|
if (!c) {
|
|
|
|
console.error("coin not found");
|
|
|
|
throw Error("coin used in payment not found");
|
|
|
|
}
|
|
|
|
c.status = CoinStatus.Dirty;
|
|
|
|
modifiedCoins.push(c);
|
|
|
|
}
|
|
|
|
|
|
|
|
await runWithWriteTransaction(
|
|
|
|
ws.db,
|
|
|
|
[Stores.coins, Stores.purchases],
|
|
|
|
async tx => {
|
|
|
|
for (let c of modifiedCoins) {
|
2019-12-03 00:52:15 +01:00
|
|
|
await tx.put(Stores.coins, c);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-03 00:52:15 +01:00
|
|
|
await tx.put(Stores.purchases, purchase);
|
2019-12-02 00:42:40 +01:00
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
for (const c of purchase.payReq.coins) {
|
2019-12-03 00:52:15 +01:00
|
|
|
refresh(ws, c.coin_pub).catch(e => {
|
|
|
|
console.log("error in refreshing after payment:", e);
|
|
|
|
});
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const nextUrl = getNextUrl(purchase.contractTerms);
|
|
|
|
ws.cachedNextUrl[purchase.contractTerms.fulfillment_url] = {
|
|
|
|
nextUrl,
|
|
|
|
lastSessionId: sessionId,
|
|
|
|
};
|
|
|
|
|
|
|
|
return { nextUrl };
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if a payment for the given taler://pay/ URI is possible.
|
|
|
|
*
|
|
|
|
* If the payment is possible, the signature are already generated but not
|
|
|
|
* yet send to the merchant.
|
|
|
|
*/
|
|
|
|
export async function preparePay(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
talerPayUri: string,
|
|
|
|
): Promise<PreparePayResult> {
|
|
|
|
const uriResult = parsePayUri(talerPayUri);
|
|
|
|
|
|
|
|
if (!uriResult) {
|
|
|
|
return {
|
|
|
|
status: "error",
|
|
|
|
error: "URI not supported",
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
const proposalId = await startDownloadProposal(
|
|
|
|
ws,
|
|
|
|
uriResult.downloadUrl,
|
|
|
|
uriResult.sessionId,
|
2019-12-02 00:42:40 +01:00
|
|
|
);
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
let proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
|
|
|
|
if (!proposal) {
|
|
|
|
throw Error(`could not get proposal ${proposalId}`);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-03 00:52:15 +01:00
|
|
|
if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
|
|
|
|
const existingProposalId = proposal.repurchaseProposalId;
|
|
|
|
if (!existingProposalId) {
|
|
|
|
throw Error("invalid proposal state");
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-03 00:52:15 +01:00
|
|
|
proposal = await oneShotGet(ws.db, Stores.proposals, existingProposalId);
|
|
|
|
if (!proposal) {
|
|
|
|
throw Error("existing proposal is in wrong state");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const d = proposal.download;
|
|
|
|
if (!d) {
|
|
|
|
console.error("bad proposal", proposal);
|
|
|
|
throw Error("proposal is in invalid state");
|
|
|
|
}
|
|
|
|
const contractTerms = d.contractTerms;
|
|
|
|
const merchantSig = d.merchantSig;
|
|
|
|
if (!contractTerms || !merchantSig) {
|
|
|
|
throw Error("BUG: proposal is in invalid state");
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
console.log("proposal", proposal);
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
// First check if we already payed for it.
|
2019-12-03 00:52:15 +01:00
|
|
|
const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (!purchase) {
|
2019-12-03 00:52:15 +01:00
|
|
|
const paymentAmount = Amounts.parseOrThrow(contractTerms.amount);
|
2019-12-02 00:42:40 +01:00
|
|
|
let wireFeeLimit;
|
2019-12-03 00:52:15 +01:00
|
|
|
if (contractTerms.max_wire_fee) {
|
|
|
|
wireFeeLimit = Amounts.parseOrThrow(contractTerms.max_wire_fee);
|
2019-12-02 00:42:40 +01:00
|
|
|
} else {
|
|
|
|
wireFeeLimit = Amounts.getZero(paymentAmount.currency);
|
|
|
|
}
|
|
|
|
// If not already payed, check if we could pay for it.
|
|
|
|
const res = await getCoinsForPayment(ws, {
|
2019-12-03 00:52:15 +01:00
|
|
|
allowedAuditors: contractTerms.auditors,
|
|
|
|
allowedExchanges: contractTerms.exchanges,
|
|
|
|
depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee),
|
2019-12-02 00:42:40 +01:00
|
|
|
paymentAmount,
|
2019-12-03 00:52:15 +01:00
|
|
|
wireFeeAmortization: contractTerms.wire_fee_amortization || 1,
|
2019-12-02 00:42:40 +01:00
|
|
|
wireFeeLimit,
|
2019-12-03 00:52:15 +01:00
|
|
|
wireFeeTime: extractTalerStampOrThrow(contractTerms.timestamp),
|
|
|
|
wireMethod: contractTerms.wire_method,
|
2019-12-02 00:42:40 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
if (!res) {
|
|
|
|
console.log("not confirming payment, insufficient coins");
|
|
|
|
return {
|
|
|
|
status: "insufficient-balance",
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms: contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
proposalId: proposal.proposalId,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// Only create speculative signature if we don't already have one for this proposal
|
|
|
|
if (
|
|
|
|
!ws.speculativePayData ||
|
|
|
|
(ws.speculativePayData &&
|
|
|
|
ws.speculativePayData.orderDownloadId !== proposalId)
|
|
|
|
) {
|
|
|
|
const { exchangeUrl, cds, totalAmount } = res;
|
|
|
|
const payCoinInfo = await ws.cryptoApi.signDeposit(
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
cds,
|
|
|
|
totalAmount,
|
|
|
|
);
|
|
|
|
ws.speculativePayData = {
|
|
|
|
exchangeUrl,
|
|
|
|
payCoinInfo,
|
|
|
|
proposal,
|
|
|
|
orderDownloadId: proposalId,
|
|
|
|
};
|
|
|
|
logger.trace("created speculative pay data for payment");
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
status: "payment-possible",
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms: contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
proposalId: proposal.proposalId,
|
|
|
|
totalFees: res.totalFees,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (uriResult.sessionId) {
|
2019-12-03 00:52:15 +01:00
|
|
|
await submitPay(ws, proposalId, uriResult.sessionId);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
status: "paid",
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms: purchase.contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
nextUrl: getNextUrl(purchase.contractTerms),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the speculative pay data, but only if coins have not changed in between.
|
|
|
|
*/
|
|
|
|
async function getSpeculativePayData(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
): Promise<SpeculativePayData | undefined> {
|
|
|
|
const sp = ws.speculativePayData;
|
|
|
|
if (!sp) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (sp.orderDownloadId !== proposalId) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub);
|
|
|
|
const coins: CoinRecord[] = [];
|
|
|
|
for (let coinKey of coinKeys) {
|
|
|
|
const cc = await oneShotGet(ws.db, Stores.coins, coinKey);
|
|
|
|
if (cc) {
|
|
|
|
coins.push(cc);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (let i = 0; i < coins.length; i++) {
|
|
|
|
const specCoin = sp.payCoinInfo.originalCoins[i];
|
|
|
|
const currentCoin = coins[i];
|
|
|
|
|
|
|
|
// Coin does not exist anymore!
|
|
|
|
if (!currentCoin) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return sp;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add a contract to the wallet and sign coins, and send them.
|
|
|
|
*/
|
|
|
|
export async function confirmPay(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
sessionIdOverride: string | undefined,
|
|
|
|
): Promise<ConfirmPayResult> {
|
|
|
|
logger.trace(
|
|
|
|
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
|
|
|
|
);
|
|
|
|
const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
|
|
|
|
|
|
|
|
if (!proposal) {
|
|
|
|
throw Error(`proposal with id ${proposalId} not found`);
|
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
const d = proposal.download;
|
|
|
|
if (!d) {
|
|
|
|
throw Error("proposal is in invalid state");
|
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
const sessionId = sessionIdOverride || proposal.downloadSessionId;
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
let purchase = await oneShotGet(ws.db, Stores.purchases, d.contractTermsHash);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (purchase) {
|
2019-12-03 00:52:15 +01:00
|
|
|
return submitPay(ws, proposalId, sessionId);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
const contractAmount = Amounts.parseOrThrow(d.contractTerms.amount);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
let wireFeeLimit;
|
2019-12-03 00:52:15 +01:00
|
|
|
if (!d.contractTerms.max_wire_fee) {
|
2019-12-02 00:42:40 +01:00
|
|
|
wireFeeLimit = Amounts.getZero(contractAmount.currency);
|
|
|
|
} else {
|
2019-12-03 00:52:15 +01:00
|
|
|
wireFeeLimit = Amounts.parseOrThrow(d.contractTerms.max_wire_fee);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const res = await getCoinsForPayment(ws, {
|
2019-12-03 00:52:15 +01:00
|
|
|
allowedAuditors: d.contractTerms.auditors,
|
|
|
|
allowedExchanges: d.contractTerms.exchanges,
|
|
|
|
depositFeeLimit: Amounts.parseOrThrow(d.contractTerms.max_fee),
|
|
|
|
paymentAmount: Amounts.parseOrThrow(d.contractTerms.amount),
|
|
|
|
wireFeeAmortization: d.contractTerms.wire_fee_amortization || 1,
|
2019-12-02 00:42:40 +01:00
|
|
|
wireFeeLimit,
|
2019-12-03 00:52:15 +01:00
|
|
|
wireFeeTime: extractTalerStampOrThrow(d.contractTerms.timestamp),
|
|
|
|
wireMethod: d.contractTerms.wire_method,
|
2019-12-02 00:42:40 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
logger.trace("coin selection result", res);
|
|
|
|
|
|
|
|
if (!res) {
|
|
|
|
// Should not happen, since checkPay should be called first
|
|
|
|
console.log("not confirming payment, insufficient coins");
|
|
|
|
throw Error("insufficient balance");
|
|
|
|
}
|
|
|
|
|
|
|
|
const sd = await getSpeculativePayData(ws, proposalId);
|
|
|
|
if (!sd) {
|
|
|
|
const { exchangeUrl, cds, totalAmount } = res;
|
|
|
|
const payCoinInfo = await ws.cryptoApi.signDeposit(
|
2019-12-03 00:52:15 +01:00
|
|
|
d.contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
cds,
|
|
|
|
totalAmount,
|
|
|
|
);
|
|
|
|
purchase = await recordConfirmPay(ws, proposal, payCoinInfo, exchangeUrl);
|
|
|
|
} else {
|
|
|
|
purchase = await recordConfirmPay(
|
|
|
|
ws,
|
|
|
|
sd.proposal,
|
|
|
|
sd.payCoinInfo,
|
|
|
|
sd.exchangeUrl,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
return submitPay(ws, proposalId, sessionId);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-03 14:40:05 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function getFullRefundFees(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
refundPermissions: MerchantRefundPermission[],
|
|
|
|
): Promise<AmountJson> {
|
|
|
|
if (refundPermissions.length === 0) {
|
|
|
|
throw Error("no refunds given");
|
|
|
|
}
|
|
|
|
const coin0 = await oneShotGet(
|
|
|
|
ws.db,
|
|
|
|
Stores.coins,
|
|
|
|
refundPermissions[0].coin_pub,
|
|
|
|
);
|
|
|
|
if (!coin0) {
|
|
|
|
throw Error("coin not found");
|
|
|
|
}
|
|
|
|
let feeAcc = Amounts.getZero(
|
|
|
|
Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
|
|
|
|
);
|
|
|
|
|
|
|
|
const denoms = await oneShotIterIndex(
|
|
|
|
ws.db,
|
|
|
|
Stores.denominations.exchangeBaseUrlIndex,
|
|
|
|
coin0.exchangeBaseUrl,
|
|
|
|
).toArray();
|
|
|
|
|
|
|
|
for (const rp of refundPermissions) {
|
|
|
|
const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub);
|
|
|
|
if (!coin) {
|
|
|
|
throw Error("coin not found");
|
|
|
|
}
|
|
|
|
const denom = await oneShotGet(ws.db, Stores.denominations, [
|
|
|
|
coin0.exchangeBaseUrl,
|
|
|
|
coin.denomPub,
|
|
|
|
]);
|
|
|
|
if (!denom) {
|
|
|
|
throw Error(`denom not found (${coin.denomPub})`);
|
|
|
|
}
|
|
|
|
// FIXME: this assumes that the refund already happened.
|
|
|
|
// When it hasn't, the refresh cost is inaccurate. To fix this,
|
|
|
|
// we need introduce a flag to tell if a coin was refunded or
|
|
|
|
// refreshed normally (and what about incremental refunds?)
|
|
|
|
const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
|
|
|
|
const refundFee = Amounts.parseOrThrow(rp.refund_fee);
|
|
|
|
const refreshCost = getTotalRefreshCost(
|
|
|
|
denoms,
|
|
|
|
denom,
|
|
|
|
Amounts.sub(refundAmount, refundFee).amount,
|
|
|
|
);
|
|
|
|
feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
|
|
|
|
}
|
|
|
|
return feeAcc;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function submitRefunds(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
): Promise<void> {
|
|
|
|
const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
|
|
|
|
if (!purchase) {
|
|
|
|
console.error(
|
|
|
|
"not submitting refunds, payment not found:",
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const pendingKeys = Object.keys(purchase.refundsPending);
|
|
|
|
if (pendingKeys.length === 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
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 URL("refund", exchangeUrl);
|
|
|
|
const resp = await ws.http.postJson(reqUrl.href, req);
|
|
|
|
if (resp.status !== 200) {
|
|
|
|
console.error("refund failed", resp);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Transactionally mark successful refunds as done
|
|
|
|
const transformPurchase = (
|
|
|
|
t: PurchaseRecord | undefined,
|
|
|
|
): PurchaseRecord | undefined => {
|
|
|
|
if (!t) {
|
|
|
|
console.warn("purchase not found, not updating refund");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (t.refundsPending[pk]) {
|
|
|
|
t.refundsDone[pk] = t.refundsPending[pk];
|
|
|
|
delete t.refundsPending[pk];
|
|
|
|
}
|
|
|
|
return t;
|
|
|
|
};
|
|
|
|
const transformCoin = (
|
|
|
|
c: CoinRecord | undefined,
|
|
|
|
): CoinRecord | undefined => {
|
|
|
|
if (!c) {
|
|
|
|
console.warn("coin not found, can't apply refund");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
|
|
|
|
const refundFee = Amounts.parseOrThrow(perm.refund_fee);
|
|
|
|
c.status = CoinStatus.Dirty;
|
|
|
|
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
|
|
|
|
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
|
|
|
|
|
|
|
|
return c;
|
|
|
|
};
|
|
|
|
|
|
|
|
await runWithWriteTransaction(
|
|
|
|
ws.db,
|
|
|
|
[Stores.purchases, Stores.coins],
|
|
|
|
async tx => {
|
|
|
|
await tx.mutate(Stores.purchases, proposalId, transformPurchase);
|
|
|
|
await tx.mutate(Stores.coins, perm.coin_pub, transformCoin);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
refresh(ws, perm.coin_pub);
|
|
|
|
}
|
|
|
|
|
|
|
|
ws.badge.showNotification();
|
|
|
|
ws.notifier.notify();
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function acceptRefundResponse(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
refundResponse: MerchantRefundResponse,
|
|
|
|
): Promise<string> {
|
|
|
|
const refundPermissions = refundResponse.refund_permissions;
|
|
|
|
|
|
|
|
if (!refundPermissions.length) {
|
|
|
|
console.warn("got empty refund list");
|
|
|
|
throw Error("empty refund");
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add refund to purchase if not already added.
|
|
|
|
*/
|
|
|
|
function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined {
|
|
|
|
if (!t) {
|
|
|
|
console.error("purchase not found, not adding refunds");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
t.timestamp_refund = getTimestampNow();
|
|
|
|
|
|
|
|
for (const perm of refundPermissions) {
|
|
|
|
if (
|
|
|
|
!t.refundsPending[perm.merchant_sig] &&
|
|
|
|
!t.refundsDone[perm.merchant_sig]
|
|
|
|
) {
|
|
|
|
t.refundsPending[perm.merchant_sig] = perm;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return t;
|
|
|
|
}
|
|
|
|
|
|
|
|
const hc = refundResponse.h_contract_terms;
|
|
|
|
|
|
|
|
// Add the refund permissions to the purchase within a DB transaction
|
|
|
|
await oneShotMutate(ws.db, Stores.purchases, hc, f);
|
|
|
|
ws.notifier.notify();
|
|
|
|
|
|
|
|
await submitRefunds(ws, hc);
|
|
|
|
|
|
|
|
return hc;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Accept a refund, return the contract hash for the contract
|
|
|
|
* that was involved in the refund.
|
|
|
|
*/
|
|
|
|
export async function applyRefund(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
talerRefundUri: string,
|
|
|
|
): Promise<string> {
|
|
|
|
const parseResult = parseRefundUri(talerRefundUri);
|
|
|
|
|
|
|
|
if (!parseResult) {
|
|
|
|
throw Error("invalid refund URI");
|
|
|
|
}
|
|
|
|
|
|
|
|
const refundUrl = parseResult.refundUrl;
|
|
|
|
|
|
|
|
logger.trace("processing refund");
|
|
|
|
let resp;
|
|
|
|
try {
|
|
|
|
resp = await ws.http.get(refundUrl);
|
|
|
|
} catch (e) {
|
|
|
|
console.error("error downloading refund permission", e);
|
|
|
|
throw e;
|
|
|
|
}
|
|
|
|
|
|
|
|
const refundResponse = MerchantRefundResponse.checked(resp.responseJson);
|
|
|
|
return acceptRefundResponse(ws, refundResponse);
|
|
|
|
}
|