/*
This file is part of GNU Taler
(C) 2019 GNUnet e.V.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see
*/
import {
MerchantRefundResponse,
RefundRequest,
MerchantRefundPermission,
} from "../talerTypes";
import { PurchaseRecord, Stores, CoinRecord, CoinStatus } from "../dbTypes";
import { getTimestampNow } from "../walletTypes";
import {
oneShotMutate,
oneShotGet,
runWithWriteTransaction,
oneShotIterIndex,
} from "../util/query";
import { InternalWalletState } from "./state";
import { parseRefundUri } from "../util/taleruri";
import { Logger } from "../util/logging";
import { AmountJson } from "../util/amounts";
import * as Amounts from "../util/amounts";
import { getTotalRefreshCost, refresh } from "./refresh";
const logger = new Logger("refund.ts");
export async function getFullRefundFees(
ws: InternalWalletState,
refundPermissions: MerchantRefundPermission[],
): Promise {
if (refundPermissions.length === 0) {
throw Error("no refunds given");
}
const coin0 = await oneShotGet(
ws.db,
Stores.coins,
refundPermissions[0].coin_pub,
);
if (!coin0) {
throw Error("coin not found");
}
let feeAcc = Amounts.getZero(
Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
);
const denoms = await oneShotIterIndex(
ws.db,
Stores.denominations.exchangeBaseUrlIndex,
coin0.exchangeBaseUrl,
).toArray();
for (const rp of refundPermissions) {
const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub);
if (!coin) {
throw Error("coin not found");
}
const denom = await oneShotGet(ws.db, Stores.denominations, [
coin0.exchangeBaseUrl,
coin.denomPub,
]);
if (!denom) {
throw Error(`denom not found (${coin.denomPub})`);
}
// FIXME: this assumes that the refund already happened.
// When it hasn't, the refresh cost is inaccurate. To fix this,
// we need introduce a flag to tell if a coin was refunded or
// refreshed normally (and what about incremental refunds?)
const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
const refundFee = Amounts.parseOrThrow(rp.refund_fee);
const refreshCost = getTotalRefreshCost(
denoms,
denom,
Amounts.sub(refundAmount, refundFee).amount,
);
feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
}
return feeAcc;
}
async function submitRefunds(
ws: InternalWalletState,
contractTermsHash: string,
): Promise {
const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash);
if (!purchase) {
console.error(
"not submitting refunds, contract terms not found:",
contractTermsHash,
);
return;
}
const pendingKeys = Object.keys(purchase.refundsPending);
if (pendingKeys.length === 0) {
return;
}
for (const pk of pendingKeys) {
const perm = purchase.refundsPending[pk];
const req: RefundRequest = {
coin_pub: perm.coin_pub,
h_contract_terms: purchase.contractTermsHash,
merchant_pub: purchase.contractTerms.merchant_pub,
merchant_sig: perm.merchant_sig,
refund_amount: perm.refund_amount,
refund_fee: perm.refund_fee,
rtransaction_id: perm.rtransaction_id,
};
console.log("sending refund permission", perm);
// FIXME: not correct once we support multiple exchanges per payment
const exchangeUrl = purchase.payReq.coins[0].exchange_url;
const reqUrl = new URL("refund", exchangeUrl);
const resp = await ws.http.postJson(reqUrl.href, req);
if (resp.status !== 200) {
console.error("refund failed", resp);
continue;
}
// Transactionally mark successful refunds as done
const transformPurchase = (
t: PurchaseRecord | undefined,
): PurchaseRecord | undefined => {
if (!t) {
console.warn("purchase not found, not updating refund");
return;
}
if (t.refundsPending[pk]) {
t.refundsDone[pk] = t.refundsPending[pk];
delete t.refundsPending[pk];
}
return t;
};
const transformCoin = (
c: CoinRecord | undefined,
): CoinRecord | undefined => {
if (!c) {
console.warn("coin not found, can't apply refund");
return;
}
const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
const refundFee = Amounts.parseOrThrow(perm.refund_fee);
c.status = CoinStatus.Dirty;
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
return c;
};
await runWithWriteTransaction(
ws.db,
[Stores.purchases, Stores.coins],
async tx => {
await tx.mutate(Stores.purchases, contractTermsHash, transformPurchase);
await tx.mutate(Stores.coins, perm.coin_pub, transformCoin);
},
);
refresh(ws, perm.coin_pub);
}
ws.badge.showNotification();
ws.notifier.notify();
}
export async function acceptRefundResponse(
ws: InternalWalletState,
refundResponse: MerchantRefundResponse,
): Promise {
const refundPermissions = refundResponse.refund_permissions;
if (!refundPermissions.length) {
console.warn("got empty refund list");
throw Error("empty refund");
}
/**
* Add refund to purchase if not already added.
*/
function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined {
if (!t) {
console.error("purchase not found, not adding refunds");
return;
}
t.timestamp_refund = getTimestampNow();
for (const perm of refundPermissions) {
if (
!t.refundsPending[perm.merchant_sig] &&
!t.refundsDone[perm.merchant_sig]
) {
t.refundsPending[perm.merchant_sig] = perm;
}
}
return t;
}
const hc = refundResponse.h_contract_terms;
// Add the refund permissions to the purchase within a DB transaction
await oneShotMutate(ws.db, Stores.purchases, hc, f);
ws.notifier.notify();
await submitRefunds(ws, hc);
return hc;
}
/**
* Accept a refund, return the contract hash for the contract
* that was involved in the refund.
*/
export async function applyRefund(
ws: InternalWalletState,
talerRefundUri: string,
): Promise {
const parseResult = parseRefundUri(talerRefundUri);
if (!parseResult) {
throw Error("invalid refund URI");
}
const refundUrl = parseResult.refundUrl;
logger.trace("processing refund");
let resp;
try {
resp = await ws.http.get(refundUrl);
} catch (e) {
console.error("error downloading refund permission", e);
throw e;
}
const refundResponse = MerchantRefundResponse.checked(resp.responseJson);
return acceptRefundResponse(ws, refundResponse);
}