wallet-core/packages/taler-wallet-core/src/operations/transactions.ts

521 lines
17 KiB
TypeScript
Raw Normal View History

2020-05-12 10:38:58 +02:00
/*
This file is part of GNU Taler
(C) 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 <http://www.gnu.org/licenses/>
*/
/**
* Imports.
*/
import { InternalWalletState } from "./state";
import {
Stores,
WalletRefundItem,
RefundState,
ReserveRecordStatus,
AbortStatus,
ReserveRecord,
2021-03-17 17:56:37 +01:00
} from "../db.js";
import { AmountJson, Amounts, timestampCmp } from "@gnu-taler/taler-util";
2020-05-12 10:38:58 +02:00
import {
TransactionsRequest,
TransactionsResponse,
Transaction,
TransactionType,
2020-05-12 12:49:40 +02:00
PaymentStatus,
WithdrawalType,
WithdrawalDetails,
OrderShortInfo,
2021-03-17 17:56:37 +01:00
} from "@gnu-taler/taler-util";
2020-07-16 19:22:56 +02:00
import { getFundingPaytoUris } from "./reserves";
2020-05-12 10:38:58 +02:00
/**
* Create an event ID from the type and the primary key for the event.
*/
2021-05-21 11:47:11 +02:00
export function makeEventId(
type: TransactionType | TombstoneTag,
...args: string[]
): string {
return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
2020-05-12 10:38:58 +02:00
}
2020-05-15 13:28:15 +02:00
function shouldSkipCurrency(
transactionsRequest: TransactionsRequest | undefined,
currency: string,
): boolean {
if (!transactionsRequest?.currency) {
return false;
}
return transactionsRequest.currency.toLowerCase() !== currency.toLowerCase();
}
function shouldSkipSearch(
transactionsRequest: TransactionsRequest | undefined,
fields: string[],
): boolean {
if (!transactionsRequest?.search) {
return false;
}
const needle = transactionsRequest.search.trim();
for (const f of fields) {
if (f.indexOf(needle) >= 0) {
return false;
}
}
return true;
}
2020-05-12 10:38:58 +02:00
/**
2021-04-27 23:42:25 +02:00
* Retrieve the full event history for this wallet.
2020-05-12 10:38:58 +02:00
*/
export async function getTransactions(
ws: InternalWalletState,
transactionsRequest?: TransactionsRequest,
): Promise<TransactionsResponse> {
const transactions: Transaction[] = [];
await ws.db.runWithReadTransaction(
[
Stores.coins,
Stores.denominations,
Stores.exchanges,
2020-05-12 10:38:58 +02:00
Stores.proposals,
Stores.purchases,
Stores.refreshGroups,
Stores.reserves,
Stores.tips,
Stores.withdrawalGroups,
Stores.planchets,
Stores.recoupGroups,
2021-01-18 23:35:41 +01:00
Stores.depositGroups,
Stores.tombstones,
2020-05-12 10:38:58 +02:00
],
// Report withdrawals that are currently in progress.
2020-05-12 10:38:58 +02:00
async (tx) => {
tx.iter(Stores.withdrawalGroups).forEachAsync(async (wsr) => {
if (
2020-05-15 13:28:15 +02:00
shouldSkipCurrency(
transactionsRequest,
wsr.rawWithdrawalAmount.currency,
)
) {
return;
2020-05-12 10:38:58 +02:00
}
2020-05-15 13:28:15 +02:00
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
2020-09-08 15:57:08 +02:00
const r = await tx.get(Stores.reserves, wsr.reservePub);
if (!r) {
return;
}
let amountRaw: AmountJson | undefined = undefined;
if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
amountRaw = r.instructedAmount;
} else {
amountRaw = wsr.denomsSel.totalWithdrawCost;
}
let withdrawalDetails: WithdrawalDetails;
if (r.bankInfo) {
withdrawalDetails = {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: true,
bankConfirmationUrl: r.bankInfo.confirmUrl,
};
} else {
const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl);
if (!exchange) {
// FIXME: report somehow
return;
}
withdrawalDetails = {
type: WithdrawalType.ManualTransfer,
exchangePaytoUris:
exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [],
};
}
2020-09-08 15:57:08 +02:00
transactions.push({
type: TransactionType.Withdrawal,
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(amountRaw),
withdrawalDetails,
exchangeBaseUrl: wsr.exchangeBaseUrl,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
transactionId: makeEventId(
TransactionType.Withdrawal,
wsr.withdrawalGroupId,
),
...(wsr.lastError ? { error: wsr.lastError } : {}),
});
2020-05-12 10:38:58 +02:00
});
// Report pending withdrawals based on reserves that
// were created, but where the actual withdrawal group has
// not started yet.
tx.iter(Stores.reserves).forEachAsync(async (r) => {
2020-05-15 13:28:15 +02:00
if (shouldSkipCurrency(transactionsRequest, r.currency)) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
if (r.initialWithdrawalStarted) {
2020-05-12 10:38:58 +02:00
return;
}
if (r.reserveStatus === ReserveRecordStatus.BANK_ABORTED) {
return;
}
let withdrawalDetails: WithdrawalDetails;
if (r.bankInfo) {
withdrawalDetails = {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: false,
bankConfirmationUrl: r.bankInfo.confirmUrl,
};
} else {
withdrawalDetails = {
type: WithdrawalType.ManualTransfer,
2020-07-16 19:22:56 +02:00
exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub),
};
}
2020-05-12 10:38:58 +02:00
transactions.push({
type: TransactionType.Withdrawal,
amountRaw: Amounts.stringify(r.instructedAmount),
amountEffective: Amounts.stringify(r.initialDenomSel.totalCoinValue),
exchangeBaseUrl: r.exchangeBaseUrl,
2020-05-12 10:38:58 +02:00
pending: true,
timestamp: r.timestampCreated,
withdrawalDetails: withdrawalDetails,
2020-05-12 10:38:58 +02:00
transactionId: makeEventId(
TransactionType.Withdrawal,
r.initialWithdrawalGroupId,
2020-05-12 10:38:58 +02:00
),
...(r.lastError ? { error: r.lastError } : {}),
2020-05-12 10:38:58 +02:00
});
});
2021-01-18 23:35:41 +01:00
tx.iter(Stores.depositGroups).forEachAsync(async (dg) => {
const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount);
if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
return;
}
transactions.push({
type: TransactionType.Deposit,
amountRaw: Amounts.stringify(dg.effectiveDepositAmount),
amountEffective: Amounts.stringify(dg.totalPayCost),
pending: !dg.timestampFinished,
timestamp: dg.timestampCreated,
targetPaytoUri: dg.wire.payto_uri,
transactionId: makeEventId(
TransactionType.Deposit,
dg.depositGroupId,
),
depositGroupId: dg.depositGroupId,
...(dg.lastError ? { error: dg.lastError } : {}),
});
});
tx.iter(Stores.purchases).forEachAsync(async (pr) => {
if (
2020-05-15 13:28:15 +02:00
shouldSkipCurrency(
transactionsRequest,
2021-01-04 13:30:38 +01:00
pr.download.contractData.amount.currency,
2020-05-15 13:28:15 +02:00
)
) {
return;
}
2021-01-04 13:30:38 +01:00
const contractData = pr.download.contractData;
if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
2020-05-15 13:28:15 +02:00
return;
}
const proposal = await tx.get(Stores.proposals, pr.proposalId);
if (!proposal) {
return;
}
const info: OrderShortInfo = {
2021-01-04 13:30:38 +01:00
merchant: contractData.merchant,
orderId: contractData.orderId,
products: contractData.products,
summary: contractData.summary,
summary_i18n: contractData.summaryI18n,
contractTermsHash: contractData.contractTermsHash,
};
2021-01-04 13:30:38 +01:00
if (contractData.fulfillmentUrl !== "") {
info.fulfillmentUrl = contractData.fulfillmentUrl;
2020-08-24 16:30:15 +02:00
}
const paymentTransactionId = makeEventId(
TransactionType.Payment,
pr.proposalId,
);
const err = pr.lastPayError ?? pr.lastRefundStatusError;
transactions.push({
type: TransactionType.Payment,
2021-01-04 13:30:38 +01:00
amountRaw: Amounts.stringify(contractData.amount),
2020-09-08 17:15:33 +02:00
amountEffective: Amounts.stringify(pr.totalPayCost),
2020-05-12 12:49:40 +02:00
status: pr.timestampFirstSuccessfulPay
? PaymentStatus.Paid
: PaymentStatus.Accepted,
pending:
!pr.timestampFirstSuccessfulPay &&
pr.abortStatus === AbortStatus.None,
timestamp: pr.timestampAccept,
transactionId: paymentTransactionId,
2020-09-08 22:52:22 +02:00
proposalId: pr.proposalId,
info: info,
...(err ? { error: err } : {}),
});
const refundGroupKeys = new Set<string>();
for (const rk of Object.keys(pr.refunds)) {
const refund = pr.refunds[rk];
const groupKey = `${refund.executionTime.t_ms}`;
refundGroupKeys.add(groupKey);
}
for (const groupKey of refundGroupKeys.values()) {
const refundTombstoneId = makeEventId(
TombstoneTag.DeleteRefund,
pr.proposalId,
groupKey,
);
const tombstone = await tx.get(Stores.tombstones, refundTombstoneId);
if (tombstone) {
continue;
}
const refundTransactionId = makeEventId(
TransactionType.Refund,
pr.proposalId,
groupKey,
);
let r0: WalletRefundItem | undefined;
2021-01-04 13:30:38 +01:00
let amountRaw = Amounts.getZero(contractData.amount.currency);
2021-01-13 00:51:30 +01:00
let amountEffective = Amounts.getZero(contractData.amount.currency);
for (const rk of Object.keys(pr.refunds)) {
const refund = pr.refunds[rk];
const myGroupKey = `${refund.executionTime.t_ms}`;
if (myGroupKey !== groupKey) {
continue;
}
if (!r0) {
r0 = refund;
}
if (refund.type === RefundState.Applied) {
2020-09-04 08:34:11 +02:00
amountRaw = Amounts.add(amountRaw, refund.refundAmount).amount;
amountEffective = Amounts.add(
amountEffective,
Amounts.sub(
refund.refundAmount,
refund.refundFee,
refund.totalRefreshCostBound,
).amount,
).amount;
}
}
if (!r0) {
throw Error("invariant violated");
}
transactions.push({
type: TransactionType.Refund,
info,
refundedTransactionId: paymentTransactionId,
transactionId: refundTransactionId,
timestamp: r0.obtainedTime,
amountEffective: Amounts.stringify(amountEffective),
amountRaw: Amounts.stringify(amountRaw),
pending: false,
});
}
});
tx.iter(Stores.tips).forEachAsync(async (tipRecord) => {
if (
shouldSkipCurrency(
transactionsRequest,
tipRecord.tipAmountRaw.currency,
)
) {
return;
}
if (!tipRecord.acceptedTimestamp) {
return;
}
transactions.push({
type: TransactionType.Tip,
amountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
pending: !tipRecord.pickedUpTimestamp,
timestamp: tipRecord.acceptedTimestamp,
transactionId: makeEventId(
TransactionType.Tip,
tipRecord.walletTipId,
),
2020-11-18 17:33:02 +01:00
merchantBaseUrl: tipRecord.merchantBaseUrl,
error: tipRecord.lastError,
});
});
2020-05-12 10:38:58 +02:00
},
);
2020-05-15 20:22:58 +02:00
const txPending = transactions.filter((x) => x.pending);
const txNotPending = transactions.filter((x) => !x.pending);
txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
txNotPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
2020-05-12 10:38:58 +02:00
return { transactions: [...txNotPending, ...txPending] };
2020-05-12 10:38:58 +02:00
}
export enum TombstoneTag {
2021-05-20 17:11:44 +02:00
DeleteWithdrawalGroup = "delete-withdrawal-group",
DeleteReserve = "delete-reserve",
DeletePayment = "delete-payment",
DeleteTip = "delete-tip",
DeleteRefreshGroup = "delete-refresh-group",
DeleteDepositGroup = "delete-deposit-group",
DeleteRefund = "delete-refund",
}
/**
* Permanentely delete a transaction based on the transaction ID.
*/
export async function deleteTransaction(
ws: InternalWalletState,
transactionId: string,
): Promise<void> {
const [type, ...rest] = transactionId.split(":");
if (type === TransactionType.Withdrawal) {
const withdrawalGroupId = rest[0];
2021-05-20 17:11:44 +02:00
await ws.db.runWithWriteTransaction(
[Stores.withdrawalGroups, Stores.reserves, Stores.tombstones],
async (tx) => {
const withdrawalGroupRecord = await tx.get(
Stores.withdrawalGroups,
withdrawalGroupId,
);
if (withdrawalGroupRecord) {
await tx.delete(Stores.withdrawalGroups, withdrawalGroupId);
await tx.put(Stores.tombstones, {
2021-05-20 17:11:44 +02:00
id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
});
return;
}
const reserveRecord: ReserveRecord | undefined = await tx.getIndexed(
Stores.reserves.byInitialWithdrawalGroupId,
withdrawalGroupId,
);
if (reserveRecord && !reserveRecord.initialWithdrawalStarted) {
const reservePub = reserveRecord.reservePub;
await tx.delete(Stores.reserves, reservePub);
await tx.put(Stores.tombstones, {
2021-05-20 17:11:44 +02:00
id: TombstoneTag.DeleteReserve + ":" + reservePub,
});
}
},
);
} else if (type === TransactionType.Payment) {
const proposalId = rest[0];
await ws.db.runWithWriteTransaction(
[Stores.proposals, Stores.purchases, Stores.tombstones],
async (tx) => {
let found = false;
const proposal = await tx.get(Stores.proposals, proposalId);
if (proposal) {
found = true;
await tx.delete(Stores.proposals, proposalId);
}
const purchase = await tx.get(Stores.purchases, proposalId);
if (purchase) {
found = true;
await tx.delete(Stores.proposals, proposalId);
}
if (found) {
await tx.put(Stores.tombstones, {
id: TombstoneTag.DeletePayment + ":" + proposalId,
});
}
},
);
} else if (type === TransactionType.Refresh) {
const refreshGroupId = rest[0];
await ws.db.runWithWriteTransaction(
[Stores.refreshGroups, Stores.tombstones],
async (tx) => {
const rg = await tx.get(Stores.refreshGroups, refreshGroupId);
if (rg) {
await tx.delete(Stores.refreshGroups, refreshGroupId);
await tx.put(Stores.tombstones, {
id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
});
}
},
);
} else if (type === TransactionType.Tip) {
const tipId = rest[0];
await ws.db.runWithWriteTransaction(
[Stores.tips, Stores.tombstones],
async (tx) => {
const tipRecord = await tx.get(Stores.tips, tipId);
if (tipRecord) {
await tx.delete(Stores.tips, tipId);
await tx.put(Stores.tombstones, {
id: TombstoneTag.DeleteTip + ":" + tipId,
});
}
},
);
} else if (type === TransactionType.Deposit) {
const depositGroupId = rest[0];
await ws.db.runWithWriteTransaction(
[Stores.depositGroups, Stores.tombstones],
async (tx) => {
const tipRecord = await tx.get(Stores.depositGroups, depositGroupId);
if (tipRecord) {
await tx.delete(Stores.depositGroups, depositGroupId);
await tx.put(Stores.tombstones, {
id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
});
}
},
);
} else if (type === TransactionType.Refund) {
const proposalId = rest[0];
const executionTimeStr = rest[1];
await ws.db.runWithWriteTransaction(
[Stores.proposals, Stores.purchases, Stores.tombstones],
async (tx) => {
const purchase = await tx.get(Stores.purchases, proposalId);
if (purchase) {
// This should just influence the history view,
// but won't delete any actual refund information.
await tx.put(Stores.tombstones, {
id: makeEventId(
TombstoneTag.DeleteRefund,
proposalId,
executionTimeStr,
),
});
}
},
);
} else {
throw Error(`can't delete a '${type}' transaction`);
}
}