/*
This file is part of GNU Taler
(C) 2019-2019 Taler Systems S.A.
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
*/
/**
* Implementation of the refund operation.
*
* @author Florian Dold
*/
/**
* Imports.
*/
import { InternalWalletState } from "./state";
import {
OperationErrorDetails,
RefreshReason,
CoinPublicKey,
} from "../types/walletTypes";
import {
Stores,
updateRetryInfoTimeout,
initRetryInfo,
CoinStatus,
RefundReason,
RefundState,
PurchaseRecord,
} from "../types/dbTypes";
import { NotificationType } from "../types/notifications";
import { parseRefundUri } from "../util/taleruri";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
import { Amounts, AmountJson } from "../util/amounts";
import {
MerchantCoinRefundStatus,
MerchantCoinRefundSuccessStatus,
MerchantCoinRefundFailureStatus,
codecForMerchantOrderStatusPaid,
AmountString,
} from "../types/talerTypes";
import { guardOperationException } from "./errors";
import { getTimestampNow } from "../util/time";
import { Logger } from "../util/logging";
import { readSuccessResponseJsonOrThrow } from "../util/http";
import { TransactionHandle } from "../util/query";
import { URL } from "../util/url";
const logger = new Logger("refund.ts");
/**
* Retry querying and applying refunds for an order later.
*/
async function incrementPurchaseQueryRefundRetry(
ws: InternalWalletState,
proposalId: string,
err: OperationErrorDetails | undefined,
): Promise {
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);
});
if (err) {
ws.notify({
type: NotificationType.RefundStatusOperationError,
error: err,
});
}
}
function getRefundKey(d: MerchantCoinRefundStatus): string {
return `${d.coin_pub}-${d.rtransaction_id}`;
}
async function applySuccessfulRefund(
tx: TransactionHandle,
p: PurchaseRecord,
refreshCoinsMap: Record,
r: MerchantCoinRefundSuccessStatus,
): Promise {
// FIXME: check signature before storing it as valid!
const refundKey = getRefundKey(r);
const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) {
console.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.getIndexed(
Stores.denominations.denomPubHashIndex,
coin.denomPubHash,
);
if (!denom) {
throw Error("inconsistent database");
}
refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
const refundAmount = Amounts.parseOrThrow(r.refund_amount);
const refundFee = denom.feeRefund;
coin.status = CoinStatus.Dormant;
coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
await tx.put(Stores.coins, coin);
const allDenoms = await tx
.iterIndexed(
Stores.denominations.exchangeBaseUrlIndex,
coin.exchangeBaseUrl,
)
.toArray();
const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
.amount,
denom.feeRefund,
).amount;
const totalRefreshCostBound = getTotalRefreshCost(
allDenoms,
denom,
amountLeft,
);
p.refunds[refundKey] = {
type: RefundState.Applied,
executionTime: r.execution_time,
refundAmount: Amounts.parseOrThrow(r.refund_amount),
refundFee: denom.feeRefund,
totalRefreshCostBound,
};
}
async function storePendingRefund(
tx: TransactionHandle,
p: PurchaseRecord,
r: MerchantCoinRefundFailureStatus,
): Promise {
const refundKey = getRefundKey(r);
const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) {
console.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.getIndexed(
Stores.denominations.denomPubHashIndex,
coin.denomPubHash,
);
if (!denom) {
throw Error("inconsistent database");
}
const allDenoms = await tx
.iterIndexed(
Stores.denominations.exchangeBaseUrlIndex,
coin.exchangeBaseUrl,
)
.toArray();
const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
.amount,
denom.feeRefund,
).amount;
const totalRefreshCostBound = getTotalRefreshCost(
allDenoms,
denom,
amountLeft,
);
p.refunds[refundKey] = {
type: RefundState.Pending,
executionTime: r.execution_time,
refundAmount: Amounts.parseOrThrow(r.refund_amount),
refundFee: denom.feeRefund,
totalRefreshCostBound,
};
}
async function acceptRefunds(
ws: InternalWalletState,
proposalId: string,
refunds: MerchantCoinRefundStatus[],
reason: RefundReason,
): Promise {
logger.trace("handling refunds", refunds);
const now = getTimestampNow();
await ws.db.runWithWriteTransaction(
[
Stores.purchases,
Stores.coins,
Stores.denominations,
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;
}
const refreshCoinsMap: Record = {};
for (const refundStatus of refunds) {
const refundKey = getRefundKey(refundStatus);
const existingRefundInfo = p.refunds[refundKey];
// Already failed.
if (existingRefundInfo?.type === RefundState.Failed) {
continue;
}
// Already applied.
if (existingRefundInfo?.type === RefundState.Applied) {
continue;
}
// Still pending.
if (
refundStatus.type === "failure" &&
existingRefundInfo?.type === RefundState.Pending
) {
continue;
}
// Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
if (refundStatus.type === "success") {
await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
} else {
await storePendingRefund(tx, p, refundStatus);
}
}
const refreshCoinsPubs = Object.values(refreshCoinsMap);
await createRefreshGroup(ws, tx, refreshCoinsPubs, RefreshReason.Refund);
// Are we done with querying yet, or do we need to do another round
// after a retry delay?
let queryDone = true;
if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
queryDone = false;
}
let numPendingRefunds = 0;
for (const ri of Object.values(p.refunds)) {
switch (ri.type) {
case RefundState.Pending:
numPendingRefunds++;
break;
}
}
if (numPendingRefunds > 0) {
queryDone = false;
}
if (queryDone) {
p.timestampLastRefundStatus = now;
p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo(false);
p.refundStatusRequested = false;
logger.trace("refund query done");
} else {
// No error, but we need to try again!
p.timestampLastRefundStatus = now;
p.refundStatusRetryInfo.retryCounter++;
updateRetryInfoTimeout(p.refundStatusRetryInfo);
p.lastRefundStatusError = undefined;
logger.trace("refund query not done");
}
await tx.put(Stores.purchases, p);
},
);
ws.notify({
type: NotificationType.RefundQueried,
});
}
/**
* Summary of the refund status of a purchase.
*/
export interface RefundSummary {
pendingAtExchange: boolean;
amountEffectivePaid: AmountJson;
amountRefundGranted: AmountJson;
amountRefundGone: AmountJson;
}
export interface ApplyRefundResponse {
contractTermsHash: string;
proposalId: string;
amountEffectivePaid: AmountString;
amountRefundGranted: AmountString;
amountRefundGone: AmountString;
pendingAtExchange: boolean;
}
/**
* 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 {
const parseResult = parseRefundUri(talerRefundUri);
logger.trace("applying refund", parseResult);
if (!parseResult) {
throw Error("invalid refund URI");
}
let purchase = await ws.db.getIndexed(Stores.purchases.orderIdIndex, [
parseResult.merchantBaseUrl,
parseResult.orderId,
]);
if (!purchase) {
throw Error(
`no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
);
}
const proposalId = purchase.proposalId;
logger.info("processing purchase for refund");
const success = await ws.db.runWithWriteTransaction(
[Stores.purchases],
async (tx) => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
logger.error("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) {
ws.notify({
type: NotificationType.RefundStarted,
});
await processPurchaseQueryRefund(ws, proposalId);
}
purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
throw Error("purchase no longer exists");
}
const p = purchase;
let amountRefundGranted = Amounts.getZero(
purchase.contractData.amount.currency,
);
let amountRefundGone = Amounts.getZero(purchase.contractData.amount.currency);
let pendingAtExchange = false;
Object.keys(purchase.refunds).forEach((rk) => {
const refund = p.refunds[rk];
if (refund.type === RefundState.Pending) {
pendingAtExchange = true;
}
if (
refund.type === RefundState.Applied ||
refund.type === RefundState.Pending
) {
amountRefundGranted = Amounts.add(
amountRefundGranted,
Amounts.sub(
refund.refundAmount,
refund.refundFee,
refund.totalRefreshCostBound,
).amount,
).amount;
} else {
amountRefundGone = Amounts.add(amountRefundGone, refund.refundAmount)
.amount;
}
});
return {
contractTermsHash: purchase.contractData.contractTermsHash,
proposalId: purchase.proposalId,
amountEffectivePaid: Amounts.stringify(purchase.payCostInfo.totalCost),
amountRefundGone: Amounts.stringify(amountRefundGone),
amountRefundGranted: Amounts.stringify(amountRefundGranted),
pendingAtExchange,
};
}
export async function processPurchaseQueryRefund(
ws: InternalWalletState,
proposalId: string,
forceNow = false,
): Promise {
const onOpErr = (e: OperationErrorDetails): Promise =>
incrementPurchaseQueryRefundRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
onOpErr,
);
}
async function resetPurchaseQueryRefundRetry(
ws: InternalWalletState,
proposalId: string,
): Promise {
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 {
if (forceNow) {
await resetPurchaseQueryRefundRetry(ws, proposalId);
}
const purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
return;
}
if (!purchase.refundStatusRequested) {
return;
}
const requestUrl = new URL(
`orders/${purchase.contractData.orderId}`,
purchase.contractData.merchantBaseUrl,
);
requestUrl.searchParams.set(
"h_contract",
purchase.contractData.contractTermsHash,
);
const request = await ws.http.get(requestUrl.href);
logger.trace("got json", JSON.stringify(await request.json(), undefined, 2));
const refundResponse = await readSuccessResponseJsonOrThrow(
request,
codecForMerchantOrderStatusPaid(),
);
await acceptRefunds(
ws,
proposalId,
refundResponse.refunds,
RefundReason.NormalRefund,
);
}