/*
 This file is part of GNU Taler
 (C) 2019 Taler Systems S.A.

 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.

 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

/**
 * Imports.
 */
import {
  AbsoluteTime,
  Amounts,
  j2s,
  Logger,
  NotificationType,
  OrderShortInfo,
  PeerContractTerms,
  RefundInfoShort,
  RefundPaymentInfo,
  stringifyPayPullUri,
  stringifyPayPushUri,
  TalerErrorCode,
  TalerPreciseTimestamp,
  TalerProtocolTimestamp,
  Transaction,
  TransactionByIdRequest,
  TransactionIdStr,
  TransactionMajorState,
  TransactionsRequest,
  TransactionsResponse,
  TransactionState,
  TransactionType,
  WithdrawalType,
} from "@gnu-taler/taler-util";
import {
  DepositElementStatus,
  DepositGroupRecord,
  ExchangeDetailsRecord,
  OperationRetryRecord,
  PeerPullPaymentIncomingRecord,
  PeerPullDebitRecordStatus,
  PeerPullPaymentInitiationRecord,
  PeerPushPaymentIncomingRecord,
  PeerPushPaymentIncomingStatus,
  PeerPushPaymentInitiationRecord,
  PurchaseRecord,
  PurchaseStatus,
  RefreshGroupRecord,
  RefreshOperationStatus,
  RefundGroupRecord,
  TipRecord,
  WalletContractData,
  WithdrawalGroupRecord,
  WithdrawalGroupStatus,
  WithdrawalRecordType,
} from "../db.js";
import { GetReadOnlyAccess, WalletStoresV1 } from "../index.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,
  resetPendingTaskTimeout,
  TaskIdentifiers,
  TombstoneTag,
} from "./common.js";
import {
  abortDepositGroup,
  failDepositTransaction,
  computeDepositTransactionStatus,
  deleteDepositGroup,
  resumeDepositGroup,
  suspendDepositGroup,
  computeDepositTransactionActions,
} from "./deposits.js";
import { getExchangeDetails } from "./exchanges.js";
import {
  abortPayMerchant,
  failPaymentTransaction,
  computePayMerchantTransactionState,
  computeRefundTransactionState,
  expectProposalDownload,
  extractContractData,
  resumePayMerchant,
  suspendPayMerchant,
  computePayMerchantTransactionActions,
} from "./pay-merchant.js";
import {
  abortRefreshGroup,
  failRefreshGroup,
  computeRefreshTransactionState,
  resumeRefreshGroup,
  suspendRefreshGroup,
  computeRefreshTransactionActions,
} from "./refresh.js";
import {
  abortTipTransaction,
  failTipTransaction,
  computeTipTransactionStatus,
  resumeTipTransaction,
  suspendTipTransaction,
  computeTipTransactionActions,
} from "./tip.js";
import {
  abortWithdrawalTransaction,
  augmentPaytoUrisForWithdrawal,
  failWithdrawalTransaction,
  computeWithdrawalTransactionStatus,
  resumeWithdrawalTransaction,
  suspendWithdrawalTransaction,
  computeWithdrawalTransactionActions,
} from "./withdraw.js";
import {
  computePeerPullCreditTransactionState,
  computePeerPullCreditTransactionActions,
  suspendPeerPullCreditTransaction,
  failPeerPullCreditTransaction,
  resumePeerPullCreditTransaction,
  abortPeerPullCreditTransaction,
} from "./pay-peer-pull-credit.js";
import {
  computePeerPullDebitTransactionState,
  computePeerPullDebitTransactionActions,
  suspendPeerPullDebitTransaction,
  failPeerPullDebitTransaction,
  resumePeerPullDebitTransaction,
  abortPeerPullDebitTransaction,
} from "./pay-peer-pull-debit.js";
import {
  computePeerPushCreditTransactionState,
  computePeerPushCreditTransactionActions,
  suspendPeerPushCreditTransaction,
  failPeerPushCreditTransaction,
  resumePeerPushCreditTransaction,
  abortPeerPushCreditTransaction,
} from "./pay-peer-push-credit.js";
import {
  computePeerPushDebitTransactionState,
  computePeerPushDebitTransactionActions,
  suspendPeerPushDebitTransaction,
  failPeerPushDebitTransaction,
  resumePeerPushDebitTransaction,
  abortPeerPushDebitTransaction,
} from "./pay-peer-push-debit.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.InternalWithdrawal]: 12,
};

export async function getTransactionById(
  ws: InternalWalletState,
  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 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,
          );
        });
    }

    case TransactionType.Payment: {
      const proposalId = parsedTx.proposalId;
      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 download = await expectProposalDownload(ws, purchase, tx);
          const contractData = download.contractData;
          const payOpId = TaskIdentifiers.forPay(purchase);
          const payRetryRecord = await tx.operationRetries.get(payOpId);

          return buildTransactionForPurchase(
            purchase,
            contractData,
            [], // FIXME: Add refunds from refund group records here.
            payRetryRecord,
          );
        });
    }

    case TransactionType.Refresh: {
      // FIXME: We should return info about the refresh here!
      throw Error(`no tx for refresh`);
    }

    case TransactionType.Tip: {
      const tipId = parsedTx.walletTipId;
      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);
        });
    }

    case TransactionType.Deposit: {
      const depositGroupId = parsedTx.depositGroupId;
      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);
        });
    }

    case TransactionType.Refund: {
      return await ws.db
        .mktx((x) => [x.refundGroups, x.contractTerms, x.purchases])
        .runReadOnly(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 ws.db
        .mktx((x) => [x.peerPullPaymentIncoming])
        .runReadWrite(async (tx) => {
          const debit = await tx.peerPullPaymentIncoming.get(
            parsedTx.peerPullPaymentIncomingId,
          );
          if (!debit) throw Error("not found");
          return buildTransactionForPullPaymentDebit(debit);
        });
    }

    case TransactionType.PeerPushDebit: {
      return await ws.db
        .mktx((x) => [x.peerPushPaymentInitiations, x.contractTerms])
        .runReadWrite(async (tx) => {
          const debit = await tx.peerPushPaymentInitiations.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 peerPushPaymentIncomingId = parsedTx.peerPushPaymentIncomingId;
      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,
          );
        });
    }

    case TransactionType.PeerPullCredit: {
      const pursePub = parsedTx.pursePub;
      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,
          );
        });
    }
  }
}

function buildTransactionForPushPaymentDebit(
  pi: PeerPushPaymentInitiationRecord,
  contractTerms: PeerContractTerms,
  ort?: OperationRetryRecord,
): Transaction {
  return {
    type: TransactionType.PeerPushDebit,
    txState: computePeerPushDebitTransactionState(pi),
    txActions: computePeerPushDebitTransactionActions(pi),
    amountEffective: pi.totalCost,
    amountRaw: pi.amount,
    exchangeBaseUrl: pi.exchangeBaseUrl,
    info: {
      expiration: contractTerms.purse_expiration,
      summary: contractTerms.summary,
    },
    timestamp: pi.timestampCreated,
    talerUri: stringifyPayPushUri({
      exchangeBaseUrl: pi.exchangeBaseUrl,
      contractPriv: pi.contractPriv,
    }),
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.PeerPushDebit,
      pursePub: pi.pursePub,
    }),
    ...(ort?.lastError ? { error: ort.lastError } : {}),
  };
}

function buildTransactionForPullPaymentDebit(
  pi: PeerPullPaymentIncomingRecord,
  ort?: OperationRetryRecord,
): Transaction {
  return {
    type: TransactionType.PeerPullDebit,
    txState: computePeerPullDebitTransactionState(pi),
    txActions: computePeerPullDebitTransactionActions(pi),
    amountEffective: pi.coinSel?.totalCost
      ? pi.coinSel?.totalCost
      : Amounts.stringify(pi.contractTerms.amount),
    amountRaw: Amounts.stringify(pi.contractTerms.amount),
    exchangeBaseUrl: pi.exchangeBaseUrl,
    info: {
      expiration: pi.contractTerms.purse_expiration,
      summary: pi.contractTerms.summary,
    },
    timestamp: pi.timestampCreated,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.PeerPullDebit,
      peerPullPaymentIncomingId: 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: computePeerPullCreditTransactionState(pullCredit),
      txActions: computePeerPullCreditTransactionActions(pullCredit),
      amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
      amountRaw: Amounts.stringify(wsr.instructedAmount),
      exchangeBaseUrl: wsr.exchangeBaseUrl,
      // Old transactions don't have it!
      timestamp: pullCredit.mergeTimestamp ?? TalerPreciseTimestamp.now(),
      info: {
        expiration: wsr.wgInfo.contractTerms.purse_expiration,
        summary: wsr.wgInfo.contractTerms.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,
          }
        : {}),
    };
  }

  return {
    type: TransactionType.PeerPullCredit,
    txState: computePeerPullCreditTransactionState(pullCredit),
    txActions: computePeerPullCreditTransactionActions(pullCredit),
    amountEffective: Amounts.stringify(pullCredit.estimatedAmountEffective),
    amountRaw: Amounts.stringify(peerContractTerms.amount),
    exchangeBaseUrl: pullCredit.exchangeBaseUrl,
    // Old transactions don't have it!
    timestamp: pullCredit.mergeTimestamp ?? TalerProtocolTimestamp.now(),
    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");
    }

    return {
      type: TransactionType.PeerPushCredit,
      txState: computePeerPushCreditTransactionState(pushInc),
      txActions: computePeerPushCreditTransactionActions(pushInc),
      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,
      },
      timestamp: wsr.timestampStart,
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.PeerPushCredit,
        peerPushPaymentIncomingId: pushInc.peerPushPaymentIncomingId,
      }),
      kycUrl: pushInc.kycUrl,
      ...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}),
    };
  }

  return {
    type: TransactionType.PeerPushCredit,
    txState: computePeerPushCreditTransactionState(pushInc),
    txActions: computePeerPushCreditTransactionActions(pushInc),
    // 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,
    },
    kycUrl: pushInc.kycUrl,
    timestamp: pushInc.timestamp,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.PeerPushCredit,
      peerPushPaymentIncomingId: pushInc.peerPushPaymentIncomingId,
    }),
    ...(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),
    txActions: computeWithdrawalTransactionActions(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.PendingReady,
    },
    kycUrl: wgRecord.kycUrl,
    exchangeBaseUrl: wgRecord.exchangeBaseUrl,
    timestamp: wgRecord.timestampStart,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.Withdrawal,
      withdrawalGroupId: wgRecord.withdrawalGroupId,
    }),
    ...(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),
    txActions: computeWithdrawalTransactionActions(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.PendingReady,
    },
    exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
    timestamp: 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,
    };
  }

  return {
    type: TransactionType.Refund,
    amountEffective: refundRecord.amountEffective,
    amountRaw: refundRecord.amountRaw,
    refundedTransactionId: constructTransactionIdentifier({
      tag: TransactionType.Payment,
      proposalId: refundRecord.proposalId,
    }),
    timestamp: refundRecord.timestampCreated,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.Refund,
      refundGroupId: refundRecord.refundGroupId,
    }),
    txState: computeRefundTransactionState(refundRecord),
    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.estimatedOutputPerCoin,
  ).amount;
  return {
    type: TransactionType.Refresh,
    txState: computeRefreshTransactionState(refreshGroupRecord),
    txActions: computeRefreshTransactionActions(refreshGroupRecord),
    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,
    timestamp: refreshGroupRecord.timestampCreated,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.Refresh,
      refreshGroupId: refreshGroupRecord.refreshGroupId,
    }),
    ...(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),
    txActions: computeDepositTransactionActions(dg),
    amountRaw: Amounts.stringify(dg.effectiveDepositAmount),
    amountEffective: Amounts.stringify(dg.totalPayCost),
    timestamp: dg.timestampCreated,
    targetPaytoUri: dg.wire.payto_uri,
    wireTransferDeadline: dg.contractTermsRaw.wire_transfer_deadline,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.Deposit,
      depositGroupId: 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),
    txActions: computeTipTransactionActions(tipRecord),
    amountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
    amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
    timestamp: tipRecord.acceptedTimestamp,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.Tip,
      walletTipId: tipRecord.walletTipId,
    }),
    merchantBaseUrl: tipRecord.merchantBaseUrl,
    ...(ort?.lastError ? { error: ort.lastError } : {}),
  };
}

async function lookupMaybeContractData(
  tx: GetReadOnlyAccess<{
    purchases: typeof WalletStoresV1.purchases;
    contractTerms: typeof WalletStoresV1.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,
    products: contractData.products,
    summary: contractData.summary,
    summary_i18n: contractData.summaryI18n,
    contractTermsHash: contractData.contractTermsHash,
  };

  if (contractData.fulfillmentUrl !== "") {
    info.fulfillmentUrl = contractData.fulfillmentUrl;
  }

  const refunds: RefundInfoShort[] = [];

  const timestamp = purchaseRecord.timestampAccept;
  checkDbInvariant(!!timestamp);
  checkDbInvariant(!!purchaseRecord.payInfo);

  return {
    type: TransactionType.Payment,
    txState: computePayMerchantTransactionState(purchaseRecord),
    txActions: computePayMerchantTransactionActions(purchaseRecord),
    amountRaw: Amounts.stringify(contractData.amount),
    amountEffective: 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,
    transactionId: constructTransactionIdentifier({
      tag: TransactionType.Payment,
      proposalId: purchaseRecord.proposalId,
    }),
    proposalId: purchaseRecord.proposalId,
    info,
    refundQueryActive:
      purchaseRecord.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
    ...(ort?.lastError ? { error: ort.lastError } : {}),
  };
}

/**
 * Retrieve the full event history for this wallet.
 */
export async function getTransactions(
  ws: InternalWalletState,
  transactionsRequest?: TransactionsRequest,
): Promise<TransactionsResponse> {
  const transactions: Transaction[] = [];

  await ws.db
    .mktx((x) => [
      x.coins,
      x.denominations,
      x.depositGroups,
      x.exchangeDetails,
      x.exchanges,
      x.operationRetries,
      x.peerPullPaymentIncoming,
      x.peerPushPaymentInitiations,
      x.peerPushPaymentIncoming,
      x.peerPullPaymentInitiations,
      x.planchets,
      x.purchases,
      x.contractTerms,
      x.recoupGroups,
      x.tips,
      x.tombstones,
      x.withdrawalGroups,
      x.refreshGroups,
      x.refundGroups,
    ])
    .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 !== PeerPullDebitRecordStatus.PendingDeposit &&
          pi.status !== PeerPullDebitRecordStatus.DonePaid
        ) {
          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.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,
          ),
        );
      });

      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.refundGroups.iter().forEachAsync(async (refundGroup) => {
        const currency = Amounts.currencyOf(refundGroup.amountRaw);
        if (shouldSkipCurrency(transactionsRequest, currency)) {
          return;
        }
        const contractData = await lookupMaybeContractData(
          tx,
          refundGroup.proposalId,
        );
        transactions.push(buildTransactionForRefund(refundGroup, contractData));
      });

      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 payOpId = TaskIdentifiers.forPay(purchase);
        const payRetryRecord = await tx.operationRetries.get(payOpId);
        transactions.push(
          await buildTransactionForPurchase(
            purchase,
            contractData,
            [], // FIXME!
            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 isPending = (x: Transaction) =>
    x.txState.major === TransactionMajorState.Pending ||
    x.txState.major === TransactionMajorState.Aborting ||
    x.txState.major === TransactionMajorState.Dialog;

  const txPending = transactions.filter((x) => isPending(x));
  const txNotPending = transactions.filter((x) => !isPending(x));

  const txCmp = (h1: Transaction, h2: Transaction) => {
    const tsCmp = AbsoluteTime.cmp(
      AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
      AbsoluteTime.fromPreciseTimestamp(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; refundGroupId: string }
  | { tag: TransactionType.Tip; walletTipId: string }
  | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
  | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: 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.peerPullPaymentIncomingId}` as TransactionIdStr;
    case TransactionType.PeerPushCredit:
      return `txn:${pTxId.tag}:${pTxId.peerPushPaymentIncomingId}` 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.Tip:
      return `txn:${pTxId.tag}:${pTxId.walletTipId}` as TransactionIdStr;
    case TransactionType.Withdrawal:
      return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
    case TransactionType.InternalWithdrawal:
      return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` 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,
        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,
        refundGroupId: rest[0],
      };
    case TransactionType.Tip:
      return {
        tag: TransactionType.Tip,
        walletTipId: rest[0],
      };
    case TransactionType.Withdrawal:
      return {
        tag: TransactionType.Withdrawal,
        withdrawalGroupId: rest[0],
      };
    default:
      return undefined;
  }
}

export function stopLongpolling(ws: InternalWalletState, taskId: string) {
  const longpoll = ws.activeLongpoll[taskId];
  if (longpoll) {
    logger.info(`cancelling long-polling for ${taskId}`);
    longpoll.cancel();
    delete ws.activeLongpoll[taskId];
  }
}

/**
 * Immediately retry the underlying operation
 * of a transaction.
 */
export async function retryTransaction(
  ws: InternalWalletState,
  transactionId: string,
): Promise<void> {
  logger.info(`resetting retry timeout for ${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.PeerPullCredit,
        pursePub: parsedTx.pursePub,
      });
      await resetPendingTaskTimeout(ws, taskId);
      stopLongpolling(ws, taskId);
      break;
    }
    case TransactionType.Deposit: {
      const taskId = constructTaskIdentifier({
        tag: PendingTaskType.Deposit,
        depositGroupId: parsedTx.depositGroupId,
      });
      await resetPendingTaskTimeout(ws, taskId);
      stopLongpolling(ws, taskId);
      break;
    }
    case TransactionType.InternalWithdrawal:
    case TransactionType.Withdrawal: {
      // FIXME: Abort current long-poller!
      const taskId = constructTaskIdentifier({
        tag: PendingTaskType.Withdraw,
        withdrawalGroupId: parsedTx.withdrawalGroupId,
      });
      await resetPendingTaskTimeout(ws, taskId);
      stopLongpolling(ws, taskId);
      break;
    }
    case TransactionType.Payment: {
      const taskId = constructTaskIdentifier({
        tag: PendingTaskType.Purchase,
        proposalId: parsedTx.proposalId,
      });
      await resetPendingTaskTimeout(ws, taskId);
      stopLongpolling(ws, taskId);
      break;
    }
    case TransactionType.Tip: {
      const taskId = constructTaskIdentifier({
        tag: PendingTaskType.TipPickup,
        walletTipId: parsedTx.walletTipId,
      });
      await resetPendingTaskTimeout(ws, taskId);
      stopLongpolling(ws, taskId);
      break;
    }
    case TransactionType.Refresh: {
      const taskId = constructTaskIdentifier({
        tag: PendingTaskType.Refresh,
        refreshGroupId: parsedTx.refreshGroupId,
      });
      await resetPendingTaskTimeout(ws, taskId);
      stopLongpolling(ws, taskId);
      break;
    }
    case TransactionType.PeerPullDebit: {
      const taskId = constructTaskIdentifier({
        tag: PendingTaskType.PeerPullDebit,
        peerPullPaymentIncomingId: parsedTx.peerPullPaymentIncomingId,
      });
      await resetPendingTaskTimeout(ws, taskId);
      stopLongpolling(ws, taskId);
      break;
    }
    case TransactionType.PeerPushCredit: {
      const taskId = constructTaskIdentifier({
        tag: PendingTaskType.PeerPushCredit,
        peerPushPaymentIncomingId: parsedTx.peerPushPaymentIncomingId,
      });
      await resetPendingTaskTimeout(ws, taskId);
      stopLongpolling(ws, taskId);
      break;
    }
    case TransactionType.PeerPushDebit: {
      const taskId = constructTaskIdentifier({
        tag: PendingTaskType.PeerPushDebit,
        pursePub: parsedTx.pursePub,
      });
      await resetPendingTaskTimeout(ws, taskId);
      stopLongpolling(ws, taskId);
      break;
    }
    case TransactionType.Refund:
      // Nothing to do for a refund transaction.
      break;
    default:
      assertUnreachable(parsedTx);
  }
}

/**
 * Suspends a pending transaction, stopping any associated network activities,
 * but with a chance of trying again at a later time. This could be useful if
 * a user needs to save battery power or bandwidth and an operation is expected
 * to take longer (such as a backup, recovery or very large withdrawal operation).
 */
export async function suspendTransaction(
  ws: InternalWalletState,
  transactionId: string,
): Promise<void> {
  const tx = parseTransactionIdentifier(transactionId);
  if (!tx) {
    throw Error("invalid transaction ID");
  }
  switch (tx.tag) {
    case TransactionType.Deposit:
      await suspendDepositGroup(ws, tx.depositGroupId);
      return;
    case TransactionType.Refresh:
      await suspendRefreshGroup(ws, tx.refreshGroupId);
      return;
    case TransactionType.InternalWithdrawal:
    case TransactionType.Withdrawal:
      await suspendWithdrawalTransaction(ws, tx.withdrawalGroupId);
      return;
    case TransactionType.Payment:
      await suspendPayMerchant(ws, tx.proposalId);
      return;
    case TransactionType.PeerPullCredit:
      await suspendPeerPullCreditTransaction(ws, tx.pursePub);
      break;
    case TransactionType.PeerPushDebit:
      await suspendPeerPushDebitTransaction(ws, tx.pursePub);
      break;
    case TransactionType.PeerPullDebit:
      await suspendPeerPullDebitTransaction(ws, tx.peerPullPaymentIncomingId);
      break;
    case TransactionType.PeerPushCredit:
      await suspendPeerPushCreditTransaction(ws, tx.peerPushPaymentIncomingId);
      break;
    case TransactionType.Refund:
      throw Error("refund transactions can't be suspended or resumed");
    case TransactionType.Tip:
      await suspendTipTransaction(ws, tx.walletTipId);
      break;
    default:
      assertUnreachable(tx);
  }
}

export async function failTransaction(
  ws: InternalWalletState,
  transactionId: string,
): Promise<void> {
  const tx = parseTransactionIdentifier(transactionId);
  if (!tx) {
    throw Error("invalid transaction ID");
  }
  switch (tx.tag) {
    case TransactionType.Deposit:
      await failDepositTransaction(ws, tx.depositGroupId);
      return;
    case TransactionType.InternalWithdrawal:
    case TransactionType.Withdrawal:
      await failWithdrawalTransaction(ws, tx.withdrawalGroupId);
      return;
    case TransactionType.Payment:
      await failPaymentTransaction(ws, tx.proposalId);
      return;
    case TransactionType.Refund:
      throw Error("can't do cancel-aborting on refund transaction");
    case TransactionType.Tip:
      await failTipTransaction(ws, tx.walletTipId);
      return;
    case TransactionType.Refresh:
      await failRefreshGroup(ws, tx.refreshGroupId);
      return;
    case TransactionType.PeerPullCredit:
      await failPeerPullCreditTransaction(ws, tx.pursePub);
      return;
    case TransactionType.PeerPullDebit:
      await failPeerPullDebitTransaction(ws, tx.peerPullPaymentIncomingId);
      return;
    case TransactionType.PeerPushCredit:
      await failPeerPushCreditTransaction(ws, tx.peerPushPaymentIncomingId);
      return;
    case TransactionType.PeerPushDebit:
      await failPeerPushDebitTransaction(ws, tx.pursePub);
      return;
    default:
      assertUnreachable(tx);
  }
}

/**
 * Resume a suspended transaction.
 */
export async function resumeTransaction(
  ws: InternalWalletState,
  transactionId: string,
): Promise<void> {
  const tx = parseTransactionIdentifier(transactionId);
  if (!tx) {
    throw Error("invalid transaction ID");
  }
  switch (tx.tag) {
    case TransactionType.Deposit:
      await resumeDepositGroup(ws, tx.depositGroupId);
      return;
    case TransactionType.Refresh:
      await resumeRefreshGroup(ws, tx.refreshGroupId);
      return;
    case TransactionType.InternalWithdrawal:
    case TransactionType.Withdrawal:
      await resumeWithdrawalTransaction(ws, tx.withdrawalGroupId);
      return;
    case TransactionType.Payment:
      await resumePayMerchant(ws, tx.proposalId);
      return;
    case TransactionType.PeerPullCredit:
      await resumePeerPullCreditTransaction(ws, tx.pursePub);
      break;
    case TransactionType.PeerPushDebit:
      await resumePeerPushDebitTransaction(ws, tx.pursePub);
      break;
    case TransactionType.PeerPullDebit:
      await resumePeerPullDebitTransaction(ws, tx.peerPullPaymentIncomingId);
      break;
    case TransactionType.PeerPushCredit:
      await resumePeerPushCreditTransaction(ws, tx.peerPushPaymentIncomingId);
      break;
    case TransactionType.Refund:
      throw Error("refund transactions can't be suspended or resumed");
    case TransactionType.Tip:
      await resumeTipTransaction(ws, tx.walletTipId);
      break;
  }
}

/**
 * Permanently delete a transaction based on the transaction ID.
 */
export async function deleteTransaction(
  ws: InternalWalletState,
  transactionId: string,
): Promise<void> {
  const parsedTx = parseTransactionIdentifier(transactionId);

  if (!parsedTx) {
    throw Error("invalid transaction ID");
  }

  switch (parsedTx.tag) {
    case TransactionType.PeerPushCredit: {
      const peerPushPaymentIncomingId = parsedTx.peerPushPaymentIncomingId;
      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,
          });
        });
      return;
    }

    case TransactionType.PeerPullCredit: {
      const pursePub = parsedTx.pursePub;
      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,
          });
        });

      return;
    }

    case TransactionType.Withdrawal: {
      const withdrawalGroupId = parsedTx.withdrawalGroupId;
      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;
          }
        });
      return;
    }

    case TransactionType.Payment: {
      const proposalId = parsedTx.proposalId;
      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,
            });
          }
        });
      return;
    }

    case TransactionType.Refresh: {
      const refreshGroupId = parsedTx.refreshGroupId;
      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,
            });
          }
        });

      return;
    }

    case TransactionType.Tip: {
      const tipId = parsedTx.walletTipId;
      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,
            });
          }
        });
      return;
    }

    case TransactionType.Deposit: {
      const depositGroupId = parsedTx.depositGroupId;
      await deleteDepositGroup(ws, depositGroupId);
      return;
    }

    case TransactionType.Refund: {
      const refundGroupId = parsedTx.refundGroupId;
      await ws.db
        .mktx((x) => [x.refundGroups, x.tombstones, x.refundItems])
        .runReadWrite(async (tx) => {
          const refundRecord = await tx.refundGroups.get(refundGroupId);
          if (!refundRecord) {
            return;
          }
          await tx.refundGroups.delete(refundGroupId);
          await tx.tombstones.put({ id: transactionId });
          // FIXME: Also tombstone the refund items, so that they won't reappear.
        });
      return;
    }

    case TransactionType.PeerPullDebit: {
      const peerPullPaymentIncomingId = parsedTx.peerPullPaymentIncomingId;
      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: transactionId });
          }
        });

      return;
    }

    case TransactionType.PeerPushDebit: {
      const pursePub = parsedTx.pursePub;
      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: transactionId });
          }
        });
      return;
    }
  }
}

export async function abortTransaction(
  ws: InternalWalletState,
  transactionId: string,
): Promise<void> {
  const txId = parseTransactionIdentifier(transactionId);
  if (!txId) {
    throw Error("invalid transaction identifier");
  }

  switch (txId.tag) {
    case TransactionType.Payment: {
      await abortPayMerchant(ws, txId.proposalId);
      break;
    }
    case TransactionType.Withdrawal:
    case TransactionType.InternalWithdrawal: {
      await abortWithdrawalTransaction(ws, txId.withdrawalGroupId);
      break;
    }
    case TransactionType.Deposit:
      await abortDepositGroup(ws, txId.depositGroupId);
      break;
    case TransactionType.Tip:
      await abortTipTransaction(ws, txId.walletTipId);
      break;
    case TransactionType.Refund:
      throw Error("can't abort refund transactions");
    case TransactionType.Refresh:
      await abortRefreshGroup(ws, txId.refreshGroupId);
      break;
    case TransactionType.PeerPullCredit:
      await abortPeerPullCreditTransaction(ws, txId.pursePub);
      break;
    case TransactionType.PeerPullDebit:
      await abortPeerPullDebitTransaction(ws, txId.peerPullPaymentIncomingId);
      break;
    case TransactionType.PeerPushCredit:
      await abortPeerPushCreditTransaction(ws, txId.peerPushPaymentIncomingId);
      break;
    case TransactionType.PeerPushDebit:
      await abortPeerPushDebitTransaction(ws, txId.pursePub);
      break;
    default: {
      assertUnreachable(txId);
    }
  }
}

export interface TransitionInfo {
  oldTxState: TransactionState;
  newTxState: TransactionState;
}

/**
 * Notify of a state transition if necessary.
 */
export function notifyTransition(
  ws: InternalWalletState,
  transactionId: string,
  transitionInfo: TransitionInfo | undefined,
): void {
  if (
    transitionInfo &&
    !(
      transitionInfo.oldTxState.major === transitionInfo.newTxState.major &&
      transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
    )
  ) {
    ws.notify({
      type: NotificationType.TransactionStateTransition,
      oldTxState: transitionInfo.oldTxState,
      newTxState: transitionInfo.newTxState,
      transactionId,
    });
  }
}