388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
/*
|
|
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 <http://www.gnu.org/licenses/>
|
|
*/
|
|
|
|
/**
|
|
* Imports.
|
|
*/
|
|
import { InternalWalletState } from "./state";
|
|
import {
|
|
Stores,
|
|
TipRecord,
|
|
ProposalStatus,
|
|
ProposalRecord,
|
|
} from "../types/dbTypes";
|
|
import * as Amounts from "../util/amounts";
|
|
import { AmountJson } from "../util/amounts";
|
|
import {
|
|
HistoryQuery,
|
|
HistoryEvent,
|
|
HistoryEventType,
|
|
OrderShortInfo,
|
|
ReserveType,
|
|
ReserveCreationDetail,
|
|
} from "../types/history";
|
|
import { assertUnreachable } from "../util/assertUnreachable";
|
|
import { TransactionHandle, Store } from "../util/query";
|
|
import { ReserveTransactionType } from "../types/ReserveTransaction";
|
|
|
|
/**
|
|
* Create an event ID from the type and the primary key for the event.
|
|
*/
|
|
function makeEventId(type: HistoryEventType, ...args: 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: download.contractTerms.amount,
|
|
orderId: download.contractTerms.order_id,
|
|
merchantBaseUrl: download.contractTerms.merchant_base_url,
|
|
proposalId: proposal.proposalId,
|
|
summary: download.contractTerms.summary || "",
|
|
};
|
|
}
|
|
|
|
|
|
async function collectProposalHistory(
|
|
tx: TransactionHandle,
|
|
history: HistoryEvent[],
|
|
historyQuery?: HistoryQuery,
|
|
) {
|
|
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.REJECTED:
|
|
{
|
|
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.exchanges,
|
|
Stores.exchangeUpdatedEvents,
|
|
Stores.proposals,
|
|
Stores.purchases,
|
|
Stores.refreshGroups,
|
|
Stores.reserves,
|
|
Stores.tips,
|
|
Stores.withdrawalSession,
|
|
Stores.payEvents,
|
|
Stores.refundEvents,
|
|
Stores.reserveUpdatedEvents,
|
|
],
|
|
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.withdrawalSession).forEach(wsr => {
|
|
if (wsr.timestampFinish) {
|
|
history.push({
|
|
type: HistoryEventType.Withdrawn,
|
|
withdrawSessionId: wsr.withdrawSessionId,
|
|
eventId: makeEventId(
|
|
HistoryEventType.Withdrawn,
|
|
wsr.withdrawSessionId,
|
|
),
|
|
amountWithdrawnEffective: Amounts.toString(wsr.totalCoinValue),
|
|
amountWithdrawnRaw: Amounts.toString(wsr.rawWithdrawalAmount),
|
|
exchangeBaseUrl: wsr.exchangeBaseUrl,
|
|
timestamp: wsr.timestampFinish,
|
|
withdrawalSource: wsr.source,
|
|
});
|
|
}
|
|
});
|
|
|
|
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 orderShortInfo = getOrderShortInfo(proposal);
|
|
if (!orderShortInfo) {
|
|
return;
|
|
}
|
|
history.push({
|
|
type: HistoryEventType.PaymentSent,
|
|
eventId: makeEventId(HistoryEventType.PaymentSent, pe.proposalId),
|
|
orderShortInfo,
|
|
replay: pe.isReplay,
|
|
sessionId: pe.sessionId,
|
|
timestamp: pe.timestamp,
|
|
});
|
|
});
|
|
|
|
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++;
|
|
if (session) {
|
|
numRefreshedInputCoins++;
|
|
amountsRaw.push(session.amountRefreshInput);
|
|
amountsEffective.push(session.amountRefreshOutput);
|
|
numOutputCoins += session.newDenoms.length;
|
|
} else {
|
|
const c = await tx.get(Stores.coins, rg.oldCoinPubs[i]);
|
|
if (!c) {
|
|
continue;
|
|
}
|
|
amountsRaw.push(c.currentAmount);
|
|
}
|
|
}
|
|
let amountRefreshedRaw = Amounts.sum(amountsRaw).amount;
|
|
let amountRefreshedEffective: AmountJson;
|
|
if (amountsEffective.length == 0) {
|
|
amountRefreshedEffective = Amounts.getZero(amountRefreshedRaw.currency);
|
|
} else {
|
|
amountRefreshedEffective = Amounts.sum(amountsEffective).amount;
|
|
}
|
|
history.push({
|
|
type: HistoryEventType.Refreshed,
|
|
refreshGroupId: rg.refreshGroupId,
|
|
eventId: makeEventId(HistoryEventType.Refreshed, rg.refreshGroupId),
|
|
timestamp: rg.timestampFinished,
|
|
refreshReason: rg.reason,
|
|
amountRefreshedEffective: Amounts.toString(amountRefreshedEffective),
|
|
amountRefreshedRaw: Amounts.toString(amountRefreshedRaw),
|
|
numInputCoins,
|
|
numOutputCoins,
|
|
numRefreshedInputCoins,
|
|
});
|
|
});
|
|
|
|
tx.iter(Stores.reserveUpdatedEvents).forEachAsync(async (ru) => {
|
|
const reserve = await tx.get(Stores.reserves, ru.reservePub);
|
|
if (!reserve) {
|
|
return;
|
|
}
|
|
let reserveCreationDetail: ReserveCreationDetail;
|
|
if (reserve.bankWithdrawStatusUrl) {
|
|
reserveCreationDetail = {
|
|
type: ReserveType.TalerBankWithdraw,
|
|
bankUrl: reserve.bankWithdrawStatusUrl,
|
|
}
|
|
} else {
|
|
reserveCreationDetail = {
|
|
type: ReserveType.Manual,
|
|
}
|
|
}
|
|
history.push({
|
|
type: HistoryEventType.ReserveBalanceUpdated,
|
|
eventId: makeEventId(HistoryEventType.ReserveBalanceUpdated, ru.reserveUpdateId),
|
|
amountExpected: ru.amountExpected,
|
|
amountReserveBalance: ru.amountReserveBalance,
|
|
timestamp: reserve.timestampCreated,
|
|
newHistoryTransactions: ru.newHistoryTransactions,
|
|
reserveShortInfo: {
|
|
exchangeBaseUrl: reserve.exchangeBaseUrl,
|
|
reserveCreationDetail,
|
|
reservePub: reserve.reservePub,
|
|
}
|
|
});
|
|
});
|
|
|
|
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,
|
|
tipAmount: Amounts.toString(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 = Amounts.parseOrThrow(purchase.contractTerms.amount);
|
|
let amountRefundedRaw = Amounts.getZero(purchaseAmount.currency);
|
|
let amountRefundedInvalid = Amounts.getZero(purchaseAmount.currency);
|
|
let amountRefundedEffective = Amounts.getZero(purchaseAmount.currency);
|
|
Object.keys(purchase.refundState.refundsDone).forEach((x, i) => {
|
|
const r = purchase.refundState.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.refundState.refundsFailed).forEach((x, i) => {
|
|
const r = purchase.refundState.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.toString(amountRefundedEffective),
|
|
amountRefundedRaw: Amounts.toString(amountRefundedRaw),
|
|
amountRefundedInvalid: Amounts.toString(amountRefundedInvalid),
|
|
});
|
|
});
|
|
},
|
|
);
|
|
|
|
history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms));
|
|
|
|
return { history };
|
|
}
|