/*
 This file is part of GNU Taler
 (C) 2021 Taler Systems S.A.
 GNU Taler is free software; you can redistribute it and/or modify it under the
 terms of the GNU General Public License as published by the Free Software
 Foundation; either version 3, or (at your option) any later version.
 GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 You should have received a copy of the GNU General Public License along with
 GNU Taler; see the file COPYING.  If not, see 
 */
/**
 * Imports.
 */
import {
  AbsoluteTime,
  AmountJson,
  Amounts,
  CancellationToken,
  canonicalJson,
  codecForDepositSuccess,
  codecForTackTransactionAccepted,
  codecForTackTransactionWired,
  CoinDepositPermission,
  CoinRefreshRequest,
  CreateDepositGroupRequest,
  CreateDepositGroupResponse,
  DepositGroupFees,
  durationFromSpec,
  encodeCrock,
  ExchangeDepositRequest,
  ExchangeRefundRequest,
  getRandomBytes,
  hashTruncate32,
  hashWire,
  HttpStatusCode,
  j2s,
  Logger,
  MerchantContractTerms,
  NotificationType,
  parsePaytoUri,
  PayCoinSelection,
  PrepareDepositRequest,
  PrepareDepositResponse,
  RefreshReason,
  stringToBytes,
  TalerErrorCode,
  TalerProtocolTimestamp,
  TalerPreciseTimestamp,
  TrackTransaction,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  URL,
  WireFee,
  TransactionAction,
} from "@gnu-taler/taler-util";
import {
  DenominationRecord,
  DepositGroupRecord,
  DepositElementStatus,
} from "../db.js";
import { TalerError } from "@gnu-taler/taler-util";
import {
  createRefreshGroup,
  DepositOperationStatus,
  DepositTrackingInfo,
  getTotalRefreshCost,
  KycPendingInfo,
  KycUserType,
  PendingTaskType,
  RefreshOperationStatus,
} from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import { constructTaskIdentifier, OperationAttemptResult, spendCoins, TombstoneTag } from "./common.js";
import { getExchangeDetails } from "./exchanges.js";
import {
  extractContractData,
  generateDepositPermissions,
  getTotalPaymentCost,
} from "./pay-merchant.js";
import { selectPayCoinsNew } from "../util/coinSelection.js";
import {
  constructTransactionIdentifier,
  notifyTransition,
  parseTransactionIdentifier,
  stopLongpolling,
} from "./transactions.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
/**
 * Logger.
 */
const logger = new Logger("deposits.ts");
/**
 * Get the (DD37-style) transaction status based on the
 * database record of a deposit group.
 */
export function computeDepositTransactionStatus(
  dg: DepositGroupRecord,
): TransactionState {
  switch (dg.operationStatus) {
    case DepositOperationStatus.Finished: {
      return {
        major: TransactionMajorState.Done,
      };
    }
    // FIXME: We should actually use separate pending states for this!
    case DepositOperationStatus.Pending: {
      const numTotal = dg.payCoinSelection.coinPubs.length;
      let numDeposited = 0;
      let numKycRequired = 0;
      let numWired = 0;
      for (let i = 0; i < numTotal; i++) {
        if (dg.depositedPerCoin[i]) {
          numDeposited++;
        }
        switch (dg.transactionPerCoin[i]) {
          case DepositElementStatus.KycRequired:
            numKycRequired++;
            break;
          case DepositElementStatus.Wired:
            numWired++;
            break;
        }
      }
      if (numKycRequired > 0) {
        return {
          major: TransactionMajorState.Pending,
          minor: TransactionMinorState.KycRequired,
        };
      }
      if (numDeposited == numTotal) {
        return {
          major: TransactionMajorState.Pending,
          minor: TransactionMinorState.Track,
        };
      }
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.Deposit,
      };
    }
    case DepositOperationStatus.Suspended:
      return {
        major: TransactionMajorState.Suspended,
      };
    case DepositOperationStatus.Aborting:
      return {
        major: TransactionMajorState.Aborting,
      };
    case DepositOperationStatus.Aborted:
      return {
        major: TransactionMajorState.Aborted,
      };
    case DepositOperationStatus.Failed:
      return {
        major: TransactionMajorState.Failed,
      };
    case DepositOperationStatus.SuspendedAborting:
      return {
        major: TransactionMajorState.SuspendedAborting,
      };
    default:
      throw Error(`unexpected deposit group state (${dg.operationStatus})`);
  }
}
export function computeDepositTransactionActions(
  dg: DepositGroupRecord,
): TransactionAction[] {
  switch (dg.operationStatus) {
    case DepositOperationStatus.Finished: {
      return [TransactionAction.Delete];
    }
    case DepositOperationStatus.Pending: {
      const numTotal = dg.payCoinSelection.coinPubs.length;
      let numDeposited = 0;
      let numKycRequired = 0;
      let numWired = 0;
      for (let i = 0; i < numTotal; i++) {
        if (dg.depositedPerCoin[i]) {
          numDeposited++;
        }
        switch (dg.transactionPerCoin[i]) {
          case DepositElementStatus.KycRequired:
            numKycRequired++;
            break;
          case DepositElementStatus.Wired:
            numWired++;
            break;
        }
      }
      if (numKycRequired > 0) {
        return [TransactionAction.Suspend, TransactionAction.Fail];
      }
      if (numDeposited == numTotal) {
        return [TransactionAction.Suspend, TransactionAction.Fail];
      }
      return [TransactionAction.Suspend, TransactionAction.Abort];
    }
    case DepositOperationStatus.Suspended:
      return [TransactionAction.Resume];
    case DepositOperationStatus.Aborting:
      return [TransactionAction.Fail, TransactionAction.Suspend];
    case DepositOperationStatus.Aborted:
      return [TransactionAction.Delete];
    case DepositOperationStatus.Failed:
      return [TransactionAction.Delete];
    case DepositOperationStatus.SuspendedAborting:
      return [TransactionAction.Resume, TransactionAction.Fail];
    default:
      throw Error(`unexpected deposit group state (${dg.operationStatus})`);
  }
}
export async function suspendDepositGroup(
  ws: InternalWalletState,
  depositGroupId: string,
): Promise {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Deposit,
    depositGroupId,
  });
  const retryTag = constructTaskIdentifier({
    tag: PendingTaskType.Deposit,
    depositGroupId,
  });
  const transitionInfo = await ws.db
    .mktx((x) => [x.depositGroups])
    .runReadWrite(async (tx) => {
      const dg = await tx.depositGroups.get(depositGroupId);
      if (!dg) {
        logger.warn(
          `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
        );
        return undefined;
      }
      const oldState = computeDepositTransactionStatus(dg);
      switch (dg.operationStatus) {
        case DepositOperationStatus.Finished:
          return undefined;
        case DepositOperationStatus.Pending: {
          dg.operationStatus = DepositOperationStatus.Suspended;
          await tx.depositGroups.put(dg);
          return {
            oldTxState: oldState,
            newTxState: computeDepositTransactionStatus(dg),
          };
        }
        case DepositOperationStatus.Suspended:
          return undefined;
      }
      return undefined;
    });
  stopLongpolling(ws, retryTag);
  notifyTransition(ws, transactionId, transitionInfo);
}
export async function resumeDepositGroup(
  ws: InternalWalletState,
  depositGroupId: string,
): Promise {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Deposit,
    depositGroupId,
  });
  const transitionInfo = await ws.db
    .mktx((x) => [x.depositGroups])
    .runReadWrite(async (tx) => {
      const dg = await tx.depositGroups.get(depositGroupId);
      if (!dg) {
        logger.warn(
          `can't resume deposit group, depositGroupId=${depositGroupId} not found`,
        );
        return;
      }
      const oldState = computeDepositTransactionStatus(dg);
      switch (dg.operationStatus) {
        case DepositOperationStatus.Finished:
          return;
        case DepositOperationStatus.Pending: {
          return;
        }
        case DepositOperationStatus.Suspended:
          dg.operationStatus = DepositOperationStatus.Pending;
          await tx.depositGroups.put(dg);
          return {
            oldTxState: oldState,
            newTxState: computeDepositTransactionStatus(dg),
          };
      }
      return undefined;
    });
  ws.workAvailable.trigger();
  notifyTransition(ws, transactionId, transitionInfo);
}
export async function abortDepositGroup(
  ws: InternalWalletState,
  depositGroupId: string,
): Promise {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Deposit,
    depositGroupId,
  });
  const retryTag = constructTaskIdentifier({
    tag: PendingTaskType.Deposit,
    depositGroupId,
  });
  const transitionInfo = await ws.db
    .mktx((x) => [x.depositGroups])
    .runReadWrite(async (tx) => {
      const dg = await tx.depositGroups.get(depositGroupId);
      if (!dg) {
        logger.warn(
          `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
        );
        return undefined;
      }
      const oldState = computeDepositTransactionStatus(dg);
      switch (dg.operationStatus) {
        case DepositOperationStatus.Finished:
          return undefined;
        case DepositOperationStatus.Pending: {
          dg.operationStatus = DepositOperationStatus.Aborting;
          await tx.depositGroups.put(dg);
          return {
            oldTxState: oldState,
            newTxState: computeDepositTransactionStatus(dg),
          };
        }
        case DepositOperationStatus.Suspended:
          // FIXME: Can we abort a suspended transaction?!
          return undefined;
      }
      return undefined;
    });
  stopLongpolling(ws, retryTag);
  // Need to process the operation again.
  ws.workAvailable.trigger();
  notifyTransition(ws, transactionId, transitionInfo);
}
export async function failDepositTransaction(
  ws: InternalWalletState,
  depositGroupId: string,
): Promise {
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Deposit,
    depositGroupId,
  });
  const retryTag = constructTaskIdentifier({
    tag: PendingTaskType.Deposit,
    depositGroupId,
  });
  const transitionInfo = await ws.db
    .mktx((x) => [x.depositGroups])
    .runReadWrite(async (tx) => {
      const dg = await tx.depositGroups.get(depositGroupId);
      if (!dg) {
        logger.warn(
          `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
        );
        return undefined;
      }
      const oldState = computeDepositTransactionStatus(dg);
      switch (dg.operationStatus) {
        case DepositOperationStatus.SuspendedAborting:
        case DepositOperationStatus.Aborting: {
          dg.operationStatus = DepositOperationStatus.Failed;
          await tx.depositGroups.put(dg);
          return {
            oldTxState: oldState,
            newTxState: computeDepositTransactionStatus(dg),
          };
        }
      }
      return undefined;
    });
  // FIXME: Also cancel ongoing work (via cancellation token, once implemented)
  stopLongpolling(ws, retryTag);
  notifyTransition(ws, transactionId, transitionInfo);
}
export async function deleteDepositGroup(
  ws: InternalWalletState,
  depositGroupId: string,
) {
  // FIXME: We should check first if we are in a final state
  // where deletion is allowed.
  await ws.db
    .mktx((x) => [x.depositGroups, x.tombstones])
    .runReadWrite(async (tx) => {
      const tipRecord = await tx.depositGroups.get(depositGroupId);
      if (tipRecord) {
        await tx.depositGroups.delete(depositGroupId);
        await tx.tombstones.put({
          id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
        });
      }
    });
}
/**
 * Check KYC status with the exchange, throw an appropriate exception when KYC
 * is required.
 *
 * FIXME: Why does this throw an exception when KYC is required?
 * Should we not return some proper result record here?
 */
async function checkDepositKycStatus(
  ws: InternalWalletState,
  exchangeUrl: string,
  kycInfo: KycPendingInfo,
  userType: KycUserType,
): Promise {
  const url = new URL(
    `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
    exchangeUrl,
  );
  logger.info(`kyc url ${url.href}`);
  const kycStatusReq = await ws.http.fetch(url.href, {
    method: "GET",
  });
  if (kycStatusReq.status === HttpStatusCode.Ok) {
    logger.warn("kyc requested, but already fulfilled");
    return;
  } else if (kycStatusReq.status === HttpStatusCode.Accepted) {
    const kycStatus = await kycStatusReq.json();
    logger.info(`kyc status: ${j2s(kycStatus)}`);
    // FIXME: This error code is totally wrong
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
      {
        kycUrl: kycStatus.kyc_url,
      },
      `KYC check required for deposit`,
    );
  } else {
    throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`);
  }
}
/**
 * Check whether the refresh associated with the
 * aborting deposit group is done.
 *
 * If done, mark the deposit transaction as aborted.
 *
 * Otherwise continue waiting.
 *
 * FIXME:  Wait for the refresh group notifications instead of periodically
 * checking the refresh group status.
 * FIXME: This is just one transaction, can't we do this in the initial
 * transaction of processDepositGroup?
 */
async function waitForRefreshOnDepositGroup(
  ws: InternalWalletState,
  depositGroup: DepositGroupRecord,
): Promise {
  const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
  checkLogicInvariant(!!abortRefreshGroupId);
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Deposit,
    depositGroupId: depositGroup.depositGroupId,
  });
  const transitionInfo = await ws.db
    .mktx((x) => [x.refreshGroups, x.depositGroups])
    .runReadWrite(async (tx) => {
      const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
      let newOpState: DepositOperationStatus | undefined;
      if (!refreshGroup) {
        // Maybe it got manually deleted? Means that we should
        // just go into aborted.
        logger.warn("no aborting refresh group found for deposit group");
        newOpState = DepositOperationStatus.Aborted;
      } else {
        if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
          newOpState = DepositOperationStatus.Aborted;
        } else if (
          refreshGroup.operationStatus === RefreshOperationStatus.Failed
        ) {
          newOpState = DepositOperationStatus.Aborted;
        }
      }
      if (newOpState) {
        const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
        if (!newDg) {
          return;
        }
        const oldTxState = computeDepositTransactionStatus(newDg);
        newDg.operationStatus = newOpState;
        const newTxState = computeDepositTransactionStatus(newDg);
        await tx.depositGroups.put(newDg);
        return { oldTxState, newTxState };
      }
      return undefined;
    });
  notifyTransition(ws, transactionId, transitionInfo);
  return OperationAttemptResult.pendingEmpty();
}
async function refundDepositGroup(
  ws: InternalWalletState,
  depositGroup: DepositGroupRecord,
): Promise {
  const newTxPerCoin = [...depositGroup.transactionPerCoin];
  logger.info(`status per coin: ${j2s(depositGroup.transactionPerCoin)}`);
  for (let i = 0; i < depositGroup.transactionPerCoin.length; i++) {
    const st = depositGroup.transactionPerCoin[i];
    switch (st) {
      case DepositElementStatus.RefundFailed:
      case DepositElementStatus.RefundSuccess:
        break;
      default: {
        const coinPub = depositGroup.payCoinSelection.coinPubs[i];
        const coinExchange = await ws.db
          .mktx((x) => [x.coins])
          .runReadOnly(async (tx) => {
            const coinRecord = await tx.coins.get(coinPub);
            checkDbInvariant(!!coinRecord);
            return coinRecord.exchangeBaseUrl;
          });
        const refundAmount = depositGroup.payCoinSelection.coinContributions[i];
        // We use a constant refund transaction ID, since there can
        // only be one refund.
        const rtid = 1;
        const sig = await ws.cryptoApi.signRefund({
          coinPub,
          contractTermsHash: depositGroup.contractTermsHash,
          merchantPriv: depositGroup.merchantPriv,
          merchantPub: depositGroup.merchantPub,
          refundAmount: refundAmount,
          rtransactionId: rtid,
        });
        const refundReq: ExchangeRefundRequest = {
          h_contract_terms: depositGroup.contractTermsHash,
          merchant_pub: depositGroup.merchantPub,
          merchant_sig: sig.sig,
          refund_amount: refundAmount,
          rtransaction_id: rtid,
        };
        const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
        const httpResp = await ws.http.fetch(refundUrl.href, {
          method: "POST",
          body: refundReq,
        });
        logger.info(
          `coin ${i} refund HTTP status for coin: ${httpResp.status}`,
        );
        let newStatus: DepositElementStatus;
        if (httpResp.status === 200) {
          // FIXME: validate response
          newStatus = DepositElementStatus.RefundSuccess;
        } else {
          // FIXME: Store problem somewhere!
          newStatus = DepositElementStatus.RefundFailed;
        }
        // FIXME: Handle case where refund request needs to be tried again
        newTxPerCoin[i] = newStatus;
        break;
      }
    }
  }
  let isDone = true;
  for (let i = 0; i < newTxPerCoin.length; i++) {
    if (
      newTxPerCoin[i] != DepositElementStatus.RefundFailed &&
      newTxPerCoin[i] != DepositElementStatus.RefundSuccess
    ) {
      isDone = false;
    }
  }
  const currency = Amounts.currencyOf(depositGroup.totalPayCost);
  await ws.db
    .mktx((x) => [
      x.depositGroups,
      x.refreshGroups,
      x.coins,
      x.denominations,
      x.coinAvailability,
    ])
    .runReadWrite(async (tx) => {
      const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
      if (!newDg) {
        return;
      }
      newDg.transactionPerCoin = newTxPerCoin;
      const refreshCoins: CoinRefreshRequest[] = [];
      for (let i = 0; i < newTxPerCoin.length; i++) {
        refreshCoins.push({
          amount: depositGroup.payCoinSelection.coinContributions[i],
          coinPub: depositGroup.payCoinSelection.coinPubs[i],
        });
      }
      if (isDone) {
        const rgid = await createRefreshGroup(
          ws,
          tx,
          currency,
          refreshCoins,
          RefreshReason.AbortDeposit,
        );
        newDg.abortRefreshGroupId = rgid.refreshGroupId;
      }
      await tx.depositGroups.put(newDg);
    });
  return OperationAttemptResult.pendingEmpty();
}
/**
 * Process a deposit group that is not in its final state yet.
 */
export async function processDepositGroup(
  ws: InternalWalletState,
  depositGroupId: string,
  options: {
    cancellationToken?: CancellationToken;
  } = {},
): Promise {
  const depositGroup = await ws.db
    .mktx((x) => [x.depositGroups])
    .runReadOnly(async (tx) => {
      return tx.depositGroups.get(depositGroupId);
    });
  if (!depositGroup) {
    logger.warn(`deposit group ${depositGroupId} not found`);
    return OperationAttemptResult.finishedEmpty();
  }
  if (depositGroup.timestampFinished) {
    logger.trace(`deposit group ${depositGroupId} already finished`);
    return OperationAttemptResult.finishedEmpty();
  }
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Deposit,
    depositGroupId,
  });
  const txStateOld = computeDepositTransactionStatus(depositGroup);
  if (depositGroup.operationStatus === DepositOperationStatus.Pending) {
    const contractData = extractContractData(
      depositGroup.contractTermsRaw,
      depositGroup.contractTermsHash,
      "",
    );
    // Check for cancellation before expensive operations.
    options.cancellationToken?.throwIfCancelled();
    // FIXME: Cache these!
    const depositPermissions = await generateDepositPermissions(
      ws,
      depositGroup.payCoinSelection,
      contractData,
    );
    for (let i = 0; i < depositPermissions.length; i++) {
      const perm = depositPermissions[i];
      let didDeposit: boolean = false;
      if (!depositGroup.depositedPerCoin[i]) {
        const requestBody: ExchangeDepositRequest = {
          contribution: Amounts.stringify(perm.contribution),
          merchant_payto_uri: depositGroup.wire.payto_uri,
          wire_salt: depositGroup.wire.salt,
          h_contract_terms: depositGroup.contractTermsHash,
          ub_sig: perm.ub_sig,
          timestamp: depositGroup.contractTermsRaw.timestamp,
          wire_transfer_deadline:
            depositGroup.contractTermsRaw.wire_transfer_deadline,
          refund_deadline: depositGroup.contractTermsRaw.refund_deadline,
          coin_sig: perm.coin_sig,
          denom_pub_hash: perm.h_denom,
          merchant_pub: depositGroup.merchantPub,
          h_age_commitment: perm.h_age_commitment,
        };
        // Check for cancellation before making network request.
        options.cancellationToken?.throwIfCancelled();
        const url = new URL(
          `coins/${perm.coin_pub}/deposit`,
          perm.exchange_url,
        );
        logger.info(`depositing to ${url}`);
        const httpResp = await ws.http.fetch(url.href, {
          method: "POST",
          body: requestBody,
          cancellationToken: options.cancellationToken,
        });
        await readSuccessResponseJsonOrThrow(
          httpResp,
          codecForDepositSuccess(),
        );
        didDeposit = true;
      }
      let updatedTxStatus: DepositElementStatus | undefined = undefined;
      let newWiredCoin:
        | {
            id: string;
            value: DepositTrackingInfo;
          }
        | undefined;
      if (depositGroup.transactionPerCoin[i] !== DepositElementStatus.Wired) {
        const track = await trackDeposit(ws, depositGroup, perm);
        if (track.type === "accepted") {
          if (!track.kyc_ok && track.requirement_row !== undefined) {
            updatedTxStatus = DepositElementStatus.KycRequired;
            const { requirement_row: requirementRow } = track;
            const paytoHash = encodeCrock(
              hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")),
            );
            await checkDepositKycStatus(
              ws,
              perm.exchange_url,
              { paytoHash, requirementRow },
              "individual",
            );
          } else {
            updatedTxStatus = DepositElementStatus.Accepted;
          }
        } else if (track.type === "wired") {
          updatedTxStatus = DepositElementStatus.Wired;
          const payto = parsePaytoUri(depositGroup.wire.payto_uri);
          if (!payto) {
            throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`);
          }
          const fee = await getExchangeWireFee(
            ws,
            payto.targetType,
            perm.exchange_url,
            track.execution_time,
          );
          const raw = Amounts.parseOrThrow(track.coin_contribution);
          const wireFee = Amounts.parseOrThrow(fee.wireFee);
          newWiredCoin = {
            value: {
              amountRaw: Amounts.stringify(raw),
              wireFee: Amounts.stringify(wireFee),
              exchangePub: track.exchange_pub,
              timestampExecuted: track.execution_time,
              wireTransferId: track.wtid,
            },
            id: track.exchange_sig,
          };
        } else {
          updatedTxStatus = DepositElementStatus.Unknown;
        }
      }
      if (updatedTxStatus !== undefined || didDeposit) {
        await ws.db
          .mktx((x) => [x.depositGroups])
          .runReadWrite(async (tx) => {
            const dg = await tx.depositGroups.get(depositGroupId);
            if (!dg) {
              return;
            }
            if (didDeposit) {
              dg.depositedPerCoin[i] = didDeposit;
            }
            if (updatedTxStatus !== undefined) {
              dg.transactionPerCoin[i] = updatedTxStatus;
            }
            if (newWiredCoin) {
              /**
               * FIXME: if there is a new wire information from the exchange
               * it should add up to the previous tracking states.
               *
               * This may loose information by overriding prev state.
               *
               * And: add checks to integration tests
               */
              if (!dg.trackingState) {
                dg.trackingState = {};
              }
              dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
            }
            await tx.depositGroups.put(dg);
          });
      }
    }
    const txStatusNew = await ws.db
      .mktx((x) => [x.depositGroups])
      .runReadWrite(async (tx) => {
        const dg = await tx.depositGroups.get(depositGroupId);
        if (!dg) {
          return undefined;
        }
        let allDepositedAndWired = true;
        for (let i = 0; i < depositGroup.depositedPerCoin.length; i++) {
          if (
            !depositGroup.depositedPerCoin[i] ||
            depositGroup.transactionPerCoin[i] !== DepositElementStatus.Wired
          ) {
            allDepositedAndWired = false;
            break;
          }
        }
        if (allDepositedAndWired) {
          dg.timestampFinished = TalerPreciseTimestamp.now();
          dg.operationStatus = DepositOperationStatus.Finished;
          await tx.depositGroups.put(dg);
        }
        return computeDepositTransactionStatus(dg);
      });
    if (!txStatusNew) {
      // Doesn't exist anymore!
      return OperationAttemptResult.finishedEmpty();
    }
    // Notify if state transitioned
    if (
      txStateOld.major !== txStatusNew.major ||
      txStateOld.minor !== txStatusNew.minor
    ) {
      ws.notify({
        type: NotificationType.TransactionStateTransition,
        transactionId,
        oldTxState: txStateOld,
        newTxState: txStatusNew,
      });
    }
    // FIXME: consider other cases like aborting, suspend, ...
    if (
      txStatusNew.major === TransactionMajorState.Pending ||
      txStatusNew.major === TransactionMajorState.Aborting
    ) {
      return OperationAttemptResult.pendingEmpty();
    } else {
      return OperationAttemptResult.finishedEmpty();
    }
  }
  if (depositGroup.operationStatus === DepositOperationStatus.Aborting) {
    logger.info("processing deposit tx in 'aborting'");
    const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
    if (!abortRefreshGroupId) {
      logger.info("refunding deposit group");
      return refundDepositGroup(ws, depositGroup);
    }
    logger.info("waiting for refresh");
    return waitForRefreshOnDepositGroup(ws, depositGroup);
  }
  return OperationAttemptResult.finishedEmpty();
}
async function getExchangeWireFee(
  ws: InternalWalletState,
  wireType: string,
  baseUrl: string,
  time: TalerProtocolTimestamp,
): Promise {
  const exchangeDetails = await ws.db
    .mktx((x) => [x.exchanges, x.exchangeDetails])
    .runReadOnly(async (tx) => {
      const ex = await tx.exchanges.get(baseUrl);
      if (!ex || !ex.detailsPointer) return undefined;
      return await tx.exchangeDetails.indexes.byPointer.get([
        baseUrl,
        ex.detailsPointer.currency,
        ex.detailsPointer.masterPublicKey,
      ]);
    });
  if (!exchangeDetails) {
    throw Error(`exchange missing: ${baseUrl}`);
  }
  const fees = exchangeDetails.wireInfo.feesForType[wireType];
  if (!fees || fees.length === 0) {
    throw Error(
      `exchange ${baseUrl} doesn't have fees for wire type ${wireType}`,
    );
  }
  const fee = fees.find((x) => {
    return AbsoluteTime.isBetween(
      AbsoluteTime.fromProtocolTimestamp(time),
      AbsoluteTime.fromProtocolTimestamp(x.startStamp),
      AbsoluteTime.fromProtocolTimestamp(x.endStamp),
    );
  });
  if (!fee) {
    throw Error(
      `exchange ${exchangeDetails.exchangeBaseUrl} doesn't have fees for wire type ${wireType} at ${time.t_s}`,
    );
  }
  return fee;
}
async function trackDeposit(
  ws: InternalWalletState,
  depositGroup: DepositGroupRecord,
  dp: CoinDepositPermission,
): Promise {
  const wireHash = depositGroup.contractTermsRaw.h_wire;
  const url = new URL(
    `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${dp.coin_pub}`,
    dp.exchange_url,
  );
  const sigResp = await ws.cryptoApi.signTrackTransaction({
    coinPub: dp.coin_pub,
    contractTermsHash: depositGroup.contractTermsHash,
    merchantPriv: depositGroup.merchantPriv,
    merchantPub: depositGroup.merchantPub,
    wireHash,
  });
  url.searchParams.set("merchant_sig", sigResp.sig);
  const httpResp = await ws.http.fetch(url.href, { method: "GET" });
  logger.trace(`deposits response status: ${httpResp.status}`);
  switch (httpResp.status) {
    case HttpStatusCode.Accepted: {
      const accepted = await readSuccessResponseJsonOrThrow(
        httpResp,
        codecForTackTransactionAccepted(),
      );
      return { type: "accepted", ...accepted };
    }
    case HttpStatusCode.Ok: {
      const wired = await readSuccessResponseJsonOrThrow(
        httpResp,
        codecForTackTransactionWired(),
      );
      return { type: "wired", ...wired };
    }
    default: {
      throw Error(
        `unexpected response from track-transaction (${httpResp.status})`,
      );
    }
  }
}
/**
 * Check if creating a deposit group is possible and calculate
 * the associated fees.
 *
 * FIXME: This should be renamed to checkDepositGroup,
 * as it doesn't prepare anything
 */
export async function prepareDepositGroup(
  ws: InternalWalletState,
  req: PrepareDepositRequest,
): Promise {
  const p = parsePaytoUri(req.depositPaytoUri);
  if (!p) {
    throw Error("invalid payto URI");
  }
  const amount = Amounts.parseOrThrow(req.amount);
  const exchangeInfos: { url: string; master_pub: string }[] = [];
  await ws.db
    .mktx((x) => [x.exchanges, x.exchangeDetails])
    .runReadOnly(async (tx) => {
      const allExchanges = await tx.exchanges.iter().toArray();
      for (const e of allExchanges) {
        const details = await getExchangeDetails(tx, e.baseUrl);
        if (!details || amount.currency !== details.currency) {
          continue;
        }
        exchangeInfos.push({
          master_pub: details.masterPublicKey,
          url: e.baseUrl,
        });
      }
    });
  const now = AbsoluteTime.now();
  const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
  const contractTerms: MerchantContractTerms = {
    exchanges: exchangeInfos,
    amount: req.amount,
    max_fee: Amounts.stringify(amount),
    max_wire_fee: Amounts.stringify(amount),
    wire_method: p.targetType,
    timestamp: nowRounded,
    merchant_base_url: "",
    summary: "",
    nonce: "",
    wire_transfer_deadline: nowRounded,
    order_id: "",
    h_wire: "",
    pay_deadline: AbsoluteTime.toProtocolTimestamp(
      AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
    ),
    merchant: {
      name: "(wallet)",
    },
    merchant_pub: "",
    refund_deadline: TalerProtocolTimestamp.zero(),
  };
  const { h: contractTermsHash } = await ws.cryptoApi.hashString({
    str: canonicalJson(contractTerms),
  });
  const contractData = extractContractData(
    contractTerms,
    contractTermsHash,
    "",
  );
  const payCoinSel = await selectPayCoinsNew(ws, {
    auditors: [],
    exchanges: contractData.allowedExchanges,
    wireMethod: contractData.wireMethod,
    contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
    depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
    wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
    wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
    prevPayCoins: [],
  });
  if (payCoinSel.type !== "success") {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
      {
        insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
      },
    );
  }
  const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
  const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
    ws,
    p.targetType,
    payCoinSel.coinSel,
  );
  const fees = await getTotalFeesForDepositAmount(
    ws,
    p.targetType,
    amount,
    payCoinSel.coinSel,
  );
  return {
    totalDepositCost: Amounts.stringify(totalDepositCost),
    effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount),
    fees,
  };
}
export function generateDepositGroupTxId(): string {
  const depositGroupId = encodeCrock(getRandomBytes(32));
  return constructTransactionIdentifier({
    tag: TransactionType.Deposit,
    depositGroupId: depositGroupId,
  });
}
export async function createDepositGroup(
  ws: InternalWalletState,
  req: CreateDepositGroupRequest,
): Promise {
  const p = parsePaytoUri(req.depositPaytoUri);
  if (!p) {
    throw Error("invalid payto URI");
  }
  const amount = Amounts.parseOrThrow(req.amount);
  const exchangeInfos: { url: string; master_pub: string }[] = [];
  await ws.db
    .mktx((x) => [x.exchanges, x.exchangeDetails])
    .runReadOnly(async (tx) => {
      const allExchanges = await tx.exchanges.iter().toArray();
      for (const e of allExchanges) {
        const details = await getExchangeDetails(tx, e.baseUrl);
        if (!details || amount.currency !== details.currency) {
          continue;
        }
        exchangeInfos.push({
          master_pub: details.masterPublicKey,
          url: e.baseUrl,
        });
      }
    });
  const now = AbsoluteTime.now();
  const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
  const noncePair = await ws.cryptoApi.createEddsaKeypair({});
  const merchantPair = await ws.cryptoApi.createEddsaKeypair({});
  const wireSalt = encodeCrock(getRandomBytes(16));
  const wireHash = hashWire(req.depositPaytoUri, wireSalt);
  const contractTerms: MerchantContractTerms = {
    exchanges: exchangeInfos,
    amount: req.amount,
    max_fee: Amounts.stringify(amount),
    max_wire_fee: Amounts.stringify(amount),
    wire_method: p.targetType,
    timestamp: nowRounded,
    merchant_base_url: "",
    summary: "",
    nonce: noncePair.pub,
    wire_transfer_deadline: nowRounded,
    order_id: "",
    h_wire: wireHash,
    pay_deadline: AbsoluteTime.toProtocolTimestamp(
      AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
    ),
    merchant: {
      name: "(wallet)",
    },
    merchant_pub: merchantPair.pub,
    refund_deadline: TalerProtocolTimestamp.zero(),
  };
  const { h: contractTermsHash } = await ws.cryptoApi.hashString({
    str: canonicalJson(contractTerms),
  });
  const contractData = extractContractData(
    contractTerms,
    contractTermsHash,
    "",
  );
  const payCoinSel = await selectPayCoinsNew(ws, {
    auditors: [],
    exchanges: contractData.allowedExchanges,
    wireMethod: contractData.wireMethod,
    contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
    depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
    wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
    wireFeeLimit: Amounts.parseOrThrow(contractData.maxWireFee),
    prevPayCoins: [],
  });
  if (payCoinSel.type !== "success") {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
      {
        insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
      },
    );
  }
  const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
  let depositGroupId: string;
  if (req.transactionId) {
    const txId = parseTransactionIdentifier(req.transactionId);
    if (!txId || txId.tag !== TransactionType.Deposit) {
      throw Error("invalid transaction ID");
    }
    depositGroupId = txId.depositGroupId;
  } else {
    depositGroupId = encodeCrock(getRandomBytes(32));
  }
  const counterpartyEffectiveDepositAmount =
    await getCounterpartyEffectiveDepositAmount(
      ws,
      p.targetType,
      payCoinSel.coinSel,
    );
  const depositGroup: DepositGroupRecord = {
    contractTermsHash,
    contractTermsRaw: contractTerms,
    depositGroupId,
    noncePriv: noncePair.priv,
    noncePub: noncePair.pub,
    timestampCreated: AbsoluteTime.toPreciseTimestamp(now),
    timestampFinished: undefined,
    transactionPerCoin: payCoinSel.coinSel.coinPubs.map(
      () => DepositElementStatus.Unknown,
    ),
    payCoinSelection: payCoinSel.coinSel,
    payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
    depositedPerCoin: payCoinSel.coinSel.coinPubs.map(() => false),
    merchantPriv: merchantPair.priv,
    merchantPub: merchantPair.pub,
    totalPayCost: Amounts.stringify(totalDepositCost),
    effectiveDepositAmount: Amounts.stringify(
      counterpartyEffectiveDepositAmount,
    ),
    wire: {
      payto_uri: req.depositPaytoUri,
      salt: wireSalt,
    },
    operationStatus: DepositOperationStatus.Pending,
  };
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Deposit,
    depositGroupId,
  });
  const newTxState = await ws.db
    .mktx((x) => [
      x.depositGroups,
      x.coins,
      x.recoupGroups,
      x.denominations,
      x.refreshGroups,
      x.coinAvailability,
    ])
    .runReadWrite(async (tx) => {
      await spendCoins(ws, tx, {
        allocationId: transactionId,
        coinPubs: payCoinSel.coinSel.coinPubs,
        contributions: payCoinSel.coinSel.coinContributions.map((x) =>
          Amounts.parseOrThrow(x),
        ),
        refreshReason: RefreshReason.PayDeposit,
      });
      await tx.depositGroups.put(depositGroup);
      return computeDepositTransactionStatus(depositGroup);
    });
  ws.notify({
    type: NotificationType.TransactionStateTransition,
    transactionId,
    oldTxState: {
      major: TransactionMajorState.None,
    },
    newTxState,
  });
  return {
    depositGroupId,
    transactionId,
  };
}
/**
 * Get the amount that will be deposited on the users bank
 * account after depositing, not considering aggregation.
 */
export async function getCounterpartyEffectiveDepositAmount(
  ws: InternalWalletState,
  wireType: string,
  pcs: PayCoinSelection,
): Promise {
  const amt: AmountJson[] = [];
  const fees: AmountJson[] = [];
  const exchangeSet: Set = new Set();
  await ws.db
    .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails])
    .runReadOnly(async (tx) => {
      for (let i = 0; i < pcs.coinPubs.length; i++) {
        const coin = await tx.coins.get(pcs.coinPubs[i]);
        if (!coin) {
          throw Error("can't calculate deposit amount, coin not found");
        }
        const denom = await ws.getDenomInfo(
          ws,
          tx,
          coin.exchangeBaseUrl,
          coin.denomPubHash,
        );
        if (!denom) {
          throw Error("can't find denomination to calculate deposit amount");
        }
        amt.push(Amounts.parseOrThrow(pcs.coinContributions[i]));
        fees.push(Amounts.parseOrThrow(denom.feeDeposit));
        exchangeSet.add(coin.exchangeBaseUrl);
      }
      for (const exchangeUrl of exchangeSet.values()) {
        const exchangeDetails = await getExchangeDetails(tx, exchangeUrl);
        if (!exchangeDetails) {
          continue;
        }
        // FIXME/NOTE: the line below _likely_ throws exception
        // about "find method not found on undefined" when the wireType
        // is not supported by the Exchange.
        const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
          return AbsoluteTime.isBetween(
            AbsoluteTime.now(),
            AbsoluteTime.fromProtocolTimestamp(x.startStamp),
            AbsoluteTime.fromProtocolTimestamp(x.endStamp),
          );
        })?.wireFee;
        if (fee) {
          fees.push(Amounts.parseOrThrow(fee));
        }
      }
    });
  return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
}
/**
 * Get the fee amount that will be charged when trying to deposit the
 * specified amount using the selected coins and the wire method.
 */
export async function getTotalFeesForDepositAmount(
  ws: InternalWalletState,
  wireType: string,
  total: AmountJson,
  pcs: PayCoinSelection,
): Promise {
  const wireFee: AmountJson[] = [];
  const coinFee: AmountJson[] = [];
  const refreshFee: AmountJson[] = [];
  const exchangeSet: Set = new Set();
  await ws.db
    .mktx((x) => [x.coins, x.denominations, x.exchanges, x.exchangeDetails])
    .runReadOnly(async (tx) => {
      for (let i = 0; i < pcs.coinPubs.length; i++) {
        const coin = await tx.coins.get(pcs.coinPubs[i]);
        if (!coin) {
          throw Error("can't calculate deposit amount, coin not found");
        }
        const denom = await ws.getDenomInfo(
          ws,
          tx,
          coin.exchangeBaseUrl,
          coin.denomPubHash,
        );
        if (!denom) {
          throw Error("can't find denomination to calculate deposit amount");
        }
        coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
        exchangeSet.add(coin.exchangeBaseUrl);
        const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
          .iter(coin.exchangeBaseUrl)
          .filter((x) =>
            Amounts.isSameCurrency(
              DenominationRecord.getValue(x),
              pcs.coinContributions[i],
            ),
          );
        const amountLeft = Amounts.sub(
          denom.value,
          pcs.coinContributions[i],
        ).amount;
        const refreshCost = getTotalRefreshCost(
          allDenoms,
          denom,
          amountLeft,
          ws.config.testing.denomselAllowLate,
        );
        refreshFee.push(refreshCost);
      }
      for (const exchangeUrl of exchangeSet.values()) {
        const exchangeDetails = await getExchangeDetails(tx, exchangeUrl);
        if (!exchangeDetails) {
          continue;
        }
        const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find(
          (x) => {
            return AbsoluteTime.isBetween(
              AbsoluteTime.now(),
              AbsoluteTime.fromProtocolTimestamp(x.startStamp),
              AbsoluteTime.fromProtocolTimestamp(x.endStamp),
            );
          },
        )?.wireFee;
        if (fee) {
          wireFee.push(Amounts.parseOrThrow(fee));
        }
      }
    });
  return {
    coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount),
    wire: Amounts.stringify(Amounts.sumOrZero(total.currency, wireFee).amount),
    refresh: Amounts.stringify(
      Amounts.sumOrZero(total.currency, refreshFee).amount,
    ),
  };
}