diff options
Diffstat (limited to 'packages/taler-wallet-core/src/transactions.ts')
-rw-r--r-- | packages/taler-wallet-core/src/transactions.ts | 2039 |
1 files changed, 2039 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts new file mode 100644 index 000000000..f6216d641 --- /dev/null +++ b/packages/taler-wallet-core/src/transactions.ts @@ -0,0 +1,2039 @@ +/* + 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 { GlobalIDB } from "@gnu-taler/idb-bridge"; +import { + AbsoluteTime, + Amounts, + assertUnreachable, + checkDbInvariant, + DepositTransactionTrackingState, + j2s, + Logger, + NotificationType, + OrderShortInfo, + PeerContractTerms, + RefundInfoShort, + RefundPaymentInfo, + ScopeType, + stringifyPayPullUri, + stringifyPayPushUri, + TalerErrorCode, + TalerPreciseTimestamp, + Transaction, + TransactionAction, + TransactionByIdRequest, + TransactionIdStr, + TransactionMajorState, + TransactionRecordFilter, + TransactionsRequest, + TransactionsResponse, + TransactionState, + TransactionType, + TransactionWithdrawal, + WalletContractData, + WithdrawalTransactionByURIRequest, + WithdrawalType, +} from "@gnu-taler/taler-util"; +import { + constructTaskIdentifier, + PendingTaskType, + TaskIdentifiers, + TaskIdStr, + TransactionContext, +} from "./common.js"; +import { + DenomLossEventRecord, + DepositElementStatus, + DepositGroupRecord, + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + OperationRetryRecord, + PeerPullCreditRecord, + PeerPullDebitRecordStatus, + PeerPullPaymentIncomingRecord, + PeerPushCreditStatus, + PeerPushDebitRecord, + PeerPushDebitStatus, + PeerPushPaymentIncomingRecord, + PurchaseRecord, + PurchaseStatus, + RefreshGroupRecord, + RefreshOperationStatus, + RefundGroupRecord, + timestampPreciseFromDb, + timestampProtocolFromDb, + WalletDbReadOnlyTransaction, + WithdrawalGroupRecord, + WithdrawalGroupStatus, + WithdrawalRecordType, +} from "./db.js"; +import { + computeDepositTransactionActions, + computeDepositTransactionStatus, + DepositTransactionContext, +} from "./deposits.js"; +import { + computeDenomLossTransactionStatus, + DenomLossTransactionContext, + ExchangeWireDetails, + getExchangeWireDetailsInTx, +} from "./exchanges.js"; +import { + computePayMerchantTransactionActions, + computePayMerchantTransactionState, + computeRefundTransactionState, + expectProposalDownload, + extractContractData, + PayMerchantTransactionContext, + RefundTransactionContext, +} from "./pay-merchant.js"; +import { + computePeerPullCreditTransactionActions, + computePeerPullCreditTransactionState, + PeerPullCreditTransactionContext, +} from "./pay-peer-pull-credit.js"; +import { + computePeerPullDebitTransactionActions, + computePeerPullDebitTransactionState, + PeerPullDebitTransactionContext, +} from "./pay-peer-pull-debit.js"; +import { + computePeerPushCreditTransactionActions, + computePeerPushCreditTransactionState, + PeerPushCreditTransactionContext, +} from "./pay-peer-push-credit.js"; +import { + computePeerPushDebitTransactionActions, + computePeerPushDebitTransactionState, + PeerPushDebitTransactionContext, +} from "./pay-peer-push-debit.js"; +import { + computeRefreshTransactionActions, + computeRefreshTransactionState, + RefreshTransactionContext, +} from "./refresh.js"; +import type { WalletExecutionContext } from "./wallet.js"; +import { + augmentPaytoUrisForWithdrawal, + computeWithdrawalTransactionActions, + computeWithdrawalTransactionStatus, + WithdrawTransactionContext, +} from "./withdraw.js"; + +const logger = new Logger("taler-wallet-core:transactions.ts"); + +function shouldSkipCurrency( + transactionsRequest: TransactionsRequest | undefined, + currency: string, + exchangesInTransaction: string[], +): boolean { + if (transactionsRequest?.scopeInfo) { + const sameCurrency = Amounts.isSameCurrency( + currency, + transactionsRequest.scopeInfo.currency, + ); + switch (transactionsRequest.scopeInfo.type) { + case ScopeType.Global: { + return !sameCurrency; + } + case ScopeType.Exchange: { + return ( + !sameCurrency || + (exchangesInTransaction.length > 0 && + !exchangesInTransaction.includes(transactionsRequest.scopeInfo.url)) + ); + } + case ScopeType.Auditor: { + // same currency and same auditor + throw Error("filering balance in auditor scope is not implemented"); + } + default: + assertUnreachable(transactionsRequest.scopeInfo); + } + } + // FIXME: remove next release + if (transactionsRequest?.currency) { + return ( + transactionsRequest.currency.toLowerCase() !== currency.toLowerCase() + ); + } + return false; +} + +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.Payment]: 3, + [TransactionType.PeerPullCredit]: 4, + [TransactionType.PeerPullDebit]: 5, + [TransactionType.PeerPushCredit]: 6, + [TransactionType.PeerPushDebit]: 7, + [TransactionType.Refund]: 8, + [TransactionType.Deposit]: 9, + [TransactionType.Refresh]: 10, + [TransactionType.Recoup]: 11, + [TransactionType.InternalWithdrawal]: 12, + [TransactionType.DenomLoss]: 13, +}; + +export async function getTransactionById( + wex: WalletExecutionContext, + req: TransactionByIdRequest, +): Promise<Transaction> { + const parsedTx = parseTransactionIdentifier(req.transactionId); + + if (!parsedTx) { + throw Error("invalid transaction ID"); + } + + switch (parsedTx.tag) { + case TransactionType.InternalWithdrawal: + case TransactionType.Withdrawal: { + const withdrawalGroupId = parsedTx.withdrawalGroupId; + return await wex.db.runReadWriteTx( + { + storeNames: [ + "withdrawalGroups", + "exchangeDetails", + "exchanges", + "operationRetries", + ], + }, + 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 getExchangeWireDetailsInTx( + tx, + withdrawalGroupRecord.exchangeBaseUrl, + ); + if (!exchangeDetails) throw Error("not exchange details"); + + return buildTransactionForManualWithdraw( + withdrawalGroupRecord, + exchangeDetails, + ort, + ); + }, + ); + } + + case TransactionType.DenomLoss: { + const rec = await wex.db.runReadOnlyTx( + { storeNames: ["denomLossEvents"] }, + async (tx) => { + return tx.denomLossEvents.get(parsedTx.denomLossEventId); + }, + ); + if (!rec) { + throw Error("denom loss record not found"); + } + return buildTransactionForDenomLoss(rec); + } + + case TransactionType.Recoup: + throw new Error("not yet supported"); + + case TransactionType.Payment: { + const proposalId = parsedTx.proposalId; + return await wex.db.runReadWriteTx( + { + storeNames: [ + "purchases", + "tombstones", + "operationRetries", + "contractTerms", + "refundGroups", + ], + }, + async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) throw Error("not found"); + const download = await expectProposalDownload(wex, purchase, tx); + const contractData = download.contractData; + const payOpId = TaskIdentifiers.forPay(purchase); + const payRetryRecord = await tx.operationRetries.get(payOpId); + + const refunds = await tx.refundGroups.indexes.byProposalId.getAll( + purchase.proposalId, + ); + + return buildTransactionForPurchase( + purchase, + contractData, + refunds, + payRetryRecord, + ); + }, + ); + } + + case TransactionType.Refresh: { + // FIXME: We should return info about the refresh here!; + const refreshGroupId = parsedTx.refreshGroupId; + return await wex.db.runReadOnlyTx( + { storeNames: ["refreshGroups", "operationRetries"] }, + async (tx) => { + const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId); + if (!refreshGroupRec) { + throw Error("not found"); + } + const retries = await tx.operationRetries.get( + TaskIdentifiers.forRefresh(refreshGroupRec), + ); + return buildTransactionForRefresh(refreshGroupRec, retries); + }, + ); + } + + case TransactionType.Deposit: { + const depositGroupId = parsedTx.depositGroupId; + return await wex.db.runReadWriteTx( + { storeNames: ["depositGroups", "operationRetries"] }, + 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); + }, + ); + } + + case TransactionType.Refund: { + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "refundGroups", + "purchases", + "operationRetries", + "contractTerms", + ], + }, + async (tx) => { + const refundRecord = await tx.refundGroups.get( + parsedTx.refundGroupId, + ); + if (!refundRecord) { + throw Error("not found"); + } + const contractData = await lookupMaybeContractData( + tx, + refundRecord?.proposalId, + ); + return buildTransactionForRefund(refundRecord, contractData); + }, + ); + } + case TransactionType.PeerPullDebit: { + return await wex.db.runReadWriteTx( + { storeNames: ["peerPullDebit", "contractTerms"] }, + async (tx) => { + const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId); + if (!debit) throw Error("not found"); + const contractTermsRec = await tx.contractTerms.get( + debit.contractTermsHash, + ); + if (!contractTermsRec) + throw Error("contract terms for peer-pull-debit not found"); + return buildTransactionForPullPaymentDebit( + debit, + contractTermsRec.contractTermsRaw, + ); + }, + ); + } + + case TransactionType.PeerPushDebit: { + return await wex.db.runReadWriteTx( + { storeNames: ["peerPushDebit", "contractTerms"] }, + async (tx) => { + const debit = await tx.peerPushDebit.get(parsedTx.pursePub); + if (!debit) throw Error("not found"); + const ct = await tx.contractTerms.get(debit.contractTermsHash); + checkDbInvariant(!!ct); + return buildTransactionForPushPaymentDebit( + debit, + ct.contractTermsRaw, + ); + }, + ); + } + + case TransactionType.PeerPushCredit: { + const peerPushCreditId = parsedTx.peerPushCreditId; + return await wex.db.runReadWriteTx( + { + storeNames: [ + "peerPushCredit", + "contractTerms", + "withdrawalGroups", + "operationRetries", + ], + }, + async (tx) => { + const pushInc = await tx.peerPushCredit.get(peerPushCreditId); + 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, + ); + }, + ); + } + + case TransactionType.PeerPullCredit: { + const pursePub = parsedTx.pursePub; + return await wex.db.runReadWriteTx( + { + storeNames: [ + "peerPullCredit", + "contractTerms", + "withdrawalGroups", + "operationRetries", + ], + }, + async (tx) => { + const pushInc = await tx.peerPullCredit.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, + ); + }, + ); + } + } +} + +function buildTransactionForPushPaymentDebit( + pi: PeerPushDebitRecord, + contractTerms: PeerContractTerms, + ort?: OperationRetryRecord, +): Transaction { + let talerUri: string | undefined = undefined; + switch (pi.status) { + case PeerPushDebitStatus.PendingReady: + case PeerPushDebitStatus.SuspendedReady: + talerUri = stringifyPayPushUri({ + exchangeBaseUrl: pi.exchangeBaseUrl, + contractPriv: pi.contractPriv, + }); + } + const txState = computePeerPushDebitTransactionState(pi); + return { + type: TransactionType.PeerPushDebit, + txState, + txActions: computePeerPushDebitTransactionActions(pi), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(pi.totalCost)) + : pi.totalCost, + amountRaw: pi.amount, + exchangeBaseUrl: pi.exchangeBaseUrl, + info: { + expiration: contractTerms.purse_expiration, + summary: contractTerms.summary, + }, + timestamp: timestampPreciseFromDb(pi.timestampCreated), + talerUri, + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPushDebit, + pursePub: pi.pursePub, + }), + ...(ort?.lastError ? { error: ort.lastError } : {}), + }; +} + +function buildTransactionForPullPaymentDebit( + pi: PeerPullPaymentIncomingRecord, + contractTerms: PeerContractTerms, + ort?: OperationRetryRecord, +): Transaction { + const txState = computePeerPullDebitTransactionState(pi); + return { + type: TransactionType.PeerPullDebit, + txState, + txActions: computePeerPullDebitTransactionActions(pi), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(pi.amount)) + : pi.coinSel?.totalCost + ? pi.coinSel?.totalCost + : Amounts.stringify(pi.amount), + amountRaw: Amounts.stringify(pi.amount), + exchangeBaseUrl: pi.exchangeBaseUrl, + info: { + expiration: contractTerms.purse_expiration, + summary: contractTerms.summary, + }, + timestamp: timestampPreciseFromDb(pi.timestampCreated), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullDebit, + peerPullDebitId: pi.peerPullDebitId, + }), + ...(ort?.lastError ? { error: ort.lastError } : {}), + }; +} + +function buildTransactionForPeerPullCredit( + pullCredit: PeerPullCreditRecord, + 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 + ); + }); + const txState = computePeerPullCreditTransactionState(pullCredit); + return { + type: TransactionType.PeerPullCredit, + txState, + txActions: computePeerPullCreditTransactionActions(pullCredit), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount)) + : Amounts.stringify(wsr.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wsr.instructedAmount), + exchangeBaseUrl: wsr.exchangeBaseUrl, + timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp), + info: { + expiration: peerContractTerms.purse_expiration, + summary: peerContractTerms.summary, + }, + talerUri: stringifyPayPullUri({ + exchangeBaseUrl: wsr.exchangeBaseUrl, + contractPriv: wsr.wgInfo.contractPriv, + }), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub: pullCredit.pursePub, + }), + kycUrl: pullCredit.kycUrl, + ...(wsrOrt?.lastError + ? { + error: silentWithdrawalErrorForInvoice + ? undefined + : wsrOrt.lastError, + } + : {}), + }; + } + + const txState = computePeerPullCreditTransactionState(pullCredit); + return { + type: TransactionType.PeerPullCredit, + txState, + txActions: computePeerPullCreditTransactionActions(pullCredit), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount)) + : Amounts.stringify(pullCredit.estimatedAmountEffective), + amountRaw: Amounts.stringify(peerContractTerms.amount), + exchangeBaseUrl: pullCredit.exchangeBaseUrl, + timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp), + info: { + expiration: peerContractTerms.purse_expiration, + summary: peerContractTerms.summary, + }, + talerUri: stringifyPayPullUri({ + exchangeBaseUrl: pullCredit.exchangeBaseUrl, + contractPriv: pullCredit.contractPriv, + }), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPullCredit, + pursePub: pullCredit.pursePub, + }), + kycUrl: pullCredit.kycUrl, + ...(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"); + } + + const txState = computePeerPushCreditTransactionState(pushInc); + return { + type: TransactionType.PeerPushCredit, + txState, + txActions: computePeerPushCreditTransactionActions(pushInc), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount)) + : Amounts.stringify(wsr.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wsr.instructedAmount), + exchangeBaseUrl: wsr.exchangeBaseUrl, + info: { + expiration: peerContractTerms.purse_expiration, + summary: peerContractTerms.summary, + }, + timestamp: timestampPreciseFromDb(wsr.timestampStart), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId: pushInc.peerPushCreditId, + }), + kycUrl: pushInc.kycUrl, + ...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}), + }; + } + + const txState = computePeerPushCreditTransactionState(pushInc); + return { + type: TransactionType.PeerPushCredit, + txState, + txActions: computePeerPushCreditTransactionActions(pushInc), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount)) + : // FIXME: This is wrong, needs to consider fees! + Amounts.stringify(peerContractTerms.amount), + amountRaw: Amounts.stringify(peerContractTerms.amount), + exchangeBaseUrl: pushInc.exchangeBaseUrl, + info: { + expiration: peerContractTerms.purse_expiration, + summary: peerContractTerms.summary, + }, + kycUrl: pushInc.kycUrl, + timestamp: timestampPreciseFromDb(pushInc.timestamp), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.PeerPushCredit, + peerPushCreditId: pushInc.peerPushCreditId, + }), + ...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}), + }; +} + +function buildTransactionForBankIntegratedWithdraw( + wgRecord: WithdrawalGroupRecord, + ort?: OperationRetryRecord, +): TransactionWithdrawal { + if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) + throw Error(""); + + const txState = computeWithdrawalTransactionStatus(wgRecord); + return { + type: TransactionType.Withdrawal, + txState, + txActions: computeWithdrawalTransactionActions(wgRecord), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount)) + : Amounts.stringify(wgRecord.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(wgRecord.instructedAmount), + withdrawalDetails: { + type: WithdrawalType.TalerBankIntegrationApi, + confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false, + exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts, + reservePub: wgRecord.reservePub, + bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl, + reserveIsReady: + wgRecord.status === WithdrawalGroupStatus.Done || + wgRecord.status === WithdrawalGroupStatus.PendingReady, + }, + kycUrl: wgRecord.kycUrl, + exchangeBaseUrl: wgRecord.exchangeBaseUrl, + timestamp: timestampPreciseFromDb(wgRecord.timestampStart), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: wgRecord.withdrawalGroupId, + }), + ...(ort?.lastError ? { error: ort.lastError } : {}), + }; +} + +export function isUnsuccessfulTransaction(state: TransactionState): boolean { + return ( + state.major === TransactionMajorState.Aborted || + state.major === TransactionMajorState.Expired || + state.major === TransactionMajorState.Aborting || + state.major === TransactionMajorState.Deleted || + state.major === TransactionMajorState.Failed + ); +} + +function buildTransactionForManualWithdraw( + withdrawalGroup: WithdrawalGroupRecord, + exchangeDetails: ExchangeWireDetails, + ort?: OperationRetryRecord, +): TransactionWithdrawal { + 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, + ); + + const txState = computeWithdrawalTransactionStatus(withdrawalGroup); + + return { + type: TransactionType.Withdrawal, + txState, + txActions: computeWithdrawalTransactionActions(withdrawalGroup), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify( + Amounts.zeroOfAmount(withdrawalGroup.instructedAmount), + ) + : Amounts.stringify(withdrawalGroup.denomsSel.totalCoinValue), + amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount), + withdrawalDetails: { + type: WithdrawalType.ManualTransfer, + reservePub: withdrawalGroup.reservePub, + exchangePaytoUris, + exchangeCreditAccountDetails: + withdrawalGroup.wgInfo.exchangeCreditAccounts, + reserveIsReady: + withdrawalGroup.status === WithdrawalGroupStatus.Done || + withdrawalGroup.status === WithdrawalGroupStatus.PendingReady, + }, + kycUrl: withdrawalGroup.kycUrl, + exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl, + timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Withdrawal, + withdrawalGroupId: withdrawalGroup.withdrawalGroupId, + }), + ...(ort?.lastError ? { error: ort.lastError } : {}), + }; +} + +function buildTransactionForRefund( + refundRecord: RefundGroupRecord, + maybeContractData: WalletContractData | undefined, +): Transaction { + let paymentInfo: RefundPaymentInfo | undefined = undefined; + + if (maybeContractData) { + paymentInfo = { + merchant: maybeContractData.merchant, + summary: maybeContractData.summary, + summary_i18n: maybeContractData.summaryI18n, + }; + } + + const txState = computeRefundTransactionState(refundRecord); + return { + type: TransactionType.Refund, + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective)) + : refundRecord.amountEffective, + amountRaw: refundRecord.amountRaw, + refundedTransactionId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: refundRecord.proposalId, + }), + timestamp: timestampPreciseFromDb(refundRecord.timestampCreated), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Refund, + refundGroupId: refundRecord.refundGroupId, + }), + txState, + txActions: [], + paymentInfo, + }; +} + +function buildTransactionForRefresh( + refreshGroupRecord: RefreshGroupRecord, + ort?: OperationRetryRecord, +): Transaction { + const inputAmount = Amounts.sumOrZero( + refreshGroupRecord.currency, + refreshGroupRecord.inputPerCoin, + ).amount; + const outputAmount = Amounts.sumOrZero( + refreshGroupRecord.currency, + refreshGroupRecord.expectedOutputPerCoin, + ).amount; + const txState = computeRefreshTransactionState(refreshGroupRecord); + return { + type: TransactionType.Refresh, + txState, + txActions: computeRefreshTransactionActions(refreshGroupRecord), + refreshReason: refreshGroupRecord.reason, + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(inputAmount)) + : Amounts.stringify(Amounts.sub(outputAmount, inputAmount).amount), + amountRaw: Amounts.stringify( + Amounts.zeroOfCurrency(refreshGroupRecord.currency), + ), + refreshInputAmount: Amounts.stringify(inputAmount), + refreshOutputAmount: Amounts.stringify(outputAmount), + originatingTransactionId: refreshGroupRecord.originatingTransactionId, + timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Refresh, + refreshGroupId: refreshGroupRecord.refreshGroupId, + }), + ...(ort?.lastError ? { error: ort.lastError } : {}), + }; +} + +function buildTransactionForDenomLoss(rec: DenomLossEventRecord): Transaction { + const txState = computeDenomLossTransactionStatus(rec); + return { + type: TransactionType.DenomLoss, + txState, + txActions: [TransactionAction.Delete], + amountRaw: Amounts.stringify(rec.amount), + amountEffective: Amounts.stringify(rec.amount), + timestamp: timestampPreciseFromDb(rec.timestampCreated), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.DenomLoss, + denomLossEventId: rec.denomLossEventId, + }), + lossEventType: rec.eventType, + exchangeBaseUrl: rec.exchangeBaseUrl, + }; +} + +function buildTransactionForDeposit( + dg: DepositGroupRecord, + ort?: OperationRetryRecord, +): Transaction { + let deposited = true; + if (dg.statusPerCoin) { + for (const d of dg.statusPerCoin) { + if (d == DepositElementStatus.DepositPending) { + deposited = false; + } + } + } else { + deposited = false; + } + + const trackingState: DepositTransactionTrackingState[] = []; + + for (const ts of Object.values(dg.trackingState ?? {})) { + trackingState.push({ + amountRaw: ts.amountRaw, + timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted), + wireFee: ts.wireFee, + wireTransferId: ts.wireTransferId, + }); + } + + let wireTransferProgress = 0; + if (dg.statusPerCoin) { + wireTransferProgress = + (100 * + dg.statusPerCoin.reduce( + (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0), + 0, + )) / + dg.statusPerCoin.length; + } + + const txState = computeDepositTransactionStatus(dg); + return { + type: TransactionType.Deposit, + txState, + txActions: computeDepositTransactionActions(dg), + amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost)) + : Amounts.stringify(dg.totalPayCost), + timestamp: timestampPreciseFromDb(dg.timestampCreated), + targetPaytoUri: dg.wire.payto_uri, + wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Deposit, + depositGroupId: dg.depositGroupId, + }), + wireTransferProgress, + depositGroupId: dg.depositGroupId, + trackingState, + deposited, + ...(ort?.lastError ? { error: ort.lastError } : {}), + }; +} + +async function lookupMaybeContractData( + tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>, + proposalId: string, +): Promise<WalletContractData | undefined> { + let contractData: WalletContractData | undefined = undefined; + const purchaseTx = await tx.purchases.get(proposalId); + if (purchaseTx && purchaseTx.download) { + const download = purchaseTx.download; + const contractTermsRecord = await tx.contractTerms.get( + download.contractTermsHash, + ); + if (!contractTermsRecord) { + return; + } + contractData = extractContractData( + contractTermsRecord?.contractTermsRaw, + download.contractTermsHash, + download.contractTermsMerchantSig, + ); + } + + return contractData; +} + +async function buildTransactionForPurchase( + purchaseRecord: PurchaseRecord, + contractData: WalletContractData, + refundsInfo: RefundGroupRecord[], + ort?: OperationRetryRecord, +): Promise<Transaction> { + const zero = Amounts.zeroOfAmount(contractData.amount); + + const info: OrderShortInfo = { + merchant: contractData.merchant, + orderId: contractData.orderId, + summary: contractData.summary, + summary_i18n: contractData.summaryI18n, + contractTermsHash: contractData.contractTermsHash, + }; + + if (contractData.fulfillmentUrl !== "") { + info.fulfillmentUrl = contractData.fulfillmentUrl; + } + + const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({ + amountEffective: r.amountEffective, + amountRaw: r.amountRaw, + timestamp: TalerPreciseTimestamp.round( + timestampPreciseFromDb(r.timestampCreated), + ), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Refund, + refundGroupId: r.refundGroupId, + }), + })); + + const timestamp = purchaseRecord.timestampAccept; + checkDbInvariant(!!timestamp); + checkDbInvariant(!!purchaseRecord.payInfo); + + const txState = computePayMerchantTransactionState(purchaseRecord); + return { + type: TransactionType.Payment, + txState, + txActions: computePayMerchantTransactionActions(purchaseRecord), + amountRaw: Amounts.stringify(contractData.amount), + amountEffective: isUnsuccessfulTransaction(txState) + ? Amounts.stringify(zero) + : Amounts.stringify(purchaseRecord.payInfo.totalPayCost), + totalRefundRaw: Amounts.stringify(zero), // FIXME! + totalRefundEffective: Amounts.stringify(zero), // FIXME! + refundPending: + purchaseRecord.refundAmountAwaiting === undefined + ? undefined + : Amounts.stringify(purchaseRecord.refundAmountAwaiting), + refunds, + posConfirmation: purchaseRecord.posConfirmation, + timestamp: timestampPreciseFromDb(timestamp), + transactionId: constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: purchaseRecord.proposalId, + }), + proposalId: purchaseRecord.proposalId, + info, + refundQueryActive: + purchaseRecord.purchaseStatus === PurchaseStatus.PendingQueryingRefund, + ...(ort?.lastError ? { error: ort.lastError } : {}), + }; +} + +export async function getWithdrawalTransactionByUri( + wex: WalletExecutionContext, + request: WithdrawalTransactionByURIRequest, +): Promise<TransactionWithdrawal | undefined> { + return await wex.db.runReadWriteTx( + { + storeNames: [ + "withdrawalGroups", + "exchangeDetails", + "exchanges", + "operationRetries", + ], + }, + async (tx) => { + const withdrawalGroupRecord = + await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + request.talerWithdrawUri, + ); + + if (!withdrawalGroupRecord) { + return undefined; + } + + 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 getExchangeWireDetailsInTx( + tx, + withdrawalGroupRecord.exchangeBaseUrl, + ); + if (!exchangeDetails) throw Error("not exchange details"); + + return buildTransactionForManualWithdraw( + withdrawalGroupRecord, + exchangeDetails, + ort, + ); + }, + ); +} + +/** + * Retrieve the full event history for this wallet. + */ +export async function getTransactions( + wex: WalletExecutionContext, + transactionsRequest?: TransactionsRequest, +): Promise<TransactionsResponse> { + const transactions: Transaction[] = []; + + const filter: TransactionRecordFilter = {}; + if (transactionsRequest?.filterByState) { + filter.onlyState = transactionsRequest.filterByState; + } + + await wex.db.runReadOnlyTx( + { + storeNames: [ + "coins", + "denominations", + "depositGroups", + "exchangeDetails", + "exchanges", + "operationRetries", + "peerPullDebit", + "peerPushDebit", + "peerPushCredit", + "peerPullCredit", + "planchets", + "purchases", + "contractTerms", + "recoupGroups", + "rewards", + "tombstones", + "withdrawalGroups", + "refreshGroups", + "refundGroups", + "denomLossEvents", + ], + }, + async (tx) => { + await iterRecordsForPeerPushDebit(tx, filter, async (pi) => { + const amount = Amounts.parseOrThrow(pi.amount); + const exchangesInTx = [pi.exchangeBaseUrl]; + if ( + shouldSkipCurrency( + transactionsRequest, + amount.currency, + exchangesInTx, + ) + ) { + return; + } + if (shouldSkipSearch(transactionsRequest, [])) { + return; + } + const ct = await tx.contractTerms.get(pi.contractTermsHash); + checkDbInvariant(!!ct); + transactions.push( + buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw), + ); + }); + + await iterRecordsForPeerPullDebit(tx, filter, async (pi) => { + const amount = Amounts.parseOrThrow(pi.amount); + const exchangesInTx = [pi.exchangeBaseUrl]; + if ( + shouldSkipCurrency( + transactionsRequest, + amount.currency, + exchangesInTx, + ) + ) { + return; + } + if (shouldSkipSearch(transactionsRequest, [])) { + return; + } + if ( + pi.status !== PeerPullDebitRecordStatus.PendingDeposit && + pi.status !== PeerPullDebitRecordStatus.Done + ) { + // FIXME: Why?! + return; + } + + const contractTermsRec = await tx.contractTerms.get( + pi.contractTermsHash, + ); + if (!contractTermsRec) { + return; + } + + transactions.push( + buildTransactionForPullPaymentDebit( + pi, + contractTermsRec.contractTermsRaw, + ), + ); + }); + + await iterRecordsForPeerPushCredit(tx, filter, async (pi) => { + if (!pi.currency) { + // Legacy transaction + return; + } + const exchangesInTx = [pi.exchangeBaseUrl]; + if ( + shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx) + ) { + return; + } + if (shouldSkipSearch(transactionsRequest, [])) { + return; + } + if (pi.status === PeerPushCreditStatus.DialogProposed) { + // 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, + ), + ); + }); + + await iterRecordsForPeerPullCredit(tx, filter, async (pi) => { + const currency = Amounts.currencyOf(pi.amount); + const exchangesInTx = [pi.exchangeBaseUrl]; + if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) { + 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, + ), + ); + }); + + await iterRecordsForRefund(tx, filter, async (refundGroup) => { + const currency = Amounts.currencyOf(refundGroup.amountRaw); + + const exchangesInTx: string[] = []; + const p = await tx.purchases.get(refundGroup.proposalId); + if (!p || !p.payInfo || !p.payInfo.payCoinSelection) { + //refund with no payment + return; + } + + // FIXME: This is very slow, should become obsolete with materialized transactions. + for (const cp of p.payInfo.payCoinSelection.coinPubs) { + const c = await tx.coins.get(cp); + if (c?.exchangeBaseUrl) { + exchangesInTx.push(c.exchangeBaseUrl); + } + } + + if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) { + return; + } + const contractData = await lookupMaybeContractData( + tx, + refundGroup.proposalId, + ); + transactions.push(buildTransactionForRefund(refundGroup, contractData)); + }); + + await iterRecordsForRefresh(tx, filter, async (rg) => { + const exchangesInTx = rg.infoPerExchange + ? Object.keys(rg.infoPerExchange) + : []; + if ( + shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx) + ) { + 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)); + } + }); + + await iterRecordsForWithdrawal(tx, filter, async (wsr) => { + const exchangesInTx = [wsr.exchangeBaseUrl]; + if ( + shouldSkipCurrency( + transactionsRequest, + Amounts.currencyOf(wsr.rawWithdrawalAmount), + exchangesInTx, + ) + ) { + 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 getExchangeWireDetailsInTx( + 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; + } + }); + + await iterRecordsForDenomLoss(tx, filter, async (rec) => { + const amount = Amounts.parseOrThrow(rec.amount); + const exchangesInTx = [rec.exchangeBaseUrl]; + if ( + shouldSkipCurrency( + transactionsRequest, + amount.currency, + exchangesInTx, + ) + ) { + return; + } + transactions.push(buildTransactionForDenomLoss(rec)); + }); + + await iterRecordsForDeposit(tx, filter, async (dg) => { + const amount = Amounts.parseOrThrow(dg.amount); + const exchangesInTx = dg.infoPerExchange + ? Object.keys(dg.infoPerExchange) + : []; + if ( + shouldSkipCurrency( + transactionsRequest, + amount.currency, + exchangesInTx, + ) + ) { + return; + } + const opId = TaskIdentifiers.forDeposit(dg); + const retryRecord = await tx.operationRetries.get(opId); + + transactions.push(buildTransactionForDeposit(dg, retryRecord)); + }); + + await iterRecordsForPurchase(tx, filter, async (purchase) => { + const download = purchase.download; + if (!download) { + return; + } + if (!purchase.payInfo) { + return; + } + + const exchangesInTx: string[] = []; + for (const cp of purchase.payInfo.payCoinSelection?.coinPubs ?? []) { + const c = await tx.coins.get(cp); + if (c?.exchangeBaseUrl) { + exchangesInTx.push(c.exchangeBaseUrl); + } + } + + if ( + shouldSkipCurrency( + transactionsRequest, + download.currency, + exchangesInTx, + ) + ) { + 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 payOpId = TaskIdentifiers.forPay(purchase); + const payRetryRecord = await tx.operationRetries.get(payOpId); + + const refunds = await tx.refundGroups.indexes.byProposalId.getAll( + purchase.proposalId, + ); + + transactions.push( + await buildTransactionForPurchase( + purchase, + contractData, + refunds, + payRetryRecord, + ), + ); + }); + }, + ); + + // 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 isPending = (x: Transaction) => + x.txState.major === TransactionMajorState.Pending || + x.txState.major === TransactionMajorState.Aborting || + x.txState.major === TransactionMajorState.Dialog; + + let sortSign: number; + if (transactionsRequest?.sort == "descending") { + sortSign = -1; + } else { + sortSign = 1; + } + + const txCmp = (h1: Transaction, h2: Transaction) => { + // Order transactions by timestamp. Newest transactions come first. + const tsCmp = AbsoluteTime.cmp( + AbsoluteTime.fromPreciseTimestamp(h1.timestamp), + AbsoluteTime.fromPreciseTimestamp(h2.timestamp), + ); + // If the timestamp is exactly the same, order by transaction type. + if (tsCmp === 0) { + return Math.sign(txOrder[h1.type] - txOrder[h2.type]); + } + return sortSign * tsCmp; + }; + + if (transactionsRequest?.sort === "stable-ascending") { + transactions.sort(txCmp); + return { transactions }; + } + + const txPending = transactions.filter((x) => isPending(x)); + const txNotPending = transactions.filter((x) => !isPending(x)); + + txPending.sort(txCmp); + txNotPending.sort(txCmp); + + return { transactions: [...txPending, ...txNotPending] }; +} + +export type ParsedTransactionIdentifier = + | { tag: TransactionType.Deposit; depositGroupId: string } + | { tag: TransactionType.Payment; proposalId: string } + | { tag: TransactionType.PeerPullDebit; peerPullDebitId: string } + | { tag: TransactionType.PeerPullCredit; pursePub: string } + | { tag: TransactionType.PeerPushCredit; peerPushCreditId: string } + | { tag: TransactionType.PeerPushDebit; pursePub: string } + | { tag: TransactionType.Refresh; refreshGroupId: string } + | { tag: TransactionType.Refund; refundGroupId: string } + | { tag: TransactionType.Withdrawal; withdrawalGroupId: string } + | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string } + | { tag: TransactionType.Recoup; recoupGroupId: string } + | { tag: TransactionType.DenomLoss; denomLossEventId: string }; + +export function constructTransactionIdentifier( + pTxId: ParsedTransactionIdentifier, +): TransactionIdStr { + switch (pTxId.tag) { + case TransactionType.Deposit: + return `txn:${pTxId.tag}:${pTxId.depositGroupId}` as TransactionIdStr; + case TransactionType.Payment: + return `txn:${pTxId.tag}:${pTxId.proposalId}` as TransactionIdStr; + case TransactionType.PeerPullCredit: + return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr; + case TransactionType.PeerPullDebit: + return `txn:${pTxId.tag}:${pTxId.peerPullDebitId}` as TransactionIdStr; + case TransactionType.PeerPushCredit: + return `txn:${pTxId.tag}:${pTxId.peerPushCreditId}` as TransactionIdStr; + case TransactionType.PeerPushDebit: + return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr; + case TransactionType.Refresh: + return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr; + case TransactionType.Refund: + return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr; + case TransactionType.Withdrawal: + return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr; + case TransactionType.InternalWithdrawal: + return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr; + case TransactionType.Recoup: + return `txn:${pTxId.tag}:${pTxId.recoupGroupId}` as TransactionIdStr; + case TransactionType.DenomLoss: + return `txn:${pTxId.tag}:${pTxId.denomLossEventId}` as TransactionIdStr; + default: + assertUnreachable(pTxId); + } +} + +/** + * Parse a transaction identifier string into a typed, structured representation. + */ +export function parseTransactionIdentifier( + transactionId: string, +): ParsedTransactionIdentifier | undefined { + const txnParts = transactionId.split(":"); + + if (txnParts.length < 3) { + throw Error("id should have al least 3 parts separated by ':'"); + } + + const [prefix, type, ...rest] = txnParts; + + if (prefix != "txn") { + throw Error("invalid transaction identifier"); + } + + 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, + peerPullDebitId: rest[0], + }; + case TransactionType.PeerPushCredit: + return { + tag: TransactionType.PeerPushCredit, + peerPushCreditId: 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, + refundGroupId: rest[0], + }; + case TransactionType.Withdrawal: + return { + tag: TransactionType.Withdrawal, + withdrawalGroupId: rest[0], + }; + case TransactionType.DenomLoss: + return { + tag: TransactionType.DenomLoss, + denomLossEventId: rest[0], + }; + default: + return undefined; + } +} + +function maybeTaskFromTransaction( + transactionId: string, +): TaskIdStr | undefined { + 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: + return constructTaskIdentifier({ + tag: PendingTaskType.PeerPullCredit, + pursePub: parsedTx.pursePub, + }); + case TransactionType.Deposit: + return constructTaskIdentifier({ + tag: PendingTaskType.Deposit, + depositGroupId: parsedTx.depositGroupId, + }); + case TransactionType.InternalWithdrawal: + case TransactionType.Withdrawal: + return constructTaskIdentifier({ + tag: PendingTaskType.Withdraw, + withdrawalGroupId: parsedTx.withdrawalGroupId, + }); + case TransactionType.Payment: + return constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId: parsedTx.proposalId, + }); + case TransactionType.Refresh: + return constructTaskIdentifier({ + tag: PendingTaskType.Refresh, + refreshGroupId: parsedTx.refreshGroupId, + }); + case TransactionType.PeerPullDebit: + return constructTaskIdentifier({ + tag: PendingTaskType.PeerPullDebit, + peerPullDebitId: parsedTx.peerPullDebitId, + }); + case TransactionType.PeerPushCredit: + return constructTaskIdentifier({ + tag: PendingTaskType.PeerPushCredit, + peerPushCreditId: parsedTx.peerPushCreditId, + }); + case TransactionType.PeerPushDebit: + return constructTaskIdentifier({ + tag: PendingTaskType.PeerPushDebit, + pursePub: parsedTx.pursePub, + }); + case TransactionType.Refund: + // Nothing to do for a refund transaction. + return undefined; + case TransactionType.Recoup: + return constructTaskIdentifier({ + tag: PendingTaskType.Recoup, + recoupGroupId: parsedTx.recoupGroupId, + }); + case TransactionType.DenomLoss: + // Nothing to do for denom loss + return undefined; + default: + assertUnreachable(parsedTx); + } +} + +/** + * Immediately retry the underlying operation + * of a transaction. + */ +export async function retryTransaction( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + logger.info(`resetting retry timeout for ${transactionId}`); + const taskId = maybeTaskFromTransaction(transactionId); + if (taskId) { + wex.taskScheduler.resetTaskRetries(taskId); + } +} + +async function getContextForTransaction( + wex: WalletExecutionContext, + transactionId: string, +): Promise<TransactionContext> { + const tx = parseTransactionIdentifier(transactionId); + if (!tx) { + throw Error("invalid transaction ID"); + } + switch (tx.tag) { + case TransactionType.Deposit: + return new DepositTransactionContext(wex, tx.depositGroupId); + case TransactionType.Refresh: + return new RefreshTransactionContext(wex, tx.refreshGroupId); + case TransactionType.InternalWithdrawal: + case TransactionType.Withdrawal: + return new WithdrawTransactionContext(wex, tx.withdrawalGroupId); + case TransactionType.Payment: + return new PayMerchantTransactionContext(wex, tx.proposalId); + case TransactionType.PeerPullCredit: + return new PeerPullCreditTransactionContext(wex, tx.pursePub); + case TransactionType.PeerPushDebit: + return new PeerPushDebitTransactionContext(wex, tx.pursePub); + case TransactionType.PeerPullDebit: + return new PeerPullDebitTransactionContext(wex, tx.peerPullDebitId); + case TransactionType.PeerPushCredit: + return new PeerPushCreditTransactionContext(wex, tx.peerPushCreditId); + case TransactionType.Refund: + return new RefundTransactionContext(wex, tx.refundGroupId); + case TransactionType.Recoup: + //return new RecoupTransactionContext(ws, tx.recoupGroupId); + throw new Error("not yet supported"); + case TransactionType.DenomLoss: + return new DenomLossTransactionContext(wex, tx.denomLossEventId); + default: + assertUnreachable(tx); + } +} + +/** + * 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( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.suspendTransaction(); +} + +export async function failTransaction( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.failTransaction(); +} + +/** + * Resume a suspended transaction. + */ +export async function resumeTransaction( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.resumeTransaction(); +} + +/** + * Permanently delete a transaction based on the transaction ID. + */ +export async function deleteTransaction( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.deleteTransaction(); + if (ctx.taskId) { + wex.taskScheduler.stopShepherdTask(ctx.taskId); + } +} + +export async function abortTransaction( + wex: WalletExecutionContext, + transactionId: string, +): Promise<void> { + const ctx = await getContextForTransaction(wex, transactionId); + await ctx.abortTransaction(); +} + +export interface TransitionInfo { + oldTxState: TransactionState; + newTxState: TransactionState; +} + +/** + * Notify of a state transition if necessary. + */ +export function notifyTransition( + wex: WalletExecutionContext, + transactionId: string, + transitionInfo: TransitionInfo | undefined, + experimentalUserData: any = undefined, +): void { + if ( + transitionInfo && + !( + transitionInfo.oldTxState.major === transitionInfo.newTxState.major && + transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor + ) + ) { + wex.ws.notify({ + type: NotificationType.TransactionStateTransition, + oldTxState: transitionInfo.oldTxState, + newTxState: transitionInfo.newTxState, + transactionId, + experimentalUserData, + }); + } +} + +/** + * Iterate refresh records based on a filter. + */ +async function iterRecordsForRefresh( + tx: WalletDbReadOnlyTransaction<["refreshGroups"]>, + filter: TransactionRecordFilter, + f: (r: RefreshGroupRecord) => Promise<void>, +): Promise<void> { + let refreshGroups: RefreshGroupRecord[]; + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + RefreshOperationStatus.Pending, + RefreshOperationStatus.Suspended, + ); + refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange); + } else { + refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(); + } + + for (const r of refreshGroups) { + await f(r); + } +} + +async function iterRecordsForWithdrawal( + tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>, + filter: TransactionRecordFilter, + f: (r: WithdrawalGroupRecord) => Promise<void>, +): Promise<void> { + let withdrawalGroupRecords: WithdrawalGroupRecord[]; + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + withdrawalGroupRecords = + await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange); + } else { + withdrawalGroupRecords = + await tx.withdrawalGroups.indexes.byStatus.getAll(); + } + for (const wgr of withdrawalGroupRecords) { + await f(wgr); + } +} + +async function iterRecordsForDeposit( + tx: WalletDbReadOnlyTransaction<["depositGroups"]>, + filter: TransactionRecordFilter, + f: (r: DepositGroupRecord) => Promise<void>, +): Promise<void> { + let dgs: DepositGroupRecord[]; + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange); + } else { + dgs = await tx.depositGroups.indexes.byStatus.getAll(); + } + + for (const dg of dgs) { + await f(dg); + } +} + +async function iterRecordsForDenomLoss( + tx: WalletDbReadOnlyTransaction<["denomLossEvents"]>, + filter: TransactionRecordFilter, + f: (r: DenomLossEventRecord) => Promise<void>, +): Promise<void> { + let dgs: DenomLossEventRecord[]; + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange); + } else { + dgs = await tx.denomLossEvents.indexes.byStatus.getAll(); + } + + for (const dg of dgs) { + await f(dg); + } +} + +async function iterRecordsForRefund( + tx: WalletDbReadOnlyTransaction<["refundGroups"]>, + filter: TransactionRecordFilter, + f: (r: RefundGroupRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.refundGroups.iter().forEachAsync(f); + } +} + +async function iterRecordsForPurchase( + tx: WalletDbReadOnlyTransaction<["purchases"]>, + filter: TransactionRecordFilter, + f: (r: PurchaseRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.purchases.indexes.byStatus.iter().forEachAsync(f); + } +} + +async function iterRecordsForPeerPullCredit( + tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>, + filter: TransactionRecordFilter, + f: (r: PeerPullCreditRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f); + } +} + +async function iterRecordsForPeerPullDebit( + tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>, + filter: TransactionRecordFilter, + f: (r: PeerPullPaymentIncomingRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f); + } +} + +async function iterRecordsForPeerPushDebit( + tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>, + filter: TransactionRecordFilter, + f: (r: PeerPushDebitRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f); + } +} + +async function iterRecordsForPeerPushCredit( + tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>, + filter: TransactionRecordFilter, + f: (r: PeerPushPaymentIncomingRecord) => Promise<void>, +): Promise<void> { + if (filter.onlyState === "nonfinal") { + const keyRange = GlobalIDB.KeyRange.bound( + OPERATION_STATUS_ACTIVE_FIRST, + OPERATION_STATUS_ACTIVE_LAST, + ); + await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); + } else { + await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f); + } +} |