adopt new merchant refund API
This commit is contained in:
parent
e404f5e6d3
commit
5be0708a10
@ -453,8 +453,8 @@ export async function getHistory(
|
||||
let amountRefundedRaw = Amounts.getZero(purchaseAmount.currency);
|
||||
let amountRefundedInvalid = Amounts.getZero(purchaseAmount.currency);
|
||||
let amountRefundedEffective = Amounts.getZero(purchaseAmount.currency);
|
||||
Object.keys(purchase.refundState.refundsDone).forEach((x, i) => {
|
||||
const r = purchase.refundState.refundsDone[x];
|
||||
Object.keys(purchase.refundsDone).forEach((x, i) => {
|
||||
const r = purchase.refundsDone[x];
|
||||
if (r.refundGroupId !== re.refundGroupId) {
|
||||
return;
|
||||
}
|
||||
@ -471,8 +471,8 @@ export async function getHistory(
|
||||
refundFee,
|
||||
).amount;
|
||||
});
|
||||
Object.keys(purchase.refundState.refundsFailed).forEach((x, i) => {
|
||||
const r = purchase.refundState.refundsFailed[x];
|
||||
Object.keys(purchase.refundsFailed).forEach((x, i) => {
|
||||
const r = purchase.refundsFailed[x];
|
||||
if (r.refundGroupId !== re.refundGroupId) {
|
||||
return;
|
||||
}
|
||||
|
@ -31,7 +31,6 @@ import {
|
||||
ProposalRecord,
|
||||
ProposalStatus,
|
||||
PurchaseRecord,
|
||||
RefundReason,
|
||||
Stores,
|
||||
updateRetryInfoTimeout,
|
||||
PayEventRecord,
|
||||
@ -40,7 +39,6 @@ import {
|
||||
import { NotificationType } from "../types/notifications";
|
||||
import {
|
||||
PayReq,
|
||||
codecForMerchantRefundResponse,
|
||||
codecForProposal,
|
||||
codecForContractTerms,
|
||||
CoinDepositPermission,
|
||||
@ -57,7 +55,6 @@ import { Logger } from "../util/logging";
|
||||
import { getOrderDownloadUrl, parsePayUri } from "../util/taleruri";
|
||||
import { guardOperationException } from "./errors";
|
||||
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
|
||||
import { acceptRefundResponse } from "./refund";
|
||||
import { InternalWalletState } from "./state";
|
||||
import { getTimestampNow, timestampAddDuration } from "../util/time";
|
||||
import { strcmp, canonicalJson } from "../util/helpers";
|
||||
@ -446,17 +443,13 @@ async function recordConfirmPay(
|
||||
payRetryInfo: initRetryInfo(),
|
||||
refundStatusRetryInfo: initRetryInfo(),
|
||||
refundStatusRequested: false,
|
||||
lastRefundApplyError: undefined,
|
||||
refundApplyRetryInfo: initRetryInfo(),
|
||||
timestampFirstSuccessfulPay: undefined,
|
||||
autoRefundDeadline: undefined,
|
||||
paymentSubmitPending: true,
|
||||
refundState: {
|
||||
refundGroups: [],
|
||||
refundsDone: {},
|
||||
refundsFailed: {},
|
||||
refundsPending: {},
|
||||
},
|
||||
refundGroups: [],
|
||||
refundsDone: {},
|
||||
refundsFailed: {},
|
||||
refundsPending: {},
|
||||
};
|
||||
|
||||
await ws.db.runWithWriteTransaction(
|
||||
@ -511,67 +504,6 @@ function getNextUrl(contractData: WalletContractData): string {
|
||||
}
|
||||
}
|
||||
|
||||
export async function abortFailedPayment(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
): Promise<void> {
|
||||
const purchase = await ws.db.get(Stores.purchases, proposalId);
|
||||
if (!purchase) {
|
||||
throw Error("Purchase not found, unable to abort with refund");
|
||||
}
|
||||
if (purchase.timestampFirstSuccessfulPay) {
|
||||
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 ws.db.put(Stores.purchases, purchase);
|
||||
|
||||
let resp;
|
||||
|
||||
const abortReq = { ...purchase.payReq, mode: "abort-refund" };
|
||||
|
||||
const payUrl = new URL("pay", purchase.contractData.merchantBaseUrl).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;
|
||||
}
|
||||
|
||||
if (resp.status !== 200) {
|
||||
throw Error(`unexpected status for /pay (${resp.status})`);
|
||||
}
|
||||
|
||||
const refundResponse = codecForMerchantRefundResponse().decode(
|
||||
await resp.json(),
|
||||
);
|
||||
await acceptRefundResponse(
|
||||
ws,
|
||||
purchase.proposalId,
|
||||
refundResponse,
|
||||
RefundReason.AbortRefund,
|
||||
);
|
||||
|
||||
await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
|
||||
const p = await tx.get(Stores.purchases, proposalId);
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
p.abortDone = true;
|
||||
await tx.put(Stores.purchases, p);
|
||||
});
|
||||
}
|
||||
|
||||
async function incrementProposalRetry(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
|
@ -396,26 +396,6 @@ async function gatherPurchasePending(
|
||||
});
|
||||
}
|
||||
}
|
||||
const numRefundsPending = Object.keys(pr.refundState.refundsPending).length;
|
||||
if (numRefundsPending > 0) {
|
||||
const numRefundsDone = Object.keys(pr.refundState.refundsDone).length;
|
||||
resp.nextRetryDelay = updateRetryDelay(
|
||||
resp.nextRetryDelay,
|
||||
now,
|
||||
pr.refundApplyRetryInfo.nextRetry,
|
||||
);
|
||||
if (!onlyDue || pr.refundApplyRetryInfo.nextRetry.t_ms <= now.t_ms) {
|
||||
resp.pendingOperations.push({
|
||||
type: PendingOperationType.RefundApply,
|
||||
numRefundsDone,
|
||||
numRefundsPending,
|
||||
givesLifeness: true,
|
||||
proposalId: pr.proposalId,
|
||||
retryInfo: pr.refundApplyRetryInfo,
|
||||
lastError: pr.lastRefundApplyError,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -43,16 +43,14 @@ import { parseRefundUri } from "../util/taleruri";
|
||||
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
|
||||
import { Amounts } from "../util/amounts";
|
||||
import {
|
||||
MerchantRefundPermission,
|
||||
MerchantRefundDetails,
|
||||
MerchantRefundResponse,
|
||||
RefundRequest,
|
||||
codecForMerchantRefundResponse,
|
||||
} from "../types/talerTypes";
|
||||
import { AmountJson } from "../util/amounts";
|
||||
import { guardOperationException, OperationFailedError } from "./errors";
|
||||
import { randomBytes } from "../crypto/primitives/nacl-fast";
|
||||
import { encodeCrock } from "../crypto/talerCrypto";
|
||||
import { HttpResponseStatus } from "../util/http";
|
||||
import { getTimestampNow } from "../util/time";
|
||||
import { Logger } from "../util/logging";
|
||||
|
||||
@ -80,31 +78,9 @@ async function incrementPurchaseQueryRefundRetry(
|
||||
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.refundApplyRetryInfo);
|
||||
pr.lastRefundApplyError = err;
|
||||
await tx.put(Stores.purchases, pr);
|
||||
});
|
||||
ws.notify({ type: NotificationType.RefundApplyOperationError });
|
||||
}
|
||||
|
||||
export async function getFullRefundFees(
|
||||
ws: InternalWalletState,
|
||||
refundPermissions: MerchantRefundPermission[],
|
||||
refundPermissions: MerchantRefundDetails[],
|
||||
): Promise<AmountJson> {
|
||||
if (refundPermissions.length === 0) {
|
||||
throw Error("no refunds given");
|
||||
@ -149,88 +125,196 @@ export async function getFullRefundFees(
|
||||
return feeAcc;
|
||||
}
|
||||
|
||||
export async function acceptRefundResponse(
|
||||
function getRefundKey(d: MerchantRefundDetails): string {
|
||||
return `{d.coin_pub}-{d.rtransaction_id}`;
|
||||
}
|
||||
|
||||
async function acceptRefundResponse(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
refundResponse: MerchantRefundResponse,
|
||||
reason: RefundReason,
|
||||
): Promise<void> {
|
||||
const refundPermissions = refundResponse.refund_permissions;
|
||||
|
||||
let numNewRefunds = 0;
|
||||
const refunds = refundResponse.refunds;
|
||||
|
||||
const refundGroupId = encodeCrock(randomBytes(32));
|
||||
|
||||
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;
|
||||
}
|
||||
let numNewRefunds = 0;
|
||||
|
||||
if (!p.refundStatusRequested) {
|
||||
return;
|
||||
}
|
||||
const finishedRefunds: MerchantRefundDetails[] = [];
|
||||
const unfinishedRefunds: MerchantRefundDetails[] = [];
|
||||
const failedRefunds: MerchantRefundDetails[] = [];
|
||||
|
||||
for (const perm of refundPermissions) {
|
||||
const isDone = p.refundState.refundsDone[perm.merchant_sig];
|
||||
const isPending = p.refundState.refundsPending[perm.merchant_sig];
|
||||
if (!isDone && !isPending) {
|
||||
p.refundState.refundsPending[perm.merchant_sig] = {
|
||||
perm,
|
||||
for (const rd of refunds) {
|
||||
if (rd.exchange_http_status === 200) {
|
||||
// FIXME: also verify signature if necessary.
|
||||
finishedRefunds.push(rd);
|
||||
} else if (
|
||||
rd.exchange_http_status >= 400 &&
|
||||
rd.exchange_http_status < 400
|
||||
) {
|
||||
failedRefunds.push(rd);
|
||||
} else {
|
||||
unfinishedRefunds.push(rd);
|
||||
}
|
||||
}
|
||||
|
||||
await ws.db.runWithWriteTransaction(
|
||||
[Stores.purchases, Stores.coins, Stores.refreshGroups, Stores.refundEvents],
|
||||
async (tx) => {
|
||||
const p = await tx.get(Stores.purchases, proposalId);
|
||||
if (!p) {
|
||||
console.error("purchase not found, not adding refunds");
|
||||
return;
|
||||
}
|
||||
|
||||
// Groups that newly failed/succeeded
|
||||
const changedGroups: { [refundGroupId: string]: boolean } = {};
|
||||
|
||||
for (const rd of failedRefunds) {
|
||||
const refundKey = getRefundKey(rd);
|
||||
if (p.refundsFailed[refundKey]) {
|
||||
continue;
|
||||
}
|
||||
if (!p.refundsFailed[refundKey]) {
|
||||
p.refundsFailed[refundKey] = {
|
||||
perm: rd,
|
||||
refundGroupId,
|
||||
};
|
||||
numNewRefunds++;
|
||||
changedGroups[refundGroupId] = true;
|
||||
}
|
||||
const oldPending = p.refundsPending[refundKey];
|
||||
if (oldPending) {
|
||||
delete p.refundsPending[refundKey];
|
||||
changedGroups[oldPending.refundGroupId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const rd of unfinishedRefunds) {
|
||||
const refundKey = getRefundKey(rd);
|
||||
if (!p.refundsPending[refundKey]) {
|
||||
p.refundsPending[refundKey] = {
|
||||
perm: rd,
|
||||
refundGroupId,
|
||||
};
|
||||
numNewRefunds++;
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid duplicates
|
||||
const refreshCoinsMap: { [coinPub: string]: CoinPublicKey } = {};
|
||||
|
||||
for (const rd of finishedRefunds) {
|
||||
const refundKey = getRefundKey(rd);
|
||||
if (p.refundsDone[refundKey]) {
|
||||
continue;
|
||||
}
|
||||
p.refundsDone[refundKey] = {
|
||||
perm: rd,
|
||||
refundGroupId,
|
||||
};
|
||||
numNewRefunds++;
|
||||
const oldPending = p.refundsPending[refundKey];
|
||||
if (oldPending) {
|
||||
delete p.refundsPending[refundKey];
|
||||
changedGroups[oldPending.refundGroupId] = true;
|
||||
} else {
|
||||
numNewRefunds++;
|
||||
}
|
||||
|
||||
const c = await tx.get(Stores.coins, rd.coin_pub);
|
||||
|
||||
if (!c) {
|
||||
console.warn("coin not found, can't apply refund");
|
||||
return;
|
||||
}
|
||||
refreshCoinsMap[c.coinPub] = { coinPub: c.coinPub };
|
||||
logger.trace(`commiting refund ${refundKey} to coin ${c.coinPub}`);
|
||||
logger.trace(
|
||||
`coin amount before is ${Amounts.stringify(c.currentAmount)}`,
|
||||
);
|
||||
logger.trace(`refund amount (via merchant) is ${refundKey}`);
|
||||
logger.trace(`refund fee (via merchant) is ${refundKey}`);
|
||||
const refundAmount = Amounts.parseOrThrow(rd.refund_amount);
|
||||
const refundFee = Amounts.parseOrThrow(rd.refund_fee);
|
||||
c.status = CoinStatus.Dormant;
|
||||
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
|
||||
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
|
||||
logger.trace(
|
||||
`coin amount after is ${Amounts.stringify(c.currentAmount)}`,
|
||||
);
|
||||
await tx.put(Stores.coins, c);
|
||||
}
|
||||
}
|
||||
|
||||
// Are we done with querying yet, or do we need to do another round
|
||||
// after a retry delay?
|
||||
let queryDone = true;
|
||||
// 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
|
||||
) {
|
||||
if (numNewRefunds === 0) {
|
||||
if (
|
||||
p.autoRefundDeadline &&
|
||||
p.autoRefundDeadline.t_ms > getTimestampNow().t_ms
|
||||
) {
|
||||
queryDone = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(unfinishedRefunds).length != 0) {
|
||||
queryDone = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (queryDone) {
|
||||
p.timestampLastRefundStatus = 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.timestampLastRefundStatus = getTimestampNow();
|
||||
p.refundStatusRetryInfo.retryCounter++;
|
||||
updateRetryInfoTimeout(p.refundStatusRetryInfo);
|
||||
p.lastRefundStatusError = undefined;
|
||||
console.log("refund query not done");
|
||||
}
|
||||
if (queryDone) {
|
||||
p.timestampLastRefundStatus = getTimestampNow();
|
||||
p.lastRefundStatusError = undefined;
|
||||
p.refundStatusRetryInfo = initRetryInfo(false);
|
||||
p.refundStatusRequested = false;
|
||||
console.log("refund query done");
|
||||
} else {
|
||||
// No error, but we need to try again!
|
||||
p.timestampLastRefundStatus = getTimestampNow();
|
||||
p.refundStatusRetryInfo.retryCounter++;
|
||||
updateRetryInfoTimeout(p.refundStatusRetryInfo);
|
||||
p.lastRefundStatusError = undefined;
|
||||
console.log("refund query not done");
|
||||
}
|
||||
|
||||
if (numNewRefunds > 0) {
|
||||
await tx.put(Stores.purchases, p);
|
||||
|
||||
const coinsPubsToBeRefreshed = Object.values(refreshCoinsMap);
|
||||
if (coinsPubsToBeRefreshed.length > 0) {
|
||||
await createRefreshGroup(
|
||||
tx,
|
||||
coinsPubsToBeRefreshed,
|
||||
RefreshReason.Refund,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any of the refund groups are done, and we
|
||||
// can emit an corresponding event.
|
||||
const now = getTimestampNow();
|
||||
p.lastRefundApplyError = undefined;
|
||||
p.refundApplyRetryInfo = initRetryInfo();
|
||||
p.refundState.refundGroups.push({
|
||||
timestampQueried: now,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
await tx.put(Stores.purchases, p);
|
||||
});
|
||||
for (const g of Object.keys(changedGroups)) {
|
||||
let groupDone = true;
|
||||
for (const pk of Object.keys(p.refundsPending)) {
|
||||
const r = p.refundsPending[pk];
|
||||
if (r.refundGroupId == g) {
|
||||
groupDone = false;
|
||||
}
|
||||
}
|
||||
if (groupDone) {
|
||||
const refundEvent: RefundEventRecord = {
|
||||
proposalId,
|
||||
refundGroupId: g,
|
||||
timestamp: now,
|
||||
};
|
||||
await tx.put(Stores.refundEvents, refundEvent);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ws.notify({
|
||||
type: NotificationType.RefundQueried,
|
||||
});
|
||||
if (numNewRefunds > 0) {
|
||||
await processPurchaseApplyRefund(ws, proposalId);
|
||||
}
|
||||
}
|
||||
|
||||
async function startRefundQuery(
|
||||
@ -362,201 +446,3 @@ async function processPurchaseQueryRefundImpl(
|
||||
RefundReason.NormalRefund,
|
||||
);
|
||||
}
|
||||
|
||||
export async function processPurchaseApplyRefund(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
forceNow = false,
|
||||
): Promise<void> {
|
||||
const onOpErr = (e: OperationError): Promise<void> =>
|
||||
incrementPurchaseApplyRefundRetry(ws, proposalId, e);
|
||||
await guardOperationException(
|
||||
() => processPurchaseApplyRefundImpl(ws, proposalId, forceNow),
|
||||
onOpErr,
|
||||
);
|
||||
}
|
||||
|
||||
async function resetPurchaseApplyRefundRetry(
|
||||
ws: InternalWalletState,
|
||||
proposalId: string,
|
||||
): Promise<void> {
|
||||
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.refundState.refundsPending);
|
||||
if (pendingKeys.length === 0) {
|
||||
console.log("no pending refunds");
|
||||
return;
|
||||
}
|
||||
|
||||
const newRefundsDone: { [sig: string]: RefundInfo } = {};
|
||||
const newRefundsFailed: { [sig: string]: RefundInfo } = {};
|
||||
for (const pk of pendingKeys) {
|
||||
const info = purchase.refundState.refundsPending[pk];
|
||||
const perm = info.perm;
|
||||
const req: RefundRequest = {
|
||||
coin_pub: perm.coin_pub,
|
||||
h_contract_terms: purchase.contractData.contractTermsHash,
|
||||
merchant_pub: purchase.contractData.merchantPub,
|
||||
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(`coins/${perm.coin_pub}/refund`, exchangeUrl);
|
||||
const resp = await ws.http.postJson(reqUrl.href, req);
|
||||
console.log("sent refund permission");
|
||||
switch (resp.status) {
|
||||
case HttpResponseStatus.Ok:
|
||||
newRefundsDone[pk] = info;
|
||||
break;
|
||||
case HttpResponseStatus.Gone:
|
||||
// We're too late, refund is expired.
|
||||
newRefundsFailed[pk] = info;
|
||||
break;
|
||||
default: {
|
||||
let body: string | null = null;
|
||||
// FIXME: error handling!
|
||||
body = await resp.json();
|
||||
const m = "refund request (at exchange) failed";
|
||||
throw new OperationFailedError({
|
||||
message: m,
|
||||
type: "network",
|
||||
details: {
|
||||
body,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let allRefundsProcessed = false;
|
||||
await ws.db.runWithWriteTransaction(
|
||||
[Stores.purchases, Stores.coins, Stores.refreshGroups, Stores.refundEvents],
|
||||
async (tx) => {
|
||||
const p = await tx.get(Stores.purchases, proposalId);
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Groups that failed/succeeded
|
||||
const groups: { [refundGroupId: string]: boolean } = {};
|
||||
|
||||
// Avoid duplicates
|
||||
const refreshCoinsMap: { [coinPub: string]: CoinPublicKey } = {};
|
||||
|
||||
const modCoin = async (perm: MerchantRefundPermission): Promise<void> => {
|
||||
const c = await tx.get(Stores.coins, perm.coin_pub);
|
||||
if (!c) {
|
||||
console.warn("coin not found, can't apply refund");
|
||||
return;
|
||||
}
|
||||
refreshCoinsMap[c.coinPub] = { coinPub: c.coinPub };
|
||||
logger.trace(
|
||||
`commiting refund ${perm.merchant_sig} to coin ${c.coinPub}`,
|
||||
);
|
||||
logger.trace(
|
||||
`coin amount before is ${Amounts.stringify(c.currentAmount)}`,
|
||||
);
|
||||
logger.trace(`refund amount (via merchant) is ${perm.refund_amount}`);
|
||||
logger.trace(`refund fee (via merchant) is ${perm.refund_fee}`);
|
||||
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;
|
||||
logger.trace(
|
||||
`coin amount after is ${Amounts.stringify(c.currentAmount)}`,
|
||||
);
|
||||
await tx.put(Stores.coins, c);
|
||||
};
|
||||
|
||||
for (const pk of Object.keys(newRefundsFailed)) {
|
||||
if (p.refundState.refundsDone[pk]) {
|
||||
// We already processed this one.
|
||||
break;
|
||||
}
|
||||
const r = newRefundsFailed[pk];
|
||||
groups[r.refundGroupId] = true;
|
||||
delete p.refundState.refundsPending[pk];
|
||||
p.refundState.refundsFailed[pk] = r;
|
||||
}
|
||||
|
||||
for (const pk of Object.keys(newRefundsDone)) {
|
||||
if (p.refundState.refundsDone[pk]) {
|
||||
// We already processed this one.
|
||||
break;
|
||||
}
|
||||
const r = newRefundsDone[pk];
|
||||
groups[r.refundGroupId] = true;
|
||||
delete p.refundState.refundsPending[pk];
|
||||
p.refundState.refundsDone[pk] = r;
|
||||
await modCoin(r.perm);
|
||||
}
|
||||
|
||||
const now = getTimestampNow();
|
||||
for (const g of Object.keys(groups)) {
|
||||
let groupDone = true;
|
||||
for (const pk of Object.keys(p.refundState.refundsPending)) {
|
||||
const r = p.refundState.refundsPending[pk];
|
||||
if (r.refundGroupId == g) {
|
||||
groupDone = false;
|
||||
}
|
||||
}
|
||||
if (groupDone) {
|
||||
const refundEvent: RefundEventRecord = {
|
||||
proposalId,
|
||||
refundGroupId: g,
|
||||
timestamp: now,
|
||||
};
|
||||
await tx.put(Stores.refundEvents, refundEvent);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(p.refundState.refundsPending).length === 0) {
|
||||
p.refundStatusRetryInfo = initRetryInfo();
|
||||
p.lastRefundStatusError = undefined;
|
||||
allRefundsProcessed = true;
|
||||
}
|
||||
await tx.put(Stores.purchases, p);
|
||||
const coinsPubsToBeRefreshed = Object.values(refreshCoinsMap);
|
||||
if (coinsPubsToBeRefreshed.length > 0) {
|
||||
await createRefreshGroup(
|
||||
tx,
|
||||
coinsPubsToBeRefreshed,
|
||||
RefreshReason.Refund,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (allRefundsProcessed) {
|
||||
ws.notify({
|
||||
type: NotificationType.RefundFinished,
|
||||
});
|
||||
}
|
||||
|
||||
ws.notify({
|
||||
type: NotificationType.RefundsSubmitted,
|
||||
proposalId,
|
||||
});
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ import { AmountJson } from "../util/amounts";
|
||||
import {
|
||||
Auditor,
|
||||
CoinDepositPermission,
|
||||
MerchantRefundPermission,
|
||||
MerchantRefundDetails,
|
||||
PayReq,
|
||||
TipResponse,
|
||||
ExchangeSignKeyJson,
|
||||
@ -1091,7 +1091,7 @@ export interface RefundEventRecord {
|
||||
|
||||
export interface RefundInfo {
|
||||
refundGroupId: string;
|
||||
perm: MerchantRefundPermission;
|
||||
perm: MerchantRefundDetails;
|
||||
}
|
||||
|
||||
export const enum RefundReason {
|
||||
@ -1102,7 +1102,7 @@ export const enum RefundReason {
|
||||
/**
|
||||
* Refund from an aborted payment.
|
||||
*/
|
||||
AbortRefund = "abort-refund",
|
||||
AbortRefund = "abort-pay-refund",
|
||||
}
|
||||
|
||||
export interface RefundGroupInfo {
|
||||
@ -1110,28 +1110,6 @@ export interface RefundGroupInfo {
|
||||
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 stored for every time we successfully submitted
|
||||
* a payment to the merchant (both first time and re-play).
|
||||
@ -1230,9 +1208,25 @@ export interface PurchaseRecord {
|
||||
timestampAccept: Timestamp;
|
||||
|
||||
/**
|
||||
* State of refunds for this proposal.
|
||||
* Information regarding each group of refunds we receive at once.
|
||||
*/
|
||||
refundState: PurchaseRefundState;
|
||||
refundGroups: RefundGroupInfo[];
|
||||
|
||||
/**
|
||||
* Pending refunds for the purchase. A refund is pending
|
||||
* when the merchant reports a transient error from the exchange.
|
||||
*/
|
||||
refundsPending: { [refundKey: string]: RefundInfo };
|
||||
|
||||
/**
|
||||
* Applied refunds for the purchase.
|
||||
*/
|
||||
refundsDone: { [refundKey: string]: RefundInfo };
|
||||
|
||||
/**
|
||||
* Refunds that permanently failed.
|
||||
*/
|
||||
refundsFailed: { [refundKey: string]: RefundInfo };
|
||||
|
||||
/**
|
||||
* When was the last refund made?
|
||||
@ -1280,16 +1274,6 @@ export interface PurchaseRecord {
|
||||
*/
|
||||
lastRefundStatusError: OperationError | undefined;
|
||||
|
||||
/**
|
||||
* Retry information for querying the refund status with the merchant.
|
||||
*/
|
||||
refundApplyRetryInfo: RetryInfo;
|
||||
|
||||
/**
|
||||
* Last error (or undefined) for querying the refund status with the merchant.
|
||||
*/
|
||||
lastRefundApplyError: OperationError | undefined;
|
||||
|
||||
/**
|
||||
* Continue querying the refund status until this deadline has expired.
|
||||
*/
|
||||
|
@ -35,7 +35,6 @@ export const enum PendingOperationType {
|
||||
Refresh = "refresh",
|
||||
Reserve = "reserve",
|
||||
Recoup = "recoup",
|
||||
RefundApply = "refund-apply",
|
||||
RefundQuery = "refund-query",
|
||||
TipChoice = "tip-choice",
|
||||
TipPickup = "tip-pickup",
|
||||
@ -53,7 +52,6 @@ export type PendingOperationInfo = PendingOperationInfoCommon &
|
||||
| PendingProposalChoiceOperation
|
||||
| PendingProposalDownloadOperation
|
||||
| PendingRefreshOperation
|
||||
| PendingRefundApplyOperation
|
||||
| PendingRefundQueryOperation
|
||||
| PendingReserveOperation
|
||||
| PendingTipChoiceOperation
|
||||
@ -188,20 +186,6 @@ export interface PendingRefundQueryOperation {
|
||||
lastError: OperationError | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wallet is processing refunds that it received from a merchant.
|
||||
* During this operation, the wallet checks the refund permissions and sends
|
||||
* them to the exchange to obtain a refund on a coin.
|
||||
*/
|
||||
export interface PendingRefundApplyOperation {
|
||||
type: PendingOperationType.RefundApply;
|
||||
proposalId: string;
|
||||
retryInfo: RetryInfo;
|
||||
lastError: OperationError | undefined;
|
||||
numRefundsPending: number;
|
||||
numRefundsDone: number;
|
||||
}
|
||||
|
||||
export interface PendingRecoupOperation {
|
||||
type: PendingOperationType.Recoup;
|
||||
recoupGroupId: string;
|
||||
|
@ -411,7 +411,7 @@ export interface PayReq {
|
||||
/**
|
||||
* Refund permission in the format that the merchant gives it to us.
|
||||
*/
|
||||
export class MerchantRefundPermission {
|
||||
export class MerchantRefundDetails {
|
||||
/**
|
||||
* Amount to be refunded.
|
||||
*/
|
||||
@ -433,52 +433,30 @@ export class MerchantRefundPermission {
|
||||
rtransaction_id: number;
|
||||
|
||||
/**
|
||||
* Signature made by the merchant over the refund permission.
|
||||
* Exchange's key used for the signature.
|
||||
*/
|
||||
merchant_sig: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: string;
|
||||
exchange_pub?: string;
|
||||
|
||||
/**
|
||||
* Refund fee associated with the given coin.
|
||||
* must be smaller than the refund amount.
|
||||
* Exchange's signature to confirm the refund.
|
||||
*/
|
||||
refund_fee: string;
|
||||
exchange_sig?: string;
|
||||
|
||||
/**
|
||||
* SHA-512 hash of the contact of the merchant with the customer.
|
||||
* Error replay from the exchange (if any).
|
||||
*/
|
||||
h_contract_terms: string;
|
||||
exchange_reply?: any;
|
||||
|
||||
/**
|
||||
* coin's public key, both ECDHE and EdDSA.
|
||||
* Error code from the exchange (if any).
|
||||
*/
|
||||
coin_pub: string;
|
||||
exchange_code?: number;
|
||||
|
||||
/**
|
||||
* 64-bit transaction id of the refund transaction between merchant and customer
|
||||
* HTTP status code of the exchange's response
|
||||
* to the merchant's refund request.
|
||||
*/
|
||||
rtransaction_id: number;
|
||||
|
||||
/**
|
||||
* EdDSA public key of the merchant.
|
||||
*/
|
||||
merchant_pub: string;
|
||||
|
||||
/**
|
||||
* EdDSA signature of the merchant affirming the refund.
|
||||
*/
|
||||
merchant_sig: string;
|
||||
exchange_http_status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -499,7 +477,7 @@ export class MerchantRefundResponse {
|
||||
/**
|
||||
* The signed refund permissions, to be sent to the exchange.
|
||||
*/
|
||||
refund_permissions: MerchantRefundPermission[];
|
||||
refunds: MerchantRefundDetails[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -854,14 +832,18 @@ export const codecForContractTerms = (): Codec<ContractTerms> =>
|
||||
.build("ContractTerms");
|
||||
|
||||
export const codecForMerchantRefundPermission = (): Codec<
|
||||
MerchantRefundPermission
|
||||
MerchantRefundDetails
|
||||
> =>
|
||||
makeCodecForObject<MerchantRefundPermission>()
|
||||
makeCodecForObject<MerchantRefundDetails>()
|
||||
.property("refund_amount", codecForString)
|
||||
.property("refund_fee", codecForString)
|
||||
.property("coin_pub", codecForString)
|
||||
.property("rtransaction_id", codecForNumber)
|
||||
.property("merchant_sig", codecForString)
|
||||
.property("exchange_http_status", codecForNumber)
|
||||
.property("exchange_code", makeCodecOptional(codecForNumber))
|
||||
.property("exchange_reply", makeCodecOptional(codecForAny))
|
||||
.property("exchange_sig", makeCodecOptional(codecForString))
|
||||
.property("exchange_pub", makeCodecOptional(codecForString))
|
||||
.build("MerchantRefundPermission");
|
||||
|
||||
export const codecForMerchantRefundResponse = (): Codec<
|
||||
@ -871,7 +853,7 @@ export const codecForMerchantRefundResponse = (): Codec<
|
||||
.property("merchant_pub", codecForString)
|
||||
.property("h_contract_terms", codecForString)
|
||||
.property(
|
||||
"refund_permissions",
|
||||
"refunds",
|
||||
makeCodecForList(codecForMerchantRefundPermission()),
|
||||
)
|
||||
.build("MerchantRefundResponse");
|
||||
|
@ -34,7 +34,6 @@ import {
|
||||
} from "./operations/withdraw";
|
||||
|
||||
import {
|
||||
abortFailedPayment,
|
||||
preparePayForUri,
|
||||
refuseProposal,
|
||||
confirmPay,
|
||||
@ -53,7 +52,7 @@ import {
|
||||
ReserveRecordStatus,
|
||||
CoinSourceType,
|
||||
} from "./types/dbTypes";
|
||||
import { MerchantRefundPermission, CoinDumpJson } from "./types/talerTypes";
|
||||
import { MerchantRefundDetails, CoinDumpJson } from "./types/talerTypes";
|
||||
import {
|
||||
BenchmarkResult,
|
||||
ConfirmPayResult,
|
||||
@ -107,7 +106,6 @@ import { WalletNotification, NotificationType } from "./types/notifications";
|
||||
import { HistoryQuery, HistoryEvent } from "./types/history";
|
||||
import {
|
||||
processPurchaseQueryRefund,
|
||||
processPurchaseApplyRefund,
|
||||
getFullRefundFees,
|
||||
applyRefund,
|
||||
} from "./operations/refund";
|
||||
@ -218,9 +216,6 @@ export class Wallet {
|
||||
case PendingOperationType.RefundQuery:
|
||||
await processPurchaseQueryRefund(this.ws, pending.proposalId, forceNow);
|
||||
break;
|
||||
case PendingOperationType.RefundApply:
|
||||
await processPurchaseApplyRefund(this.ws, pending.proposalId, forceNow);
|
||||
break;
|
||||
case PendingOperationType.Recoup:
|
||||
await processRecoupGroup(this.ws, pending.recoupGroupId, forceNow);
|
||||
break;
|
||||
@ -658,7 +653,7 @@ export class Wallet {
|
||||
}
|
||||
|
||||
async getFullRefundFees(
|
||||
refundPermissions: MerchantRefundPermission[],
|
||||
refundPermissions: MerchantRefundDetails[],
|
||||
): Promise<AmountJson> {
|
||||
return getFullRefundFees(this.ws, refundPermissions);
|
||||
}
|
||||
@ -676,11 +671,7 @@ export class Wallet {
|
||||
}
|
||||
|
||||
async abortFailedPayment(contractTermsHash: string): Promise<void> {
|
||||
try {
|
||||
return abortFailedPayment(this.ws, contractTermsHash);
|
||||
} finally {
|
||||
this.latch.trigger();
|
||||
}
|
||||
throw Error("not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -745,20 +736,20 @@ export class Wallet {
|
||||
throw Error("unknown purchase");
|
||||
}
|
||||
const refundsDoneAmounts = Object.values(
|
||||
purchase.refundState.refundsDone,
|
||||
purchase.refundsDone,
|
||||
).map((x) => Amounts.parseOrThrow(x.perm.refund_amount));
|
||||
const refundsPendingAmounts = Object.values(
|
||||
purchase.refundState.refundsPending,
|
||||
purchase.refundsPending,
|
||||
).map((x) => Amounts.parseOrThrow(x.perm.refund_amount));
|
||||
const totalRefundAmount = Amounts.sum([
|
||||
...refundsDoneAmounts,
|
||||
...refundsPendingAmounts,
|
||||
]).amount;
|
||||
const refundsDoneFees = Object.values(
|
||||
purchase.refundState.refundsDone,
|
||||
purchase.refundsDone,
|
||||
).map((x) => Amounts.parseOrThrow(x.perm.refund_amount));
|
||||
const refundsPendingFees = Object.values(
|
||||
purchase.refundState.refundsPending,
|
||||
purchase.refundsPending,
|
||||
).map((x) => Amounts.parseOrThrow(x.perm.refund_amount));
|
||||
const totalRefundFees = Amounts.sum([
|
||||
...refundsDoneFees,
|
||||
|
Loading…
Reference in New Issue
Block a user