/* 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, constructPayPullUri, constructPayPushUri, ExtendedStatus, j2s, Logger, OrderShortInfo, PaymentStatus, PeerContractTerms, RefundInfoShort, TalerErrorCode, TalerProtocolTimestamp, Transaction, TransactionByIdRequest, TransactionIdStr, TransactionMajorState, 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, DepositElementStatus, 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, checkLogicInvariant } from "../util/invariants.js"; import { constructTaskIdentifier, TaskIdentifiers } from "../util/retries.js"; import { makeTombstoneId, makeTransactionId, parseId, resetOperationTimeout, runOperationWithErrorReporting, TombstoneTag, } from "./common.js"; import { computeDepositTransactionStatus, processDepositGroup, suspendDepositGroup, } 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 { computeTipTransactionStatus, processTip } from "./tip.js"; import { augmentPaytoUrisForWithdrawal, computeWithdrawalTransactionStatus, 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 { 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`); } } // FIXME: Just a marker helper for unknown states until DD37 is fully implemented. const mkTxStateUnknown = () => ({ major: TransactionMajorState.Unknown, }); function buildTransactionForPushPaymentDebit( pi: PeerPushPaymentInitiationRecord, contractTerms: PeerContractTerms, ort?: OperationRetryRecord, ): Transaction { return { type: TransactionType.PeerPushDebit, txState: mkTxStateUnknown(), 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, txState: mkTxStateUnknown(), 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, txState: mkTxStateUnknown(), amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue), amountRaw: Amounts.stringify(wsr.instructedAmount), exchangeBaseUrl: wsr.exchangeBaseUrl, extendedStatus: wsr.timestampFinish ? ExtendedStatus.Done : ExtendedStatus.Pending, pending: !wsr.timestampFinish, // Old transactions don't have it! timestamp: pullCredit.mergeTimestamp ?? TalerProtocolTimestamp.now(), 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, txState: mkTxStateUnknown(), amountEffective: Amounts.stringify(pullCredit.estimatedAmountEffective), amountRaw: Amounts.stringify(peerContractTerms.amount), exchangeBaseUrl: pullCredit.exchangeBaseUrl, extendedStatus: ExtendedStatus.Pending, pending: true, // Old transactions don't have it! timestamp: pullCredit.mergeTimestamp ?? TalerProtocolTimestamp.now(), 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, txState: mkTxStateUnknown(), 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, txState: mkTxStateUnknown(), // 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( wgRecord: WithdrawalGroupRecord, ort?: OperationRetryRecord, ): Transaction { if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) throw Error(""); return { type: TransactionType.Withdrawal, txState: computeWithdrawalTransactionStatus(wgRecord), amountEffective: Amounts.stringify(wgRecord.denomsSel.totalCoinValue), amountRaw: Amounts.stringify(wgRecord.instructedAmount), withdrawalDetails: { type: WithdrawalType.TalerBankIntegrationApi, confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false, reservePub: wgRecord.reservePub, bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl, reserveIsReady: wgRecord.status === WithdrawalGroupStatus.Finished || wgRecord.status === WithdrawalGroupStatus.Ready, }, exchangeBaseUrl: wgRecord.exchangeBaseUrl, extendedStatus: wgRecord.timestampFinish ? ExtendedStatus.Done : ExtendedStatus.Pending, pending: !wgRecord.timestampFinish, timestamp: wgRecord.timestampStart, transactionId: makeTransactionId( TransactionType.Withdrawal, wgRecord.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, txState: computeWithdrawalTransactionStatus(withdrawalGroup), 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, txState: mkTxStateUnknown(), 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, txState: computeDepositTransactionStatus(dg), 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 === DepositElementStatus.Wired ? 1 : 0), 0, )) / dg.transactionPerCoin.length, depositGroupId: dg.depositGroupId, trackingState: Object.values(dg.trackingState ?? {}), deposited, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } function buildTransactionForTip( tipRecord: TipRecord, ort?: OperationRetryRecord, ): Transaction { checkLogicInvariant(!!tipRecord.acceptedTimestamp); return { type: TransactionType.Tip, txState: computeTipTransactionStatus(tipRecord), 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()); return Array.from(refundByExecTime.values()); } async function buildTransactionForRefund( purchaseRecord: PurchaseRecord, contractData: WalletContractData, refundInfo: MergedRefundInfo, ort?: OperationRetryRecord, ): Promise { 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, txState: mkTxStateUnknown(), 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 { 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, txState: mkTxStateUnknown(), 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, posConfirmation: purchaseRecord.posConfirmation, 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 { 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! // FIXME: Still report if requested with verbose option? 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! // FIXME: Still report if requested with verbose option? 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)); }); }); // One-off checks, because of a bug where the wallet previously // did not migrate the DB correctly and caused these amounts // to be missing sometimes. for (let tx of transactions) { if (!tx.amountEffective) { logger.warn(`missing amountEffective in ${j2s(tx)}`); } if (!tx.amountRaw) { logger.warn(`missing amountRaw in ${j2s(tx)}`); } if (!tx.timestamp) { logger.warn(`missing timestamp in ${j2s(tx)}`); } } 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, ): TransactionIdStr { 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 { 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 { const tx = parseTransactionIdentifier(transactionId); if (!tx) { throw Error("invalid transaction ID"); } switch (tx.tag) { case TransactionType.Deposit: await suspendDepositGroup(ws, tx.depositGroupId); return; default: logger.warn(`unable to suspend transaction of type '${tx.tag}'`); } } /** * Resume a suspended transaction. */ export async function resumeTransaction( ws: InternalWalletState, transactionId: string, ): Promise { const tx = parseTransactionIdentifier(transactionId); if (!tx) { throw Error("invalid transaction ID"); } switch (tx.tag) { default: logger.warn(`unable to resume transaction of type '${tx.tag}'`); } } /** * Permanently delete a transaction based on the transaction ID. */ export async function deleteTransaction( ws: InternalWalletState, transactionId: string, ): Promise { 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 { 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`, ); } } }