introduce refund groups, react correctly to 410 Gone for /refund
This commit is contained in:
parent
7cc3b10824
commit
857c0ab4cd
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
This file is part of GNU Taler
|
This file is part of GNU Taler
|
||||||
(C) 2019 GNUnet e.V.
|
(C) Taler Systems S.A.
|
||||||
|
|
||||||
GNU Taler is free software; you can redistribute it and/or modify it under the
|
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
|
terms of the GNU General Public License as published by the Free Software
|
||||||
@ -14,6 +14,16 @@
|
|||||||
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of the payment operation, including downloading and
|
||||||
|
* claiming of proposals.
|
||||||
|
*
|
||||||
|
* @author Florian Dold
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports.
|
||||||
|
*/
|
||||||
import { AmountJson } from "../util/amounts";
|
import { AmountJson } from "../util/amounts";
|
||||||
import {
|
import {
|
||||||
Auditor,
|
Auditor,
|
||||||
@ -36,9 +46,6 @@ import {
|
|||||||
OperationError,
|
OperationError,
|
||||||
RefreshReason,
|
RefreshReason,
|
||||||
} from "../types/walletTypes";
|
} from "../types/walletTypes";
|
||||||
import {
|
|
||||||
Database
|
|
||||||
} from "../util/query";
|
|
||||||
import {
|
import {
|
||||||
Stores,
|
Stores,
|
||||||
CoinStatus,
|
CoinStatus,
|
||||||
@ -49,6 +56,7 @@ import {
|
|||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
initRetryInfo,
|
initRetryInfo,
|
||||||
updateRetryInfoTimeout,
|
updateRetryInfoTimeout,
|
||||||
|
RefundReason,
|
||||||
} from "../types/dbTypes";
|
} from "../types/dbTypes";
|
||||||
import * as Amounts from "../util/amounts";
|
import * as Amounts from "../util/amounts";
|
||||||
import {
|
import {
|
||||||
@ -56,7 +64,6 @@ import {
|
|||||||
strcmp,
|
strcmp,
|
||||||
canonicalJson,
|
canonicalJson,
|
||||||
extractTalerStampOrThrow,
|
extractTalerStampOrThrow,
|
||||||
extractTalerDurationOrThrow,
|
|
||||||
extractTalerDuration,
|
extractTalerDuration,
|
||||||
} from "../util/helpers";
|
} from "../util/helpers";
|
||||||
import { Logger } from "../util/logging";
|
import { Logger } from "../util/logging";
|
||||||
@ -69,8 +76,8 @@ import {
|
|||||||
import { getTotalRefreshCost, createRefreshGroup } from "./refresh";
|
import { getTotalRefreshCost, createRefreshGroup } from "./refresh";
|
||||||
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
|
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
|
||||||
import { guardOperationException } from "./errors";
|
import { guardOperationException } from "./errors";
|
||||||
import { assertUnreachable } from "../util/assertUnreachable";
|
|
||||||
import { NotificationType } from "../types/notifications";
|
import { NotificationType } from "../types/notifications";
|
||||||
|
import { acceptRefundResponse } from "./refund";
|
||||||
|
|
||||||
export interface SpeculativePayData {
|
export interface SpeculativePayData {
|
||||||
payCoinInfo: PayCoinInfo;
|
payCoinInfo: PayCoinInfo;
|
||||||
@ -237,15 +244,13 @@ async function getCoinsForPayment(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const coins = await ws.db.iterIndex(
|
const coins = await ws.db
|
||||||
Stores.coins.exchangeBaseUrlIndex,
|
.iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl)
|
||||||
exchange.baseUrl,
|
.toArray();
|
||||||
).toArray();
|
|
||||||
|
|
||||||
const denoms = await ws.db.iterIndex(
|
const denoms = await ws.db
|
||||||
Stores.denominations.exchangeBaseUrlIndex,
|
.iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl)
|
||||||
exchange.baseUrl,
|
.toArray();
|
||||||
).toArray();
|
|
||||||
|
|
||||||
if (!coins || coins.length === 0) {
|
if (!coins || coins.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
@ -353,8 +358,6 @@ async function recordConfirmPay(
|
|||||||
lastSessionId: sessionId,
|
lastSessionId: sessionId,
|
||||||
merchantSig: d.merchantSig,
|
merchantSig: d.merchantSig,
|
||||||
payReq,
|
payReq,
|
||||||
refundsDone: {},
|
|
||||||
refundsPending: {},
|
|
||||||
acceptTimestamp: getTimestampNow(),
|
acceptTimestamp: getTimestampNow(),
|
||||||
lastRefundStatusTimestamp: undefined,
|
lastRefundStatusTimestamp: undefined,
|
||||||
proposalId: proposal.proposalId,
|
proposalId: proposal.proposalId,
|
||||||
@ -368,6 +371,12 @@ async function recordConfirmPay(
|
|||||||
firstSuccessfulPayTimestamp: undefined,
|
firstSuccessfulPayTimestamp: undefined,
|
||||||
autoRefundDeadline: undefined,
|
autoRefundDeadline: undefined,
|
||||||
paymentSubmitPending: true,
|
paymentSubmitPending: true,
|
||||||
|
refundState: {
|
||||||
|
refundGroups: [],
|
||||||
|
refundsDone: {},
|
||||||
|
refundsFailed: {},
|
||||||
|
refundsPending: {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await ws.db.runWithWriteTransaction(
|
await ws.db.runWithWriteTransaction(
|
||||||
@ -447,7 +456,12 @@ export async function abortFailedPayment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refundResponse = MerchantRefundResponse.checked(await resp.json());
|
const refundResponse = MerchantRefundResponse.checked(await resp.json());
|
||||||
await acceptRefundResponse(ws, purchase.proposalId, refundResponse);
|
await acceptRefundResponse(
|
||||||
|
ws,
|
||||||
|
purchase.proposalId,
|
||||||
|
refundResponse,
|
||||||
|
RefundReason.AbortRefund,
|
||||||
|
);
|
||||||
|
|
||||||
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
||||||
const p = await tx.get(Stores.purchases, proposalId);
|
const p = await tx.get(Stores.purchases, proposalId);
|
||||||
@ -502,50 +516,6 @@ async function incrementPurchasePayRetry(
|
|||||||
ws.notify({ type: NotificationType.PayOperationError });
|
ws.notify({ type: NotificationType.PayOperationError });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function incrementPurchaseQueryRefundRetry(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
err: OperationError | undefined,
|
|
||||||
): Promise<void> {
|
|
||||||
console.log("incrementing purchase refund query retry with error", err);
|
|
||||||
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
|
||||||
const pr = await tx.get(Stores.purchases, proposalId);
|
|
||||||
if (!pr) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!pr.refundStatusRetryInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pr.refundStatusRetryInfo.retryCounter++;
|
|
||||||
updateRetryInfoTimeout(pr.refundStatusRetryInfo);
|
|
||||||
pr.lastRefundStatusError = err;
|
|
||||||
await tx.put(Stores.purchases, pr);
|
|
||||||
});
|
|
||||||
ws.notify({ type: NotificationType.RefundStatusOperationError });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function incrementPurchaseApplyRefundRetry(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
err: OperationError | undefined,
|
|
||||||
): Promise<void> {
|
|
||||||
console.log("incrementing purchase refund apply retry with error", err);
|
|
||||||
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
|
||||||
const pr = await tx.get(Stores.purchases, proposalId);
|
|
||||||
if (!pr) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!pr.refundApplyRetryInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pr.refundApplyRetryInfo.retryCounter++;
|
|
||||||
updateRetryInfoTimeout(pr.refundStatusRetryInfo);
|
|
||||||
pr.lastRefundApplyError = err;
|
|
||||||
await tx.put(Stores.purchases, pr);
|
|
||||||
});
|
|
||||||
ws.notify({ type: NotificationType.RefundApplyOperationError });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processDownloadProposal(
|
export async function processDownloadProposal(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
proposalId: string,
|
proposalId: string,
|
||||||
@ -695,11 +665,11 @@ async function startDownloadProposal(
|
|||||||
downloadSessionId: sessionId,
|
downloadSessionId: sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
|
await ws.db.runWithWriteTransaction([Stores.proposals], async tx => {
|
||||||
const existingRecord = await tx.getIndexed(Stores.proposals.urlAndOrderIdIndex, [
|
const existingRecord = await tx.getIndexed(
|
||||||
merchantBaseUrl,
|
Stores.proposals.urlAndOrderIdIndex,
|
||||||
orderId,
|
[merchantBaseUrl, orderId],
|
||||||
]);
|
);
|
||||||
if (existingRecord) {
|
if (existingRecord) {
|
||||||
// Created concurrently
|
// Created concurrently
|
||||||
return;
|
return;
|
||||||
@ -793,7 +763,11 @@ export async function submitPay(
|
|||||||
for (let c of modifiedCoins) {
|
for (let c of modifiedCoins) {
|
||||||
await tx.put(Stores.coins, c);
|
await tx.put(Stores.coins, c);
|
||||||
}
|
}
|
||||||
await createRefreshGroup(tx, modifiedCoins.map((x) => ({ coinPub: x.coinPub })), RefreshReason.Pay);
|
await createRefreshGroup(
|
||||||
|
tx,
|
||||||
|
modifiedCoins.map(x => ({ coinPub: x.coinPub })),
|
||||||
|
RefreshReason.Pay,
|
||||||
|
);
|
||||||
await tx.put(Stores.purchases, purchase);
|
await tx.put(Stores.purchases, purchase);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -1069,192 +1043,6 @@ export async function confirmPay(
|
|||||||
return submitPay(ws, proposalId);
|
return submitPay(ws, proposalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFullRefundFees(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
refundPermissions: MerchantRefundPermission[],
|
|
||||||
): Promise<AmountJson> {
|
|
||||||
if (refundPermissions.length === 0) {
|
|
||||||
throw Error("no refunds given");
|
|
||||||
}
|
|
||||||
const coin0 = await ws.db.get(
|
|
||||||
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 ws.db.iterIndex(
|
|
||||||
Stores.denominations.exchangeBaseUrlIndex,
|
|
||||||
coin0.exchangeBaseUrl,
|
|
||||||
).toArray();
|
|
||||||
|
|
||||||
for (const rp of refundPermissions) {
|
|
||||||
const coin = await ws.db.get(Stores.coins, rp.coin_pub);
|
|
||||||
if (!coin) {
|
|
||||||
throw Error("coin not found");
|
|
||||||
}
|
|
||||||
const denom = await ws.db.get(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 acceptRefundResponse(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
refundResponse: MerchantRefundResponse,
|
|
||||||
): Promise<void> {
|
|
||||||
const refundPermissions = refundResponse.refund_permissions;
|
|
||||||
|
|
||||||
let numNewRefunds = 0;
|
|
||||||
|
|
||||||
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
|
||||||
const p = await tx.get(Stores.purchases, proposalId);
|
|
||||||
if (!p) {
|
|
||||||
console.error("purchase not found, not adding refunds");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!p.refundStatusRequested) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const perm of refundPermissions) {
|
|
||||||
if (
|
|
||||||
!p.refundsPending[perm.merchant_sig] &&
|
|
||||||
!p.refundsDone[perm.merchant_sig]
|
|
||||||
) {
|
|
||||||
p.refundsPending[perm.merchant_sig] = perm;
|
|
||||||
numNewRefunds++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Are we done with querying yet, or do we need to do another round
|
|
||||||
// after a retry delay?
|
|
||||||
let queryDone = true;
|
|
||||||
|
|
||||||
if (numNewRefunds === 0) {
|
|
||||||
if (
|
|
||||||
p.autoRefundDeadline &&
|
|
||||||
p.autoRefundDeadline.t_ms > getTimestampNow().t_ms
|
|
||||||
) {
|
|
||||||
queryDone = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryDone) {
|
|
||||||
p.lastRefundStatusTimestamp = getTimestampNow();
|
|
||||||
p.lastRefundStatusError = undefined;
|
|
||||||
p.refundStatusRetryInfo = initRetryInfo();
|
|
||||||
p.refundStatusRequested = false;
|
|
||||||
console.log("refund query done");
|
|
||||||
} else {
|
|
||||||
// No error, but we need to try again!
|
|
||||||
p.lastRefundStatusTimestamp = getTimestampNow();
|
|
||||||
p.refundStatusRetryInfo.retryCounter++;
|
|
||||||
updateRetryInfoTimeout(p.refundStatusRetryInfo);
|
|
||||||
p.lastRefundStatusError = undefined;
|
|
||||||
console.log("refund query not done");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numNewRefunds) {
|
|
||||||
p.lastRefundApplyError = undefined;
|
|
||||||
p.refundApplyRetryInfo = initRetryInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.put(Stores.purchases, p);
|
|
||||||
});
|
|
||||||
ws.notify({
|
|
||||||
type: NotificationType.RefundQueried,
|
|
||||||
});
|
|
||||||
if (numNewRefunds > 0) {
|
|
||||||
await processPurchaseApplyRefund(ws, proposalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startRefundQuery(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const success = await ws.db.runWithWriteTransaction(
|
|
||||||
[Stores.purchases],
|
|
||||||
async tx => {
|
|
||||||
const p = await tx.get(Stores.purchases, proposalId);
|
|
||||||
if (!p) {
|
|
||||||
console.log("no purchase found for refund URL");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
p.refundStatusRequested = true;
|
|
||||||
p.lastRefundStatusError = undefined;
|
|
||||||
p.refundStatusRetryInfo = initRetryInfo();
|
|
||||||
await tx.put(Stores.purchases, p);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.notify({
|
|
||||||
type: NotificationType.RefundStarted,
|
|
||||||
});
|
|
||||||
|
|
||||||
await processPurchaseQueryRefund(ws, proposalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
|
|
||||||
console.log("applying refund");
|
|
||||||
|
|
||||||
if (!parseResult) {
|
|
||||||
throw Error("invalid refund URI");
|
|
||||||
}
|
|
||||||
|
|
||||||
const purchase = await ws.db.getIndexed(
|
|
||||||
Stores.purchases.orderIdIndex,
|
|
||||||
[parseResult.merchantBaseUrl, parseResult.orderId],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!purchase) {
|
|
||||||
throw Error("no purchase for the taler://refund/ URI was found");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("processing purchase for refund");
|
|
||||||
await startRefundQuery(ws, purchase.proposalId);
|
|
||||||
|
|
||||||
return purchase.contractTermsHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processPurchasePay(
|
export async function processPurchasePay(
|
||||||
ws: InternalWalletState,
|
ws: InternalWalletState,
|
||||||
proposalId: string,
|
proposalId: string,
|
||||||
@ -1298,176 +1086,3 @@ async function processPurchasePayImpl(
|
|||||||
logger.trace(`processing purchase pay ${proposalId}`);
|
logger.trace(`processing purchase pay ${proposalId}`);
|
||||||
await submitPay(ws, proposalId);
|
await submitPay(ws, proposalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processPurchaseQueryRefund(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
forceNow: boolean = false,
|
|
||||||
): Promise<void> {
|
|
||||||
const onOpErr = (e: OperationError) =>
|
|
||||||
incrementPurchaseQueryRefundRetry(ws, proposalId, e);
|
|
||||||
await guardOperationException(
|
|
||||||
() => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
|
|
||||||
onOpErr,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetPurchaseQueryRefundRetry(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
) {
|
|
||||||
await ws.db.mutate(Stores.purchases, proposalId, x => {
|
|
||||||
if (x.refundStatusRetryInfo.active) {
|
|
||||||
x.refundStatusRetryInfo = initRetryInfo();
|
|
||||||
}
|
|
||||||
return x;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processPurchaseQueryRefundImpl(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
forceNow: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
if (forceNow) {
|
|
||||||
await resetPurchaseQueryRefundRetry(ws, proposalId);
|
|
||||||
}
|
|
||||||
const purchase = await ws.db.get(Stores.purchases, proposalId);
|
|
||||||
if (!purchase) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!purchase.refundStatusRequested) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const refundUrlObj = new URL(
|
|
||||||
"refund",
|
|
||||||
purchase.contractTerms.merchant_base_url,
|
|
||||||
);
|
|
||||||
refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id);
|
|
||||||
const refundUrl = refundUrlObj.href;
|
|
||||||
let resp;
|
|
||||||
try {
|
|
||||||
resp = await ws.http.get(refundUrl);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("error downloading refund permission", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
if (resp.status !== 200) {
|
|
||||||
throw Error(`unexpected status code (${resp.status}) for /refund`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const refundResponse = MerchantRefundResponse.checked(await resp.json());
|
|
||||||
await acceptRefundResponse(ws, proposalId, refundResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processPurchaseApplyRefund(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
forceNow: boolean = false,
|
|
||||||
): Promise<void> {
|
|
||||||
const onOpErr = (e: OperationError) =>
|
|
||||||
incrementPurchaseApplyRefundRetry(ws, proposalId, e);
|
|
||||||
await guardOperationException(
|
|
||||||
() => processPurchaseApplyRefundImpl(ws, proposalId, forceNow),
|
|
||||||
onOpErr,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetPurchaseApplyRefundRetry(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
) {
|
|
||||||
await ws.db.mutate(Stores.purchases, proposalId, x => {
|
|
||||||
if (x.refundApplyRetryInfo.active) {
|
|
||||||
x.refundApplyRetryInfo = initRetryInfo();
|
|
||||||
}
|
|
||||||
return x;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processPurchaseApplyRefundImpl(
|
|
||||||
ws: InternalWalletState,
|
|
||||||
proposalId: string,
|
|
||||||
forceNow: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
if (forceNow) {
|
|
||||||
await resetPurchaseApplyRefundRetry(ws, proposalId);
|
|
||||||
}
|
|
||||||
const purchase = await ws.db.get(Stores.purchases, proposalId);
|
|
||||||
if (!purchase) {
|
|
||||||
console.error("not submitting refunds, payment not found:");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pendingKeys = Object.keys(purchase.refundsPending);
|
|
||||||
if (pendingKeys.length === 0) {
|
|
||||||
console.log("no pending refunds");
|
|
||||||
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);
|
|
||||||
console.log("sent refund permission");
|
|
||||||
if (resp.status !== 200) {
|
|
||||||
console.error("refund failed", resp);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let allRefundsProcessed = false;
|
|
||||||
|
|
||||||
await ws.db.runWithWriteTransaction(
|
|
||||||
[Stores.purchases, Stores.coins, Stores.refreshGroups],
|
|
||||||
async tx => {
|
|
||||||
const p = await tx.get(Stores.purchases, proposalId);
|
|
||||||
if (!p) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (p.refundsPending[pk]) {
|
|
||||||
p.refundsDone[pk] = p.refundsPending[pk];
|
|
||||||
delete p.refundsPending[pk];
|
|
||||||
}
|
|
||||||
if (Object.keys(p.refundsPending).length === 0) {
|
|
||||||
p.refundStatusRetryInfo = initRetryInfo();
|
|
||||||
p.lastRefundStatusError = undefined;
|
|
||||||
allRefundsProcessed = true;
|
|
||||||
}
|
|
||||||
await tx.put(Stores.purchases, p);
|
|
||||||
const c = await tx.get(Stores.coins, perm.coin_pub);
|
|
||||||
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.Dormant;
|
|
||||||
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
|
|
||||||
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
|
|
||||||
await tx.put(Stores.coins, c);
|
|
||||||
await createRefreshGroup(tx, [{ coinPub: perm.coin_pub }], RefreshReason.Refund);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (allRefundsProcessed) {
|
|
||||||
ws.notify({
|
|
||||||
type: NotificationType.RefundFinished,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.notify({
|
|
||||||
type: NotificationType.RefundsSubmitted,
|
|
||||||
proposalId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
@ -365,9 +365,9 @@ async function gatherPurchasePending(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const numRefundsPending = Object.keys(pr.refundsPending).length;
|
const numRefundsPending = Object.keys(pr.refundState.refundsPending).length;
|
||||||
if (numRefundsPending > 0) {
|
if (numRefundsPending > 0) {
|
||||||
const numRefundsDone = Object.keys(pr.refundsDone).length;
|
const numRefundsDone = Object.keys(pr.refundState.refundsDone).length;
|
||||||
resp.nextRetryDelay = updateRetryDelay(
|
resp.nextRetryDelay = updateRetryDelay(
|
||||||
resp.nextRetryDelay,
|
resp.nextRetryDelay,
|
||||||
now,
|
now,
|
||||||
|
@ -1002,6 +1002,63 @@ export interface WireFee {
|
|||||||
sig: string;
|
sig: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record to store information about a refund event.
|
||||||
|
*
|
||||||
|
* All information about a refund is stored with the purchase,
|
||||||
|
* this event is just for the history.
|
||||||
|
*
|
||||||
|
* The event is only present for completed refunds.
|
||||||
|
*/
|
||||||
|
export interface RefundEventRecord {
|
||||||
|
timestamp: Timestamp;
|
||||||
|
refundGroupId: string;
|
||||||
|
proposalId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundInfo {
|
||||||
|
refundGroupId: string;
|
||||||
|
perm: MerchantRefundPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum RefundReason {
|
||||||
|
/**
|
||||||
|
* Normal refund given by the merchant.
|
||||||
|
*/
|
||||||
|
NormalRefund = "normal-refund",
|
||||||
|
/**
|
||||||
|
* Refund from an aborted payment.
|
||||||
|
*/
|
||||||
|
AbortRefund = "abort-refund",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundGroupInfo {
|
||||||
|
timestampQueried: Timestamp;
|
||||||
|
reason: RefundReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseRefundState {
|
||||||
|
/**
|
||||||
|
* Information regarding each group of refunds we receive at once.
|
||||||
|
*/
|
||||||
|
refundGroups: RefundGroupInfo[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending refunds for the purchase.
|
||||||
|
*/
|
||||||
|
refundsPending: { [refundSig: string]: RefundInfo };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applied refunds for the purchase.
|
||||||
|
*/
|
||||||
|
refundsDone: { [refundSig: string]: RefundInfo };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submitted refunds for the purchase.
|
||||||
|
*/
|
||||||
|
refundsFailed: { [refundSig: string]: RefundInfo };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record that stores status information about one purchase, starting from when
|
* Record that stores status information about one purchase, starting from when
|
||||||
* the customer accepts a proposal. Includes refund status if applicable.
|
* the customer accepts a proposal. Includes refund status if applicable.
|
||||||
@ -1034,24 +1091,23 @@ export interface PurchaseRecord {
|
|||||||
*/
|
*/
|
||||||
merchantSig: string;
|
merchantSig: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp of the first time that sending a payment to the merchant
|
||||||
|
* for this purchase was successful.
|
||||||
|
*/
|
||||||
firstSuccessfulPayTimestamp: Timestamp | undefined;
|
firstSuccessfulPayTimestamp: Timestamp | undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* Pending refunds for the purchase.
|
|
||||||
*/
|
|
||||||
refundsPending: { [refundSig: string]: MerchantRefundPermission };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submitted refunds for the purchase.
|
|
||||||
*/
|
|
||||||
refundsDone: { [refundSig: string]: MerchantRefundPermission };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When was the purchase made?
|
* When was the purchase made?
|
||||||
* Refers to the time that the user accepted.
|
* Refers to the time that the user accepted.
|
||||||
*/
|
*/
|
||||||
acceptTimestamp: Timestamp;
|
acceptTimestamp: Timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of refunds for this proposal.
|
||||||
|
*/
|
||||||
|
refundState: PurchaseRefundState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When was the last refund made?
|
* When was the last refund made?
|
||||||
* Set to 0 if no refund was made on the purchase.
|
* Set to 0 if no refund was made on the purchase.
|
||||||
@ -1370,6 +1426,12 @@ export namespace Stores {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RefundEventsStore extends Store<RefundEventRecord> {
|
||||||
|
constructor() {
|
||||||
|
super("refundEvents", { keyPath: "refundGroupId" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> {
|
class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("bankWithdrawUris", { keyPath: "talerWithdrawUri" });
|
super("bankWithdrawUris", { keyPath: "talerWithdrawUri" });
|
||||||
@ -1394,6 +1456,7 @@ export namespace Stores {
|
|||||||
export const senderWires = new SenderWiresStore();
|
export const senderWires = new SenderWiresStore();
|
||||||
export const withdrawalSession = new WithdrawalSessionsStore();
|
export const withdrawalSession = new WithdrawalSessionsStore();
|
||||||
export const bankWithdrawUris = new BankWithdrawUrisStore();
|
export const bankWithdrawUris = new BankWithdrawUrisStore();
|
||||||
|
export const refundEvents = new RefundEventsStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tslint:enable:completed-docs */
|
/* tslint:enable:completed-docs */
|
||||||
|
@ -33,6 +33,11 @@ export interface HttpRequestOptions {
|
|||||||
headers?: { [name: string]: string };
|
headers?: { [name: string]: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum HttpResponseStatus {
|
||||||
|
Ok = 200,
|
||||||
|
Gone = 210,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Headers, roughly modeled after the fetch API's headers object.
|
* Headers, roughly modeled after the fetch API's headers object.
|
||||||
*/
|
*/
|
||||||
|
@ -42,11 +42,7 @@ import {
|
|||||||
preparePay,
|
preparePay,
|
||||||
confirmPay,
|
confirmPay,
|
||||||
processDownloadProposal,
|
processDownloadProposal,
|
||||||
applyRefund,
|
|
||||||
getFullRefundFees,
|
|
||||||
processPurchasePay,
|
processPurchasePay,
|
||||||
processPurchaseQueryRefund,
|
|
||||||
processPurchaseApplyRefund,
|
|
||||||
} from "./operations/pay";
|
} from "./operations/pay";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -107,6 +103,7 @@ import { AsyncOpMemoSingle } from "./util/asyncMemo";
|
|||||||
import { PendingOperationInfo, PendingOperationsResponse, PendingOperationType } from "./types/pending";
|
import { PendingOperationInfo, PendingOperationsResponse, PendingOperationType } from "./types/pending";
|
||||||
import { WalletNotification, NotificationType } from "./types/notifications";
|
import { WalletNotification, NotificationType } from "./types/notifications";
|
||||||
import { HistoryQuery, HistoryEvent } from "./types/history";
|
import { HistoryQuery, HistoryEvent } from "./types/history";
|
||||||
|
import { processPurchaseQueryRefund, processPurchaseApplyRefund, getFullRefundFees, applyRefund } from "./operations/refund";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wallet protocol version spoken with the exchange
|
* Wallet protocol version spoken with the exchange
|
||||||
@ -695,21 +692,21 @@ export class Wallet {
|
|||||||
if (!purchase) {
|
if (!purchase) {
|
||||||
throw Error("unknown purchase");
|
throw Error("unknown purchase");
|
||||||
}
|
}
|
||||||
const refundsDoneAmounts = Object.values(purchase.refundsDone).map(x =>
|
const refundsDoneAmounts = Object.values(purchase.refundState.refundsDone).map(x =>
|
||||||
Amounts.parseOrThrow(x.refund_amount),
|
Amounts.parseOrThrow(x.perm.refund_amount),
|
||||||
);
|
);
|
||||||
const refundsPendingAmounts = Object.values(
|
const refundsPendingAmounts = Object.values(
|
||||||
purchase.refundsPending,
|
purchase.refundState.refundsPending,
|
||||||
).map(x => Amounts.parseOrThrow(x.refund_amount));
|
).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
|
||||||
const totalRefundAmount = Amounts.sum([
|
const totalRefundAmount = Amounts.sum([
|
||||||
...refundsDoneAmounts,
|
...refundsDoneAmounts,
|
||||||
...refundsPendingAmounts,
|
...refundsPendingAmounts,
|
||||||
]).amount;
|
]).amount;
|
||||||
const refundsDoneFees = Object.values(purchase.refundsDone).map(x =>
|
const refundsDoneFees = Object.values(purchase.refundState.refundsDone).map(x =>
|
||||||
Amounts.parseOrThrow(x.refund_amount),
|
Amounts.parseOrThrow(x.perm.refund_amount),
|
||||||
);
|
);
|
||||||
const refundsPendingFees = Object.values(purchase.refundsPending).map(x =>
|
const refundsPendingFees = Object.values(purchase.refundState.refundsPending).map(x =>
|
||||||
Amounts.parseOrThrow(x.refund_amount),
|
Amounts.parseOrThrow(x.perm.refund_amount),
|
||||||
);
|
);
|
||||||
const totalRefundFees = Amounts.sum([
|
const totalRefundFees = Amounts.sum([
|
||||||
...refundsDoneFees,
|
...refundsDoneFees,
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"src/operations/payback.ts",
|
"src/operations/payback.ts",
|
||||||
"src/operations/pending.ts",
|
"src/operations/pending.ts",
|
||||||
"src/operations/refresh.ts",
|
"src/operations/refresh.ts",
|
||||||
|
"src/operations/refund.ts",
|
||||||
"src/operations/reserves.ts",
|
"src/operations/reserves.ts",
|
||||||
"src/operations/return.ts",
|
"src/operations/return.ts",
|
||||||
"src/operations/state.ts",
|
"src/operations/state.ts",
|
||||||
|
Loading…
Reference in New Issue
Block a user