/* 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 */ /** * Imports. */ import { InternalWalletState } from "./state"; import { Stores, ProposalStatus, ProposalRecord } from "../types/dbTypes"; import { Amounts } from "../util/amounts"; import { AmountJson } from "../util/amounts"; import { HistoryQuery, HistoryEvent, HistoryEventType, OrderShortInfo, ReserveType, ReserveCreationDetail, VerbosePayCoinDetails, VerboseRefreshDetails, } from "../types/history"; import { assertUnreachable } from "../util/assertUnreachable"; import { TransactionHandle } from "../util/query"; import { timestampCmp } from "../util/time"; import { summarizeReserveHistory } from "../util/reserveHistoryUtil"; /** * Create an event ID from the type and the primary key for the event. */ function makeEventId(type: HistoryEventType, ...args: string[]): string { return type + ";" + args.map((x) => encodeURIComponent(x)).join(";"); } function getOrderShortInfo( proposal: ProposalRecord, ): OrderShortInfo | undefined { const download = proposal.download; if (!download) { return undefined; } return { amount: Amounts.stringify(download.contractData.amount), fulfillmentUrl: download.contractData.fulfillmentUrl, orderId: download.contractData.orderId, merchantBaseUrl: download.contractData.merchantBaseUrl, proposalId: proposal.proposalId, summary: download.contractData.summary, }; } async function collectProposalHistory( tx: TransactionHandle, history: HistoryEvent[], historyQuery?: HistoryQuery, ): Promise { tx.iter(Stores.proposals).forEachAsync(async (proposal) => { const status = proposal.proposalStatus; switch (status) { case ProposalStatus.ACCEPTED: { const shortInfo = getOrderShortInfo(proposal); if (!shortInfo) { break; } history.push({ type: HistoryEventType.OrderAccepted, eventId: makeEventId( HistoryEventType.OrderAccepted, proposal.proposalId, ), orderShortInfo: shortInfo, timestamp: proposal.timestamp, }); } break; case ProposalStatus.DOWNLOADING: case ProposalStatus.PROPOSED: // no history event needed break; case ProposalStatus.REFUSED: { const shortInfo = getOrderShortInfo(proposal); if (!shortInfo) { break; } history.push({ type: HistoryEventType.OrderRefused, eventId: makeEventId( HistoryEventType.OrderRefused, proposal.proposalId, ), orderShortInfo: shortInfo, timestamp: proposal.timestamp, }); } break; case ProposalStatus.REPURCHASE: { const alreadyPaidProposal = await tx.get( Stores.proposals, proposal.repurchaseProposalId, ); if (!alreadyPaidProposal) { break; } const alreadyPaidOrderShortInfo = getOrderShortInfo( alreadyPaidProposal, ); if (!alreadyPaidOrderShortInfo) { break; } const newOrderShortInfo = getOrderShortInfo(proposal); if (!newOrderShortInfo) { break; } history.push({ type: HistoryEventType.OrderRedirected, eventId: makeEventId( HistoryEventType.OrderRedirected, proposal.proposalId, ), alreadyPaidOrderShortInfo, newOrderShortInfo, timestamp: proposal.timestamp, }); } break; default: assertUnreachable(status); } }); } /** * Retrive the full event history for this wallet. */ export async function getHistory( ws: InternalWalletState, historyQuery?: HistoryQuery, ): Promise<{ history: HistoryEvent[] }> { const history: HistoryEvent[] = []; // FIXME: do pagination instead of generating the full history // We uniquely identify history rows via their timestamp. // This works as timestamps are guaranteed to be monotonically // increasing even await ws.db.runWithReadTransaction( [ Stores.currencies, Stores.coins, Stores.denominations, Stores.exchanges, Stores.exchangeUpdatedEvents, Stores.proposals, Stores.purchases, Stores.refreshGroups, Stores.reserves, Stores.reserveHistory, Stores.tips, Stores.withdrawalGroups, Stores.payEvents, Stores.planchets, Stores.refundEvents, Stores.reserveUpdatedEvents, Stores.recoupGroups, ], async (tx) => { tx.iter(Stores.exchanges).forEach((exchange) => { history.push({ type: HistoryEventType.ExchangeAdded, builtIn: false, eventId: makeEventId( HistoryEventType.ExchangeAdded, exchange.baseUrl, ), exchangeBaseUrl: exchange.baseUrl, timestamp: exchange.timestampAdded, }); }); tx.iter(Stores.exchangeUpdatedEvents).forEach((eu) => { history.push({ type: HistoryEventType.ExchangeUpdated, eventId: makeEventId( HistoryEventType.ExchangeUpdated, eu.exchangeBaseUrl, ), exchangeBaseUrl: eu.exchangeBaseUrl, timestamp: eu.timestamp, }); }); tx.iter(Stores.withdrawalGroups).forEach((wsr) => { if (wsr.timestampFinish) { history.push({ type: HistoryEventType.Withdrawn, withdrawalGroupId: wsr.withdrawalGroupId, eventId: makeEventId( HistoryEventType.Withdrawn, wsr.withdrawalGroupId, ), amountWithdrawnEffective: Amounts.stringify( wsr.denomsSel.totalCoinValue, ), amountWithdrawnRaw: Amounts.stringify(wsr.rawWithdrawalAmount), exchangeBaseUrl: wsr.exchangeBaseUrl, timestamp: wsr.timestampFinish, withdrawalSource: wsr.source, verboseDetails: undefined, }); } }); await collectProposalHistory(tx, history, historyQuery); await tx.iter(Stores.payEvents).forEachAsync(async (pe) => { const proposal = await tx.get(Stores.proposals, pe.proposalId); if (!proposal) { return; } const purchase = await tx.get(Stores.purchases, pe.proposalId); if (!purchase) { return; } const orderShortInfo = getOrderShortInfo(proposal); if (!orderShortInfo) { return; } let verboseDetails: VerbosePayCoinDetails | undefined = undefined; if (historyQuery?.extraDebug) { const coins: { value: string; contribution: string; denomPub: string; }[] = []; for (const x of purchase.coinDepositPermissions) { const c = await tx.get(Stores.coins, x.coin_pub); if (!c) { // FIXME: what to do here?? continue; } const d = await tx.get(Stores.denominations, [ c.exchangeBaseUrl, c.denomPub, ]); if (!d) { // FIXME: what to do here?? continue; } coins.push({ contribution: x.contribution, denomPub: c.denomPub, value: Amounts.stringify(d.value), }); } verboseDetails = { coins }; } const amountPaidWithFees = Amounts.sum( purchase.coinDepositPermissions.map((x) => Amounts.parseOrThrow(x.contribution), ), ).amount; history.push({ type: HistoryEventType.PaymentSent, eventId: makeEventId(HistoryEventType.PaymentSent, pe.proposalId), orderShortInfo, replay: pe.isReplay, sessionId: pe.sessionId, timestamp: pe.timestamp, numCoins: purchase.coinDepositPermissions.length, amountPaidWithFees: Amounts.stringify(amountPaidWithFees), verboseDetails, }); }); await tx.iter(Stores.refreshGroups).forEachAsync(async (rg) => { if (!rg.timestampFinished) { return; } let numInputCoins = 0; let numRefreshedInputCoins = 0; let numOutputCoins = 0; const amountsRaw: AmountJson[] = []; const amountsEffective: AmountJson[] = []; for (let i = 0; i < rg.refreshSessionPerCoin.length; i++) { const session = rg.refreshSessionPerCoin[i]; numInputCoins++; const c = await tx.get(Stores.coins, rg.oldCoinPubs[i]); if (!c) { continue; } if (session) { numRefreshedInputCoins++; amountsRaw.push(session.amountRefreshInput); amountsRaw.push(c.currentAmount); amountsEffective.push(session.amountRefreshOutput); numOutputCoins += session.newDenoms.length; } else { amountsRaw.push(c.currentAmount); } } const amountRefreshedRaw = Amounts.sum(amountsRaw).amount; let amountRefreshedEffective: AmountJson; if (amountsEffective.length == 0) { amountRefreshedEffective = Amounts.getZero( amountRefreshedRaw.currency, ); } else { amountRefreshedEffective = Amounts.sum(amountsEffective).amount; } let verboseDetails: VerboseRefreshDetails | undefined = undefined; if (historyQuery?.extraDebug) { const outputCoins: { value: string; denomPub: string; }[] = []; for (const rs of rg.refreshSessionPerCoin) { if (!rs) { continue; } for (const nd of rs.newDenoms) { if (!nd) { continue; } const d = await tx.get(Stores.denominations, [ rs.exchangeBaseUrl, nd, ]); if (!d) { continue; } outputCoins.push({ denomPub: d.denomPub, value: Amounts.stringify(d.value), }); } } verboseDetails = { outputCoins: outputCoins, }; } history.push({ type: HistoryEventType.Refreshed, refreshGroupId: rg.refreshGroupId, eventId: makeEventId(HistoryEventType.Refreshed, rg.refreshGroupId), timestamp: rg.timestampFinished, refreshReason: rg.reason, amountRefreshedEffective: Amounts.stringify(amountRefreshedEffective), amountRefreshedRaw: Amounts.stringify(amountRefreshedRaw), numInputCoins, numOutputCoins, numRefreshedInputCoins, verboseDetails, }); }); tx.iter(Stores.reserveUpdatedEvents).forEachAsync(async (ru) => { const reserve = await tx.get(Stores.reserves, ru.reservePub); if (!reserve) { return; } let reserveCreationDetail: ReserveCreationDetail; if (reserve.bankInfo) { reserveCreationDetail = { type: ReserveType.TalerBankWithdraw, bankUrl: reserve.bankInfo.statusUrl, }; } else { reserveCreationDetail = { type: ReserveType.Manual, }; } const hist = await tx.get(Stores.reserveHistory, reserve.reservePub); if (!hist) { throw Error("inconsistent database"); } const s = summarizeReserveHistory( hist.reserveTransactions, reserve.currency, ); history.push({ type: HistoryEventType.ReserveBalanceUpdated, eventId: makeEventId( HistoryEventType.ReserveBalanceUpdated, ru.reserveUpdateId, ), timestamp: ru.timestamp, reserveShortInfo: { exchangeBaseUrl: reserve.exchangeBaseUrl, reserveCreationDetail, reservePub: reserve.reservePub, }, reserveAwaitedAmount: Amounts.stringify(s.awaitedReserveAmount), reserveBalance: Amounts.stringify(s.computedReserveBalance), reserveUnclaimedAmount: Amounts.stringify(s.unclaimedReserveAmount), }); }); tx.iter(Stores.tips).forEach((tip) => { if (tip.acceptedTimestamp) { history.push({ type: HistoryEventType.TipAccepted, eventId: makeEventId(HistoryEventType.TipAccepted, tip.tipId), timestamp: tip.acceptedTimestamp, tipId: tip.tipId, tipAmountRaw: Amounts.stringify(tip.amount), }); } }); tx.iter(Stores.refundEvents).forEachAsync(async (re) => { const proposal = await tx.get(Stores.proposals, re.proposalId); if (!proposal) { return; } const purchase = await tx.get(Stores.purchases, re.proposalId); if (!purchase) { return; } const orderShortInfo = getOrderShortInfo(proposal); if (!orderShortInfo) { return; } const purchaseAmount = purchase.contractData.amount; let amountRefundedRaw = Amounts.getZero(purchaseAmount.currency); let amountRefundedInvalid = Amounts.getZero(purchaseAmount.currency); let amountRefundedEffective = Amounts.getZero(purchaseAmount.currency); Object.keys(purchase.refundsDone).forEach((x, i) => { const r = purchase.refundsDone[x]; if (r.refundGroupId !== re.refundGroupId) { return; } const refundAmount = Amounts.parseOrThrow(r.perm.refund_amount); const refundFee = Amounts.parseOrThrow(r.perm.refund_fee); amountRefundedRaw = Amounts.add(amountRefundedRaw, refundAmount) .amount; amountRefundedEffective = Amounts.add( amountRefundedEffective, refundAmount, ).amount; amountRefundedEffective = Amounts.sub( amountRefundedEffective, refundFee, ).amount; }); Object.keys(purchase.refundsFailed).forEach((x, i) => { const r = purchase.refundsFailed[x]; if (r.refundGroupId !== re.refundGroupId) { return; } const ra = Amounts.parseOrThrow(r.perm.refund_amount); const refundFee = Amounts.parseOrThrow(r.perm.refund_fee); amountRefundedRaw = Amounts.add(amountRefundedRaw, ra).amount; amountRefundedInvalid = Amounts.add(amountRefundedInvalid, ra).amount; amountRefundedEffective = Amounts.sub( amountRefundedEffective, refundFee, ).amount; }); history.push({ type: HistoryEventType.Refund, eventId: makeEventId(HistoryEventType.Refund, re.refundGroupId), refundGroupId: re.refundGroupId, orderShortInfo, timestamp: re.timestamp, amountRefundedEffective: Amounts.stringify(amountRefundedEffective), amountRefundedRaw: Amounts.stringify(amountRefundedRaw), amountRefundedInvalid: Amounts.stringify(amountRefundedInvalid), }); }); tx.iter(Stores.recoupGroups).forEach((rg) => { if (rg.timestampFinished) { let verboseDetails: any = undefined; if (historyQuery?.extraDebug) { verboseDetails = { oldAmountPerCoin: rg.oldAmountPerCoin.map(Amounts.stringify), }; } history.push({ type: HistoryEventType.FundsRecouped, timestamp: rg.timestampFinished, eventId: makeEventId( HistoryEventType.FundsRecouped, rg.recoupGroupId, ), numCoinsRecouped: rg.coinPubs.length, verboseDetails, }); } }); }, ); history.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp)); return { history }; }