2019-12-15 19:08:07 +01:00
|
|
|
/*
|
|
|
|
This file is part of GNU Taler
|
2020-01-19 17:14:43 +01:00
|
|
|
(C) 2019-2019 Taler Systems S.A.
|
2019-12-15 19:08:07 +01:00
|
|
|
|
|
|
|
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/>
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Implementation of the refund operation.
|
|
|
|
*
|
|
|
|
* @author Florian Dold
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Imports.
|
|
|
|
*/
|
|
|
|
import { InternalWalletState } from "./state";
|
|
|
|
import {
|
|
|
|
OperationError,
|
|
|
|
RefreshReason,
|
2019-12-16 12:53:22 +01:00
|
|
|
CoinPublicKey,
|
2019-12-15 19:08:07 +01:00
|
|
|
} from "../types/walletTypes";
|
|
|
|
import {
|
|
|
|
Stores,
|
|
|
|
updateRetryInfoTimeout,
|
|
|
|
initRetryInfo,
|
|
|
|
CoinStatus,
|
|
|
|
RefundReason,
|
|
|
|
RefundEventRecord,
|
|
|
|
} from "../types/dbTypes";
|
|
|
|
import { NotificationType } from "../types/notifications";
|
|
|
|
import { parseRefundUri } from "../util/taleruri";
|
|
|
|
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
|
2020-04-02 17:03:01 +02:00
|
|
|
import { Amounts } from "../util/amounts";
|
2019-12-15 19:08:07 +01:00
|
|
|
import {
|
2020-04-27 17:41:20 +02:00
|
|
|
MerchantRefundDetails,
|
2019-12-15 19:08:07 +01:00
|
|
|
MerchantRefundResponse,
|
2019-12-19 20:42:49 +01:00
|
|
|
codecForMerchantRefundResponse,
|
2019-12-15 19:08:07 +01:00
|
|
|
} from "../types/talerTypes";
|
|
|
|
import { AmountJson } from "../util/amounts";
|
2020-05-12 12:14:48 +02:00
|
|
|
import { guardOperationException } from "./errors";
|
2019-12-15 19:08:07 +01:00
|
|
|
import { randomBytes } from "../crypto/primitives/nacl-fast";
|
|
|
|
import { encodeCrock } from "../crypto/talerCrypto";
|
2019-12-19 20:42:49 +01:00
|
|
|
import { getTimestampNow } from "../util/time";
|
2020-01-19 20:41:51 +01:00
|
|
|
import { Logger } from "../util/logging";
|
|
|
|
|
|
|
|
const logger = new Logger("refund.ts");
|
2019-12-15 19:08:07 +01:00
|
|
|
|
|
|
|
async function incrementPurchaseQueryRefundRetry(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
err: OperationError | undefined,
|
|
|
|
): Promise<void> {
|
|
|
|
console.log("incrementing purchase refund query retry with error", err);
|
2020-03-30 12:34:16 +02:00
|
|
|
await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
|
2019-12-15 19:08:07 +01:00
|
|
|
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 });
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function getFullRefundFees(
|
|
|
|
ws: InternalWalletState,
|
2020-04-27 17:41:20 +02:00
|
|
|
refundPermissions: MerchantRefundDetails[],
|
2019-12-15 19:08:07 +01:00
|
|
|
): 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;
|
|
|
|
}
|
|
|
|
|
2020-04-27 17:41:20 +02:00
|
|
|
function getRefundKey(d: MerchantRefundDetails): string {
|
|
|
|
return `{d.coin_pub}-{d.rtransaction_id}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function acceptRefundResponse(
|
2019-12-15 19:08:07 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
refundResponse: MerchantRefundResponse,
|
|
|
|
reason: RefundReason,
|
|
|
|
): Promise<void> {
|
2020-04-27 17:41:20 +02:00
|
|
|
const refunds = refundResponse.refunds;
|
2019-12-15 19:08:07 +01:00
|
|
|
|
|
|
|
const refundGroupId = encodeCrock(randomBytes(32));
|
|
|
|
|
2020-04-27 17:41:20 +02:00
|
|
|
let numNewRefunds = 0;
|
2019-12-15 19:08:07 +01:00
|
|
|
|
2020-04-27 17:41:20 +02:00
|
|
|
const finishedRefunds: MerchantRefundDetails[] = [];
|
|
|
|
const unfinishedRefunds: MerchantRefundDetails[] = [];
|
|
|
|
const failedRefunds: MerchantRefundDetails[] = [];
|
|
|
|
|
|
|
|
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);
|
2019-12-15 19:08:07 +01:00
|
|
|
}
|
2020-04-27 17:41:20 +02:00
|
|
|
}
|
2019-12-15 19:08:07 +01:00
|
|
|
|
2020-05-12 12:14:48 +02:00
|
|
|
const now = getTimestampNow();
|
|
|
|
|
2020-04-27 17:41:20 +02:00
|
|
|
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,
|
2019-12-15 19:08:07 +01:00
|
|
|
refundGroupId,
|
|
|
|
};
|
2020-04-27 17:41:20 +02:00
|
|
|
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);
|
2019-12-15 19:08:07 +01:00
|
|
|
}
|
|
|
|
|
2020-04-27 17:41:20 +02:00
|
|
|
// Are we done with querying yet, or do we need to do another round
|
|
|
|
// after a retry delay?
|
|
|
|
let queryDone = true;
|
2019-12-15 19:08:07 +01:00
|
|
|
|
2020-04-27 17:41:20 +02:00
|
|
|
if (numNewRefunds === 0) {
|
2020-05-15 09:23:35 +02:00
|
|
|
if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
|
2020-04-27 17:41:20 +02:00
|
|
|
queryDone = false;
|
|
|
|
}
|
2020-05-12 12:14:48 +02:00
|
|
|
} else {
|
|
|
|
p.refundGroups.push({
|
|
|
|
reason: RefundReason.NormalRefund,
|
|
|
|
refundGroupId,
|
|
|
|
timestampQueried: getTimestampNow(),
|
|
|
|
});
|
2020-04-27 17:41:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (Object.keys(unfinishedRefunds).length != 0) {
|
2019-12-15 19:08:07 +01:00
|
|
|
queryDone = false;
|
|
|
|
}
|
|
|
|
|
2020-04-27 17:41:20 +02:00
|
|
|
if (queryDone) {
|
2020-05-12 12:14:48 +02:00
|
|
|
p.timestampLastRefundStatus = now;
|
2020-04-27 17:41:20 +02:00
|
|
|
p.lastRefundStatusError = undefined;
|
|
|
|
p.refundStatusRetryInfo = initRetryInfo(false);
|
|
|
|
p.refundStatusRequested = false;
|
|
|
|
console.log("refund query done");
|
|
|
|
} else {
|
|
|
|
// No error, but we need to try again!
|
2020-05-12 12:14:48 +02:00
|
|
|
p.timestampLastRefundStatus = now;
|
2020-04-27 17:41:20 +02:00
|
|
|
p.refundStatusRetryInfo.retryCounter++;
|
|
|
|
updateRetryInfoTimeout(p.refundStatusRetryInfo);
|
|
|
|
p.lastRefundStatusError = undefined;
|
|
|
|
console.log("refund query not done");
|
|
|
|
}
|
2019-12-15 19:08:07 +01:00
|
|
|
|
2020-04-27 17:41:20 +02:00
|
|
|
await tx.put(Stores.purchases, p);
|
2019-12-15 19:08:07 +01:00
|
|
|
|
2020-04-27 17:41:20 +02:00
|
|
|
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.
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
2019-12-15 19:08:07 +01:00
|
|
|
|
|
|
|
ws.notify({
|
|
|
|
type: NotificationType.RefundQueried,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function startRefundQuery(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
): Promise<void> {
|
|
|
|
const success = await ws.db.runWithWriteTransaction(
|
|
|
|
[Stores.purchases],
|
2020-03-30 12:34:16 +02:00
|
|
|
async (tx) => {
|
2019-12-15 19:08:07 +01:00
|
|
|
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,
|
2020-05-15 09:23:35 +02:00
|
|
|
): Promise<{ contractTermsHash: string; proposalId: string }> {
|
2019-12-15 19:08:07 +01:00
|
|
|
const parseResult = parseRefundUri(talerRefundUri);
|
|
|
|
|
2020-01-18 23:32:03 +01:00
|
|
|
console.log("applying refund", parseResult);
|
2019-12-15 19:08:07 +01:00
|
|
|
|
|
|
|
if (!parseResult) {
|
|
|
|
throw Error("invalid refund URI");
|
|
|
|
}
|
|
|
|
|
|
|
|
const purchase = await ws.db.getIndexed(Stores.purchases.orderIdIndex, [
|
|
|
|
parseResult.merchantBaseUrl,
|
|
|
|
parseResult.orderId,
|
|
|
|
]);
|
|
|
|
|
|
|
|
if (!purchase) {
|
2020-03-30 12:34:16 +02:00
|
|
|
throw Error(
|
|
|
|
`no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
|
|
|
|
);
|
2019-12-15 19:08:07 +01:00
|
|
|
}
|
|
|
|
|
2020-03-30 12:42:28 +02:00
|
|
|
logger.info("processing purchase for refund");
|
2019-12-15 19:08:07 +01:00
|
|
|
await startRefundQuery(ws, purchase.proposalId);
|
|
|
|
|
2020-05-15 09:23:35 +02:00
|
|
|
return {
|
|
|
|
contractTermsHash: purchase.contractData.contractTermsHash,
|
|
|
|
proposalId: purchase.proposalId,
|
|
|
|
};
|
2019-12-15 19:08:07 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export async function processPurchaseQueryRefund(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2020-04-06 17:45:41 +02:00
|
|
|
forceNow = false,
|
2019-12-15 19:08:07 +01:00
|
|
|
): Promise<void> {
|
2020-04-06 20:02:01 +02:00
|
|
|
const onOpErr = (e: OperationError): Promise<void> =>
|
2019-12-15 19:08:07 +01:00
|
|
|
incrementPurchaseQueryRefundRetry(ws, proposalId, e);
|
|
|
|
await guardOperationException(
|
|
|
|
() => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
|
|
|
|
onOpErr,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
async function resetPurchaseQueryRefundRetry(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2020-04-06 20:02:01 +02:00
|
|
|
): Promise<void> {
|
2020-03-30 12:34:16 +02:00
|
|
|
await ws.db.mutate(Stores.purchases, proposalId, (x) => {
|
2019-12-15 19:08:07 +01:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2020-03-30 12:34:16 +02:00
|
|
|
const refundUrlObj = new URL("refund", purchase.contractData.merchantBaseUrl);
|
2019-12-19 20:42:49 +01:00
|
|
|
refundUrlObj.searchParams.set("order_id", purchase.contractData.orderId);
|
2019-12-15 19:08:07 +01:00
|
|
|
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`);
|
|
|
|
}
|
|
|
|
|
2020-03-30 12:34:16 +02:00
|
|
|
const refundResponse = codecForMerchantRefundResponse().decode(
|
|
|
|
await resp.json(),
|
|
|
|
);
|
2019-12-15 19:08:07 +01:00
|
|
|
await acceptRefundResponse(
|
|
|
|
ws,
|
|
|
|
proposalId,
|
|
|
|
refundResponse,
|
|
|
|
RefundReason.NormalRefund,
|
|
|
|
);
|
|
|
|
}
|