/*
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
*/
/**
* Imports.
*/
import {
AbsoluteTime,
AmountJson,
Amounts,
Logger,
OrderShortInfo,
PaymentStatus,
Transaction,
TransactionsRequest,
TransactionsResponse,
TransactionType,
WithdrawalDetails,
WithdrawalType,
} from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
import {
AbortStatus,
RefundState,
ReserveRecord,
ReserveRecordStatus,
WalletRefundItem,
} from "../db.js";
import { processDepositGroup } from "./deposits.js";
import { getExchangeDetails } from "./exchanges.js";
import { processPurchasePay } from "./pay.js";
import { processRefreshGroup } from "./refresh.js";
import { getFundingPaytoUris } from "./reserves.js";
import { processTip } from "./tip.js";
import { processWithdrawGroup } from "./withdraw.js";
const logger = new Logger("taler-wallet-core:transactions.ts");
/**
* Create an event ID from the type and the primary key for the event.
*/
export function makeEventId(
type: TransactionType | TombstoneTag,
...args: string[]
): string {
return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
}
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;
}
/**
* Retrieve the full event history for this wallet.
*/
export async function getTransactions(
ws: InternalWalletState,
transactionsRequest?: TransactionsRequest,
): Promise {
const transactions: Transaction[] = [];
await ws.db
.mktx((x) => ({
coins: x.coins,
denominations: x.denominations,
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
proposals: x.proposals,
purchases: x.purchases,
refreshGroups: x.refreshGroups,
reserves: x.reserves,
tips: x.tips,
withdrawalGroups: x.withdrawalGroups,
planchets: x.planchets,
recoupGroups: x.recoupGroups,
depositGroups: x.depositGroups,
tombstones: x.tombstones,
}))
.runReadOnly(
// Report withdrawals that are currently in progress.
async (tx) => {
tx.withdrawalGroups.iter().forEachAsync(async (wsr) => {
if (
shouldSkipCurrency(
transactionsRequest,
wsr.rawWithdrawalAmount.currency,
)
) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
const r = await tx.reserves.get(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,
reservePub: wsr.reservePub,
bankConfirmationUrl: r.bankInfo.confirmUrl,
};
} else {
const exchangeDetails = await getExchangeDetails(
tx,
wsr.exchangeBaseUrl,
);
if (!exchangeDetails) {
// FIXME: report somehow
return;
}
withdrawalDetails = {
type: WithdrawalType.ManualTransfer,
reservePub: wsr.reservePub,
exchangePaytoUris:
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ??
[],
};
}
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,
),
frozen: false,
...(wsr.lastError ? { error: wsr.lastError } : {}),
});
});
// Report pending withdrawals based on reserves that
// were created, but where the actual withdrawal group has
// not started yet.
tx.reserves.iter().forEachAsync(async (r) => {
if (shouldSkipCurrency(transactionsRequest, r.currency)) {
return;
}
if (shouldSkipSearch(transactionsRequest, [])) {
return;
}
if (r.initialWithdrawalStarted) {
return;
}
if (r.reserveStatus === ReserveRecordStatus.BankAborted) {
return;
}
let withdrawalDetails: WithdrawalDetails;
if (r.bankInfo) {
withdrawalDetails = {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: false,
reservePub: r.reservePub,
bankConfirmationUrl: r.bankInfo.confirmUrl,
};
} else {
withdrawalDetails = {
type: WithdrawalType.ManualTransfer,
reservePub: r.reservePub,
exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub),
};
}
transactions.push({
type: TransactionType.Withdrawal,
amountRaw: Amounts.stringify(r.instructedAmount),
amountEffective: Amounts.stringify(
r.initialDenomSel.totalCoinValue,
),
exchangeBaseUrl: r.exchangeBaseUrl,
pending: true,
timestamp: r.timestampCreated,
withdrawalDetails: withdrawalDetails,
transactionId: makeEventId(
TransactionType.Withdrawal,
r.initialWithdrawalGroupId,
),
frozen: false,
...(r.lastError ? { error: r.lastError } : {}),
});
});
tx.depositGroups.iter().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,
frozen: false,
timestamp: dg.timestampCreated,
targetPaytoUri: dg.wire.payto_uri,
transactionId: makeEventId(
TransactionType.Deposit,
dg.depositGroupId,
),
depositGroupId: dg.depositGroupId,
...(dg.lastError ? { error: dg.lastError } : {}),
});
});
tx.purchases.iter().forEachAsync(async (pr) => {
if (
shouldSkipCurrency(
transactionsRequest,
pr.download.contractData.amount.currency,
)
) {
return;
}
const contractData = pr.download.contractData;
if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
return;
}
const proposal = await tx.proposals.get(pr.proposalId);
if (!proposal) {
return;
}
const info: OrderShortInfo = {
merchant: contractData.merchant,
orderId: contractData.orderId,
products: contractData.products,
summary: contractData.summary,
summary_i18n: contractData.summaryI18n,
contractTermsHash: contractData.contractTermsHash,
};
if (contractData.fulfillmentUrl !== "") {
info.fulfillmentUrl = contractData.fulfillmentUrl;
}
const paymentTransactionId = makeEventId(
TransactionType.Payment,
pr.proposalId,
);
const err = pr.lastPayError ?? pr.lastRefundStatusError;
transactions.push({
type: TransactionType.Payment,
amountRaw: Amounts.stringify(contractData.amount),
amountEffective: Amounts.stringify(pr.totalPayCost),
status: pr.timestampFirstSuccessfulPay
? PaymentStatus.Paid
: PaymentStatus.Accepted,
pending:
!pr.timestampFirstSuccessfulPay &&
pr.abortStatus === AbortStatus.None,
timestamp: pr.timestampAccept,
transactionId: paymentTransactionId,
proposalId: pr.proposalId,
info: info,
frozen: pr.payFrozen ?? false,
...(err ? { error: err } : {}),
});
const refundGroupKeys = new Set();
for (const rk of Object.keys(pr.refunds)) {
const refund = pr.refunds[rk];
const groupKey = `${refund.executionTime.t_s}`;
refundGroupKeys.add(groupKey);
}
for (const groupKey of refundGroupKeys.values()) {
const refundTombstoneId = makeEventId(
TombstoneTag.DeleteRefund,
pr.proposalId,
groupKey,
);
const tombstone = await tx.tombstones.get(refundTombstoneId);
if (tombstone) {
continue;
}
const refundTransactionId = makeEventId(
TransactionType.Refund,
pr.proposalId,
groupKey,
);
let r0: WalletRefundItem | undefined;
let amountRaw = Amounts.getZero(contractData.amount.currency);
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_s}`;
if (myGroupKey !== groupKey) {
continue;
}
if (!r0) {
r0 = refund;
}
if (refund.type === RefundState.Applied) {
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,
frozen: false,
});
}
});
tx.tips.iter().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,
frozen: false,
timestamp: tipRecord.acceptedTimestamp,
transactionId: makeEventId(
TransactionType.Tip,
tipRecord.walletTipId,
),
merchantBaseUrl: tipRecord.merchantBaseUrl,
error: tipRecord.lastError,
});
});
},
);
const txPending = transactions.filter((x) => x.pending);
const txNotPending = transactions.filter((x) => !x.pending);
txPending.sort((h1, h2) =>
AbsoluteTime.cmp(
AbsoluteTime.fromTimestamp(h1.timestamp),
AbsoluteTime.fromTimestamp(h2.timestamp),
),
);
txNotPending.sort((h1, h2) =>
AbsoluteTime.cmp(
AbsoluteTime.fromTimestamp(h1.timestamp),
AbsoluteTime.fromTimestamp(h2.timestamp),
),
);
return { transactions: [...txNotPending, ...txPending] };
}
export enum TombstoneTag {
DeleteWithdrawalGroup = "delete-withdrawal-group",
DeleteReserve = "delete-reserve",
DeletePayment = "delete-payment",
DeleteTip = "delete-tip",
DeleteRefreshGroup = "delete-refresh-group",
DeleteDepositGroup = "delete-deposit-group",
DeleteRefund = "delete-refund",
}
/**
* Immediately retry the underlying operation
* of a transaction.
*/
export async function retryTransaction(
ws: InternalWalletState,
transactionId: string,
): Promise {
logger.info(`retrying transaction ${transactionId}`);
const [type, ...rest] = transactionId.split(":");
switch (type) {
case TransactionType.Deposit:
const depositGroupId = rest[0];
processDepositGroup(ws, depositGroupId, {
forceNow: true,
});
break;
case TransactionType.Withdrawal:
const withdrawalGroupId = rest[0];
await processWithdrawGroup(ws, withdrawalGroupId, { forceNow: true });
break;
case TransactionType.Payment:
const proposalId = rest[0];
await processPurchasePay(ws, proposalId, { forceNow: true });
break;
case TransactionType.Tip:
const walletTipId = rest[0];
await processTip(ws, walletTipId, { forceNow: true });
break;
case TransactionType.Refresh:
const refreshGroupId = rest[0];
await processRefreshGroup(ws, refreshGroupId, { forceNow: true });
break;
default:
break;
}
}
/**
* Permanently delete a transaction based on the transaction ID.
*/
export async function deleteTransaction(
ws: InternalWalletState,
transactionId: string,
): Promise {
const [type, ...rest] = transactionId.split(":");
if (type === TransactionType.Withdrawal) {
const withdrawalGroupId = rest[0];
await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
tombstones: x.tombstones,
}))
.runReadWrite(async (tx) => {
const withdrawalGroupRecord = await tx.withdrawalGroups.get(
withdrawalGroupId,
);
if (withdrawalGroupRecord) {
await tx.withdrawalGroups.delete(withdrawalGroupId);
await tx.tombstones.put({
id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
});
return;
}
const reserveRecord: ReserveRecord | undefined =
await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
withdrawalGroupId,
);
if (reserveRecord && !reserveRecord.initialWithdrawalStarted) {
const reservePub = reserveRecord.reservePub;
await tx.reserves.delete(reservePub);
await tx.tombstones.put({
id: TombstoneTag.DeleteReserve + ":" + reservePub,
});
}
});
} else if (type === TransactionType.Payment) {
const proposalId = rest[0];
await ws.db
.mktx((x) => ({
proposals: x.proposals,
purchases: x.purchases,
tombstones: x.tombstones,
}))
.runReadWrite(async (tx) => {
let found = false;
const proposal = await tx.proposals.get(proposalId);
if (proposal) {
found = true;
await tx.proposals.delete(proposalId);
}
const purchase = await tx.purchases.get(proposalId);
if (purchase) {
found = true;
await tx.purchases.delete(proposalId);
}
if (found) {
await tx.tombstones.put({
id: TombstoneTag.DeletePayment + ":" + proposalId,
});
}
});
} else if (type === TransactionType.Refresh) {
const refreshGroupId = rest[0];
await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
tombstones: x.tombstones,
}))
.runReadWrite(async (tx) => {
const rg = await tx.refreshGroups.get(refreshGroupId);
if (rg) {
await tx.refreshGroups.delete(refreshGroupId);
await tx.tombstones.put({
id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
});
}
});
} else if (type === TransactionType.Tip) {
const tipId = rest[0];
await ws.db
.mktx((x) => ({
tips: x.tips,
tombstones: x.tombstones,
}))
.runReadWrite(async (tx) => {
const tipRecord = await tx.tips.get(tipId);
if (tipRecord) {
await tx.tips.delete(tipId);
await tx.tombstones.put({
id: TombstoneTag.DeleteTip + ":" + tipId,
});
}
});
} else if (type === TransactionType.Deposit) {
const depositGroupId = rest[0];
await ws.db
.mktx((x) => ({
depositGroups: x.depositGroups,
tombstones: x.tombstones,
}))
.runReadWrite(async (tx) => {
const tipRecord = await tx.depositGroups.get(depositGroupId);
if (tipRecord) {
await tx.depositGroups.delete(depositGroupId);
await tx.tombstones.put({
id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
});
}
});
} else if (type === TransactionType.Refund) {
const proposalId = rest[0];
const executionTimeStr = rest[1];
await ws.db
.mktx((x) => ({
proposals: x.proposals,
purchases: x.purchases,
tombstones: x.tombstones,
}))
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (purchase) {
// This should just influence the history view,
// but won't delete any actual refund information.
await tx.tombstones.put({
id: makeEventId(
TombstoneTag.DeleteRefund,
proposalId,
executionTimeStr,
),
});
}
});
} else {
throw Error(`can't delete a '${type}' transaction`);
}
}