/* 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, 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"; import { readSuccessResponseJsonOrThrow } from "../util/http"; 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: 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; 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"); } 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) { 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) { 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); logger.trace("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: 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 request = await ws.http.get( new URL( `orders/${purchase.contractData.orderId}`, purchase.contractData.merchantBaseUrl, ).href, ); const refundResponse = await readSuccessResponseJsonOrThrow( request, codecForMerchantRefundResponse(), ); await acceptRefundResponse( ws, proposalId, refundResponse, RefundReason.NormalRefund, ); }