1809 lines
56 KiB
TypeScript
1809 lines
56 KiB
TypeScript
/*
|
|
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 {
|
|
AbsoluteTime,
|
|
AmountJson,
|
|
Amounts,
|
|
constructPayPullUri,
|
|
constructPayPushUri,
|
|
ExtendedStatus,
|
|
Logger,
|
|
OrderShortInfo,
|
|
PaymentStatus,
|
|
PeerContractTerms,
|
|
RefundInfoShort,
|
|
TalerErrorCode,
|
|
TalerProtocolTimestamp,
|
|
Transaction,
|
|
TransactionByIdRequest,
|
|
TransactionsRequest,
|
|
TransactionsResponse,
|
|
TransactionType,
|
|
WithdrawalType,
|
|
} from "@gnu-taler/taler-util";
|
|
import {
|
|
DepositGroupRecord,
|
|
ExchangeDetailsRecord,
|
|
OperationRetryRecord,
|
|
PeerPullPaymentIncomingRecord,
|
|
PeerPushPaymentInitiationRecord,
|
|
PurchaseStatus,
|
|
PurchaseRecord,
|
|
RefundState,
|
|
TipRecord,
|
|
WalletRefundItem,
|
|
WithdrawalGroupRecord,
|
|
WithdrawalRecordType,
|
|
WalletContractData,
|
|
PeerPushPaymentInitiationStatus,
|
|
PeerPullPaymentIncomingStatus,
|
|
TransactionStatus,
|
|
WithdrawalGroupStatus,
|
|
RefreshGroupRecord,
|
|
RefreshOperationStatus,
|
|
PeerPushPaymentIncomingRecord,
|
|
PeerPushPaymentIncomingStatus,
|
|
PeerPullPaymentInitiationRecord,
|
|
} from "../db.js";
|
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
|
import { PendingTaskType } from "../pending-types.js";
|
|
import { assertUnreachable } from "../util/assertUnreachable.js";
|
|
import { checkDbInvariant } from "../util/invariants.js";
|
|
import { constructTaskIdentifier, TaskIdentifiers } from "../util/retries.js";
|
|
import {
|
|
makeTombstoneId,
|
|
makeTransactionId,
|
|
parseId,
|
|
resetOperationTimeout,
|
|
runOperationWithErrorReporting,
|
|
TombstoneTag,
|
|
} from "./common.js";
|
|
import { processDepositGroup } from "./deposits.js";
|
|
import { getExchangeDetails } from "./exchanges.js";
|
|
import {
|
|
abortPay,
|
|
expectProposalDownload,
|
|
extractContractData,
|
|
processPurchasePay,
|
|
} from "./pay-merchant.js";
|
|
import { processPeerPullCredit } from "./pay-peer.js";
|
|
import { processRefreshGroup } from "./refresh.js";
|
|
import { processTip } from "./tip.js";
|
|
import {
|
|
augmentPaytoUrisForWithdrawal,
|
|
processWithdrawalGroup,
|
|
} from "./withdraw.js";
|
|
|
|
const logger = new Logger("taler-wallet-core:transactions.ts");
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Fallback order of transactions that have the same timestamp.
|
|
*/
|
|
const txOrder: { [t in TransactionType]: number } = {
|
|
[TransactionType.Withdrawal]: 1,
|
|
[TransactionType.Tip]: 2,
|
|
[TransactionType.Payment]: 3,
|
|
[TransactionType.PeerPullCredit]: 4,
|
|
[TransactionType.PeerPullDebit]: 5,
|
|
[TransactionType.PeerPushCredit]: 6,
|
|
[TransactionType.PeerPushDebit]: 7,
|
|
[TransactionType.Refund]: 8,
|
|
[TransactionType.Deposit]: 9,
|
|
[TransactionType.Refresh]: 10,
|
|
[TransactionType.Tip]: 11,
|
|
};
|
|
|
|
export async function getTransactionById(
|
|
ws: InternalWalletState,
|
|
req: TransactionByIdRequest,
|
|
): Promise<Transaction> {
|
|
const { type, args: rest } = parseId("txn", req.transactionId);
|
|
if (type === TransactionType.Withdrawal) {
|
|
const withdrawalGroupId = rest[0];
|
|
return await ws.db
|
|
.mktx((x) => [
|
|
x.withdrawalGroups,
|
|
x.exchangeDetails,
|
|
x.exchanges,
|
|
x.operationRetries,
|
|
])
|
|
.runReadWrite(async (tx) => {
|
|
const withdrawalGroupRecord = await tx.withdrawalGroups.get(
|
|
withdrawalGroupId,
|
|
);
|
|
|
|
if (!withdrawalGroupRecord) throw Error("not found");
|
|
|
|
const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
|
|
const ort = await tx.operationRetries.get(opId);
|
|
|
|
if (
|
|
withdrawalGroupRecord.wgInfo.withdrawalType ===
|
|
WithdrawalRecordType.BankIntegrated
|
|
) {
|
|
return buildTransactionForBankIntegratedWithdraw(
|
|
withdrawalGroupRecord,
|
|
ort,
|
|
);
|
|
}
|
|
const exchangeDetails = await getExchangeDetails(
|
|
tx,
|
|
withdrawalGroupRecord.exchangeBaseUrl,
|
|
);
|
|
if (!exchangeDetails) throw Error("not exchange details");
|
|
|
|
return buildTransactionForManualWithdraw(
|
|
withdrawalGroupRecord,
|
|
exchangeDetails,
|
|
ort,
|
|
);
|
|
});
|
|
} else if (type === TransactionType.Payment) {
|
|
const proposalId = rest[0];
|
|
return await ws.db
|
|
.mktx((x) => [
|
|
x.purchases,
|
|
x.tombstones,
|
|
x.operationRetries,
|
|
x.contractTerms,
|
|
])
|
|
.runReadWrite(async (tx) => {
|
|
const purchase = await tx.purchases.get(proposalId);
|
|
if (!purchase) throw Error("not found");
|
|
|
|
const filteredRefunds = await Promise.all(
|
|
Object.values(purchase.refunds).map(async (r) => {
|
|
const t = await tx.tombstones.get(
|
|
makeTombstoneId(
|
|
TombstoneTag.DeleteRefund,
|
|
purchase.proposalId,
|
|
`${r.executionTime.t_s}`,
|
|
),
|
|
);
|
|
if (!t) return r;
|
|
return undefined;
|
|
}),
|
|
);
|
|
|
|
const download = await expectProposalDownload(ws, purchase, tx);
|
|
|
|
const cleanRefunds = filteredRefunds.filter(
|
|
(x): x is WalletRefundItem => !!x,
|
|
);
|
|
|
|
const contractData = download.contractData;
|
|
const refunds = mergeRefundByExecutionTime(
|
|
cleanRefunds,
|
|
Amounts.zeroOfAmount(contractData.amount),
|
|
);
|
|
|
|
const payOpId = TaskIdentifiers.forPay(purchase);
|
|
const payRetryRecord = await tx.operationRetries.get(payOpId);
|
|
|
|
return buildTransactionForPurchase(
|
|
purchase,
|
|
contractData,
|
|
refunds,
|
|
payRetryRecord,
|
|
);
|
|
});
|
|
} else if (type === TransactionType.Refresh) {
|
|
const refreshGroupId = rest[0];
|
|
throw Error(`no tx for refresh`);
|
|
} else if (type === TransactionType.Tip) {
|
|
const tipId = rest[0];
|
|
return await ws.db
|
|
.mktx((x) => [x.tips, x.operationRetries])
|
|
.runReadWrite(async (tx) => {
|
|
const tipRecord = await tx.tips.get(tipId);
|
|
if (!tipRecord) throw Error("not found");
|
|
|
|
const retries = await tx.operationRetries.get(
|
|
TaskIdentifiers.forTipPickup(tipRecord),
|
|
);
|
|
return buildTransactionForTip(tipRecord, retries);
|
|
});
|
|
} else if (type === TransactionType.Deposit) {
|
|
const depositGroupId = rest[0];
|
|
return await ws.db
|
|
.mktx((x) => [x.depositGroups, x.operationRetries])
|
|
.runReadWrite(async (tx) => {
|
|
const depositRecord = await tx.depositGroups.get(depositGroupId);
|
|
if (!depositRecord) throw Error("not found");
|
|
|
|
const retries = await tx.operationRetries.get(
|
|
TaskIdentifiers.forDeposit(depositRecord),
|
|
);
|
|
return buildTransactionForDeposit(depositRecord, retries);
|
|
});
|
|
} else if (type === TransactionType.Refund) {
|
|
const proposalId = rest[0];
|
|
const executionTimeStr = rest[1];
|
|
|
|
return await ws.db
|
|
.mktx((x) => [
|
|
x.operationRetries,
|
|
x.purchases,
|
|
x.tombstones,
|
|
x.contractTerms,
|
|
])
|
|
.runReadWrite(async (tx) => {
|
|
const purchase = await tx.purchases.get(proposalId);
|
|
if (!purchase) throw Error("not found");
|
|
|
|
const t = await tx.tombstones.get(
|
|
makeTombstoneId(
|
|
TombstoneTag.DeleteRefund,
|
|
purchase.proposalId,
|
|
executionTimeStr,
|
|
),
|
|
);
|
|
if (t) throw Error("deleted");
|
|
|
|
const filteredRefunds = await Promise.all(
|
|
Object.values(purchase.refunds).map(async (r) => {
|
|
const t = await tx.tombstones.get(
|
|
makeTombstoneId(
|
|
TombstoneTag.DeleteRefund,
|
|
purchase.proposalId,
|
|
`${r.executionTime.t_s}`,
|
|
),
|
|
);
|
|
if (!t) return r;
|
|
return undefined;
|
|
}),
|
|
);
|
|
|
|
const cleanRefunds = filteredRefunds.filter(
|
|
(x): x is WalletRefundItem => !!x,
|
|
);
|
|
|
|
const download = await expectProposalDownload(ws, purchase, tx);
|
|
const contractData = download.contractData;
|
|
const refunds = mergeRefundByExecutionTime(
|
|
cleanRefunds,
|
|
Amounts.zeroOfAmount(contractData.amount),
|
|
);
|
|
|
|
const theRefund = refunds.find(
|
|
(r) => `${r.executionTime.t_s}` === executionTimeStr,
|
|
);
|
|
if (!theRefund) throw Error("not found");
|
|
|
|
return buildTransactionForRefund(
|
|
purchase,
|
|
contractData,
|
|
theRefund,
|
|
undefined,
|
|
);
|
|
});
|
|
} else if (type === TransactionType.PeerPullDebit) {
|
|
const peerPullPaymentIncomingId = rest[0];
|
|
return await ws.db
|
|
.mktx((x) => [x.peerPullPaymentIncoming])
|
|
.runReadWrite(async (tx) => {
|
|
const debit = await tx.peerPullPaymentIncoming.get(
|
|
peerPullPaymentIncomingId,
|
|
);
|
|
if (!debit) throw Error("not found");
|
|
return buildTransactionForPullPaymentDebit(debit);
|
|
});
|
|
} else if (type === TransactionType.PeerPushDebit) {
|
|
const pursePub = rest[0];
|
|
return await ws.db
|
|
.mktx((x) => [x.peerPushPaymentInitiations, x.contractTerms])
|
|
.runReadWrite(async (tx) => {
|
|
const debit = await tx.peerPushPaymentInitiations.get(pursePub);
|
|
if (!debit) throw Error("not found");
|
|
const ct = await tx.contractTerms.get(debit.contractTermsHash);
|
|
checkDbInvariant(!!ct);
|
|
return buildTransactionForPushPaymentDebit(debit, ct.contractTermsRaw);
|
|
});
|
|
} else if (type === TransactionType.PeerPushCredit) {
|
|
const peerPushPaymentIncomingId = rest[0];
|
|
return await ws.db
|
|
.mktx((x) => [
|
|
x.peerPushPaymentIncoming,
|
|
x.contractTerms,
|
|
x.withdrawalGroups,
|
|
x.operationRetries,
|
|
])
|
|
.runReadWrite(async (tx) => {
|
|
const pushInc = await tx.peerPushPaymentIncoming.get(
|
|
peerPushPaymentIncomingId,
|
|
);
|
|
if (!pushInc) throw Error("not found");
|
|
const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
|
|
checkDbInvariant(!!ct);
|
|
|
|
let wg: WithdrawalGroupRecord | undefined = undefined;
|
|
let wgOrt: OperationRetryRecord | undefined = undefined;
|
|
if (pushInc.withdrawalGroupId) {
|
|
wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
|
|
if (wg) {
|
|
const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
|
|
wgOrt = await tx.operationRetries.get(withdrawalOpId);
|
|
}
|
|
}
|
|
const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc);
|
|
let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
|
|
|
|
return buildTransactionForPeerPushCredit(
|
|
pushInc,
|
|
pushIncOrt,
|
|
ct.contractTermsRaw,
|
|
wg,
|
|
wgOrt,
|
|
);
|
|
});
|
|
} else if (type === TransactionType.PeerPullCredit) {
|
|
const pursePub = rest[0];
|
|
return await ws.db
|
|
.mktx((x) => [
|
|
x.peerPullPaymentInitiations,
|
|
x.contractTerms,
|
|
x.withdrawalGroups,
|
|
x.operationRetries,
|
|
])
|
|
.runReadWrite(async (tx) => {
|
|
const pushInc = await tx.peerPullPaymentInitiations.get(pursePub);
|
|
if (!pushInc) throw Error("not found");
|
|
const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
|
|
checkDbInvariant(!!ct);
|
|
|
|
let wg: WithdrawalGroupRecord | undefined = undefined;
|
|
let wgOrt: OperationRetryRecord | undefined = undefined;
|
|
if (pushInc.withdrawalGroupId) {
|
|
wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
|
|
if (wg) {
|
|
const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
|
|
wgOrt = await tx.operationRetries.get(withdrawalOpId);
|
|
}
|
|
}
|
|
const pushIncOpId =
|
|
TaskIdentifiers.forPeerPullPaymentInitiation(pushInc);
|
|
let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
|
|
|
|
return buildTransactionForPeerPullCredit(
|
|
pushInc,
|
|
pushIncOrt,
|
|
ct.contractTermsRaw,
|
|
wg,
|
|
wgOrt,
|
|
);
|
|
});
|
|
} else {
|
|
const unknownTxType: never = type;
|
|
throw Error(`can't retrieve a '${unknownTxType}' transaction`);
|
|
}
|
|
}
|
|
|
|
function buildTransactionForPushPaymentDebit(
|
|
pi: PeerPushPaymentInitiationRecord,
|
|
contractTerms: PeerContractTerms,
|
|
ort?: OperationRetryRecord,
|
|
): Transaction {
|
|
return {
|
|
type: TransactionType.PeerPushDebit,
|
|
amountEffective: pi.totalCost,
|
|
amountRaw: pi.amount,
|
|
exchangeBaseUrl: pi.exchangeBaseUrl,
|
|
info: {
|
|
expiration: contractTerms.purse_expiration,
|
|
summary: contractTerms.summary,
|
|
},
|
|
frozen: false,
|
|
extendedStatus:
|
|
pi.status != PeerPushPaymentInitiationStatus.PurseCreated
|
|
? ExtendedStatus.Pending
|
|
: ExtendedStatus.Done,
|
|
pending: pi.status != PeerPushPaymentInitiationStatus.PurseCreated,
|
|
timestamp: pi.timestampCreated,
|
|
talerUri: constructPayPushUri({
|
|
exchangeBaseUrl: pi.exchangeBaseUrl,
|
|
contractPriv: pi.contractPriv,
|
|
}),
|
|
transactionId: makeTransactionId(
|
|
TransactionType.PeerPushDebit,
|
|
pi.pursePub,
|
|
),
|
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
function buildTransactionForPullPaymentDebit(
|
|
pi: PeerPullPaymentIncomingRecord,
|
|
ort?: OperationRetryRecord,
|
|
): Transaction {
|
|
return {
|
|
type: TransactionType.PeerPullDebit,
|
|
amountEffective: pi.coinSel?.totalCost
|
|
? pi.coinSel?.totalCost
|
|
: Amounts.stringify(pi.contractTerms.amount),
|
|
amountRaw: Amounts.stringify(pi.contractTerms.amount),
|
|
exchangeBaseUrl: pi.exchangeBaseUrl,
|
|
frozen: false,
|
|
pending: false,
|
|
extendedStatus: ExtendedStatus.Done,
|
|
info: {
|
|
expiration: pi.contractTerms.purse_expiration,
|
|
summary: pi.contractTerms.summary,
|
|
},
|
|
timestamp: pi.timestampCreated,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.PeerPullDebit,
|
|
pi.peerPullPaymentIncomingId,
|
|
),
|
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
function buildTransactionForPeerPullCredit(
|
|
pullCredit: PeerPullPaymentInitiationRecord,
|
|
pullCreditOrt: OperationRetryRecord | undefined,
|
|
peerContractTerms: PeerContractTerms,
|
|
wsr: WithdrawalGroupRecord | undefined,
|
|
wsrOrt: OperationRetryRecord | undefined,
|
|
): Transaction {
|
|
if (wsr) {
|
|
if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) {
|
|
throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`);
|
|
}
|
|
/**
|
|
* FIXME: this should be handled in the withdrawal process.
|
|
* PeerPull withdrawal fails until reserve have funds but it is not
|
|
* an error from the user perspective.
|
|
*/
|
|
const silentWithdrawalErrorForInvoice =
|
|
wsrOrt?.lastError &&
|
|
wsrOrt.lastError.code ===
|
|
TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE &&
|
|
Object.values(wsrOrt.lastError.errorsPerCoin ?? {}).every((e) => {
|
|
return (
|
|
e.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
|
|
e.httpStatusCode === 409
|
|
);
|
|
});
|
|
return {
|
|
type: TransactionType.PeerPullCredit,
|
|
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
|
|
amountRaw: Amounts.stringify(wsr.instructedAmount),
|
|
exchangeBaseUrl: wsr.exchangeBaseUrl,
|
|
extendedStatus: wsr.timestampFinish
|
|
? ExtendedStatus.Done
|
|
: ExtendedStatus.Pending,
|
|
pending: !wsr.timestampFinish,
|
|
timestamp: pullCredit.mergeTimestamp,
|
|
info: {
|
|
expiration: wsr.wgInfo.contractTerms.purse_expiration,
|
|
summary: wsr.wgInfo.contractTerms.summary,
|
|
},
|
|
talerUri: constructPayPullUri({
|
|
exchangeBaseUrl: wsr.exchangeBaseUrl,
|
|
contractPriv: wsr.wgInfo.contractPriv,
|
|
}),
|
|
transactionId: makeTransactionId(
|
|
TransactionType.PeerPullCredit,
|
|
pullCredit.pursePub,
|
|
),
|
|
frozen: false,
|
|
...(wsrOrt?.lastError
|
|
? {
|
|
error: silentWithdrawalErrorForInvoice
|
|
? undefined
|
|
: wsrOrt.lastError,
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: TransactionType.PeerPullCredit,
|
|
amountEffective: Amounts.stringify(pullCredit.estimatedAmountEffective),
|
|
amountRaw: Amounts.stringify(peerContractTerms.amount),
|
|
exchangeBaseUrl: pullCredit.exchangeBaseUrl,
|
|
extendedStatus: ExtendedStatus.Pending,
|
|
pending: true,
|
|
timestamp: pullCredit.mergeTimestamp,
|
|
info: {
|
|
expiration: peerContractTerms.purse_expiration,
|
|
summary: peerContractTerms.summary,
|
|
},
|
|
talerUri: constructPayPullUri({
|
|
exchangeBaseUrl: pullCredit.exchangeBaseUrl,
|
|
contractPriv: pullCredit.contractPriv,
|
|
}),
|
|
transactionId: makeTransactionId(
|
|
TransactionType.PeerPullCredit,
|
|
pullCredit.pursePub,
|
|
),
|
|
frozen: false,
|
|
...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
function buildTransactionForPeerPushCredit(
|
|
pushInc: PeerPushPaymentIncomingRecord,
|
|
pushOrt: OperationRetryRecord | undefined,
|
|
peerContractTerms: PeerContractTerms,
|
|
wsr: WithdrawalGroupRecord | undefined,
|
|
wsrOrt: OperationRetryRecord | undefined,
|
|
): Transaction {
|
|
if (wsr) {
|
|
if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
|
|
throw Error("invalid withdrawal group type for push payment credit");
|
|
}
|
|
|
|
return {
|
|
type: TransactionType.PeerPushCredit,
|
|
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
|
|
amountRaw: Amounts.stringify(wsr.instructedAmount),
|
|
exchangeBaseUrl: wsr.exchangeBaseUrl,
|
|
info: {
|
|
expiration: wsr.wgInfo.contractTerms.purse_expiration,
|
|
summary: wsr.wgInfo.contractTerms.summary,
|
|
},
|
|
extendedStatus: wsr.timestampFinish
|
|
? ExtendedStatus.Done
|
|
: ExtendedStatus.Pending,
|
|
pending: !wsr.timestampFinish,
|
|
timestamp: wsr.timestampStart,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.PeerPushCredit,
|
|
pushInc.peerPushPaymentIncomingId,
|
|
),
|
|
frozen: false,
|
|
...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: TransactionType.PeerPushCredit,
|
|
// FIXME: This is wrong, needs to consider fees!
|
|
amountEffective: Amounts.stringify(peerContractTerms.amount),
|
|
amountRaw: Amounts.stringify(peerContractTerms.amount),
|
|
exchangeBaseUrl: pushInc.exchangeBaseUrl,
|
|
info: {
|
|
expiration: peerContractTerms.purse_expiration,
|
|
summary: peerContractTerms.summary,
|
|
},
|
|
extendedStatus: ExtendedStatus.Pending,
|
|
pending: true,
|
|
timestamp: pushInc.timestamp,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.PeerPushCredit,
|
|
pushInc.peerPushPaymentIncomingId,
|
|
),
|
|
frozen: false,
|
|
...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
function buildTransactionForBankIntegratedWithdraw(
|
|
wsr: WithdrawalGroupRecord,
|
|
ort?: OperationRetryRecord,
|
|
): Transaction {
|
|
if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated)
|
|
throw Error("");
|
|
|
|
return {
|
|
type: TransactionType.Withdrawal,
|
|
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
|
|
amountRaw: Amounts.stringify(wsr.instructedAmount),
|
|
withdrawalDetails: {
|
|
type: WithdrawalType.TalerBankIntegrationApi,
|
|
confirmed: wsr.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
|
|
reservePub: wsr.reservePub,
|
|
bankConfirmationUrl: wsr.wgInfo.bankInfo.confirmUrl,
|
|
reserveIsReady:
|
|
wsr.status === WithdrawalGroupStatus.Finished ||
|
|
wsr.status === WithdrawalGroupStatus.Ready,
|
|
},
|
|
exchangeBaseUrl: wsr.exchangeBaseUrl,
|
|
extendedStatus: wsr.timestampFinish
|
|
? ExtendedStatus.Done
|
|
: ExtendedStatus.Pending,
|
|
pending: !wsr.timestampFinish,
|
|
timestamp: wsr.timestampStart,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.Withdrawal,
|
|
wsr.withdrawalGroupId,
|
|
),
|
|
frozen: false,
|
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
function buildTransactionForManualWithdraw(
|
|
withdrawalGroup: WithdrawalGroupRecord,
|
|
exchangeDetails: ExchangeDetailsRecord,
|
|
ort?: OperationRetryRecord,
|
|
): Transaction {
|
|
if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
|
|
throw Error("");
|
|
|
|
const plainPaytoUris =
|
|
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
|
|
|
|
const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
|
|
plainPaytoUris,
|
|
withdrawalGroup.reservePub,
|
|
withdrawalGroup.instructedAmount,
|
|
);
|
|
|
|
return {
|
|
type: TransactionType.Withdrawal,
|
|
amountEffective: Amounts.stringify(
|
|
withdrawalGroup.denomsSel.totalCoinValue,
|
|
),
|
|
amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount),
|
|
withdrawalDetails: {
|
|
type: WithdrawalType.ManualTransfer,
|
|
reservePub: withdrawalGroup.reservePub,
|
|
exchangePaytoUris,
|
|
reserveIsReady:
|
|
withdrawalGroup.status === WithdrawalGroupStatus.Finished ||
|
|
withdrawalGroup.status === WithdrawalGroupStatus.Ready,
|
|
},
|
|
exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
|
|
extendedStatus: withdrawalGroup.timestampFinish
|
|
? ExtendedStatus.Done
|
|
: ExtendedStatus.Pending,
|
|
pending: !withdrawalGroup.timestampFinish,
|
|
timestamp: withdrawalGroup.timestampStart,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.Withdrawal,
|
|
withdrawalGroup.withdrawalGroupId,
|
|
),
|
|
frozen: false,
|
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
function buildTransactionForRefresh(
|
|
refreshGroupRecord: RefreshGroupRecord,
|
|
ort?: OperationRetryRecord,
|
|
): Transaction {
|
|
let extendedStatus: ExtendedStatus;
|
|
switch (refreshGroupRecord.operationStatus) {
|
|
case RefreshOperationStatus.Finished:
|
|
case RefreshOperationStatus.FinishedWithError:
|
|
extendedStatus = ExtendedStatus.Done;
|
|
break;
|
|
default:
|
|
extendedStatus = ExtendedStatus.Pending;
|
|
}
|
|
const inputAmount = Amounts.sumOrZero(
|
|
refreshGroupRecord.currency,
|
|
refreshGroupRecord.inputPerCoin,
|
|
).amount;
|
|
const outputAmount = Amounts.sumOrZero(
|
|
refreshGroupRecord.currency,
|
|
refreshGroupRecord.estimatedOutputPerCoin,
|
|
).amount;
|
|
return {
|
|
type: TransactionType.Refresh,
|
|
refreshReason: refreshGroupRecord.reason,
|
|
amountEffective: Amounts.stringify(
|
|
Amounts.zeroOfCurrency(refreshGroupRecord.currency),
|
|
),
|
|
amountRaw: Amounts.stringify(
|
|
Amounts.zeroOfCurrency(refreshGroupRecord.currency),
|
|
),
|
|
refreshInputAmount: Amounts.stringify(inputAmount),
|
|
refreshOutputAmount: Amounts.stringify(outputAmount),
|
|
originatingTransactionId:
|
|
refreshGroupRecord.reasonDetails?.originatingTransactionId,
|
|
extendedStatus:
|
|
refreshGroupRecord.operationStatus === RefreshOperationStatus.Finished ||
|
|
refreshGroupRecord.operationStatus ===
|
|
RefreshOperationStatus.FinishedWithError
|
|
? ExtendedStatus.Done
|
|
: ExtendedStatus.Pending,
|
|
pending: extendedStatus == ExtendedStatus.Pending,
|
|
timestamp: refreshGroupRecord.timestampCreated,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.Refresh,
|
|
refreshGroupRecord.refreshGroupId,
|
|
),
|
|
frozen: false,
|
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
function buildTransactionForDeposit(
|
|
dg: DepositGroupRecord,
|
|
ort?: OperationRetryRecord,
|
|
): Transaction {
|
|
let deposited = true;
|
|
for (const d of dg.depositedPerCoin) {
|
|
if (!d) {
|
|
deposited = false;
|
|
}
|
|
}
|
|
return {
|
|
type: TransactionType.Deposit,
|
|
amountRaw: Amounts.stringify(dg.effectiveDepositAmount),
|
|
amountEffective: Amounts.stringify(dg.totalPayCost),
|
|
extendedStatus: dg.timestampFinished
|
|
? ExtendedStatus.Done
|
|
: ExtendedStatus.Pending,
|
|
pending: !dg.timestampFinished,
|
|
frozen: false,
|
|
timestamp: dg.timestampCreated,
|
|
targetPaytoUri: dg.wire.payto_uri,
|
|
wireTransferDeadline: dg.contractTermsRaw.wire_transfer_deadline,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.Deposit,
|
|
dg.depositGroupId,
|
|
),
|
|
wireTransferProgress:
|
|
(100 *
|
|
dg.transactionPerCoin.reduce(
|
|
(prev, cur) => prev + (cur === TransactionStatus.Wired ? 1 : 0),
|
|
0,
|
|
)) /
|
|
dg.transactionPerCoin.length,
|
|
depositGroupId: dg.depositGroupId,
|
|
deposited,
|
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
function buildTransactionForTip(
|
|
tipRecord: TipRecord,
|
|
ort?: OperationRetryRecord,
|
|
): Transaction {
|
|
if (!tipRecord.acceptedTimestamp) throw Error("");
|
|
|
|
return {
|
|
type: TransactionType.Tip,
|
|
amountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
|
|
amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
|
|
extendedStatus: tipRecord.pickedUpTimestamp
|
|
? ExtendedStatus.Done
|
|
: ExtendedStatus.Pending,
|
|
pending: !tipRecord.pickedUpTimestamp,
|
|
frozen: false,
|
|
timestamp: tipRecord.acceptedTimestamp,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.Tip,
|
|
tipRecord.walletTipId,
|
|
),
|
|
merchantBaseUrl: tipRecord.merchantBaseUrl,
|
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* For a set of refund with the same executionTime.
|
|
*/
|
|
interface MergedRefundInfo {
|
|
executionTime: TalerProtocolTimestamp;
|
|
amountAppliedRaw: AmountJson;
|
|
amountAppliedEffective: AmountJson;
|
|
firstTimestamp: TalerProtocolTimestamp;
|
|
}
|
|
|
|
function mergeRefundByExecutionTime(
|
|
rs: WalletRefundItem[],
|
|
zero: AmountJson,
|
|
): MergedRefundInfo[] {
|
|
const refundByExecTime = rs.reduce((prev, refund) => {
|
|
const key = `${refund.executionTime.t_s}`;
|
|
|
|
// refunds count if applied
|
|
const effective =
|
|
refund.type === RefundState.Applied
|
|
? Amounts.sub(
|
|
refund.refundAmount,
|
|
refund.refundFee,
|
|
refund.totalRefreshCostBound,
|
|
).amount
|
|
: zero;
|
|
const raw =
|
|
refund.type === RefundState.Applied ? refund.refundAmount : zero;
|
|
|
|
const v = prev.get(key);
|
|
if (!v) {
|
|
prev.set(key, {
|
|
executionTime: refund.executionTime,
|
|
amountAppliedEffective: effective,
|
|
amountAppliedRaw: Amounts.parseOrThrow(raw),
|
|
firstTimestamp: refund.obtainedTime,
|
|
});
|
|
} else {
|
|
//v.executionTime is the same
|
|
v.amountAppliedEffective = Amounts.add(
|
|
v.amountAppliedEffective,
|
|
effective,
|
|
).amount;
|
|
v.amountAppliedRaw = Amounts.add(
|
|
v.amountAppliedRaw,
|
|
refund.refundAmount,
|
|
).amount;
|
|
v.firstTimestamp = TalerProtocolTimestamp.min(
|
|
v.firstTimestamp,
|
|
refund.obtainedTime,
|
|
);
|
|
}
|
|
return prev;
|
|
}, new Map<string, MergedRefundInfo>());
|
|
|
|
return Array.from(refundByExecTime.values());
|
|
}
|
|
|
|
async function buildTransactionForRefund(
|
|
purchaseRecord: PurchaseRecord,
|
|
contractData: WalletContractData,
|
|
refundInfo: MergedRefundInfo,
|
|
ort?: OperationRetryRecord,
|
|
): Promise<Transaction> {
|
|
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;
|
|
}
|
|
|
|
return {
|
|
type: TransactionType.Refund,
|
|
info,
|
|
refundedTransactionId: makeTransactionId(
|
|
TransactionType.Payment,
|
|
purchaseRecord.proposalId,
|
|
),
|
|
transactionId: makeTransactionId(
|
|
TransactionType.Refund,
|
|
purchaseRecord.proposalId,
|
|
`${refundInfo.executionTime.t_s}`,
|
|
),
|
|
timestamp: refundInfo.firstTimestamp,
|
|
amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective),
|
|
amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw),
|
|
refundPending:
|
|
purchaseRecord.refundAmountAwaiting === undefined
|
|
? undefined
|
|
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
|
|
extendedStatus: ExtendedStatus.Done,
|
|
pending: false,
|
|
frozen: false,
|
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
async function buildTransactionForPurchase(
|
|
purchaseRecord: PurchaseRecord,
|
|
contractData: WalletContractData,
|
|
refundsInfo: MergedRefundInfo[],
|
|
ort?: OperationRetryRecord,
|
|
): Promise<Transaction> {
|
|
const zero = Amounts.zeroOfAmount(contractData.amount);
|
|
|
|
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 totalRefund = refundsInfo.reduce(
|
|
(prev, cur) => {
|
|
return {
|
|
raw: Amounts.add(prev.raw, cur.amountAppliedRaw).amount,
|
|
effective: Amounts.add(prev.effective, cur.amountAppliedEffective)
|
|
.amount,
|
|
};
|
|
},
|
|
{
|
|
raw: zero,
|
|
effective: zero,
|
|
} as { raw: AmountJson; effective: AmountJson },
|
|
);
|
|
|
|
const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
|
|
amountEffective: Amounts.stringify(r.amountAppliedEffective),
|
|
amountRaw: Amounts.stringify(r.amountAppliedRaw),
|
|
timestamp: r.executionTime,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.Refund,
|
|
purchaseRecord.proposalId,
|
|
`${r.executionTime.t_s}`,
|
|
),
|
|
}));
|
|
|
|
const timestamp = purchaseRecord.timestampAccept;
|
|
checkDbInvariant(!!timestamp);
|
|
checkDbInvariant(!!purchaseRecord.payInfo);
|
|
|
|
let status: ExtendedStatus;
|
|
switch (purchaseRecord.purchaseStatus) {
|
|
case PurchaseStatus.AbortingWithRefund:
|
|
status = ExtendedStatus.Aborting;
|
|
break;
|
|
case PurchaseStatus.Paid:
|
|
case PurchaseStatus.RepurchaseDetected:
|
|
status = ExtendedStatus.Done;
|
|
break;
|
|
case PurchaseStatus.DownloadingProposal:
|
|
case PurchaseStatus.QueryingRefund:
|
|
case PurchaseStatus.Proposed:
|
|
case PurchaseStatus.Paying:
|
|
status = ExtendedStatus.Pending;
|
|
break;
|
|
case PurchaseStatus.ProposalDownloadFailed:
|
|
status = ExtendedStatus.Failed;
|
|
break;
|
|
case PurchaseStatus.PaymentAbortFinished:
|
|
status = ExtendedStatus.Aborted;
|
|
break;
|
|
default:
|
|
// FIXME: Should we have some unknown status?
|
|
status = ExtendedStatus.Pending;
|
|
}
|
|
|
|
return {
|
|
type: TransactionType.Payment,
|
|
amountRaw: Amounts.stringify(contractData.amount),
|
|
amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
|
|
totalRefundRaw: Amounts.stringify(totalRefund.raw),
|
|
totalRefundEffective: Amounts.stringify(totalRefund.effective),
|
|
refundPending:
|
|
purchaseRecord.refundAmountAwaiting === undefined
|
|
? undefined
|
|
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
|
|
status: purchaseRecord.timestampFirstSuccessfulPay
|
|
? PaymentStatus.Paid
|
|
: PaymentStatus.Accepted,
|
|
extendedStatus: status,
|
|
pending: purchaseRecord.purchaseStatus === PurchaseStatus.Paying,
|
|
refunds,
|
|
timestamp,
|
|
transactionId: makeTransactionId(
|
|
TransactionType.Payment,
|
|
purchaseRecord.proposalId,
|
|
),
|
|
proposalId: purchaseRecord.proposalId,
|
|
info,
|
|
refundQueryActive:
|
|
purchaseRecord.purchaseStatus === PurchaseStatus.QueryingRefund,
|
|
frozen:
|
|
purchaseRecord.purchaseStatus === PurchaseStatus.PaymentAbortFinished ??
|
|
false,
|
|
...(ort?.lastError ? { error: ort.lastError } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Retrieve the full event history for this wallet.
|
|
*/
|
|
export async function getTransactions(
|
|
ws: InternalWalletState,
|
|
transactionsRequest?: TransactionsRequest,
|
|
): Promise<TransactionsResponse> {
|
|
const transactions: Transaction[] = [];
|
|
|
|
await ws.db
|
|
.mktx((x) => [
|
|
x.coins,
|
|
x.denominations,
|
|
x.depositGroups,
|
|
x.exchangeDetails,
|
|
x.exchanges,
|
|
x.operationRetries,
|
|
x.peerPullPaymentIncoming,
|
|
x.peerPushPaymentInitiations,
|
|
x.peerPushPaymentIncoming,
|
|
x.peerPullPaymentInitiations,
|
|
x.planchets,
|
|
x.purchases,
|
|
x.contractTerms,
|
|
x.recoupGroups,
|
|
x.tips,
|
|
x.tombstones,
|
|
x.withdrawalGroups,
|
|
x.refreshGroups,
|
|
])
|
|
.runReadOnly(async (tx) => {
|
|
tx.peerPushPaymentInitiations.iter().forEachAsync(async (pi) => {
|
|
const amount = Amounts.parseOrThrow(pi.amount);
|
|
|
|
if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
|
|
return;
|
|
}
|
|
if (shouldSkipSearch(transactionsRequest, [])) {
|
|
return;
|
|
}
|
|
const ct = await tx.contractTerms.get(pi.contractTermsHash);
|
|
checkDbInvariant(!!ct);
|
|
transactions.push(
|
|
buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw),
|
|
);
|
|
});
|
|
|
|
tx.peerPullPaymentIncoming.iter().forEachAsync(async (pi) => {
|
|
const amount = Amounts.parseOrThrow(pi.contractTerms.amount);
|
|
if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
|
|
return;
|
|
}
|
|
if (shouldSkipSearch(transactionsRequest, [])) {
|
|
return;
|
|
}
|
|
if (
|
|
pi.status !== PeerPullPaymentIncomingStatus.Accepted &&
|
|
pi.status !== PeerPullPaymentIncomingStatus.Paid
|
|
) {
|
|
return;
|
|
}
|
|
|
|
transactions.push(buildTransactionForPullPaymentDebit(pi));
|
|
});
|
|
|
|
tx.peerPushPaymentIncoming.iter().forEachAsync(async (pi) => {
|
|
if (!pi.currency) {
|
|
// Legacy transaction
|
|
return;
|
|
}
|
|
if (shouldSkipCurrency(transactionsRequest, pi.currency)) {
|
|
return;
|
|
}
|
|
if (shouldSkipSearch(transactionsRequest, [])) {
|
|
return;
|
|
}
|
|
if (pi.status === PeerPushPaymentIncomingStatus.Proposed) {
|
|
// We don't report proposed push credit transactions, user needs
|
|
// to scan URI again and confirm to see it.
|
|
return;
|
|
}
|
|
const ct = await tx.contractTerms.get(pi.contractTermsHash);
|
|
let wg: WithdrawalGroupRecord | undefined = undefined;
|
|
let wgOrt: OperationRetryRecord | undefined = undefined;
|
|
if (pi.withdrawalGroupId) {
|
|
wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
|
|
if (wg) {
|
|
const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
|
|
wgOrt = await tx.operationRetries.get(withdrawalOpId);
|
|
}
|
|
}
|
|
const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi);
|
|
let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
|
|
|
|
checkDbInvariant(!!ct);
|
|
transactions.push(
|
|
buildTransactionForPeerPushCredit(
|
|
pi,
|
|
pushIncOrt,
|
|
ct.contractTermsRaw,
|
|
wg,
|
|
wgOrt,
|
|
),
|
|
);
|
|
});
|
|
|
|
tx.peerPullPaymentInitiations.iter().forEachAsync(async (pi) => {
|
|
const currency = Amounts.currencyOf(pi.amount);
|
|
if (shouldSkipCurrency(transactionsRequest, currency)) {
|
|
return;
|
|
}
|
|
if (shouldSkipSearch(transactionsRequest, [])) {
|
|
return;
|
|
}
|
|
const ct = await tx.contractTerms.get(pi.contractTermsHash);
|
|
let wg: WithdrawalGroupRecord | undefined = undefined;
|
|
let wgOrt: OperationRetryRecord | undefined = undefined;
|
|
if (pi.withdrawalGroupId) {
|
|
wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
|
|
if (wg) {
|
|
const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
|
|
wgOrt = await tx.operationRetries.get(withdrawalOpId);
|
|
}
|
|
}
|
|
const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi);
|
|
let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
|
|
|
|
checkDbInvariant(!!ct);
|
|
transactions.push(
|
|
buildTransactionForPeerPullCredit(
|
|
pi,
|
|
pushIncOrt,
|
|
ct.contractTermsRaw,
|
|
wg,
|
|
wgOrt,
|
|
),
|
|
);
|
|
});
|
|
|
|
tx.refreshGroups.iter().forEachAsync(async (rg) => {
|
|
if (shouldSkipCurrency(transactionsRequest, rg.currency)) {
|
|
return;
|
|
}
|
|
let required = false;
|
|
const opId = TaskIdentifiers.forRefresh(rg);
|
|
if (transactionsRequest?.includeRefreshes) {
|
|
required = true;
|
|
} else if (rg.operationStatus !== RefreshOperationStatus.Finished) {
|
|
const ort = await tx.operationRetries.get(opId);
|
|
if (ort) {
|
|
required = true;
|
|
}
|
|
}
|
|
if (required) {
|
|
const ort = await tx.operationRetries.get(opId);
|
|
transactions.push(buildTransactionForRefresh(rg, ort));
|
|
}
|
|
});
|
|
|
|
tx.withdrawalGroups.iter().forEachAsync(async (wsr) => {
|
|
if (
|
|
shouldSkipCurrency(
|
|
transactionsRequest,
|
|
Amounts.currencyOf(wsr.rawWithdrawalAmount),
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (shouldSkipSearch(transactionsRequest, [])) {
|
|
return;
|
|
}
|
|
|
|
const opId = TaskIdentifiers.forWithdrawal(wsr);
|
|
const ort = await tx.operationRetries.get(opId);
|
|
|
|
switch (wsr.wgInfo.withdrawalType) {
|
|
case WithdrawalRecordType.PeerPullCredit:
|
|
// Will be reported by the corresponding p2p transaction.
|
|
// FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
|
|
return;
|
|
case WithdrawalRecordType.PeerPushCredit:
|
|
// Will be reported by the corresponding p2p transaction.
|
|
// FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
|
|
return;
|
|
case WithdrawalRecordType.BankIntegrated:
|
|
transactions.push(
|
|
buildTransactionForBankIntegratedWithdraw(wsr, ort),
|
|
);
|
|
return;
|
|
case WithdrawalRecordType.BankManual: {
|
|
const exchangeDetails = await getExchangeDetails(
|
|
tx,
|
|
wsr.exchangeBaseUrl,
|
|
);
|
|
if (!exchangeDetails) {
|
|
// FIXME: report somehow
|
|
return;
|
|
}
|
|
|
|
transactions.push(
|
|
buildTransactionForManualWithdraw(wsr, exchangeDetails, ort),
|
|
);
|
|
return;
|
|
}
|
|
case WithdrawalRecordType.Recoup:
|
|
// FIXME: Do we also report a transaction here?
|
|
return;
|
|
}
|
|
});
|
|
|
|
tx.depositGroups.iter().forEachAsync(async (dg) => {
|
|
const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount);
|
|
if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
|
|
return;
|
|
}
|
|
const opId = TaskIdentifiers.forDeposit(dg);
|
|
const retryRecord = await tx.operationRetries.get(opId);
|
|
|
|
transactions.push(buildTransactionForDeposit(dg, retryRecord));
|
|
});
|
|
|
|
tx.purchases.iter().forEachAsync(async (purchase) => {
|
|
const download = purchase.download;
|
|
if (!download) {
|
|
return;
|
|
}
|
|
if (!purchase.payInfo) {
|
|
return;
|
|
}
|
|
if (shouldSkipCurrency(transactionsRequest, download.currency)) {
|
|
return;
|
|
}
|
|
const contractTermsRecord = await tx.contractTerms.get(
|
|
download.contractTermsHash,
|
|
);
|
|
if (!contractTermsRecord) {
|
|
return;
|
|
}
|
|
if (
|
|
shouldSkipSearch(transactionsRequest, [
|
|
contractTermsRecord?.contractTermsRaw?.summary || "",
|
|
])
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const contractData = extractContractData(
|
|
contractTermsRecord?.contractTermsRaw,
|
|
download.contractTermsHash,
|
|
download.contractTermsMerchantSig,
|
|
);
|
|
|
|
const filteredRefunds = await Promise.all(
|
|
Object.values(purchase.refunds).map(async (r) => {
|
|
const t = await tx.tombstones.get(
|
|
makeTombstoneId(
|
|
TombstoneTag.DeleteRefund,
|
|
purchase.proposalId,
|
|
`${r.executionTime.t_s}`,
|
|
),
|
|
);
|
|
if (!t) return r;
|
|
return undefined;
|
|
}),
|
|
);
|
|
|
|
const cleanRefunds = filteredRefunds.filter(
|
|
(x): x is WalletRefundItem => !!x,
|
|
);
|
|
|
|
const refunds = mergeRefundByExecutionTime(
|
|
cleanRefunds,
|
|
Amounts.zeroOfCurrency(download.currency),
|
|
);
|
|
|
|
refunds.forEach(async (refundInfo) => {
|
|
transactions.push(
|
|
await buildTransactionForRefund(
|
|
purchase,
|
|
contractData,
|
|
refundInfo,
|
|
undefined,
|
|
),
|
|
);
|
|
});
|
|
|
|
const payOpId = TaskIdentifiers.forPay(purchase);
|
|
const payRetryRecord = await tx.operationRetries.get(payOpId);
|
|
transactions.push(
|
|
await buildTransactionForPurchase(
|
|
purchase,
|
|
contractData,
|
|
refunds,
|
|
payRetryRecord,
|
|
),
|
|
);
|
|
});
|
|
|
|
tx.tips.iter().forEachAsync(async (tipRecord) => {
|
|
if (
|
|
shouldSkipCurrency(
|
|
transactionsRequest,
|
|
Amounts.parseOrThrow(tipRecord.tipAmountRaw).currency,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
if (!tipRecord.acceptedTimestamp) {
|
|
return;
|
|
}
|
|
const opId = TaskIdentifiers.forTipPickup(tipRecord);
|
|
const retryRecord = await tx.operationRetries.get(opId);
|
|
transactions.push(buildTransactionForTip(tipRecord, retryRecord));
|
|
});
|
|
});
|
|
|
|
const txPending = transactions.filter((x) => x.pending);
|
|
const txNotPending = transactions.filter((x) => !x.pending);
|
|
|
|
const txCmp = (h1: Transaction, h2: Transaction) => {
|
|
const tsCmp = AbsoluteTime.cmp(
|
|
AbsoluteTime.fromTimestamp(h1.timestamp),
|
|
AbsoluteTime.fromTimestamp(h2.timestamp),
|
|
);
|
|
if (tsCmp === 0) {
|
|
return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
|
|
}
|
|
return tsCmp;
|
|
};
|
|
|
|
txPending.sort(txCmp);
|
|
txNotPending.sort(txCmp);
|
|
|
|
return { transactions: [...txNotPending, ...txPending] };
|
|
}
|
|
|
|
export type ParsedTransactionIdentifier =
|
|
| { tag: TransactionType.Deposit; depositGroupId: string }
|
|
| { tag: TransactionType.Payment; proposalId: string }
|
|
| { tag: TransactionType.PeerPullDebit; peerPullPaymentIncomingId: string }
|
|
| { tag: TransactionType.PeerPullCredit; pursePub: string }
|
|
| { tag: TransactionType.PeerPushCredit; peerPushPaymentIncomingId: string }
|
|
| { tag: TransactionType.PeerPushDebit; pursePub: string }
|
|
| { tag: TransactionType.Refresh; refreshGroupId: string }
|
|
| { tag: TransactionType.Refund; proposalId: string; executionTime: string }
|
|
| { tag: TransactionType.Tip; walletTipId: string }
|
|
| { tag: TransactionType.Withdrawal; withdrawalGroupId: string };
|
|
|
|
export function constructTransactionIdentifier(
|
|
pTxId: ParsedTransactionIdentifier,
|
|
): string {
|
|
switch (pTxId.tag) {
|
|
case TransactionType.Deposit:
|
|
return `txn:${pTxId.tag}:${pTxId.depositGroupId}`;
|
|
case TransactionType.Payment:
|
|
return `txn:${pTxId.tag}:${pTxId.proposalId}`;
|
|
case TransactionType.PeerPullCredit:
|
|
return `txn:${pTxId.tag}:${pTxId.pursePub}`;
|
|
case TransactionType.PeerPullDebit:
|
|
return `txn:${pTxId.tag}:${pTxId.peerPullPaymentIncomingId}`;
|
|
case TransactionType.PeerPushCredit:
|
|
return `txn:${pTxId.tag}:${pTxId.peerPushPaymentIncomingId}`;
|
|
case TransactionType.PeerPushDebit:
|
|
return `txn:${pTxId.tag}:${pTxId.pursePub}`;
|
|
case TransactionType.Refresh:
|
|
return `txn:${pTxId.tag}:${pTxId.refreshGroupId}`;
|
|
case TransactionType.Refund:
|
|
return `txn:${pTxId.tag}:${pTxId.proposalId}:${pTxId.executionTime}`;
|
|
case TransactionType.Tip:
|
|
return `txn:${pTxId.tag}:${pTxId.walletTipId}`;
|
|
case TransactionType.Withdrawal:
|
|
return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}`;
|
|
default:
|
|
assertUnreachable(pTxId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a transaction identifier string into a typed, structured representation.
|
|
*/
|
|
export function parseTransactionIdentifier(
|
|
transactionId: string,
|
|
): ParsedTransactionIdentifier | undefined {
|
|
const { type, args: rest } = parseId("any", transactionId);
|
|
|
|
switch (type) {
|
|
case TransactionType.Deposit:
|
|
return { tag: TransactionType.Deposit, depositGroupId: rest[0] };
|
|
case TransactionType.Payment:
|
|
return { tag: TransactionType.Payment, proposalId: rest[0] };
|
|
case TransactionType.PeerPullCredit:
|
|
return { tag: TransactionType.PeerPullCredit, pursePub: rest[0] };
|
|
case TransactionType.PeerPullDebit:
|
|
return {
|
|
tag: TransactionType.PeerPullDebit,
|
|
peerPullPaymentIncomingId: rest[0],
|
|
};
|
|
case TransactionType.PeerPushCredit:
|
|
return {
|
|
tag: TransactionType.PeerPushCredit,
|
|
peerPushPaymentIncomingId: rest[0],
|
|
};
|
|
case TransactionType.PeerPushDebit:
|
|
return { tag: TransactionType.PeerPushDebit, pursePub: rest[0] };
|
|
case TransactionType.Refresh:
|
|
return { tag: TransactionType.Refresh, refreshGroupId: rest[0] };
|
|
case TransactionType.Refund:
|
|
return {
|
|
tag: TransactionType.Refund,
|
|
proposalId: rest[0],
|
|
executionTime: rest[1],
|
|
};
|
|
case TransactionType.Tip:
|
|
return {
|
|
tag: TransactionType.Tip,
|
|
walletTipId: rest[0],
|
|
};
|
|
case TransactionType.Withdrawal:
|
|
return {
|
|
tag: TransactionType.Withdrawal,
|
|
withdrawalGroupId: rest[0],
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function stopLongpolling(ws: InternalWalletState, taskId: string) {
|
|
const longpoll = ws.activeLongpoll[taskId];
|
|
if (longpoll) {
|
|
logger.info(`cancelling long-polling for ${taskId}`);
|
|
longpoll.cancel();
|
|
delete ws.activeLongpoll[taskId];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Immediately retry the underlying operation
|
|
* of a transaction.
|
|
*/
|
|
export async function retryTransaction(
|
|
ws: InternalWalletState,
|
|
transactionId: string,
|
|
): Promise<void> {
|
|
logger.info(`retrying transaction ${transactionId}`);
|
|
|
|
const parsedTx = parseTransactionIdentifier(transactionId);
|
|
|
|
if (!parsedTx) {
|
|
throw Error("invalid transaction identifier");
|
|
}
|
|
|
|
// FIXME: We currently don't cancel active long-polling tasks here.
|
|
|
|
switch (parsedTx.tag) {
|
|
case TransactionType.PeerPullCredit: {
|
|
const taskId = constructTaskIdentifier({
|
|
tag: PendingTaskType.PeerPullInitiation,
|
|
pursePub: parsedTx.pursePub,
|
|
});
|
|
await resetOperationTimeout(ws, taskId);
|
|
stopLongpolling(ws, taskId);
|
|
await runOperationWithErrorReporting(ws, taskId, () =>
|
|
processPeerPullCredit(ws, parsedTx.pursePub),
|
|
);
|
|
break;
|
|
}
|
|
case TransactionType.Deposit: {
|
|
const taskId = constructTaskIdentifier({
|
|
tag: PendingTaskType.Deposit,
|
|
depositGroupId: parsedTx.depositGroupId,
|
|
});
|
|
await resetOperationTimeout(ws, taskId);
|
|
stopLongpolling(ws, taskId);
|
|
await runOperationWithErrorReporting(ws, taskId, () =>
|
|
processDepositGroup(ws, parsedTx.depositGroupId),
|
|
);
|
|
break;
|
|
}
|
|
case TransactionType.Withdrawal: {
|
|
// FIXME: Abort current long-poller!
|
|
const taskId = constructTaskIdentifier({
|
|
tag: PendingTaskType.Withdraw,
|
|
withdrawalGroupId: parsedTx.withdrawalGroupId,
|
|
});
|
|
await resetOperationTimeout(ws, taskId);
|
|
stopLongpolling(ws, taskId);
|
|
await runOperationWithErrorReporting(ws, taskId, () =>
|
|
processWithdrawalGroup(ws, parsedTx.withdrawalGroupId),
|
|
);
|
|
break;
|
|
}
|
|
case TransactionType.Payment: {
|
|
const taskId = constructTaskIdentifier({
|
|
tag: PendingTaskType.Purchase,
|
|
proposalId: parsedTx.proposalId,
|
|
});
|
|
await resetOperationTimeout(ws, taskId);
|
|
stopLongpolling(ws, taskId);
|
|
await runOperationWithErrorReporting(ws, taskId, () =>
|
|
processPurchasePay(ws, parsedTx.proposalId),
|
|
);
|
|
break;
|
|
}
|
|
case TransactionType.Tip: {
|
|
const taskId = constructTaskIdentifier({
|
|
tag: PendingTaskType.TipPickup,
|
|
walletTipId: parsedTx.walletTipId,
|
|
});
|
|
await resetOperationTimeout(ws, taskId);
|
|
stopLongpolling(ws, taskId);
|
|
await runOperationWithErrorReporting(ws, taskId, () =>
|
|
processTip(ws, parsedTx.walletTipId),
|
|
);
|
|
break;
|
|
}
|
|
case TransactionType.Refresh: {
|
|
const taskId = constructTaskIdentifier({
|
|
tag: PendingTaskType.Refresh,
|
|
refreshGroupId: parsedTx.refreshGroupId,
|
|
});
|
|
await resetOperationTimeout(ws, taskId);
|
|
stopLongpolling(ws, taskId);
|
|
await runOperationWithErrorReporting(ws, taskId, () =>
|
|
processRefreshGroup(ws, parsedTx.refreshGroupId),
|
|
);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Suspends a pending transaction, stopping any associated network activities,
|
|
* but with a chance of trying again at a later time. This could be useful if
|
|
* a user needs to save battery power or bandwidth and an operation is expected
|
|
* to take longer (such as a backup, recovery or very large withdrawal operation).
|
|
*/
|
|
export async function suspendTransaction(
|
|
ws: InternalWalletState,
|
|
transactionId: string,
|
|
): Promise<void> {}
|
|
|
|
/**
|
|
* Resume a suspended transaction.
|
|
*/
|
|
export async function resumeTransaction(
|
|
ws: InternalWalletState,
|
|
transactionId: string,
|
|
): Promise<void> {}
|
|
|
|
/**
|
|
* Permanently delete a transaction based on the transaction ID.
|
|
*/
|
|
export async function deleteTransaction(
|
|
ws: InternalWalletState,
|
|
transactionId: string,
|
|
): Promise<void> {
|
|
const { type, args: rest } = parseId("txn", transactionId);
|
|
|
|
if (type === TransactionType.PeerPushCredit) {
|
|
const peerPushPaymentIncomingId = rest[0];
|
|
await ws.db
|
|
.mktx((x) => [
|
|
x.withdrawalGroups,
|
|
x.peerPushPaymentIncoming,
|
|
x.tombstones,
|
|
])
|
|
.runReadWrite(async (tx) => {
|
|
const pushInc = await tx.peerPushPaymentIncoming.get(
|
|
peerPushPaymentIncomingId,
|
|
);
|
|
if (!pushInc) {
|
|
return;
|
|
}
|
|
if (pushInc.withdrawalGroupId) {
|
|
const withdrawalGroupId = pushInc.withdrawalGroupId;
|
|
const withdrawalGroupRecord = await tx.withdrawalGroups.get(
|
|
withdrawalGroupId,
|
|
);
|
|
if (withdrawalGroupRecord) {
|
|
await tx.withdrawalGroups.delete(withdrawalGroupId);
|
|
await tx.tombstones.put({
|
|
id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
|
|
});
|
|
}
|
|
}
|
|
await tx.peerPushPaymentIncoming.delete(peerPushPaymentIncomingId);
|
|
await tx.tombstones.put({
|
|
id:
|
|
TombstoneTag.DeletePeerPushCredit + ":" + peerPushPaymentIncomingId,
|
|
});
|
|
});
|
|
} else if (type === TransactionType.PeerPullCredit) {
|
|
const pursePub = rest[0];
|
|
await ws.db
|
|
.mktx((x) => [
|
|
x.withdrawalGroups,
|
|
x.peerPullPaymentInitiations,
|
|
x.tombstones,
|
|
])
|
|
.runReadWrite(async (tx) => {
|
|
const pullIni = await tx.peerPullPaymentInitiations.get(pursePub);
|
|
if (!pullIni) {
|
|
return;
|
|
}
|
|
if (pullIni.withdrawalGroupId) {
|
|
const withdrawalGroupId = pullIni.withdrawalGroupId;
|
|
const withdrawalGroupRecord = await tx.withdrawalGroups.get(
|
|
withdrawalGroupId,
|
|
);
|
|
if (withdrawalGroupRecord) {
|
|
await tx.withdrawalGroups.delete(withdrawalGroupId);
|
|
await tx.tombstones.put({
|
|
id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
|
|
});
|
|
}
|
|
}
|
|
await tx.peerPullPaymentInitiations.delete(pursePub);
|
|
await tx.tombstones.put({
|
|
id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
|
|
});
|
|
});
|
|
} else if (type === TransactionType.Withdrawal) {
|
|
const withdrawalGroupId = rest[0];
|
|
await ws.db
|
|
.mktx((x) => [x.withdrawalGroups, 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;
|
|
}
|
|
});
|
|
} else if (type === TransactionType.Payment) {
|
|
const proposalId = rest[0];
|
|
await ws.db
|
|
.mktx((x) => [x.purchases, x.tombstones])
|
|
.runReadWrite(async (tx) => {
|
|
let found = false;
|
|
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) => [x.refreshGroups, 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) => [x.tips, 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) => [x.depositGroups, 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) => [x.purchases, 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: makeTombstoneId(
|
|
TombstoneTag.DeleteRefund,
|
|
proposalId,
|
|
executionTimeStr,
|
|
),
|
|
});
|
|
}
|
|
});
|
|
} else if (type === TransactionType.PeerPullDebit) {
|
|
const peerPullPaymentIncomingId = rest[0];
|
|
await ws.db
|
|
.mktx((x) => [x.peerPullPaymentIncoming, x.tombstones])
|
|
.runReadWrite(async (tx) => {
|
|
const debit = await tx.peerPullPaymentIncoming.get(
|
|
peerPullPaymentIncomingId,
|
|
);
|
|
if (debit) {
|
|
await tx.peerPullPaymentIncoming.delete(peerPullPaymentIncomingId);
|
|
await tx.tombstones.put({
|
|
id: makeTombstoneId(
|
|
TombstoneTag.DeletePeerPullDebit,
|
|
peerPullPaymentIncomingId,
|
|
),
|
|
});
|
|
}
|
|
});
|
|
} else if (type === TransactionType.PeerPushDebit) {
|
|
const pursePub = rest[0];
|
|
await ws.db
|
|
.mktx((x) => [x.peerPushPaymentInitiations, x.tombstones])
|
|
.runReadWrite(async (tx) => {
|
|
const debit = await tx.peerPushPaymentInitiations.get(pursePub);
|
|
if (debit) {
|
|
await tx.peerPushPaymentInitiations.delete(pursePub);
|
|
await tx.tombstones.put({
|
|
id: makeTombstoneId(TombstoneTag.DeletePeerPushDebit, pursePub),
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
const unknownTxType: never = type;
|
|
throw Error(`can't delete a '${unknownTxType}' transaction`);
|
|
}
|
|
}
|
|
|
|
export async function abortTransaction(
|
|
ws: InternalWalletState,
|
|
transactionId: string,
|
|
forceImmediateAbort?: boolean,
|
|
): Promise<void> {
|
|
const { type, args: rest } = parseId("txn", transactionId);
|
|
|
|
switch (type) {
|
|
case TransactionType.Payment: {
|
|
const proposalId = rest[0];
|
|
await abortPay(ws, proposalId, forceImmediateAbort);
|
|
break;
|
|
}
|
|
case TransactionType.PeerPushDebit: {
|
|
break;
|
|
}
|
|
default: {
|
|
const unknownTxType: any = type;
|
|
throw Error(
|
|
`can't abort a '${unknownTxType}' transaction: not yet implemented`,
|
|
);
|
|
}
|
|
}
|
|
}
|