/* 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 { OperationError, RefreshReason, CoinPublicKey, } 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"; import { Amounts } from "../util/amounts"; import { MerchantRefundDetails, MerchantRefundResponse, codecForMerchantRefundResponse, } from "../types/talerTypes"; import { AmountJson } from "../util/amounts"; import { guardOperationException } from "./errors"; import { randomBytes } from "../crypto/primitives/nacl-fast"; import { encodeCrock } from "../crypto/talerCrypto"; import { getTimestampNow } from "../util/time"; import { Logger } from "../util/logging"; const logger = new Logger("refund.ts"); async function incrementPurchaseQueryRefundRetry( ws: InternalWalletState, proposalId: string, err: OperationError | undefined, ): Promise { 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 }); } export async function getFullRefundFees( ws: InternalWalletState, refundPermissions: MerchantRefundDetails[], ): Promise { 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; } function getRefundKey(d: MerchantRefundDetails): string { return `${d.coin_pub}-${d.rtransaction_id}`; } async function acceptRefundResponse( ws: InternalWalletState, proposalId: string, refundResponse: MerchantRefundResponse, reason: RefundReason, ): Promise { const refunds = refundResponse.refunds; const refundGroupId = encodeCrock(randomBytes(32)); let numNewRefunds = 0; const finishedRefunds: MerchantRefundDetails[] = []; const unfinishedRefunds: MerchantRefundDetails[] = []; const failedRefunds: MerchantRefundDetails[] = []; console.log("handling refund response", refundResponse); const refundsRefreshCost: { [refundKey: string]: AmountJson } = {}; for (const rd of refunds) { logger.trace( `Refund ${rd.rtransaction_id} has HTTP status ${rd.exchange_http_status}`, ); 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); } } // Compute cost. // FIXME: Optimize, don't always recompute. for (const rd of [...finishedRefunds, ...unfinishedRefunds]) { const key = getRefundKey(rd); const coin = await ws.db.get(Stores.coins, rd.coin_pub); if (!coin) { continue; } const denom = await ws.db.getIndexed( Stores.denominations.denomPubHashIndex, coin.denomPubHash, ); if (!denom) { throw Error("inconsistent database"); } const amountLeft = Amounts.sub( Amounts.add(coin.currentAmount, Amounts.parseOrThrow(rd.refund_amount)) .amount, Amounts.parseOrThrow(rd.refund_fee), ).amount; const allDenoms = await ws.db .iterIndex( Stores.denominations.exchangeBaseUrlIndex, coin.exchangeBaseUrl, ) .toArray(); refundsRefreshCost[key] = getTotalRefreshCost(allDenoms, denom, amountLeft); } const now = getTimestampNow(); 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, }; 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; logger.trace(`got ${numNewRefunds} new refund permissions`); if (numNewRefunds === 0) { if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) { queryDone = false; } } else { p.refundGroups.push({ reason: RefundReason.NormalRefund, refundGroupId, timestampQueried: getTimestampNow(), }); } if (Object.keys(unfinishedRefunds).length != 0) { queryDone = false; } if (queryDone) { p.timestampLastRefundStatus = now; 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 = now; p.refundStatusRetryInfo.retryCounter++; updateRetryInfoTimeout(p.refundStatusRetryInfo); p.lastRefundStatusError = undefined; console.log("refund query not done"); } p.refundsRefreshCost = { ...p.refundsRefreshCost, ...refundsRefreshCost }; 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. 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, }); } async function startRefundQuery( ws: InternalWalletState, proposalId: string, ): Promise { 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<{ contractTermsHash: string; proposalId: string }> { const parseResult = parseRefundUri(talerRefundUri); console.log("applying refund", parseResult); 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 (${talerRefundUri}) was found`, ); } logger.info("processing purchase for refund"); await startRefundQuery(ws, purchase.proposalId); return { contractTermsHash: purchase.contractData.contractTermsHash, proposalId: purchase.proposalId, }; } export async function processPurchaseQueryRefund( ws: InternalWalletState, proposalId: string, forceNow = false, ): Promise { const onOpErr = (e: OperationError): 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 refundUrlObj = new URL("refund", purchase.contractData.merchantBaseUrl); refundUrlObj.searchParams.set("order_id", purchase.contractData.orderId); 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 = codecForMerchantRefundResponse().decode( await resp.json(), ); await acceptRefundResponse( ws, proposalId, refundResponse, RefundReason.NormalRefund, ); }