/*
 This file is part of GNU Taler
 (C) 2019-2025 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/>
 */

/**
 * Implementation of the payment operation, including downloading and
 * claiming of proposals.
 *
 * @author Florian Dold
 */

/**
 * Imports.
 */
import {
  AbortingCoin,
  AbortRequest,
  AbsoluteTime,
  AmountJson,
  Amounts,
  AmountString,
  assertUnreachable,
  BlindedDonationReceiptKeyPair,
  checkDbInvariant,
  checkLogicInvariant,
  CheckPayTemplateReponse,
  CheckPayTemplateRequest,
  ChoiceSelectionDetail,
  ChoiceSelectionDetailType,
  codecForAbortResponse,
  codecForMerchantContractTerms,
  codecForMerchantOrderStatusPaid,
  codecForMerchantOrderStatusUnpaid,
  codecForMerchantPayResponse,
  codecForPostOrderResponse,
  codecForWalletRefundResponse,
  codecForWalletTemplateDetails,
  CoinDepositPermission,
  CoinRefreshRequest,
  ConfirmPayResult,
  ConfirmPayResultType,
  ContractTermsUtil,
  DenomKeyType,
  DownloadedContractData,
  Duration,
  encodeCrock,
  ForcedCoinSel,
  GetChoicesForPaymentResult,
  getRandomBytes,
  HashCodeString,
  hashPayWalletData,
  HostPortPath,
  HttpStatusCode,
  j2s,
  Logger,
  makeErrorDetail,
  makePendingOperationFailedError,
  makeTalerErrorDetail,
  MerchantCoinRefundStatus,
  MerchantContractInputType,
  MerchantContractOutputType,
  MerchantContractTerms,
  MerchantContractTermsV1,
  MerchantContractVersion,
  MerchantPayResponse,
  MerchantUsingTemplateDetails,
  NotificationType,
  OrderShortInfo,
  parsePayTemplateUri,
  parsePayUri,
  parseTalerUri,
  PayCoinSelection,
  PaymentInsufficientBalanceDetails,
  PayWalletData,
  PreparePayResult,
  PreparePayResultType,
  PreparePayTemplateRequest,
  randomBytes,
  RefreshReason,
  RefundInfoShort,
  RefundPaymentInfo,
  ScopeInfo,
  ScopeType,
  SelectedProspectiveCoin,
  SharePaymentResult,
  SignedTokenEnvelope,
  StartRefundQueryForUriResponse,
  stringifyPayUri,
  stringifyTalerUri,
  TalerError,
  TalerErrorCode,
  TalerErrorDetail,
  TalerMerchantApi,
  TalerMerchantInstanceHttpClient,
  TalerPreciseTimestamp,
  TalerUriAction,
  TokenUseSig,
  Transaction,
  TransactionAction,
  TransactionIdStr,
  TransactionMajorState,
  TransactionMinorState,
  TransactionState,
  TransactionType,
  URL,
  WalletNotification,
} from "@gnu-taler/taler-util";
import {
  getHttpResponseErrorDetails,
  HttpResponse,
  readResponseJsonOrThrow,
  readSuccessResponseJsonOrThrow,
  readTalerErrorResponse,
  throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
  PreviousPayCoins,
  selectPayCoins,
  selectPayCoinsInTx,
} from "./coinSelection.js";
import {
  cancelableFetch,
  cancelableLongPoll,
  constructTaskIdentifier,
  genericWaitForState,
  genericWaitForStateVal,
  getGenericRecordHandle,
  LookupFullTransactionOpts,
  PendingTaskType,
  RecordHandle,
  spendCoins,
  spendTokens,
  TaskIdentifiers,
  TaskIdStr,
  TaskRunResult,
  TaskRunResultType,
  TransactionContext,
} from "./common.js";
import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js";
import {
  CoinRecord,
  ConfigRecordKey,
  DbCoinSelection,
  DenominationRecord,
  DonationPlanchetRecord,
  PurchaseRecord,
  PurchaseStatus,
  RefundGroupRecord,
  RefundGroupStatus,
  RefundItemRecord,
  RefundItemStatus,
  RefundReason,
  SlateRecord,
  timestampPreciseFromDb,
  timestampPreciseToDb,
  timestampProtocolFromDb,
  timestampProtocolToDb,
  TokenRecord,
  WalletDbAllStoresReadOnlyTransaction,
  WalletDbAllStoresReadWriteTransaction,
  WalletDbHelpers,
  WalletDbReadOnlyTransaction,
  WalletDbReadWriteTransaction,
} from "./db.js";
import { acceptDonauBlindSigs, generateDonauPlanchets } from "./donau.js";
import { getScopeForAllCoins, getScopeForAllExchanges } from "./exchanges.js";
import {
  calculateRefreshOutput,
  createRefreshGroup,
  getTotalRefreshCost,
} from "./refresh.js";
import {
  selectPayTokensInTx,
  SelectPayTokensResult,
} from "./tokenSelection.js";
import {
  applyNotifyTransition,
  BalanceEffect,
  constructTransactionIdentifier,
  isUnsuccessfulTransaction,
  parseTransactionIdentifier,
} from "./transactions.js";
import {
  EXCHANGE_COINS_LOCK,
  getDenomInfo,
  WalletExecutionContext,
  walletMerchantClient,
} from "./wallet.js";

/**
 * Logger.
 */
const logger = new Logger("pay-merchant.ts");

/**
 * Context for a merchant pay transaction.
 *
 * Used in every task and request handlers.
 */
export class PayMerchantTransactionContext implements TransactionContext {
  readonly transactionId: TransactionIdStr;
  readonly taskId: TaskIdStr;

  constructor(
    public wex: WalletExecutionContext,
    public proposalId: string,
  ) {
    this.transactionId = constructTransactionIdentifier({
      tag: TransactionType.Payment,
      proposalId,
    });
    this.taskId = constructTaskIdentifier({
      tag: PendingTaskType.Purchase,
      proposalId,
    });
  }

  /**
   * Function that updates the metadata of the transaction.
   *
   * Must be called each time the DB record for the transaction is updated.
   */
  async updateTransactionMeta(
    tx: WalletDbReadWriteTransaction<["purchases", "transactionsMeta"]>,
  ): Promise<void> {
    const purchaseRec = await tx.purchases.get(this.proposalId);
    if (!purchaseRec) {
      await tx.transactionsMeta.delete(this.transactionId);
      return;
    }
    if (!purchaseRec.download) {
      // Not ready yet.
      await tx.transactionsMeta.delete(this.transactionId);
      return;
    }

    await tx.transactionsMeta.put({
      transactionId: this.transactionId,
      status: purchaseRec.purchaseStatus,
      timestamp: purchaseRec.timestamp,
      currency: purchaseRec.download?.currency ?? "UNKNOWN",
      exchanges: purchaseRec.exchanges ?? [],
    });
  }

  async lookupFullTransaction(
    tx: WalletDbAllStoresReadOnlyTransaction,
    req?: LookupFullTransactionOpts,
  ): Promise<Transaction | undefined> {
    const proposalId = this.proposalId;
    const purchaseRec = await tx.purchases.get(proposalId);
    if (!purchaseRec) throw Error("not found");
    const download = await expectProposalDownloadInTx(
      this.wex,
      tx,
      purchaseRec,
    );
    const contractData = download.contractTerms;
    const payOpId = TaskIdentifiers.forPay(purchaseRec);
    const payRetryRec = await tx.operationRetries.get(payOpId);

    const refundsInfo = await tx.refundGroups.indexes.byProposalId.getAll(
      purchaseRec.proposalId,
    );

    let amountRaw: AmountString = "UNKNOWN:0";
    if (
      contractData.version === undefined ||
      contractData.version === MerchantContractVersion.V0
    ) {
      amountRaw = contractData.amount;
    } else if (contractData.version === MerchantContractVersion.V1) {
      const index = purchaseRec.choiceIndex;
      if (index !== undefined) {
        if (!(index in contractData.choices))
          throw Error(`invalid choice index ${index}`);
        amountRaw = contractData.choices[index].amount;
      }
    }

    let zero = Amounts.zeroOfAmount(amountRaw);

    const info: OrderShortInfo = {
      merchant: {
        name: contractData.merchant.name,
        address: contractData.merchant.address,
        email: contractData.merchant.email,
        jurisdiction: contractData.merchant.jurisdiction,
        website: contractData.merchant.website,
      },
      orderId: contractData.order_id,
      summary: contractData.summary,
      summary_i18n: contractData.summary_i18n,
      contractTermsHash: download.contractTermsHash,
    };

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

    const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
      amountEffective: r.amountEffective,
      amountRaw: r.amountRaw,
      timestamp: TalerPreciseTimestamp.round(
        timestampPreciseFromDb(r.timestampCreated),
      ),
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.Refund,
        refundGroupId: r.refundGroupId,
      }),
    }));

    const txState = computePayMerchantTransactionState(purchaseRec);

    let scopes: ScopeInfo[];
    let amountEffective: AmountString;

    if (!purchaseRec.payInfo) {
      amountEffective = Amounts.stringify(zero);
    } else {
      amountEffective = isUnsuccessfulTransaction(txState)
        ? Amounts.stringify(zero)
        : Amounts.stringify(purchaseRec.payInfo.totalPayCost);
    }

    if (purchaseRec.exchanges && purchaseRec.exchanges.length > 0) {
      scopes = await getScopeForAllExchanges(tx, purchaseRec.exchanges);
    } else if (purchaseRec.payInfo) {
      // This should not be necessary, as we now track
      // involved exchanges directly in the purchase.
      const coinList = !purchaseRec.payInfo.payCoinSelection
        ? []
        : purchaseRec.payInfo.payCoinSelection.coinPubs;
      scopes = await getScopeForAllCoins(tx, coinList);
    } else {
      scopes = [
        {
          type: ScopeType.Global,
          currency: "UNKNOWN",
        },
      ];
    }

    let contractTerms: MerchantContractTerms | undefined;
    if (req?.includeContractTerms) {
      if (!this.wex.ws.config.features.enableV1Contracts) {
        contractTerms = ContractTermsUtil.downgradeContractTerms(
          download.contractTerms,
        );
      } else {
        contractTerms = download.contractTerms;
      }
    }

    return {
      type: TransactionType.Payment,
      txState,
      stId: purchaseRec.purchaseStatus,
      scopes,
      txActions: computePayMerchantTransactionActions(purchaseRec),
      amountRaw,
      amountEffective,
      totalRefundRaw: Amounts.stringify(zero), // FIXME!
      totalRefundEffective: Amounts.stringify(zero), // FIXME!
      refundPending:
        purchaseRec.refundAmountAwaiting === undefined
          ? undefined
          : Amounts.stringify(purchaseRec.refundAmountAwaiting),
      refunds,
      posConfirmation: purchaseRec.posConfirmation,
      timestamp: timestampPreciseFromDb(purchaseRec.timestamp),
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.Payment,
        proposalId: purchaseRec.proposalId,
      }),
      abortReason: purchaseRec.abortReason,
      info,
      contractTerms,
      refundQueryActive:
        purchaseRec.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
      ...(payRetryRec?.lastError ? { error: payRetryRec.lastError } : {}),
    };
  }

  async deleteTransaction(): Promise<void> {
    await this.wex.db.runReadWriteTx(
      {
        storeNames: ["purchases", "tombstones", "transactionsMeta"],
      },
      async (tx) => {
        return this.deleteTransactionInTx(tx);
      },
    );
  }

  async deleteTransactionInTx(
    tx: WalletDbReadWriteTransaction<
      ["purchases", "tombstones", "transactionsMeta"]
    >,
    opts: { keepRelated?: boolean } = {},
  ): Promise<void> {
    const [rec, h] = await this.getRecordHandle(tx);
    if (!rec) {
      return;
    }
    let relatedTransactions: PurchaseRecord[] = [];
    // Automatically delete transactions that are a repurchase of this transaction,
    // as they're typically hidden.
    if (
      !opts?.keepRelated &&
      rec.download?.fulfillmentUrl &&
      rec.purchaseStatus !== PurchaseStatus.DoneRepurchaseDetected
    ) {
      const otherTransactions =
        await tx.purchases.indexes.byFulfillmentUrl.getAll(
          rec.download.fulfillmentUrl,
        );
      for (const otx of otherTransactions) {
        if (
          otx.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected &&
          otx.repurchaseProposalId === rec.proposalId
        ) {
          relatedTransactions.push(otx);
        }
      }
    }
    for (const rt of relatedTransactions) {
      const otherCtx = new PayMerchantTransactionContext(
        this.wex,
        rt.proposalId,
      );
      await otherCtx.deleteTransactionInTx(tx, { keepRelated: true });
    }
    await h.update(undefined);
  }

  async suspendTransaction(): Promise<void> {
    const { wex, proposalId, transactionId } = this;
    wex.taskScheduler.stopShepherdTask(this.taskId);
    await wex.db.runReadWriteTx(
      { storeNames: ["purchases", "transactionsMeta"] },
      async (tx) => {
        const purchase = await tx.purchases.get(proposalId);
        if (!purchase) {
          throw Error("purchase not found");
        }
        const oldTxState = computePayMerchantTransactionState(purchase);
        const oldStId = purchase.purchaseStatus;
        let newStatus = transitionSuspend[purchase.purchaseStatus];
        if (!newStatus) {
          return;
        }
        await tx.purchases.put(purchase);
        await this.updateTransactionMeta(tx);
        const newTxState = computePayMerchantTransactionState(purchase);
        const newStId = purchase.purchaseStatus;
        applyNotifyTransition(tx.notify, transactionId, {
          oldTxState,
          newTxState,
          balanceEffect: BalanceEffect.None,
          oldStId,
          newStId,
        });
      },
    );
  }

  async abortTransaction(reason?: TalerErrorDetail): Promise<void> {
    const { wex } = this;
    await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
      const [purchase, h] = await this.getRecordHandle(tx);
      if (!purchase) {
        throw Error("purchase not found");
      }
      switch (purchase.purchaseStatus) {
        case PurchaseStatus.Done:
          return;
        case PurchaseStatus.PendingPaying:
        case PurchaseStatus.SuspendedPaying: {
          purchase.abortReason = reason;
          purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
          if (purchase.payInfo && purchase.payInfo.payCoinSelection) {
            const coinSel = purchase.payInfo.payCoinSelection;
            const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost);
            const refreshCoins: CoinRefreshRequest[] = [];
            for (let i = 0; i < coinSel.coinPubs.length; i++) {
              refreshCoins.push({
                amount: coinSel.coinContributions[i],
                coinPub: coinSel.coinPubs[i],
              });
            }
            await createRefreshGroup(
              wex,
              tx,
              currency,
              refreshCoins,
              RefreshReason.AbortPay,
              this.transactionId,
            );
          }
          break;
        }
        case PurchaseStatus.PendingQueryingAutoRefund:
        case PurchaseStatus.SuspendedQueryingAutoRefund:
        case PurchaseStatus.PendingAcceptRefund:
        case PurchaseStatus.SuspendedPendingAcceptRefund:
        case PurchaseStatus.PendingQueryingRefund:
        case PurchaseStatus.SuspendedQueryingRefund:
          if (!purchase.timestampFirstSuccessfulPay) {
            throw Error("invalid state");
          }
          purchase.purchaseStatus = PurchaseStatus.Done;
          break;
        case PurchaseStatus.DialogProposed:
          purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
          break;
        default:
          return;
      }
      await h.update(purchase);
    });
    wex.taskScheduler.stopShepherdTask(this.taskId);
    wex.taskScheduler.startShepherdTask(this.taskId);
  }

  async resumeTransaction(): Promise<void> {
    const { wex } = this;
    await wex.db.runReadWriteTx(
      { storeNames: ["purchases", "transactionsMeta"] },
      async (tx) => {
        const [purchase, h] = await this.getRecordHandle(tx);
        if (!purchase) {
          throw Error("purchase not found");
        }
        let newStatus = transitionResume[purchase.purchaseStatus];
        if (!newStatus?.next) {
          return;
        }
        purchase.purchaseStatus = newStatus.next;
        await h.update(purchase, BalanceEffect.Any);
      },
    );
    wex.taskScheduler.startShepherdTask(this.taskId);
  }

  async failTransaction(reason?: TalerErrorDetail): Promise<void> {
    const { wex } = this;
    await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
      const [purchase, h] = await this.getRecordHandle(tx);
      if (!purchase) {
        throw Error("purchase not found");
      }
      switch (purchase.purchaseStatus) {
        case PurchaseStatus.AbortingWithRefund:
          purchase.purchaseStatus = PurchaseStatus.FailedAbort;
          purchase.failReason = reason;
          break;
        default:
          return;
      }
      await h.update(purchase, BalanceEffect.Any);
      tx._util.scheduleOnCommit(() =>
        wex.taskScheduler.stopShepherdTask(this.taskId),
      );
    });
  }

  async getRecordHandle(
    tx: WalletDbReadWriteTransaction<["purchases", "transactionsMeta"]>,
  ): Promise<[PurchaseRecord | undefined, RecordHandle<PurchaseRecord>]> {
    return getGenericRecordHandle<PurchaseRecord>(
      this,
      tx as any,
      async () => tx.purchases.get(this.proposalId),
      async (r) => {
        await tx.purchases.put(r);
      },
      async () => tx.purchases.delete(this.proposalId),
      (r) => computePayMerchantTransactionState(r),
      (r) => r.purchaseStatus,
      () => this.updateTransactionMeta(tx),
    );
  }
}

export class RefundTransactionContext implements TransactionContext {
  public transactionId: TransactionIdStr;
  public taskId: TaskIdStr | undefined = undefined;

  constructor(
    public wex: WalletExecutionContext,
    public refundGroupId: string,
  ) {
    this.transactionId = constructTransactionIdentifier({
      tag: TransactionType.Refund,
      refundGroupId,
    });
  }

  /**
   * Function that updates the metadata of the transaction.
   *
   * Must be called each time the DB record for the transaction is updated.
   */
  async updateTransactionMeta(
    tx: WalletDbReadWriteTransaction<["refundGroups", "transactionsMeta"]>,
  ): Promise<void> {
    const refundRec = await tx.refundGroups.get(this.refundGroupId);
    if (!refundRec) {
      await tx.transactionsMeta.delete(this.transactionId);
      return;
    }
    await tx.transactionsMeta.put({
      transactionId: this.transactionId,
      status: refundRec.status,
      timestamp: refundRec.timestampCreated,
      currency: Amounts.currencyOf(refundRec.amountEffective),
      // FIXME!
      exchanges: [],
    });
  }

  async lookupFullTransaction(
    tx: WalletDbAllStoresReadOnlyTransaction,
  ): Promise<Transaction | undefined> {
    const refundRecord = await tx.refundGroups.get(this.refundGroupId);
    if (!refundRecord) {
      throw Error("not found");
    }
    const maybeContractData = await lookupMaybeContractData(
      tx,
      refundRecord?.proposalId,
    );

    let paymentInfo: RefundPaymentInfo | undefined = undefined;

    if (maybeContractData) {
      paymentInfo = {
        merchant: maybeContractData.contractTerms.merchant,
        summary: maybeContractData.contractTerms.summary,
        summary_i18n: maybeContractData.contractTerms.summary_i18n,
      };
    }
    const purchaseRecord = await tx.purchases.get(refundRecord.proposalId);

    const txState = computeRefundTransactionState(refundRecord);
    return {
      type: TransactionType.Refund,
      scopes: await getScopeForAllCoins(
        tx,
        !purchaseRecord || !purchaseRecord.payInfo?.payCoinSelection
          ? []
          : purchaseRecord.payInfo.payCoinSelection.coinPubs,
      ),
      amountEffective: isUnsuccessfulTransaction(txState)
        ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
        : refundRecord.amountEffective,
      amountRaw: refundRecord.amountRaw,
      refundedTransactionId: constructTransactionIdentifier({
        tag: TransactionType.Payment,
        proposalId: refundRecord.proposalId,
      }),
      timestamp: timestampPreciseFromDb(refundRecord.timestampCreated),
      transactionId: constructTransactionIdentifier({
        tag: TransactionType.Refund,
        refundGroupId: refundRecord.refundGroupId,
      }),
      txState,
      stId: refundRecord.status,
      txActions: [TransactionAction.Delete],
      paymentInfo,
    };
  }

  async deleteTransaction(): Promise<void> {
    const { wex } = this;

    const res = await wex.db.runReadWriteTx(
      {
        storeNames: [
          "refundGroups",
          "refundItems",
          "tombstones",
          "transactionsMeta",
        ],
      },
      async (tx) => {
        return await this.deleteTransactionInTx(tx);
      },
    );
    for (const notif of res.notifs) {
      this.wex.ws.notify(notif);
    }
  }

  async deleteTransactionInTx(
    tx: WalletDbReadWriteTransaction<
      ["refundGroups", "refundItems", "tombstones", "transactionsMeta"]
    >,
  ): Promise<{ notifs: WalletNotification[] }> {
    const notifs: WalletNotification[] = [];
    const refundRecord = await tx.refundGroups.get(this.refundGroupId);
    if (!refundRecord) {
      return { notifs };
    }
    await tx.refundGroups.delete(this.refundGroupId);
    await this.updateTransactionMeta(tx);
    await tx.tombstones.put({ id: this.transactionId });
    const items = await tx.refundItems.indexes.byRefundGroupId.getAll([
      refundRecord.refundGroupId,
    ]);
    for (const item of items) {
      if (item.id != null) {
        await tx.refundItems.delete(item.id);
      }
    }
    return { notifs };
  }

  suspendTransaction(): Promise<void> {
    throw new Error("Unsupported operation");
  }

  abortTransaction(): Promise<void> {
    throw new Error("Unsupported operation");
  }

  resumeTransaction(): Promise<void> {
    throw new Error("Unsupported operation");
  }

  failTransaction(): Promise<void> {
    throw new Error("Unsupported operation");
  }
}

async function lookupMaybeContractData(
  tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>,
  proposalId: string,
): Promise<DownloadedContractData | undefined> {
  let contractData: DownloadedContractData | 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 = {
      contractTerms: contractTermsRecord.contractTermsRaw,
      contractTermsHash: contractTermsRecord.h,
      contractTermsRaw: codecForMerchantContractTerms().decode(
        contractTermsRecord.contractTermsRaw,
      ),
    };
  }

  return contractData;
}

/**
 * Compute the total cost of a payment to the customer.
 *
 * This includes the amount taken by the merchant, fees (wire/deposit) contributed
 * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
 * of coins that are too small to spend.
 */
export async function getTotalPaymentCost(
  wex: WalletExecutionContext,
  currency: string,
  pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
  return wex.db.runReadOnlyTx(
    { storeNames: ["coins", "denominations"] },
    async (tx) => {
      return getTotalPaymentCostInTx(wex, tx, currency, pcs);
    },
  );
}

export async function getTotalPaymentCostInTx(
  wex: WalletExecutionContext,
  tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
  currency: string,
  pcs: SelectedProspectiveCoin[],
): Promise<AmountJson> {
  const costs: AmountJson[] = [];
  for (let i = 0; i < pcs.length; i++) {
    const denom = await tx.denominations.get([
      pcs[i].exchangeBaseUrl,
      pcs[i].denomPubHash,
    ]);
    if (!denom) {
      throw Error(
        "can't calculate payment cost, denomination for coin not found",
      );
    }
    const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
    const refreshCost = await getTotalRefreshCost(
      wex,
      tx,
      DenominationRecord.toDenomInfo(denom),
      amountLeft,
    );
    costs.push(Amounts.parseOrThrow(pcs[i].contribution));
    costs.push(refreshCost);
  }
  const zero = Amounts.zeroOfCurrency(currency);
  return Amounts.sum([zero, ...costs]).amount;
}

async function failProposalPermanently(
  wex: WalletExecutionContext,
  proposalId: string,
  err: TalerErrorDetail,
): Promise<void> {
  const ctx = new PayMerchantTransactionContext(wex, proposalId);
  await wex.db.runReadWriteTx(
    { storeNames: ["purchases", "transactionsMeta"] },
    async (tx) => {
      const p = await tx.purchases.get(proposalId);
      if (!p) {
        return;
      }
      // FIXME: We don't store the error detail here?!
      const oldTxState = computePayMerchantTransactionState(p);
      const oldStId = p.purchaseStatus;
      p.purchaseStatus = PurchaseStatus.FailedClaim;
      const newTxState = computePayMerchantTransactionState(p);
      const newStId = p.purchaseStatus;
      await tx.purchases.put(p);
      await ctx.updateTransactionMeta(tx);
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        newStId,
        oldStId,
      });
    },
  );
}

function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
  return Duration.multiply(
    { d_ms: 15000 },
    1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5,
  );
}

export async function expectProposalDownloadByIdInTx(
  wex: WalletExecutionContext,
  tx: WalletDbReadOnlyTransaction<["contractTerms", "purchases"]>,
  proposalId: string,
): Promise<DownloadedContractData> {
  const rec = await tx.purchases.get(proposalId);
  if (!rec) {
    throw Error("purchase record not found");
  }
  return await expectProposalDownloadInTx(wex, tx, rec);
}

export async function expectProposalDownloadInTx(
  wex: WalletExecutionContext,
  tx: WalletDbReadOnlyTransaction<["contractTerms"]>,
  p: PurchaseRecord,
): Promise<DownloadedContractData> {
  if (!p.download) {
    throw Error("expected proposal to be downloaded");
  }
  const download = p.download;

  const contractTermsRec = await tx.contractTerms.get(
    download.contractTermsHash,
  );
  if (!contractTermsRec) {
    throw Error("contract terms not found");
  }

  return {
    contractTerms: codecForMerchantContractTerms().decode(
      contractTermsRec.contractTermsRaw,
    ),
    contractTermsRaw: contractTermsRec.contractTermsRaw,
    contractTermsHash: contractTermsRec.h,
  };
}

/**
 * Return the proposal download data for a purchase, throw if not available.
 */
async function expectProposalDownload(
  wex: WalletExecutionContext,
  p: PurchaseRecord,
): Promise<DownloadedContractData> {
  return await wex.db.runReadOnlyTx(
    { storeNames: ["contractTerms"] },
    async (tx) => {
      return expectProposalDownloadInTx(wex, tx, p);
    },
  );
}

async function processDownloadProposal(
  wex: WalletExecutionContext,
  proposalId: string,
): Promise<TaskRunResult> {
  const proposal = await wex.db.runReadOnlyTx(
    { storeNames: ["purchases"] },
    async (tx) => {
      return await tx.purchases.get(proposalId);
    },
  );

  if (!proposal) {
    return TaskRunResult.finished();
  }

  const ctx = new PayMerchantTransactionContext(wex, proposalId);

  if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) {
    logger.error(
      `unexpected state ${proposal.purchaseStatus}/${
        PurchaseStatus[proposal.purchaseStatus]
      } for ${ctx.transactionId} in processDownloadProposal`,
    );
    return TaskRunResult.finished();
  }

  const transactionId = ctx.transactionId;

  const orderClaimUrl = new URL(
    `orders/${proposal.orderId}/claim`,
    proposal.merchantBaseUrl,
  );
  logger.trace("downloading contract from '" + orderClaimUrl + "'");

  const requestBody: {
    nonce: string;
    token?: string;
  } = {
    nonce: proposal.noncePub,
  };
  if (proposal.claimToken) {
    requestBody.token = proposal.claimToken;
  }

  const merchantClient = walletMerchantClient(proposal.merchantBaseUrl, wex);

  const claimResp = await merchantClient.claimOrder({
    orderId: proposal.orderId,
    body: requestBody,
  });

  switch (claimResp.case) {
    case "ok":
      break;
    case HttpStatusCode.Conflict:
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
        {
          orderId: proposal.orderId,
          claimUrl: orderClaimUrl.href,
        },
        "order already claimed (likely by other wallet)",
      );
    default:
      assertUnreachable(claimResp);
  }

  const proposalResp = claimResp.body;

  // The proposalResp contains the contract terms as raw JSON,
  // as the code to parse them doesn't necessarily round-trip.
  // We need this raw JSON to compute the contract terms hash.

  // FIXME: Do better error handling, check if the
  // contract terms have all their forgettable information still
  // present.  The wallet should never accept contract terms
  // with missing information from the merchant.

  var isWellFormed = ContractTermsUtil.validateForgettable(
    proposalResp.contract_terms,
  );

  if (!isWellFormed) {
    logger.trace(
      `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
    );
    const err = makeErrorDetail(
      TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
      {},
      "validation for well-formedness failed",
    );
    await failProposalPermanently(wex, proposalId, err);
    throw makePendingOperationFailedError(
      err,
      TransactionType.Payment,
      proposalId,
    );
  }

  const contractTermsHash = ContractTermsUtil.hashContractTerms(
    proposalResp.contract_terms,
  );

  let parsedContractTerms: MerchantContractTerms;

  try {
    parsedContractTerms = codecForMerchantContractTerms().decode(
      proposalResp.contract_terms,
    );
  } catch (e) {
    const err = makeErrorDetail(
      TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
      {},
      `schema validation failed: ${e}`,
    );
    await failProposalPermanently(wex, proposalId, err);
    throw makePendingOperationFailedError(
      err,
      TransactionType.Payment,
      proposalId,
    );
  }

  isWellFormed = ContractTermsUtil.validateParsed(parsedContractTerms);

  if (!isWellFormed) {
    logger.trace(
      `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
    );
    const err = makeErrorDetail(
      TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
      {},
      "validation for well-formedness failed",
    );
    await failProposalPermanently(wex, proposalId, err);
    throw makePendingOperationFailedError(
      err,
      TransactionType.Payment,
      proposalId,
    );
  }

  const sigValid = await wex.cryptoApi.isValidContractTermsSignature({
    contractTermsHash,
    merchantPub: parsedContractTerms.merchant_pub,
    sig: proposalResp.sig,
  });

  if (!sigValid) {
    const err = makeErrorDetail(
      TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
      {
        merchantPub: parsedContractTerms.merchant_pub,
        orderId: parsedContractTerms.order_id,
      },
      "merchant's signature on contract terms is invalid",
    );
    await failProposalPermanently(wex, proposalId, err);
    throw makePendingOperationFailedError(
      err,
      TransactionType.Payment,
      proposalId,
    );
  }

  const fulfillmentUrl = parsedContractTerms.fulfillment_url;

  const baseUrlForDownload = proposal.merchantBaseUrl;
  const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;

  if (baseUrlForDownload !== baseUrlFromContractTerms) {
    const err = makeErrorDetail(
      TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
      {
        baseUrlForDownload,
        baseUrlFromContractTerms,
      },
      "merchant base URL mismatch",
    );
    await failProposalPermanently(wex, proposalId, err);
    throw makePendingOperationFailedError(
      err,
      TransactionType.Payment,
      proposalId,
    );
  }

  const contractData: DownloadedContractData = {
    contractTerms: codecForMerchantContractTerms().decode(
      proposalResp.contract_terms,
    ),
    contractTermsHash: contractTermsHash,
    contractTermsRaw: proposalResp.contract_terms,
  };

  logger.trace(`extracted contract data: ${j2s(contractData)}`);

  await wex.db.runReadWriteTx(
    { storeNames: ["purchases", "contractTerms", "transactionsMeta"] },
    async (tx) => {
      const p = await tx.purchases.get(proposalId);
      if (!p) {
        return;
      }
      if (p.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal) {
        return;
      }
      const oldTxState = computePayMerchantTransactionState(p);
      const oldStId = p.purchaseStatus;

      const secretSeed = encodeCrock(getRandomBytes(32));
      p.secretSeed = secretSeed;

      // v1: currency is resolved after choice selection
      let currency: string = "UNKNOWN";
      if (
        contractData.contractTerms.version === undefined ||
        contractData.contractTerms.version === MerchantContractVersion.V0
      ) {
        currency = Amounts.currencyOf(contractData.contractTerms.amount);
      } else if (
        contractData.contractTerms.version === MerchantContractVersion.V1
      ) {
        // if there is only one choice, or all choices have the same currency
        if (contractData.contractTerms.choices.length === 1) {
          currency = Amounts.currencyOf(
            contractData.contractTerms.choices[0].amount,
          );
        } else if (contractData.contractTerms.choices.length > 1) {
          const firstCurrency = Amounts.currencyOf(
            contractData.contractTerms.choices[0].amount,
          );
          const allSame = contractData.contractTerms.choices.every(
            (c) => Amounts.currencyOf(c.amount) === firstCurrency,
          );
          if (allSame) {
            currency = firstCurrency;
          }
        }
      }

      p.download = {
        contractTermsHash,
        contractTermsMerchantSig: proposalResp.sig,
        currency,
        fulfillmentUrl: contractData.contractTerms.fulfillment_url,
      };
      await tx.contractTerms.put({
        h: contractTermsHash,
        contractTermsRaw: proposalResp.contract_terms,
      });
      const isResourceFulfillmentUrl =
        fulfillmentUrl &&
        (fulfillmentUrl.startsWith("http://") ||
          fulfillmentUrl.startsWith("https://"));
      let repurchase: PurchaseRecord | undefined = undefined;
      const otherPurchases =
        await tx.purchases.indexes.byFulfillmentUrl.getAll(fulfillmentUrl);
      if (isResourceFulfillmentUrl) {
        for (const otherPurchase of otherPurchases) {
          if (
            otherPurchase.purchaseStatus == PurchaseStatus.Done ||
            otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying ||
            otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay
          ) {
            repurchase = otherPurchase;
            break;
          }
        }
      }

      // FIXME: Adjust this to account for refunds, don't count as repurchase
      // if original order is refunded.
      if (repurchase) {
        logger.warn("repurchase detected");
        p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
        p.repurchaseProposalId = repurchase.proposalId;
        await tx.purchases.put(p);
      } else {
        p.purchaseStatus = p.shared
          ? PurchaseStatus.DialogShared
          : PurchaseStatus.DialogProposed;
        await tx.purchases.put(p);
      }
      await ctx.updateTransactionMeta(tx);
      const newTxState = computePayMerchantTransactionState(p);
      const newStId = p.purchaseStatus;
      applyNotifyTransition(tx.notify, transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.None,
        oldStId,
        newStId,
      });
    },
  );

  return TaskRunResult.progress();
}

async function generateSlate(
  wex: WalletExecutionContext,
  purchase: PurchaseRecord,
  contractData: MerchantContractTermsV1,
  contractTermsRaw: any,
  choiceIndex: number,
  outputIndex: number,
): Promise<void> {
  checkDbInvariant(
    purchase.secretSeed !== undefined,
    "can't process slates without secretSeed",
  );

  logger.trace(
    `generating slate (${choiceIndex}, ${outputIndex}) for purchase ${purchase.proposalId}`,
  );

  let slate = await wex.db.runReadOnlyTx(
    { storeNames: ["slates"] },
    async (tx) => {
      return await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndex.get(
        [purchase.proposalId, choiceIndex, outputIndex],
      );
    },
  );

  if (slate) {
    return;
  }

  const choice = contractData.choices[choiceIndex];
  const output = choice.outputs[outputIndex];
  if (output.type !== MerchantContractOutputType.Token) {
    throw new Error(`unsupported contract output type ${output.type}`);
  }

  const family = contractData.token_families[output.token_family_slug];
  const tokenIssuePub = family.keys[output.key_index];
  const r = await wex.cryptoApi.createSlate({
    secretSeed: purchase.secretSeed,
    choiceIndex: choiceIndex,
    outputIndex: outputIndex,
    tokenIssuePub,
    genTokenUseSig: true,
    contractTerms: contractData,
    contractTermsHash: ContractTermsUtil.hashContractTerms(contractTermsRaw),
  });

  let newSlate: SlateRecord = {
    purchaseId: purchase.proposalId,
    choiceIndex: choiceIndex,
    outputIndex: outputIndex,
    merchantBaseUrl: contractData.merchant_base_url,
    kind: family.details.class,
    slug: output.token_family_slug,
    name: family.name,
    description: family.description,
    descriptionI18n: family.description_i18n,
    extraData: family.details,
    tokenIssuePub: tokenIssuePub,
    validAfter: timestampProtocolToDb(tokenIssuePub.signature_validity_start),
    validBefore: timestampProtocolToDb(tokenIssuePub.signature_validity_end),
    tokenIssuePubHash: r.tokenIssuePubHash,
    tokenUsePub: r.tokenPub,
    tokenUsePriv: r.tokenPriv,
    tokenEv: r.tokenEv,
    tokenEvHash: r.tokenEvHash,
    blindingKey: r.blindingKey,
  };

  newSlate.tokenFamilyHash = TokenRecord.hashInfo(newSlate);

  await wex.db.runReadWriteTx({ storeNames: ["slates"] }, async (tx) => {
    const s =
      await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndex.get([
        purchase.proposalId,
        choiceIndex,
        outputIndex,
      ]);
    if (s) return;
    await tx.slates.put(newSlate);
  });
}

/**
 * Create a new purchase transaction if necessary.  If a purchase
 * record for the provided arguments already exists,
 * return the old proposal ID.
 */
async function createOrReusePurchase(
  wex: WalletExecutionContext,
  merchantBaseUrl: string,
  orderId: string,
  sessionId: string | undefined,
  claimToken: string | undefined,
  noncePriv: string | undefined,
): Promise<{
  proposalId: string;
  transactionId: TransactionIdStr;
}> {
  // Find existing proposals from the same merchant
  // with the same order ID.
  const oldProposals = await wex.db.runReadOnlyTx(
    { storeNames: ["purchases"] },
    async (tx) => {
      return tx.purchases.indexes.byUrlAndOrderId.getAll([
        merchantBaseUrl,
        orderId,
      ]);
    },
  );

  if (oldProposals.length > 1) {
    logger.error(
      "BUG: more than one existing purchase transaction for same order ID and merchant base URL, this should never happen",
    );
  }

  let oldProposal = oldProposals.find((p) => {
    return (
      (!noncePriv || p.noncePriv === noncePriv) && p.claimToken === claimToken
    );
  });
  if (!oldProposal && oldProposals.length > 0) {
    // Should never happen, except for backwards compat.
    oldProposal = oldProposals[0];
  }
  // If we have already claimed this proposal with the same
  // nonce and claim token, reuse it.
  if (oldProposal) {
    logger.info(
      `Found old proposal (status=${
        PurchaseStatus[oldProposal.purchaseStatus]
      }) for order ${orderId} at ${merchantBaseUrl}`,
    );
    const oldCtx = new PayMerchantTransactionContext(
      wex,
      oldProposal.proposalId,
    );
    if (oldProposal.shared || oldProposal.createdFromShared) {
      const download = await expectProposalDownload(wex, oldProposal);
      const paid = await checkIfOrderIsAlreadyPaid(wex, download, false);
      logger.info(`old proposal paid: ${paid}`);
      // if this transaction was shared and the order is paid then it
      // means that another wallet already paid the proposal
      if (paid) {
        await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
          const [rec, h] = await oldCtx.getRecordHandle(tx);
          // The order is only paid by another wallet
          // if the merchant says it's paid but the local
          // wallet is still in a dialog state.
          switch (rec?.purchaseStatus) {
            case PurchaseStatus.DialogProposed:
            case PurchaseStatus.DialogShared:
              break;
            default:
              return;
          }
          rec.purchaseStatus = PurchaseStatus.FailedPaidByOther;
          await h.update(rec);
        });
      }
    }
    return {
      proposalId: oldProposal.proposalId,
      transactionId: oldCtx.transactionId,
    };
  }

  let noncePair: EddsaKeyPairStrings;
  let createdFromShared = false;
  if (noncePriv) {
    createdFromShared = true;
    noncePair = {
      priv: noncePriv,
      pub: (await wex.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
    };
  } else {
    noncePair = await wex.cryptoApi.createEddsaKeypair({});
  }

  const { priv, pub } = noncePair;
  const proposalId = encodeCrock(getRandomBytes(32));

  logger.info(
    `created new proposal for ${orderId} at ${merchantBaseUrl} session ${sessionId}`,
  );

  const secretSeed = encodeCrock(getRandomBytes(32));

  const proposalRecord: PurchaseRecord = {
    download: undefined,
    noncePriv: priv,
    noncePub: pub,
    claimToken,
    timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
    merchantBaseUrl,
    orderId,
    proposalId: proposalId,
    purchaseStatus: PurchaseStatus.PendingDownloadingProposal,
    repurchaseProposalId: undefined,
    downloadSessionId: sessionId,
    autoRefundDeadline: undefined,
    lastSessionId: undefined,
    merchantPaySig: undefined,
    secretSeed: secretSeed,
    payInfo: undefined,
    refundAmountAwaiting: undefined,
    timestampAccept: undefined,
    timestampFirstSuccessfulPay: undefined,
    timestampLastRefundStatus: undefined,
    pendingRemovedCoinPubs: undefined,
    posConfirmation: undefined,
    shared: false,
    createdFromShared,
  };

  const ctx = new PayMerchantTransactionContext(wex, proposalId);

  await wex.db.runReadWriteTx(
    { storeNames: ["purchases", "transactionsMeta"] },
    async (tx) => {
      await tx.purchases.put(proposalRecord);
      await ctx.updateTransactionMeta(tx);
      const oldTxState: TransactionState = {
        major: TransactionMajorState.None,
      };
      const newTxState = computePayMerchantTransactionState(proposalRecord);
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.None,
        oldStId: 0,
        newStId: proposalRecord.purchaseStatus,
      });
    },
  );

  return {
    proposalId,
    transactionId: ctx.transactionId,
  };
}

async function storeFirstPaySuccess(
  wex: WalletExecutionContext,
  proposalId: string,
  sessionId: string | undefined,
  payResponse: MerchantPayResponse,
): Promise<void> {
  const ctx = new PayMerchantTransactionContext(wex, proposalId);
  const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const [purchase, h] = await ctx.getRecordHandle(tx);
    if (!purchase) {
      return;
    }
    const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
    if (!isFirst) {
      logger.warn("payment success already stored");
      return;
    }
    if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) {
      purchase.purchaseStatus = PurchaseStatus.Done;
    }
    purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(now);
    purchase.lastSessionId = sessionId;
    purchase.merchantPaySig = payResponse.sig;
    purchase.posConfirmation = payResponse.pos_confirmation;
    const dl = purchase.download;
    checkDbInvariant(
      !!dl,
      `purchase ${purchase.orderId} without ct downloaded`,
    );
    const contractTermsRecord = await tx.contractTerms.get(
      dl.contractTermsHash,
    );
    checkDbInvariant(
      !!contractTermsRecord,
      `no contract terms found for purchase ${purchase.orderId}`,
    );
    const contractTerms = codecForMerchantContractTerms().decode(
      contractTermsRecord.contractTermsRaw,
    );
    const protoAr = contractTerms.auto_refund;
    if (protoAr) {
      const ar = Duration.fromTalerProtocolDuration(protoAr);
      logger.info("auto_refund present");
      purchase.purchaseStatus = PurchaseStatus.FinalizingQueryingAutoRefund;
      purchase.autoRefundDeadline = timestampProtocolToDb(
        AbsoluteTime.toProtocolTimestamp(
          AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
        ),
      );
    }
    await h.update(purchase);
  });
}

async function storePayReplaySuccess(
  wex: WalletExecutionContext,
  proposalId: string,
  sessionId: string | undefined,
): Promise<void> {
  const ctx = new PayMerchantTransactionContext(wex, proposalId);
  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const [purchase, h] = await ctx.getRecordHandle(tx);
    if (!purchase) {
      return;
    }
    const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
    if (isFirst) {
      throw Error("invalid payment state");
    }
    switch (purchase.purchaseStatus) {
      case PurchaseStatus.PendingPaying:
      case PurchaseStatus.PendingPayingReplay:
        purchase.purchaseStatus = PurchaseStatus.Done;
        break;
    }
    purchase.lastSessionId = sessionId;
    await h.update(purchase);
  });
}

function setCoinSel(rec: PurchaseRecord, coinSel: PayCoinSelection): void {
  checkLogicInvariant(!!rec.payInfo);
  rec.payInfo.payCoinSelection = {
    coinContributions: coinSel.coins.map((x) => x.contribution),
    coinPubs: coinSel.coins.map((x) => x.coinPub),
  };
  rec.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
  rec.exchanges = [...new Set(coinSel.coins.map((x) => x.exchangeBaseUrl))];
  rec.exchanges.sort();
}

async function reselectCoinsTx(
  tx: WalletDbAllStoresReadWriteTransaction,
  ctx: PayMerchantTransactionContext,
): Promise<void> {
  const p = await tx.purchases.get(ctx.proposalId);
  if (!p) {
    return;
  }
  if (!p.payInfo) {
    return;
  }

  const contractData = await expectProposalDownloadByIdInTx(
    ctx.wex,
    tx,
    ctx.proposalId,
  );

  const { available, amountRaw, maxFee } = ContractTermsUtil.extractAmounts(
    contractData.contractTerms,
    p.choiceIndex,
  );
  if (!available) {
    throw Error("choice index not specified for contract v1");
  }

  const prevPayCoins: PreviousPayCoins = [];
  const prevTokensPubs: string[] = [];

  const payCoinSelection = p.payInfo.payCoinSelection;
  const payTokenSelection = p.payInfo.payTokenSelection;

  if (payCoinSelection) {
    for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
      const coinPub = payCoinSelection.coinPubs[i];
      const contrib = payCoinSelection.coinContributions[i];
      prevPayCoins.push({
        coinPub,
        contribution: Amounts.parseOrThrow(contrib),
      });
    }

    const res = await selectPayCoinsInTx(ctx.wex, tx, {
      restrictExchanges: {
        auditors: [],
        exchanges: contractData.contractTerms.exchanges.map((ex) => ({
          exchangeBaseUrl: ex.url,
          exchangePub: ex.master_pub,
        })),
      },
      restrictWireMethod: contractData.contractTerms.wire_method,
      contractTermsAmount: Amounts.parseOrThrow(amountRaw),
      depositFeeLimit: Amounts.parseOrThrow(maxFee),
      prevPayCoins,
      requiredMinimumAge: contractData.contractTerms.minimum_age,
    });

    switch (res.type) {
      case "failure":
        logger.trace("insufficient funds for coin re-selection");
        return;
      case "prospective":
        return;
      case "success":
        break;
      default:
        assertUnreachable(res);
    }
    setCoinSel(p, res.coinSel);
  }

  if (payTokenSelection) {
    prevTokensPubs.push(...payTokenSelection.tokenPubs);

    if (p.choiceIndex === undefined) throw Error("assertion failed");

    if (contractData.contractTerms.version !== MerchantContractVersion.V1)
      throw Error("assertion failed");

    const res = await selectPayTokensInTx(tx, {
      proposalId: p.proposalId,
      choiceIndex: p.choiceIndex,
      contractTerms: contractData.contractTerms,
    });

    switch (res.type) {
      case "failure":
        logger.trace("insufficient tokens for token re-selection");
        return;
        break;
      case "success":
        break;
      default:
        assertUnreachable(res);
    }

    p.payInfo.payTokenSelection = {
      tokenPubs: res.tokens.map((t) => t.tokenUsePub),
    };
  }

  await tx.purchases.put(p);
  await ctx.updateTransactionMeta(tx);

  if (p.payInfo.payCoinSelection) {
    await spendCoins(ctx.wex, tx, {
      transactionId: ctx.transactionId,
      coinPubs: p.payInfo.payCoinSelection.coinPubs,
      contributions: p.payInfo.payCoinSelection.coinContributions.map((x) =>
        Amounts.parseOrThrow(x),
      ),
      refreshReason: RefreshReason.PayMerchant,
    });
  }

  if (p.payInfo.payTokenSelection) {
    await spendTokens(tx, {
      transactionId: ctx.transactionId,
      tokenPubs: p.payInfo.payTokenSelection.tokenPubs,
    });
  }
}

/**
 * Handle a 409 Conflict or 400 Bad Request response from the merchant.
 *
 * We do this by going through the coin history provided by the exchange and
 * (1) verifying the signatures from the exchange
 * (2) adjusting the remaining coin value and refreshing it
 * (3) re-do coin selection with the bad coin removed
 */
async function handleInsufficientFunds(
  wex: WalletExecutionContext,
  proposalId: string,
  err: TalerErrorDetail,
): Promise<TaskRunResult> {
  logger.trace("handling insufficient funds, trying to re-select coins");

  const ctx = new PayMerchantTransactionContext(wex, proposalId);

  const proposal = await wex.db.runReadOnlyTx(
    { storeNames: ["purchases"] },
    async (tx) => {
      return tx.purchases.get(proposalId);
    },
  );
  if (!proposal) {
    return TaskRunResult.finished();
  }

  logger.trace(`got error details: ${j2s(err)}`);

  if (
    err.code === TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
  ) {
    const exchangeReply = (err as any).exchange_reply;
    if (
      exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
    ) {
      if (logger.shouldLogTrace()) {
        logger.trace("got exchange error reply (see below)");
        logger.trace(j2s(exchangeReply));
      }
      await ctx.abortTransaction({
        code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
        message: `unable to handle /pay exchange error response (${exchangeReply.code})`,
        exchangeReply,
      });
      return TaskRunResult.progress();
    }

    const brokenCoinPub = (exchangeReply as any).coin_pub;
    logger.trace(`excluded broken coin pub=${brokenCoinPub}`);

    if (!brokenCoinPub) {
      await ctx.abortTransaction({
        code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
        message: "Exchange claimed bad coin, but coin was not used.",
        brokenCoinPub,
      });
      return TaskRunResult.progress();
    }
  } else if (
    err.code ===
    TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND
  ) {
    // We might support this in the future.
    await ctx.abortTransaction({
      code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
      message: "Denomination used in payment became invalid.",
      errorDetails: err,
    });
    return TaskRunResult.progress();
  } else {
    // Caller should have checked.
    throw Error(`unsupported error code: ${err.code}`);
  }

  if (!proposal.payInfo) {
    return TaskRunResult.backoff();
  }

  // FIXME: Above code should go into the transaction.
  // TODO: also do token re-selection.

  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    await reselectCoinsTx(tx, ctx);
  });

  wex.ws.notify({
    type: NotificationType.BalanceChange,
    hintTransactionId: constructTransactionIdentifier({
      tag: TransactionType.Payment,
      proposalId,
    }),
  });

  return TaskRunResult.progress();
}

async function lookupProposalOrRepurchase(
  wex: WalletExecutionContext,
  proposalId: string,
): Promise<
  | {
      proposalId: string;
      purchaseRec: PurchaseRecord;
      contractTerms: MerchantContractTerms;
      contractTermsHash: string;
    }
  | undefined
> {
  return await wex.db.runReadOnlyTx(
    { storeNames: ["purchases", "contractTerms"] },
    async (tx) => {
      let purchaseRec = await tx.purchases.get(proposalId);

      if (!purchaseRec) {
        return undefined;
      }

      if (
        purchaseRec.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected
      ) {
        const existingProposalId = purchaseRec.repurchaseProposalId;
        if (existingProposalId) {
          logger.trace("using existing purchase for same product");
          const oldProposal = await tx.purchases.get(existingProposalId);
          if (oldProposal) {
            purchaseRec = oldProposal;
          }
        }
      }

      let { contractTerms, contractTermsHash } =
        await expectProposalDownloadByIdInTx(wex, tx, proposalId);

      proposalId = purchaseRec.proposalId;

      return {
        proposalId,
        purchaseRec: purchaseRec,
        contractTerms,
        contractTermsHash,
      };
    },
  );
}

// FIXME: Should take a transaction ID instead of a proposal ID
// FIXME: Does way more than checking the payment
// FIXME: Should return immediately.
async function checkPaymentByProposalId(
  wex: WalletExecutionContext,
  proposalId: string,
  sessionId?: string,
): Promise<PreparePayResult> {
  const lookupRes = await lookupProposalOrRepurchase(wex, proposalId);
  if (!lookupRes) {
    throw Error(`could not get proposal ${proposalId}`);
  }
  // Might be redirected to another proposal ID due to repurchase detection.
  proposalId = lookupRes.proposalId;

  const purchaseRec = lookupRes.purchaseRec;
  let contractTerms = lookupRes.contractTerms;
  const contractTermsHash = lookupRes.contractTermsHash;

  const ctx = new PayMerchantTransactionContext(wex, proposalId);

  const transactionId = ctx.transactionId;

  const talerUri = stringifyTalerUri({
    type: TalerUriAction.Pay,
    merchantBaseUrl: purchaseRec.merchantBaseUrl as HostPortPath, // FIXME: change record type
    orderId: purchaseRec.orderId,
    sessionId: purchaseRec.lastSessionId ?? purchaseRec.downloadSessionId ?? "",
    claimToken: purchaseRec.claimToken,
  });

  const scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    let exchangeUrls = contractTerms.exchanges.map((x) => x.url);
    return await getScopeForAllExchanges(tx, exchangeUrls);
  });

  const handleUnconfirmed = async (): Promise<PreparePayResult> => {
    if (contractTerms.version === MerchantContractVersion.V1) {
      if (!wex.ws.config.features.enableV1Contracts) {
        let v0Contract =
          ContractTermsUtil.downgradeContractTerms(contractTerms);
        logger.warn(
          `contract uses v1 features, trying to downgrade, as enableV1Contracts is false`,
        );
        if (!v0Contract) {
          throw Error(
            "payment not possible (uses v1 contract features and downgrade failed)",
          );
        }
        contractTerms = v0Contract;
      } else {
        return {
          status: PreparePayResultType.ChoiceSelection,
          transactionId,
          contractTerms: contractTerms,
          contractTermsHash: contractTermsHash,
          talerUri,
        };
      }
    }

    const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
    // If not already paid, check if we could pay for it.
    const res = await selectPayCoins(wex, {
      restrictExchanges: {
        auditors: [],
        exchanges: contractTerms.exchanges.map((ex) => ({
          exchangeBaseUrl: ex.url,
          exchangePub: ex.master_pub,
        })),
      },
      contractTermsAmount: instructedAmount,
      depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee),
      prevPayCoins: [],
      requiredMinimumAge: contractTerms.minimum_age,
      restrictWireMethod: contractTerms.wire_method,
    });

    let coins: SelectedProspectiveCoin[] | undefined = undefined;

    const allowedExchangeUrls = contractTerms.exchanges.map((x) => x.url);

    switch (res.type) {
      case "failure": {
        logger.info("not allowing payment, insufficient coins");
        logger.info(
          `insufficient balance details: ${j2s(
            res.insufficientBalanceDetails,
          )}`,
        );
        let scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
          return getScopeForAllExchanges(tx, allowedExchangeUrls);
        });
        return {
          status: PreparePayResultType.InsufficientBalance,
          contractTerms,
          transactionId,
          amountRaw: Amounts.stringify(contractTerms.amount),
          scopes,
          talerUri,
          balanceDetails: res.insufficientBalanceDetails,
        };
      }
      case "prospective":
        coins = res.result.prospectiveCoins;
        break;
      case "success":
        coins = res.coinSel.coins;
        break;
      default:
        assertUnreachable(res);
    }

    const currency = Amounts.currencyOf(contractTerms.amount);
    const totalCost = await getTotalPaymentCost(wex, currency, coins);
    logger.trace("costInfo", totalCost);
    logger.trace("coinsForPayment", res);

    const exchanges = new Set<string>(coins.map((x) => x.exchangeBaseUrl));

    const scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
      return await getScopeForAllExchanges(tx, [...exchanges]);
    });

    return {
      status: PreparePayResultType.PaymentPossible,
      contractTerms,
      transactionId,
      amountEffective: Amounts.stringify(totalCost),
      amountRaw: Amounts.stringify(instructedAmount),
      scopes,
      contractTermsHash,
      talerUri,
    };
  };

  const handlePaid = async (): Promise<PreparePayResult> => {
    logger.trace(
      "automatically re-submitting payment with different session ID",
    );
    logger.trace(`last: ${purchaseRec.lastSessionId}, current: ${sessionId}`);
    await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
      const [p, h] = await ctx.getRecordHandle(tx);
      if (!p) {
        return;
      }
      p.lastSessionId = sessionId;
      p.purchaseStatus = PurchaseStatus.PendingPayingReplay;
      await h.update(p);
    });

    wex.taskScheduler.startShepherdTask(ctx.taskId);

    // FIXME: Consider changing the API here so that we don't have to
    // wait inline for the repurchase.

    await waitPaymentResult(wex, proposalId, sessionId);
    const download = await expectProposalDownload(wex, purchaseRec);
    const { available, amountRaw } = ContractTermsUtil.extractAmounts(
      download.contractTerms,
      purchaseRec.choiceIndex,
    );
    if (!available) {
      throw Error("choice index not specified for contract v1");
    }

    return {
      status: PreparePayResultType.AlreadyConfirmed,
      contractTerms,
      contractTermsHash: download.contractTermsHash,
      paid: true,
      amountRaw: Amounts.stringify(amountRaw),
      amountEffective: purchaseRec.payInfo
        ? Amounts.stringify(purchaseRec.payInfo.totalPayCost)
        : undefined,
      scopes,
      transactionId,
      talerUri,
    };
  };

  const handleConfirmed = async (): Promise<PreparePayResult> => {
    const paid = isPurchasePaid(purchaseRec);

    const { available, amountRaw } = ContractTermsUtil.extractAmounts(
      contractTerms,
      purchaseRec.choiceIndex,
    );
    if (!available) {
      throw Error("choice index not specified for contract v1");
    }

    return {
      status: PreparePayResultType.AlreadyConfirmed,
      contractTerms: contractTerms,
      contractTermsHash: contractTermsHash,
      paid,
      amountRaw: Amounts.stringify(amountRaw),
      amountEffective: purchaseRec.payInfo
        ? Amounts.stringify(purchaseRec.payInfo.totalPayCost)
        : undefined,
      ...(paid ? { nextUrl: contractTerms.order_id } : {}),
      scopes,
      transactionId,
      talerUri,
    };
  };

  const handlePaidByOther = async (): Promise<PreparePayResult> => {
    const { amountRaw } = ContractTermsUtil.extractAmounts(
      contractTerms,
      purchaseRec.choiceIndex,
    );
    return {
      status: PreparePayResultType.AlreadyConfirmed,
      contractTerms,
      contractTermsHash: contractTermsHash,
      paid: true,
      amountRaw: amountRaw ? Amounts.stringify(amountRaw) : "UNKNOWN:0",
      amountEffective: purchaseRec.payInfo
        ? Amounts.stringify(purchaseRec.payInfo.totalPayCost)
        : undefined,
      scopes,
      transactionId,
      talerUri,
    };
  };

  if (!purchaseRec) {
    return handleUnconfirmed();
  }

  switch (purchaseRec?.purchaseStatus) {
    case PurchaseStatus.DialogProposed:
    case PurchaseStatus.DialogShared:
      return await handleUnconfirmed();
    case PurchaseStatus.Done:
    case PurchaseStatus.PendingPayingReplay:
    case PurchaseStatus.DoneRepurchaseDetected:
      return handlePaid();
    case PurchaseStatus.AbortedIncompletePayment:
    case PurchaseStatus.AbortedOrderDeleted:
    case PurchaseStatus.AbortedProposalRefused:
    case PurchaseStatus.AbortedRefunded:
    case PurchaseStatus.AbortingWithRefund:
    case PurchaseStatus.FailedAbort:
    case PurchaseStatus.FailedClaim:
    case PurchaseStatus.FinalizingQueryingAutoRefund:
    case PurchaseStatus.PendingAcceptRefund:
    case PurchaseStatus.PendingPaying:
    case PurchaseStatus.PendingQueryingAutoRefund:
    case PurchaseStatus.PendingQueryingRefund:
    case PurchaseStatus.SuspendedAbortingWithRefund:
    case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund:
    case PurchaseStatus.SuspendedPaying:
    case PurchaseStatus.SuspendedPayingReplay:
    case PurchaseStatus.SuspendedPendingAcceptRefund:
    case PurchaseStatus.SuspendedQueryingAutoRefund:
    case PurchaseStatus.SuspendedQueryingRefund:
      return handleConfirmed();
    case PurchaseStatus.FailedPaidByOther:
      return handlePaidByOther();
    case PurchaseStatus.PendingDownloadingProposal:
    case PurchaseStatus.SuspendedDownloadingProposal:
      throw Error("expected proposal to be downloaded");
    default:
      assertUnreachable(purchaseRec.purchaseStatus);
  }
}

function isPurchasePaid(purchase: PurchaseRecord): boolean {
  return (
    purchase.purchaseStatus === PurchaseStatus.Done ||
    purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
    purchase.purchaseStatus === PurchaseStatus.FinalizingQueryingAutoRefund ||
    purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund
  );
}

/**
 * Check if a payment for the given taler://pay/ URI is possible.
 */
export async function preparePayForUri(
  wex: WalletExecutionContext,
  talerPayUri: string,
): Promise<PreparePayResult> {
  const uriResult = parsePayUri(talerPayUri);

  if (!uriResult) {
    throw TalerError.fromDetail(
      TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
      {
        talerPayUri,
      },
      `invalid taler://pay URI (${talerPayUri})`,
    );
  }

  const proposalRes = await createOrReusePurchase(
    wex,
    uriResult.merchantBaseUrl,
    uriResult.orderId,
    uriResult.sessionId,
    uriResult.claimToken,
    uriResult.noncePriv,
  );

  await waitProposalDownloaded(wex, proposalRes.proposalId);

  return checkPaymentByProposalId(
    wex,
    proposalRes.proposalId,
    uriResult.sessionId,
  );
}

/**
 * Wait until a proposal is at least downloaded.
 */
async function waitProposalDownloaded(
  wex: WalletExecutionContext,
  proposalId: string,
): Promise<void> {
  // FIXME: This doesn't support cancellation yet
  const ctx = new PayMerchantTransactionContext(wex, proposalId);

  logger.info(`waiting for ${ctx.transactionId} to be downloaded`);

  wex.taskScheduler.startShepherdTask(ctx.taskId);

  await genericWaitForState(wex, {
    filterNotification(notif) {
      return (
        notif.type === NotificationType.TransactionStateTransition &&
        notif.transactionId === ctx.transactionId
      );
    },
    async checkState() {
      const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx(
        { storeNames: ["purchases", "operationRetries"] },
        async (tx) => {
          return {
            purchase: await tx.purchases.get(ctx.proposalId),
            retryInfo: await tx.operationRetries.get(ctx.taskId),
          };
        },
      );
      if (!purchase) {
        throw Error("purchase does not exist anymore");
      }
      if (purchase.download) {
        return true;
      }
      if (retryInfo) {
        if (retryInfo.lastError) {
          throw TalerError.fromUncheckedDetail(retryInfo.lastError);
        } else {
          throw Error("transient error while waiting for proposal download");
        }
      }
      return false;
    },
  });
}

async function downloadTemplate(
  wex: WalletExecutionContext,
  merchantBaseUrl: string,
  templateId: string,
): Promise<TalerMerchantApi.WalletTemplateDetails> {
  const reqUrl = new URL(`templates/${templateId}`, merchantBaseUrl);
  const httpReq = await cancelableFetch(wex, reqUrl);
  const resp = await readSuccessResponseJsonOrThrow(
    httpReq,
    codecForWalletTemplateDetails(),
  );
  return resp;
}

export async function checkPayForTemplate(
  wex: WalletExecutionContext,
  req: CheckPayTemplateRequest,
): Promise<CheckPayTemplateReponse> {
  const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
  if (!parsedUri) {
    throw Error("invalid taler-template URI");
  }
  const templateDetails = await downloadTemplate(
    wex,
    parsedUri.merchantBaseUrl,
    parsedUri.templateId,
  );

  const merchantApi = new TalerMerchantInstanceHttpClient(
    parsedUri.merchantBaseUrl,
    wex.http,
  );

  const cfg = await merchantApi.getConfig();
  if (cfg.type === "fail") {
    if (cfg.detail) {
      throw TalerError.fromUncheckedDetail(cfg.detail);
    } else {
      throw TalerError.fromException(
        new Error("failed to get merchant remote config"),
      );
    }
  }

  // FIXME: Put body.currencies *and* body.currency in the set of
  // supported currencies.

  return {
    templateDetails,
    supportedCurrencies: Object.keys(cfg.body.currencies),
  };
}

export async function preparePayForTemplate(
  wex: WalletExecutionContext,
  req: PreparePayTemplateRequest,
): Promise<PreparePayResult> {
  const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
  if (!parsedUri) {
    throw Error("invalid taler-template URI");
  }
  logger.trace(`parsed URI: ${j2s(parsedUri)}`);
  const templateDetails: MerchantUsingTemplateDetails = {};

  const templateInfo = await downloadTemplate(
    wex,
    parsedUri.merchantBaseUrl,
    parsedUri.templateId,
  );

  const templateParamsAmount = req.templateParams?.amount as
    | AmountString
    | undefined;
  if (templateParamsAmount === null) {
    const amountFromUri = templateInfo.editable_defaults?.amount;
    if (amountFromUri != null) {
      templateDetails.amount = amountFromUri as AmountString;
    }
  } else {
    templateDetails.amount = templateParamsAmount;
  }

  const templateParamsSummary = req.templateParams?.summary;
  if (templateParamsSummary === null) {
    const summaryFromUri = templateInfo.editable_defaults?.summary;
    if (summaryFromUri != null) {
      templateDetails.summary = summaryFromUri;
    }
  } else {
    templateDetails.summary = templateParamsSummary;
  }

  const reqUrl = new URL(
    `templates/${parsedUri.templateId}`,
    parsedUri.merchantBaseUrl,
  );
  const httpReq = await cancelableFetch(wex, reqUrl, {
    method: "POST",
    body: templateDetails,
  });
  const resp = await readSuccessResponseJsonOrThrow(
    httpReq,
    codecForPostOrderResponse(),
  );

  const payUri = stringifyPayUri({
    merchantBaseUrl: parsedUri.merchantBaseUrl,
    orderId: resp.order_id,
    sessionId: "",
    claimToken: resp.token,
  });

  return await preparePayForUri(wex, payUri);
}

/**
 * Generate deposit permissions for a purchase.
 *
 * Accesses the database and the crypto worker.
 */
export async function generateDepositPermissions(
  wex: WalletExecutionContext,
  payCoinSel: DbCoinSelection,
  contractData: MerchantContractTerms,
  contractTermsHash: HashCodeString,
  walletData?: PayWalletData,
): Promise<CoinDepositPermission[]> {
  const depositPermissions: CoinDepositPermission[] = [];
  const coinWithDenom: Array<{
    coin: CoinRecord;
    denom: DenominationRecord;
  }> = [];
  await wex.db.runReadOnlyTx(
    { storeNames: ["coins", "denominations"] },
    async (tx) => {
      for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
        const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
        if (!coin) {
          throw Error("can't pay, allocated coin not found anymore");
        }
        const denom = await tx.denominations.get([
          coin.exchangeBaseUrl,
          coin.denomPubHash,
        ]);
        if (!denom) {
          throw Error(
            "can't pay, denomination of allocated coin not found anymore",
          );
        }
        coinWithDenom.push({ coin, denom });
      }
    },
  );

  for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
    const { coin, denom } = coinWithDenom[i];
    let wireInfoHash: string;
    wireInfoHash = contractData.h_wire;
    const dp = await wex.cryptoApi.signDepositPermission({
      coinPriv: coin.coinPriv,
      coinPub: coin.coinPub,
      contractTermsHash,
      denomPubHash: coin.denomPubHash,
      denomKeyType: denom.denomPub.cipher,
      denomSig: coin.denomSig,
      exchangeBaseUrl: coin.exchangeBaseUrl,
      feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
      merchantPub: contractData.merchant_pub,
      refundDeadline: contractData.refund_deadline,
      spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]),
      timestamp: contractData.timestamp,
      wireInfoHash,
      ageCommitmentProof: coin.ageCommitmentProof,
      requiredMinimumAge: contractData.minimum_age,
      walletDataHash: walletData
        ? encodeCrock(hashPayWalletData(walletData))
        : undefined,
    });
    depositPermissions.push(dp);
  }
  return depositPermissions;
}

/**
 * Wait until either:
 * a) the payment succeeded (if provided under the {@param waitSessionId}), or
 * b) the attempt to pay failed (merchant unavailable, etc.)
 */
async function waitPaymentResult(
  wex: WalletExecutionContext,
  proposalId: string,
  waitSessionId?: string,
): Promise<ConfirmPayResult> {
  const ctx = new PayMerchantTransactionContext(wex, proposalId);
  wex.taskScheduler.startShepherdTask(ctx.taskId);

  return await genericWaitForStateVal<ConfirmPayResult>(wex, {
    filterNotification(notif) {
      return (
        notif.type === NotificationType.TransactionStateTransition &&
        notif.transactionId === ctx.transactionId
      );
    },
    async checkState() {
      const txRes = await ctx.wex.db.runReadOnlyTx(
        { storeNames: ["purchases", "operationRetries"] },
        async (tx) => {
          const purchase = await tx.purchases.get(ctx.proposalId);
          const retryRecord = await tx.operationRetries.get(ctx.taskId);
          return { purchase, retryRecord };
        },
      );

      if (!txRes.purchase) {
        throw Error("purchase gone");
      }

      const purchase = txRes.purchase;

      logger.info(
        `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
      );

      const d = await expectProposalDownload(ctx.wex, purchase);

      if (txRes.purchase.timestampFirstSuccessfulPay) {
        if (
          waitSessionId == null ||
          txRes.purchase.lastSessionId === waitSessionId
        ) {
          return {
            type: ConfirmPayResultType.Done,
            contractTerms: d.contractTermsRaw,
            transactionId: ctx.transactionId,
          };
        }
      }

      if (txRes.retryRecord) {
        return {
          type: ConfirmPayResultType.Pending,
          lastError: txRes.retryRecord.lastError,
          transactionId: ctx.transactionId,
        };
      }

      if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) {
        return {
          type: ConfirmPayResultType.Done,
          contractTerms: d.contractTermsRaw,
          transactionId: ctx.transactionId,
        };
      }

      return undefined;
    },
  });
}

export async function getChoicesForPayment(
  wex: WalletExecutionContext,
  transactionId: string,
  forcedCoinSel?: ForcedCoinSel,
): Promise<GetChoicesForPaymentResult> {
  const parsedTx = parseTransactionIdentifier(transactionId);
  if (parsedTx?.tag !== TransactionType.Payment) {
    throw Error("expected payment transaction ID");
  }
  const proposalId = parsedTx.proposalId;
  const proposal = await wex.db.runReadOnlyTx(
    { storeNames: ["purchases"] },
    async (tx) => {
      return tx.purchases.get(proposalId);
    },
  );

  if (!proposal) {
    throw Error(`proposal with id ${proposalId} not found`);
  }

  const d = await expectProposalDownload(wex, proposal);
  if (!d) {
    throw Error("proposal is in invalid state");
  }

  const choices: ChoiceSelectionDetail[] = [];
  await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    const tokenSels: SelectPayTokensResult[] = [];
    const contractTerms: MerchantContractTerms = d.contractTerms;
    switch (contractTerms.version) {
      case undefined:
      case MerchantContractVersion.V0:
        tokenSels.push({
          type: "success",
          tokens: [],
          details: {
            tokensRequested: 0,
            tokensAvailable: 0,
            tokensUnexpected: 0,
            tokensUntrusted: 0,
            perTokenFamily: {},
          },
        });
        break;
      case MerchantContractVersion.V1:
        for (let i = 0; i < contractTerms.choices.length; i++) {
          tokenSels.push(
            await selectPayTokensInTx(tx, {
              proposalId,
              choiceIndex: i,
              contractTerms: contractTerms,
            }),
          );
        }
        break;
      default:
        assertUnreachable(contractTerms);
    }

    for (let i = 0; i < tokenSels.length; i++) {
      const choiceIndex = i;
      const tokenSelection = tokenSels[choiceIndex];
      logger.trace("token selection result", tokenSelection);

      let balanceDetails: PaymentInsufficientBalanceDetails | undefined =
        undefined;

      const { available, amountRaw, maxFee } = ContractTermsUtil.extractAmounts(
        contractTerms,
        choiceIndex,
      );
      if (!available) {
        throw Error("choice index not specified for contract v1");
      }

      let amountEffective: AmountJson | undefined = undefined;
      const currency = Amounts.currencyOf(amountRaw);

      const selectCoinsResult = await selectPayCoinsInTx(wex, tx, {
        restrictExchanges: {
          auditors: [],
          exchanges: contractTerms.exchanges.map((ex) => ({
            exchangeBaseUrl: ex.url,
            exchangePub: ex.master_pub,
          })),
        },
        restrictWireMethod: contractTerms.wire_method,
        contractTermsAmount: Amounts.parseOrThrow(amountRaw),
        depositFeeLimit: Amounts.parseOrThrow(maxFee),
        prevPayCoins: [],
        requiredMinimumAge: contractTerms.minimum_age,
        forcedSelection: forcedCoinSel,
      });

      logger.trace("coin selection result", selectCoinsResult);

      switch (selectCoinsResult.type) {
        case "success": {
          amountEffective = await getTotalPaymentCostInTx(
            wex,
            tx,
            currency,
            selectCoinsResult.coinSel.coins,
          );
          break;
        }
        case "prospective": {
          amountEffective = await getTotalPaymentCostInTx(
            wex,
            tx,
            currency,
            selectCoinsResult.result.prospectiveCoins,
          );
          break;
        }
        case "failure": {
          logger.info("choice not payable, insufficient coins");
          balanceDetails = selectCoinsResult.insufficientBalanceDetails;
          break;
        }
        default:
          assertUnreachable(selectCoinsResult);
      }

      let choice: ChoiceSelectionDetail;
      if (
        tokenSelection.type === "failure" ||
        selectCoinsResult.type === "failure"
      ) {
        choice = {
          status: ChoiceSelectionDetailType.InsufficientBalance,
          amountRaw: amountRaw,
          balanceDetails: balanceDetails,
          tokenDetails: tokenSelection.details,
        };
      } else {
        choice = {
          status: ChoiceSelectionDetailType.PaymentPossible,
          amountRaw: amountRaw,
          amountEffective: Amounts.stringify(amountEffective!),
          tokenDetails: tokenSelection.details,
        };
      }

      choices.push(choice);
    }
  });

  return {
    choices,
    contractTerms: d.contractTerms,
    ...(await calculateDefaultChoice(wex, choices, d.contractTerms)),
  };
}

async function calculateDefaultChoice(
  wex: WalletExecutionContext,
  choiceDetails: ChoiceSelectionDetail[],
  contractTerms: MerchantContractTerms,
): Promise<{
  defaultChoiceIndex?: number;
  automaticExecution?: boolean;
  automaticExecutableIndex?: number;
}> {
  let defaultChoiceIndex: number | undefined;
  let automaticExecution: boolean | undefined;
  let automaticExecutableIndex: number | undefined;
  switch (contractTerms.version) {
    case undefined:
    case MerchantContractVersion.V0:
      return {
        defaultChoiceIndex: 0,
        automaticExecution: false,
      };
    case MerchantContractVersion.V1:
      if (contractTerms.choices.length === 0)
        throw Error(`contract v1 has no choices`);

      // If there's only one choice, use it.
      if (
        contractTerms.choices.length === 1 &&
        choiceDetails[0].status === ChoiceSelectionDetailType.PaymentPossible
      ) {
        defaultChoiceIndex = 0;
        break;
      }

      // If there are more, choose the payable one with lowest amount.
      var cheapestPayableIndex = 0;
      var cheapestPayableChoice = contractTerms.choices[0];
      for (let i = 1; i < contractTerms.choices.length; i++) {
        const choice = contractTerms.choices[i];
        const details = choiceDetails[i];
        if (
          details.status === ChoiceSelectionDetailType.PaymentPossible &&
          choice.amount < cheapestPayableChoice.amount
        ) {
          cheapestPayableIndex = i;
          cheapestPayableChoice = choice;
        }
      }

      defaultChoiceIndex = cheapestPayableIndex;

      // If the cheapest choice has one subscription input and one
      // subscription output of the same type, and it's free, then it can be
      // automatically confirmed without user confirmation.
      //
      // TODO: in the future, a setting should allow the user to specify
      // merchants where discounts should be automatically spent.
      automaticExecution = false;
      for (let i = 0; i < contractTerms.choices.length; i++) {
        const choice = contractTerms.choices[i];
        const details = choiceDetails[i];
        if (
          Amounts.isZero(choice.amount) &&
          choice.inputs.length === 1 &&
          choice.outputs.length === 1 &&
          !details.tokenDetails?.tokensUnexpected &&
          choice.inputs[0].type === MerchantContractInputType.Token &&
          choice.outputs[0].type === MerchantContractOutputType.Token &&
          (choice.inputs[0].count ?? 1) === 1 &&
          (choice.outputs[0].count ?? 1) === 1 &&
          choice.inputs[0].token_family_slug ===
            choice.inputs[0].token_family_slug
        ) {
          automaticExecution =
            details.status === ChoiceSelectionDetailType.PaymentPossible;
          automaticExecutableIndex = i;
          break;
        }
      }
      break;
    default:
      assertUnreachable(contractTerms);
  }

  return {
    defaultChoiceIndex,
    automaticExecution,
    automaticExecutableIndex,
  };
}

/**
 * Confirm payment for a proposal previously claimed by the wallet.
 */
export async function confirmPay(
  wex: WalletExecutionContext,
  args: {
    transactionId: string;
    sessionIdOverride?: string;
    forcedCoinSel?: ForcedCoinSel;
    choiceIndex?: number;
    useDonau?: boolean;
  },
): Promise<ConfirmPayResult> {
  const { transactionId, sessionIdOverride, forcedCoinSel } = args;
  let { choiceIndex } = args;

  const parsedTx = parseTransactionIdentifier(transactionId);
  if (parsedTx?.tag !== TransactionType.Payment) {
    throw Error("expected payment transaction ID");
  }
  const proposalId = parsedTx.proposalId;
  const ctx = new PayMerchantTransactionContext(wex, proposalId);
  logger.trace(
    `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
  );
  const proposal = await wex.db.runReadOnlyTx(
    { storeNames: ["purchases"] },
    async (tx) => {
      return tx.purchases.get(proposalId);
    },
  );

  if (!proposal) {
    throw Error(`proposal with id ${proposalId} not found`);
  }

  const d = await expectProposalDownload(wex, proposal);
  if (!d) {
    throw Error("proposal is in invalid state");
  }

  const existingPurchase = await wex.db.runReadWriteTx(
    { storeNames: ["purchases", "transactionsMeta"] },
    async (tx) => {
      const purchase = await tx.purchases.get(proposalId);
      if (
        purchase &&
        sessionIdOverride !== undefined &&
        sessionIdOverride != purchase.lastSessionId
      ) {
        logger.trace(`changing session ID to ${sessionIdOverride}`);
        purchase.lastSessionId = sessionIdOverride;
        if (purchase.purchaseStatus === PurchaseStatus.Done) {
          purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
        }
        await tx.purchases.put(purchase);
        await ctx.updateTransactionMeta(tx);
      }
      return purchase;
    },
  );

  if (existingPurchase && existingPurchase.payInfo) {
    if (
      choiceIndex !== undefined &&
      choiceIndex !== existingPurchase.choiceIndex
    ) {
      throw Error(`cannot change choice index of existing purchase`);
    }

    logger.trace("confirmPay: submitting payment for existing purchase");
    const ctx = new PayMerchantTransactionContext(
      wex,
      existingPurchase.proposalId,
    );
    await wex.taskScheduler.resetTaskRetries(ctx.taskId);
    // This will become the default behavior on the future.
    if (wex.ws.devExperimentState.flagConfirmPayNoWait) {
      return {
        type: ConfirmPayResultType.Pending,
        transactionId: transactionId as TransactionIdStr,
        lastError: undefined,
      };
    }
    return waitPaymentResult(wex, proposalId);
  }

  logger.trace("confirmPay: purchase record does not exist yet");

  const contractTerms = d.contractTerms;

  if (
    choiceIndex == null &&
    contractTerms.version === MerchantContractVersion.V1 &&
    !wex.ws.config.features.enableV1Contracts
  ) {
    choiceIndex = ContractTermsUtil.getV0CompatChoiceIndex(contractTerms);
    if (choiceIndex == null) {
      throw Error("unable to confirm payment, contracts v1 support required");
    }
  }

  const { available, amountRaw, maxFee } = ContractTermsUtil.extractAmounts(
    contractTerms,
    choiceIndex,
  );
  if (!available) {
    throw Error("choice index not specified for contract v1");
  }

  const currency = Amounts.currencyOf(amountRaw);

  let sessionId: string | undefined;
  if (sessionIdOverride) {
    sessionId = sessionIdOverride;
  } else {
    sessionId = proposal.downloadSessionId;
  }

  logger.trace(
    `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
  );

  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const p = await tx.purchases.get(proposal.proposalId);
    if (!p) {
      return;
    }

    let selectTokensResult: SelectPayTokensResult | undefined;

    if (contractTerms.version === MerchantContractVersion.V1) {
      selectTokensResult = await selectPayTokensInTx(tx, {
        proposalId,
        choiceIndex: choiceIndex!,
        contractTerms,
      });

      switch (selectTokensResult.type) {
        case "failure": {
          logger.warn("not confirming payment, insufficient tokens");
          throw Error("insufficient tokens");
        }
      }

      logger.trace("token selection result", selectTokensResult);
    }

    const selectCoinsResult = await selectPayCoinsInTx(wex, tx, {
      restrictExchanges: {
        auditors: [],
        exchanges: contractTerms.exchanges.map((ex) => ({
          exchangeBaseUrl: ex.url,
          exchangePub: ex.master_pub,
        })),
      },
      restrictWireMethod: contractTerms.wire_method,
      contractTermsAmount: Amounts.parseOrThrow(amountRaw),
      depositFeeLimit: Amounts.parseOrThrow(maxFee),
      prevPayCoins: [],
      requiredMinimumAge: contractTerms.minimum_age,
      forcedSelection: forcedCoinSel,
    });

    let coins: SelectedProspectiveCoin[] | undefined = undefined;

    switch (selectCoinsResult.type) {
      case "failure": {
        // Should not happen, since checkPay should be called first
        // FIXME: Actually, this should be handled gracefully,
        // and the status should be stored in the DB.
        logger.warn("not confirming payment, insufficient coins");
        throw Error("insufficient balance");
      }
      case "prospective": {
        coins = selectCoinsResult.result.prospectiveCoins;
        break;
      }
      case "success":
        coins = selectCoinsResult.coinSel.coins;
        break;
      default:
        assertUnreachable(selectCoinsResult);
    }

    logger.trace("coin selection result", selectCoinsResult);

    const payCostInfo = await getTotalPaymentCostInTx(wex, tx, currency, coins);

    p.choiceIndex = choiceIndex;
    if (
      p.download &&
      choiceIndex !== undefined &&
      contractTerms.version === MerchantContractVersion.V1
    ) {
      const amount = contractTerms.choices[choiceIndex].amount;
      p.download.currency = Amounts.currencyOf(amount);
    }

    const confRes = await WalletDbHelpers.getConfig(
      tx,
      ConfigRecordKey.DonauConfig,
    );

    logger.info(
      `dona conf: ${j2s(confRes)}, useDonau: ${
        args.useDonau
      }, choiceIndex: ${choiceIndex}, oidx=${p.donauOutputIndex}, ctVersion=${
        contractTerms.version
      }`,
    );

    if (
      confRes != null &&
      args.useDonau &&
      choiceIndex != null &&
      contractTerms.version === MerchantContractVersion.V1 &&
      p.donauOutputIndex == null
    ) {
      const choice = contractTerms.choices[choiceIndex];

      logger.info(`have outputs: ${j2s(choice.outputs)}`);

      for (let j = 0; j < choice.outputs.length; j++) {
        const out = choice.outputs[j];
        if (
          out.type === MerchantContractOutputType.TaxReceipt &&
          out.donau_urls.includes(confRes.value.donauBaseUrl)
        ) {
          p.donauOutputIndex = j;
          p.donauBaseUrl = confRes.value.donauBaseUrl;
          p.donauYear = new Date().getFullYear();
          p.donauAmount =
            out.amount ?? contractTerms.choices[choiceIndex].amount;
          p.donauTaxIdHash = confRes.value.donauTaxIdHash;
          p.donauTaxId = confRes.value.donauTaxId;
          p.donauTaxIdSalt = confRes.value.donauSalt;
          break;
        }
      }
    }

    const oldTxState = computePayMerchantTransactionState(p);
    const oldStId = p.purchaseStatus;
    switch (p.purchaseStatus) {
      case PurchaseStatus.DialogShared:
      case PurchaseStatus.DialogProposed:
        p.payInfo = {
          totalPayCost: Amounts.stringify(payCostInfo),
        };
        if (selectTokensResult?.type === "success") {
          const tokens = selectTokensResult.tokens;
          p.payInfo.payTokenSelection = {
            tokenPubs: tokens.map((t) => t.tokenUsePub),
          };
        }
        if (selectCoinsResult.type === "success") {
          setCoinSel(p, selectCoinsResult.coinSel);
        }
        p.lastSessionId = sessionId;
        p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());
        p.purchaseStatus = PurchaseStatus.PendingPaying;
        await tx.purchases.put(p);
        await ctx.updateTransactionMeta(tx);
        if (p.payInfo.payTokenSelection) {
          await spendTokens(tx, {
            tokenPubs: p.payInfo.payTokenSelection.tokenPubs,
            transactionId: ctx.transactionId,
          });
        }
        if (p.payInfo.payCoinSelection) {
          const sel = p.payInfo.payCoinSelection;
          await spendCoins(wex, tx, {
            transactionId: transactionId as TransactionIdStr,
            coinPubs: sel.coinPubs,
            contributions: sel.coinContributions.map((x) =>
              Amounts.parseOrThrow(x),
            ),
            refreshReason: RefreshReason.PayMerchant,
          });
        }
        break;
      case PurchaseStatus.Done:
      case PurchaseStatus.PendingPaying:
      default:
        break;
    }
    const newTxState = computePayMerchantTransactionState(p);
    applyNotifyTransition(tx.notify, transactionId, {
      oldTxState,
      newTxState,
      balanceEffect: BalanceEffect.Any,
      oldStId,
      newStId: p.purchaseStatus,
    });
  });

  // TODO: pre-generate slates based on choice priority!
  if (
    choiceIndex != null &&
    contractTerms.version === MerchantContractVersion.V1
  ) {
    const choice = contractTerms.choices[choiceIndex];
    for (let j = 0; j < choice.outputs.length; j++) {
      const tok = choice.outputs[j];
      switch (tok.type) {
        case MerchantContractOutputType.Token: {
          await generateSlate(
            wex,
            proposal,
            contractTerms,
            d.contractTermsRaw,
            choiceIndex,
            j,
          );
          break;
        }
        case MerchantContractOutputType.TaxReceipt:
          // FIXME: What if we have multiple?
          await generateDonauPlanchets(wex, proposalId);
          break;
      }
    }
  }

  // In case we're sharing the payment and we're long-polling
  wex.taskScheduler.stopShepherdTask(ctx.taskId);

  // This will become the default behavior on the future.
  if (wex.ws.devExperimentState.flagConfirmPayNoWait) {
    wex.taskScheduler.startShepherdTask(ctx.taskId);
    return {
      type: ConfirmPayResultType.Pending,
      transactionId: transactionId as TransactionIdStr,
      lastError: undefined,
    };
  }

  // Wait until we have completed the first attempt to pay.
  return waitPaymentResult(wex, proposalId);
}

export async function processPurchase(
  wex: WalletExecutionContext,
  proposalId: string,
): Promise<TaskRunResult> {
  const purchase = await wex.db.runReadOnlyTx(
    { storeNames: ["purchases"] },
    async (tx) => {
      return tx.purchases.get(proposalId);
    },
  );
  if (!purchase) {
    return {
      type: TaskRunResultType.Error,
      errorDetail: {
        // FIXME: allocate more specific error code
        code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
        when: AbsoluteTime.now(),
        hint: `trying to pay for purchase that is not in the database`,
        proposalId: proposalId,
      },
    };
  }

  if (!wex.ws.networkAvailable) {
    return TaskRunResult.networkRequired();
  }

  switch (purchase.purchaseStatus) {
    case PurchaseStatus.PendingDownloadingProposal:
      return processDownloadProposal(wex, proposalId);
    case PurchaseStatus.PendingPaying:
    case PurchaseStatus.PendingPayingReplay:
      return processPurchasePay(wex, proposalId);
    case PurchaseStatus.PendingQueryingRefund:
      return processPurchaseQueryRefund(wex, purchase);
    case PurchaseStatus.FinalizingQueryingAutoRefund:
    case PurchaseStatus.PendingQueryingAutoRefund:
      return processPurchaseAutoRefund(wex, purchase);
    case PurchaseStatus.AbortingWithRefund:
      return processPurchaseAbortingRefund(wex, purchase);
    case PurchaseStatus.PendingAcceptRefund:
      return processPurchaseAcceptRefund(wex, purchase);
    case PurchaseStatus.DialogShared:
      return processPurchaseDialogShared(wex, purchase);
    case PurchaseStatus.FailedClaim:
    case PurchaseStatus.Done:
    case PurchaseStatus.DoneRepurchaseDetected:
    case PurchaseStatus.DialogProposed:
    case PurchaseStatus.AbortedProposalRefused:
    case PurchaseStatus.AbortedIncompletePayment:
    case PurchaseStatus.AbortedOrderDeleted:
    case PurchaseStatus.AbortedRefunded:
    case PurchaseStatus.SuspendedAbortingWithRefund:
    case PurchaseStatus.SuspendedDownloadingProposal:
    case PurchaseStatus.SuspendedPaying:
    case PurchaseStatus.SuspendedPayingReplay:
    case PurchaseStatus.SuspendedPendingAcceptRefund:
    case PurchaseStatus.SuspendedQueryingAutoRefund:
    case PurchaseStatus.SuspendedQueryingRefund:
    case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund:
    case PurchaseStatus.FailedAbort:
    case PurchaseStatus.FailedPaidByOther:
      return TaskRunResult.finished();
    default:
      assertUnreachable(purchase.purchaseStatus);
  }
}

async function processPurchasePay(
  wex: WalletExecutionContext,
  proposalId: string,
): Promise<TaskRunResult> {
  const purchase = await wex.db.runReadOnlyTx(
    { storeNames: ["purchases"] },
    async (tx) => {
      return tx.purchases.get(proposalId);
    },
  );
  if (!purchase) {
    return {
      type: TaskRunResultType.Error,
      errorDetail: {
        // FIXME: allocate more specific error code
        code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
        when: AbsoluteTime.now(),
        hint: `trying to pay for purchase that is not in the database`,
        proposalId: proposalId,
      },
    };
  }
  switch (purchase.purchaseStatus) {
    case PurchaseStatus.PendingPaying:
    case PurchaseStatus.PendingPayingReplay:
      break;
    default:
      return TaskRunResult.finished();
  }
  logger.trace(`processing purchase pay ${proposalId}`);

  const ctx = new PayMerchantTransactionContext(wex, proposalId);

  const sessionId = purchase.lastSessionId;

  logger.trace(`paying with session ID ${sessionId}`);
  const payInfo = purchase.payInfo;
  checkDbInvariant(!!payInfo, `purchase ${purchase.orderId} without payInfo`);

  const download = await expectProposalDownload(wex, purchase);

  if (purchase.shared) {
    const paid = await checkIfOrderIsAlreadyPaid(wex, download, false);

    if (paid) {
      await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
        const [p, h] = await ctx.getRecordHandle(tx);
        if (!p) {
          return;
        }
        p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
        await h.update(p);
      });

      return {
        type: TaskRunResultType.Error,
        errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, {
          orderId: purchase.orderId,
          fulfillmentUrl: download.contractTerms.fulfillment_url,
        }),
      };
    }
  }

  const choiceIndex = purchase.choiceIndex;
  const { available, amountRaw, maxFee } = ContractTermsUtil.extractAmounts(
    download.contractTerms,
    choiceIndex,
  );
  if (!available) {
    throw Error("choice index not specified for contract v1");
  }

  const currency = Amounts.currencyOf(amountRaw);

  if (!payInfo.payCoinSelection) {
    const selectCoinsResult = await selectPayCoins(wex, {
      restrictExchanges: {
        auditors: [],
        exchanges: download.contractTerms.exchanges.map((ex) => ({
          exchangeBaseUrl: ex.url,
          exchangePub: ex.master_pub,
        })),
      },
      restrictWireMethod: download.contractTerms.wire_method,
      contractTermsAmount: Amounts.parseOrThrow(amountRaw),
      depositFeeLimit: Amounts.parseOrThrow(maxFee),
      prevPayCoins: [],
      requiredMinimumAge: download.contractTerms.minimum_age,
    });
    switch (selectCoinsResult.type) {
      case "failure": {
        // Should not happen, since checkPay should be called first
        // FIXME: Actually, this should be handled gracefully,
        // and the status should be stored in the DB.
        logger.warn("not confirming payment, insufficient coins");
        throw Error("insufficient balance");
      }
      case "prospective": {
        throw Error("insufficient balance (pending refresh)");
      }
      case "success":
        break;
      default:
        assertUnreachable(selectCoinsResult);
    }

    logger.trace("coin selection result", selectCoinsResult);

    const payCostInfo = await getTotalPaymentCost(
      wex,
      currency,
      selectCoinsResult.coinSel.coins,
    );

    const transitionDone = await wex.db.runReadWriteTx(
      {
        storeNames: [
          "coinAvailability",
          "coinHistory",
          "coins",
          "denominations",
          "purchases",
          "refreshGroups",
          "refreshSessions",
          "tokens",
          "transactionsMeta",
        ],
      },
      async (tx) => {
        const p = await tx.purchases.get(proposalId);
        if (!p) {
          return false;
        }
        if (p.payInfo?.payCoinSelection) {
          return false;
        }
        switch (p.purchaseStatus) {
          case PurchaseStatus.DialogShared:
          case PurchaseStatus.DialogProposed:
          case PurchaseStatus.PendingPaying:
            p.payInfo = {
              totalPayCost: Amounts.stringify(payCostInfo),
              payCoinSelection: {
                coinContributions: selectCoinsResult.coinSel.coins.map(
                  (x) => x.contribution,
                ),
                coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
              },
            };
            p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
            p.purchaseStatus = PurchaseStatus.PendingPaying;
            await tx.purchases.put(p);
            await ctx.updateTransactionMeta(tx);
            await spendCoins(wex, tx, {
              transactionId: ctx.transactionId,
              coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
              contributions: selectCoinsResult.coinSel.coins.map((x) =>
                Amounts.parseOrThrow(x.contribution),
              ),
              refreshReason: RefreshReason.PayMerchant,
            });
            return true;
          case PurchaseStatus.Done:
          default:
            break;
        }
        return false;
      },
    );

    if (transitionDone) {
      return TaskRunResult.progress();
    } else {
      return TaskRunResult.backoff();
    }
  }

  if (!purchase.merchantPaySig) {
    const payUrl = new URL(
      `orders/${download.contractTerms.order_id}/pay`,
      download.contractTerms.merchant_base_url,
    );

    let slates: SlateRecord[] | undefined = undefined;
    let donauPlanchets: DonationPlanchetRecord[] | undefined = undefined;
    let wallet_data: PayWalletData | undefined = undefined;
    if (
      download.contractTerms.version === MerchantContractVersion.V1 &&
      purchase.choiceIndex !== undefined
    ) {
      logger.info("assembling tokens");
      logger.info(`donau output index: ${purchase.donauOutputIndex}`);
      const index = purchase.choiceIndex;
      slates = [];
      wallet_data = { choice_index: index, tokens_evs: [] };
      await wex.db.runReadOnlyTx(
        {
          storeNames: ["slates"],
        },
        async (tx) => {
          (
            await tx.slates.indexes.byPurchaseIdAndChoiceIndex.getAll([
              purchase.proposalId,
              index,
            ])
          ).forEach((s) => {
            slates?.push(s);
            wallet_data?.tokens_evs.push(s.tokenEv);
          });
        },
      );
      if (purchase.donauOutputIndex != null) {
        if (purchase.donauBaseUrl == null || purchase.donauYear == null) {
          throw Error("incomplete donau info in DB");
        }
        const budikeypairs: BlindedDonationReceiptKeyPair[] = [];
        // FIXME: Merge with transaction above
        const res = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
          const recs =
            await tx.donationPlanchets.indexes.byProposalId.getAll(proposalId);
          for (const rec of recs) {
            budikeypairs.push({
              blinded_udi: rec.blindedUdi,
              h_donation_unit_pub: rec.donationUnitPubHash,
            });
          }
          if (recs.length > 0) {
            return {
              donauPlanchets: recs,
            };
          }
          return undefined;
        });
        if (res?.donauPlanchets) {
          donauPlanchets = res.donauPlanchets;
        }
        wallet_data.donau = {
          url: purchase.donauBaseUrl,
          year: purchase.donauYear,
          budikeypairs,
        };
        logger.info(`sending donau data: ${j2s(wallet_data.donau)}`);
      }
      // Note that we may have fewer slates that output tokens,
      // as there are other output types (e.g. slates).
    }

    let depositPermissions: CoinDepositPermission[];
    // FIXME: Cache!
    depositPermissions = await generateDepositPermissions(
      wex,
      payInfo.payCoinSelection,
      download.contractTerms,
      download.contractTermsHash,
      wallet_data,
    );

    const reqBody: any = {
      coins: depositPermissions,
      wallet_data,
      session_id: purchase.lastSessionId,
    };

    if (wallet_data && payInfo.payTokenSelection) {
      reqBody.tokens = await generateTokenSigs(
        wex,
        proposalId,
        download.contractTermsHash,
        encodeCrock(hashPayWalletData(wallet_data)),
        payInfo.payTokenSelection.tokenPubs,
      );
    }

    if (logger.shouldLogTrace()) {
      logger.trace(`making pay request ... ${j2s(reqBody)}`);
    }

    const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
      cancelableFetch(wex, payUrl, {
        method: "POST",
        body: reqBody,
        timeout: getPayRequestTimeout(purchase),
      }),
    );

    logger.trace(`got resp ${JSON.stringify(resp)}`);

    if (resp.status === HttpStatusCode.Conflict) {
      const err = await readTalerErrorResponse(resp);
      if (
        err.code ===
        TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
      ) {
        return handleInsufficientFunds(wex, proposalId, err);
      }
    }

    if (resp.status === HttpStatusCode.BadRequest) {
      const err = await readTalerErrorResponse(resp);
      if (
        err.code ===
        TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND
      ) {
        return handleInsufficientFunds(wex, proposalId, err);
      }
    }

    if (resp.status === HttpStatusCode.BadGateway) {
      const err = await readTalerErrorResponse(resp);
      switch (err.code) {
        case TalerErrorCode.MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS: {
          const exchangeEc = (err.exchange_reply as any)?.code;
          switch (exchangeEc) {
            case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN:
              // We might want to handle this in the future by re-denomination,
              // for now we just abort.
              await ctx.abortTransaction({
                code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
                message: "Denomination used in payment became invalid.",
                errorDetails: err,
              });
              return TaskRunResult.progress();
          }
          break;
        }
      }
      // We don't know the specific error, so it's safer to retry.
      return throwUnexpectedRequestError(resp, err);
    }

    if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
      logger.warn(`pay transaction aborted, merchant has KYC problems`);
      await ctx.abortTransaction(
        makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING, {
          exchangeResponse: await resp.json(),
        }),
      );
      return TaskRunResult.progress();
    }

    if (resp.status === HttpStatusCode.Gone) {
      logger.warn(`pay transaction aborted, order expired`);
      await ctx.abortTransaction(
        makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_ORDER_GONE, {}),
      );
      return TaskRunResult.progress();
    }

    if (resp.status != 200) {
      logger.info(
        `got error response (http status ${resp.status}) from merchant`,
      );
      const err = await readTalerErrorResponse(resp);
      if (logger.shouldLogTrace()) {
        logger.trace(`error body: ${j2s(err)}`);
      }
      return throwUnexpectedRequestError(resp, err);
    }

    const merchantResp = await readSuccessResponseJsonOrThrow(
      resp,
      codecForMerchantPayResponse(),
    );

    logger.trace("got success from pay URL", merchantResp);

    const merchantPub = download.contractTerms.merchant_pub;
    const { valid } = await wex.cryptoApi.isValidPaymentSignature({
      contractHash: download.contractTermsHash,
      merchantPub,
      sig: merchantResp.sig,
    });

    if (!valid) {
      logger.error("merchant payment signature invalid");
      // FIXME: properly display error
      throw Error("merchant payment signature invalid");
    }

    // Here we make the assumption that slate signatures always
    // come before donau signatures.
    // But the merchant API should be improved
    // to separate them.

    /** Start index of processed outpok tokens. */
    let outTokOffset = 0;

    logger.info(`have slates: ${slates?.length}`);

    let tokenSigs: SignedTokenEnvelope[] | undefined;
    if (payInfo.slateTokenSigs) {
      tokenSigs = payInfo.slateTokenSigs;
    } else if (
      slates &&
      slates.length > 0 &&
      merchantResp.token_sigs &&
      merchantResp.token_sigs.length > 0
    ) {
      const slatesLen = slates.length;
      const sigsLen = merchantResp.token_sigs.length;
      logger.trace(`received ${sigsLen} token signatures from merchant`);
      if (slatesLen > sigsLen) {
        throw Error(
          `merchant returned mismatching number of token signatures (${slatesLen} vs ${sigsLen})`,
        );
      }
      tokenSigs = merchantResp.token_sigs.slice(0, slatesLen);
      outTokOffset = slatesLen;
    }

    if (tokenSigs) {
      await wex.db.runReadWriteTx(
        {
          storeNames: ["purchases"],
        },
        async (tx) => {
          if (!purchase.payInfo) {
            return;
          }
          purchase.payInfo.slateTokenSigs = tokenSigs;
          tx.purchases.put(purchase);
        },
      );
    }

    // store token outputs
    if (slates && tokenSigs) {
      for (let i = 0; i < slates.length; i++) {
        const slate = slates[i];
        const sigEv = tokenSigs[i];
        await validateAndStoreToken(wex, slate, sigEv);
      }
    }

    if (donauPlanchets && merchantResp.token_sigs) {
      const donauBlindedSigs = merchantResp.token_sigs.slice(
        outTokOffset,
        outTokOffset + donauPlanchets.length,
      );
      if (donauPlanchets.length != donauBlindedSigs.length) {
        throw Error(
          `wrong number of donau signatures (planchets ${donauPlanchets.length} vs sigs ${donauBlindedSigs.length}`,
        );
      }
      logger.info(`got ${donauPlanchets.length} donau sigs`);
      const donauUrl = purchase.donauBaseUrl;
      if (!donauUrl) {
        throw Error("bad db: no donau URL");
      }
      await acceptDonauBlindSigs(
        wex,
        donauUrl,
        donauPlanchets,
        donauBlindedSigs,
      );
    }

    // cleanup token inputs
    if (payInfo.payTokenSelection?.tokenPubs) {
      await cleanupUsedTokens(wex, payInfo.payTokenSelection.tokenPubs);
    }

    await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp);
  } else {
    const payAgainUrl = new URL(
      `orders/${download.contractTerms.order_id}/paid`,
      download.contractTerms.merchant_base_url,
    );
    const reqBody = {
      sig: purchase.merchantPaySig,
      h_contract: download.contractTermsHash,
      session_id: sessionId ?? "",
    };
    logger.trace(`/paid request body: ${j2s(reqBody)}`);
    const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
      cancelableFetch(wex, payAgainUrl, {
        method: "POST",
        body: reqBody,
      }),
    );
    logger.trace(`/paid response status: ${resp.status}`);
    if (
      resp.status !== HttpStatusCode.NoContent &&
      resp.status != HttpStatusCode.Ok
    ) {
      throw TalerError.fromDetail(
        TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
        getHttpResponseErrorDetails(resp),
        "/paid failed",
      );
    }
    await storePayReplaySuccess(wex, proposalId, sessionId);
  }

  return TaskRunResult.progress();
}

export async function validateAndStoreToken(
  wex: WalletExecutionContext,
  slate: SlateRecord,
  blindedEv: SignedTokenEnvelope,
): Promise<void> {
  const { tokenIssuePub, tokenIssuePubHash, tokenUsePub, blindingKey } = slate;
  logger.trace(
    `validating token ${tokenIssuePubHash} for purchase ${slate.purchaseId}`,
  );
  const tokenIssueSig = await wex.cryptoApi.unblindTokenIssueSignature({
    slate: {
      tokenIssuePub,
      blindingKey,
    },
    evSig: blindedEv.blind_sig,
  });

  if (tokenIssueSig.cipher !== tokenIssuePub.cipher)
    throw Error("token issue signature mismatch");

  if (!(tokenIssuePub.cipher === DenomKeyType.Rsa))
    throw Error("unsupported cipher");

  const rsaVerifyResp = await wex.cryptoApi.rsaVerify({
    hm: tokenUsePub,
    pk: tokenIssuePub.rsa_pub,
    sig: tokenIssueSig.rsa_signature,
  });

  if (!rsaVerifyResp.valid) {
    logger.error("token issue signature invalid");
    // TODO: properly display error
    throw Error("token issue signature invalid");
  }

  logger.trace(
    `token ${tokenIssuePubHash} for purchase ${slate.purchaseId} is valid, will be stored`,
  );

  const token: TokenRecord = {
    tokenIssueSig,
    ...slate,
  };

  // insert token and delete slate
  await wex.db.runReadWriteTx(
    {
      storeNames: ["slates", "tokens"],
    },
    async (tx) => {
      await tx.tokens.add(token);
      await tx.slates.delete(slate.tokenUsePub);
    },
  );
}

export async function generateTokenSigs(
  wex: WalletExecutionContext,
  proposalId: string,
  contractTermsHash: string,
  walletDataHash: string,
  tokenPubs: string[],
): Promise<TokenUseSig[]> {
  const tokens: TokenRecord[] = [];
  const sigs: TokenUseSig[] = [];
  await wex.db.runReadOnlyTx(
    {
      storeNames: ["tokens", "purchases"],
    },
    async (tx) => {
      for (const pub of tokenPubs) {
        const token = await tx.tokens.get(pub);
        checkDbInvariant(!!token, `token not found for ${pub}`);
        tokens.push(token);
      }
    },
  );

  for (const token of tokens) {
    if (token.tokenUseSig && token.purchaseId === proposalId) {
      sigs.push(token.tokenUseSig);
      continue;
    }

    const { sig } = await wex.cryptoApi.signTokenUse({
      tokenUsePriv: token.tokenUsePriv,
      walletDataHash,
      contractTermsHash,
    });

    sigs.push({
      token_sig: sig,
      token_pub: token.tokenUsePub,
      ub_sig: token.tokenIssueSig,
      h_issue: token.tokenIssuePubHash,
    });
  }

  await wex.db.runReadWriteTx(
    {
      storeNames: ["tokens"],
    },
    async (tx) => {
      for (let i = 0; i < sigs.length; i++) {
        const token = tokens[i];
        const sig = sigs[i];
        token.tokenUseSig = sig;
        tx.tokens.put(token);
      }
    },
  );

  return sigs;
}

export async function cleanupUsedTokens(
  wex: WalletExecutionContext,
  tokenPubs: string[],
): Promise<void> {
  await wex.db.runReadWriteTx(
    {
      storeNames: ["tokens"],
    },
    async (tx) => {
      for (const pub of tokenPubs) {
        logger.trace(`cleaning up used token ${pub}`);
        tx.tokens.delete(pub);
      }
    },
  );
}

export async function refuseProposal(
  wex: WalletExecutionContext,
  proposalId: string,
): Promise<void> {
  const ctx = new PayMerchantTransactionContext(wex, proposalId);
  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const [proposal, h] = await ctx.getRecordHandle(tx);
    switch (proposal?.purchaseStatus) {
      case PurchaseStatus.DialogProposed:
      case PurchaseStatus.DialogShared:
        break;
      default:
        return;
    }
    proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
    await h.update(proposal);
  });
}

const transitionSuspend: {
  [x in PurchaseStatus]?: {
    next: PurchaseStatus | undefined;
  };
} = {
  [PurchaseStatus.PendingDownloadingProposal]: {
    next: PurchaseStatus.SuspendedDownloadingProposal,
  },
  [PurchaseStatus.AbortingWithRefund]: {
    next: PurchaseStatus.SuspendedAbortingWithRefund,
  },
  [PurchaseStatus.PendingPaying]: {
    next: PurchaseStatus.SuspendedPaying,
  },
  [PurchaseStatus.PendingPayingReplay]: {
    next: PurchaseStatus.SuspendedPayingReplay,
  },
  [PurchaseStatus.PendingQueryingAutoRefund]: {
    next: PurchaseStatus.SuspendedQueryingAutoRefund,
  },
  [PurchaseStatus.FinalizingQueryingAutoRefund]: {
    next: PurchaseStatus.SuspendedFinalizingQueryingAutoRefund,
  },
};

const transitionResume: {
  [x in PurchaseStatus]?: {
    next: PurchaseStatus | undefined;
  };
} = {
  [PurchaseStatus.SuspendedDownloadingProposal]: {
    next: PurchaseStatus.PendingDownloadingProposal,
  },
  [PurchaseStatus.SuspendedAbortingWithRefund]: {
    next: PurchaseStatus.AbortingWithRefund,
  },
  [PurchaseStatus.SuspendedPaying]: {
    next: PurchaseStatus.PendingPaying,
  },
  [PurchaseStatus.SuspendedPayingReplay]: {
    next: PurchaseStatus.PendingPayingReplay,
  },
  [PurchaseStatus.SuspendedQueryingAutoRefund]: {
    next: PurchaseStatus.PendingQueryingAutoRefund,
  },
  [PurchaseStatus.SuspendedFinalizingQueryingAutoRefund]: {
    next: PurchaseStatus.FinalizingQueryingAutoRefund,
  },
};

export function computePayMerchantTransactionState(
  purchaseRecord: PurchaseRecord,
): TransactionState {
  switch (purchaseRecord.purchaseStatus) {
    // Pending States
    case PurchaseStatus.PendingDownloadingProposal:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.ClaimProposal,
      };
    case PurchaseStatus.PendingPaying:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.SubmitPayment,
      };
    case PurchaseStatus.PendingPayingReplay:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.RebindSession,
      };
    case PurchaseStatus.PendingQueryingAutoRefund:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.AutoRefund,
      };
    case PurchaseStatus.PendingQueryingRefund:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.CheckRefund,
      };
    case PurchaseStatus.PendingAcceptRefund:
      return {
        major: TransactionMajorState.Pending,
        minor: TransactionMinorState.AcceptRefund,
      };
    // Suspended Pending States
    case PurchaseStatus.SuspendedDownloadingProposal:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.ClaimProposal,
      };
    case PurchaseStatus.SuspendedPaying:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.SubmitPayment,
      };
    case PurchaseStatus.SuspendedPayingReplay:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.RebindSession,
      };
    case PurchaseStatus.SuspendedQueryingAutoRefund:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.AutoRefund,
      };
    case PurchaseStatus.SuspendedQueryingRefund:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.CheckRefund,
      };
    case PurchaseStatus.SuspendedPendingAcceptRefund:
      return {
        major: TransactionMajorState.Suspended,
        minor: TransactionMinorState.AcceptRefund,
      };
    // Aborting States
    case PurchaseStatus.AbortingWithRefund:
      return {
        major: TransactionMajorState.Aborting,
      };
    // Suspended Aborting States
    case PurchaseStatus.SuspendedAbortingWithRefund:
      return {
        major: TransactionMajorState.SuspendedAborting,
      };
    // Dialog States
    case PurchaseStatus.DialogProposed:
      return {
        major: TransactionMajorState.Dialog,
        minor: TransactionMinorState.MerchantOrderProposed,
      };
    case PurchaseStatus.DialogShared:
      return {
        major: TransactionMajorState.Dialog,
        minor: TransactionMinorState.MerchantOrderProposed,
      };
    // Final States
    case PurchaseStatus.AbortedProposalRefused:
      return {
        major: TransactionMajorState.Aborted,
        minor: TransactionMinorState.Refused,
      };
    case PurchaseStatus.AbortedOrderDeleted:
    case PurchaseStatus.AbortedRefunded:
      return {
        major: TransactionMajorState.Aborted,
      };
    case PurchaseStatus.Done:
      return {
        major: TransactionMajorState.Done,
      };
    case PurchaseStatus.DoneRepurchaseDetected:
      return {
        major: TransactionMajorState.Failed,
        minor: TransactionMinorState.Repurchase,
      };
    case PurchaseStatus.AbortedIncompletePayment:
      return {
        major: TransactionMajorState.Aborted,
      };
    case PurchaseStatus.FailedClaim:
      return {
        major: TransactionMajorState.Failed,
        minor: TransactionMinorState.ClaimProposal,
      };
    case PurchaseStatus.FailedAbort:
      return {
        major: TransactionMajorState.Failed,
        minor: TransactionMinorState.AbortingBank,
      };
    case PurchaseStatus.FailedPaidByOther:
      return {
        major: TransactionMajorState.Failed,
        minor: TransactionMinorState.PaidByOther,
      };
    case PurchaseStatus.FinalizingQueryingAutoRefund:
      return {
        major: TransactionMajorState.Finalizing,
        minor: TransactionMinorState.AutoRefund,
      };
    case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund:
      return {
        major: TransactionMajorState.SuspendedFinalizing,
        minor: TransactionMinorState.AutoRefund,
      };
    default:
      assertUnreachable(purchaseRecord.purchaseStatus);
  }
}

export function computePayMerchantTransactionActions(
  purchaseRecord: PurchaseRecord,
): TransactionAction[] {
  switch (purchaseRecord.purchaseStatus) {
    // Pending States
    case PurchaseStatus.PendingDownloadingProposal:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case PurchaseStatus.PendingPaying:
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case PurchaseStatus.PendingPayingReplay:
      // Special "abort" since it goes back to "done".
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case PurchaseStatus.PendingQueryingAutoRefund:
      // Special "abort" since it goes back to "done".
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case PurchaseStatus.PendingQueryingRefund:
      // Special "abort" since it goes back to "done".
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    case PurchaseStatus.PendingAcceptRefund:
      // Special "abort" since it goes back to "done".
      return [
        TransactionAction.Retry,
        TransactionAction.Suspend,
        TransactionAction.Abort,
      ];
    // Suspended Pending States
    case PurchaseStatus.SuspendedDownloadingProposal:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PurchaseStatus.SuspendedPaying:
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PurchaseStatus.SuspendedPayingReplay:
      // Special "abort" since it goes back to "done".
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PurchaseStatus.SuspendedQueryingAutoRefund:
      // Special "abort" since it goes back to "done".
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PurchaseStatus.SuspendedQueryingRefund:
      // Special "abort" since it goes back to "done".
      return [TransactionAction.Resume, TransactionAction.Abort];
    case PurchaseStatus.SuspendedPendingAcceptRefund:
      // Special "abort" since it goes back to "done".
      return [TransactionAction.Resume, TransactionAction.Abort];
    // Aborting States
    case PurchaseStatus.AbortingWithRefund:
      return [
        TransactionAction.Retry,
        TransactionAction.Fail,
        TransactionAction.Suspend,
      ];
    case PurchaseStatus.SuspendedAbortingWithRefund:
      return [TransactionAction.Fail, TransactionAction.Resume];
    // Dialog States
    case PurchaseStatus.DialogProposed:
      return [TransactionAction.Retry, TransactionAction.Delete];
    case PurchaseStatus.DialogShared:
      return [TransactionAction.Retry, TransactionAction.Delete];
    // Final States
    case PurchaseStatus.AbortedProposalRefused:
    case PurchaseStatus.AbortedOrderDeleted:
    case PurchaseStatus.AbortedRefunded:
      return [TransactionAction.Delete];
    case PurchaseStatus.Done:
      return [TransactionAction.Delete];
    case PurchaseStatus.DoneRepurchaseDetected:
      return [TransactionAction.Delete];
    case PurchaseStatus.AbortedIncompletePayment:
      return [TransactionAction.Delete];
    case PurchaseStatus.FailedClaim:
      return [TransactionAction.Delete];
    case PurchaseStatus.FailedAbort:
      return [TransactionAction.Delete];
    case PurchaseStatus.FailedPaidByOther:
      return [TransactionAction.Delete];
    case PurchaseStatus.FinalizingQueryingAutoRefund:
      return [
        TransactionAction.Suspend,
        TransactionAction.Retry,
        TransactionAction.Delete,
      ];
    case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund:
      return [TransactionAction.Resume, TransactionAction.Delete];
    default:
      assertUnreachable(purchaseRecord.purchaseStatus);
  }
}

export async function sharePayment(
  wex: WalletExecutionContext,
  merchantBaseUrl: string,
  orderId: string,
): Promise<SharePaymentResult> {
  // First, translate the order ID into a proposal ID
  const proposalId = await wex.db.runReadOnlyTx(
    {
      storeNames: ["purchases"],
    },
    async (tx) => {
      const p = await tx.purchases.indexes.byUrlAndOrderId.get([
        merchantBaseUrl,
        orderId,
      ]);
      return p?.proposalId;
    },
  );

  if (!proposalId) {
    throw Error(`no proposal found for order id ${orderId}`);
  }

  const ctx = new PayMerchantTransactionContext(wex, proposalId);

  const result = await wex.db.runReadWriteTx(
    { storeNames: ["purchases", "transactionsMeta"] },
    async (tx) => {
      const p = await tx.purchases.get(proposalId);
      if (!p) {
        logger.warn("purchase does not exist anymore");
        return undefined;
      }
      if (
        p.purchaseStatus !== PurchaseStatus.DialogProposed &&
        p.purchaseStatus !== PurchaseStatus.DialogShared
      ) {
        // FIXME: purchase can be shared before being paid
        return undefined;
      }
      const oldTxState = computePayMerchantTransactionState(p);
      const oldStId = p.purchaseStatus;
      if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
        p.purchaseStatus = PurchaseStatus.DialogShared;
        p.shared = true;
        await tx.purchases.put(p);
      }

      await ctx.updateTransactionMeta(tx);

      const newTxState = computePayMerchantTransactionState(p);

      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        oldStId,
        newStId: p.purchaseStatus,
      });

      return {
        proposalId: p.proposalId,
        nonce: p.noncePriv,
        session: p.lastSessionId ?? p.downloadSessionId,
        token: p.claimToken,
      };
    },
  );

  if (result === undefined) {
    throw Error("This purchase can't be shared");
  }

  // schedule a task to watch for the status
  wex.taskScheduler.startShepherdTask(ctx.taskId);

  const privatePayUri = stringifyPayUri({
    merchantBaseUrl: merchantBaseUrl as HostPortPath, // FIXME: change function argument
    orderId,
    sessionId: result.session ?? "",
    noncePriv: result.nonce,
    claimToken: result.token,
  });

  return { privatePayUri };
}

async function checkIfOrderIsAlreadyPaid(
  wex: WalletExecutionContext,
  contract: DownloadedContractData,
  doLongPolling: boolean,
) {
  const requestUrl = new URL(
    `orders/${contract.contractTerms.order_id}`,
    contract.contractTerms.merchant_base_url,
  );
  requestUrl.searchParams.set("h_contract", contract.contractTermsHash);

  let resp: HttpResponse;

  if (doLongPolling) {
    resp = await cancelableLongPoll(wex, requestUrl);
  } else {
    resp = await cancelableFetch(wex, requestUrl);
  }

  if (
    resp.status === HttpStatusCode.Ok ||
    resp.status === HttpStatusCode.Accepted ||
    resp.status === HttpStatusCode.Found
  ) {
    return true;
  } else if (resp.status === HttpStatusCode.PaymentRequired) {
    return false;
  }
  // forbidden, not found, not acceptable
  throw Error(`this order cant be paid: ${resp.status}`);
}

/**
 * While the transaction is in the dialog(shared) state,
 * we long-poll the merchant. We do this to find out if
 * another wallet paid for the order.
 */
async function processPurchaseDialogShared(
  wex: WalletExecutionContext,
  purchase: PurchaseRecord,
): Promise<TaskRunResult> {
  const proposalId = purchase.proposalId;
  logger.trace(`processing dialog-shared for proposal ${proposalId}`);
  const ctx = new PayMerchantTransactionContext(wex, proposalId);
  const txRes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
    const rec = await tx.purchases.get(proposalId);
    if (!rec) {
      return undefined;
    }
    const download = await expectProposalDownloadByIdInTx(wex, tx, proposalId);
    return { download, rec };
  });
  if (!txRes) {
    // Transaction longer exists.
    return TaskRunResult.finished();
  }
  const { download, rec } = txRes;
  if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) {
    return TaskRunResult.finished();
  }

  const contractTerms = download.contractTerms;

  let paidByOther = false;

  const requestUrl = new URL(
    `orders/${contractTerms.order_id}`,
    contractTerms.merchant_base_url,
  );
  requestUrl.searchParams.set("h_contract", download.contractTermsHash);
  if (rec.lastSessionId != null) {
    requestUrl.searchParams.set("session_id", rec.lastSessionId);
  }

  let httpResp: HttpResponse;

  httpResp = await cancelableLongPoll(wex, requestUrl);

  switch (httpResp.status) {
    case HttpStatusCode.Ok:
    case HttpStatusCode.Accepted:
    case HttpStatusCode.Found:
      paidByOther = true;
      break;
    case HttpStatusCode.PaymentRequired:
      const resp = await readResponseJsonOrThrow(
        httpResp,
        codecForMerchantOrderStatusUnpaid(),
      );
      if (resp.already_paid_order_id != null) {
        paidByOther = true;
      }
      break;
  }

  if (!paidByOther) {
    return TaskRunResult.backoff();
  }

  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const [p, h] = await ctx.getRecordHandle(tx);
    switch (p?.purchaseStatus) {
      case PurchaseStatus.DialogShared:
        p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
        break;
      default:
        return;
    }
    await h.update(p);
  });
  return TaskRunResult.progress();
}

async function processPurchaseAutoRefund(
  wex: WalletExecutionContext,
  purchase: PurchaseRecord,
): Promise<TaskRunResult> {
  const proposalId = purchase.proposalId;
  const ctx = new PayMerchantTransactionContext(wex, proposalId);

  logger.trace(`processing auto-refund for proposal ${proposalId}`);

  const download = await expectProposalDownload(wex, purchase);
  const { available, amountRaw } = ContractTermsUtil.extractAmounts(
    download.contractTerms,
    purchase.choiceIndex,
  );
  if (!available) {
    throw Error("choice index not specified for contract v1");
  }

  const noAutoRefundOrExpired =
    !purchase.autoRefundDeadline ||
    AbsoluteTime.isExpired(
      AbsoluteTime.fromProtocolTimestamp(
        timestampProtocolFromDb(purchase.autoRefundDeadline),
      ),
    );

  const totalKnownRefund = await wex.db.runReadOnlyTx(
    { storeNames: ["refundGroups"] },
    async (tx) => {
      const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
        purchase.proposalId,
      );
      const am = Amounts.parseOrThrow(amountRaw);
      return refunds.reduce((prev, cur) => {
        if (
          cur.status === RefundGroupStatus.Done ||
          cur.status === RefundGroupStatus.Pending
        ) {
          return Amounts.add(prev, cur.amountRaw).amount;
        }
        return prev;
      }, Amounts.zeroOfAmount(am));
    },
  );

  const fullyRefunded = Amounts.cmp(amountRaw, totalKnownRefund) <= 0;

  // We stop with the auto-refund state when the auto-refund period
  // is over or the product is already fully refunded.

  if (noAutoRefundOrExpired || fullyRefunded) {
    await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
      const [p, h] = await ctx.getRecordHandle(tx);
      switch (p?.purchaseStatus) {
        case PurchaseStatus.PendingQueryingAutoRefund:
        case PurchaseStatus.FinalizingQueryingAutoRefund:
          break;
        default:
          return;
      }
      p.purchaseStatus = PurchaseStatus.Done;
      p.refundAmountAwaiting = undefined;
      await h.update(p);
    });
    return TaskRunResult.progress();
  }

  const requestUrl = new URL(
    `orders/${download.contractTerms.order_id}`,
    download.contractTerms.merchant_base_url,
  );
  requestUrl.searchParams.set("h_contract", download.contractTermsHash);

  requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund));

  const resp = await cancelableLongPoll(wex, requestUrl);

  // FIXME: Check other status codes!

  const orderStatus = await readSuccessResponseJsonOrThrow(
    resp,
    codecForMerchantOrderStatusPaid(),
  );

  if (!orderStatus.refund_pending) {
    return TaskRunResult.longpollReturnedPending();
  }

  await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
    const [rec, h] = await ctx.getRecordHandle(tx);
    switch (rec?.purchaseStatus) {
      case PurchaseStatus.PendingQueryingAutoRefund:
      case PurchaseStatus.FinalizingQueryingAutoRefund:
        break;
      default:
        return;
    }
    rec.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
    await h.update(rec);
  });
  return TaskRunResult.progress();
}

async function processPurchaseAbortingRefund(
  wex: WalletExecutionContext,
  purchase: PurchaseRecord,
): Promise<TaskRunResult> {
  const proposalId = purchase.proposalId;
  const download = await expectProposalDownload(wex, purchase);
  logger.trace(`processing aborting-refund for proposal ${proposalId}`);

  const requestUrl = new URL(
    `orders/${download.contractTerms.order_id}/abort`,
    download.contractTerms.merchant_base_url,
  );

  const abortingCoins: AbortingCoin[] = [];

  const payCoinSelection = purchase.payInfo?.payCoinSelection;
  if (!payCoinSelection) {
    throw Error("can't abort, no coins selected");
  }

  await wex.db.runReadOnlyTx({ storeNames: ["coins"] }, async (tx) => {
    for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
      const coinPub = payCoinSelection.coinPubs[i];
      const coin = await tx.coins.get(coinPub);
      checkDbInvariant(!!coin, `coin not found for ${coinPub}`);
      abortingCoins.push({
        coin_pub: coinPub,
        contribution: Amounts.stringify(payCoinSelection.coinContributions[i]),
        exchange_url: coin.exchangeBaseUrl,
      });
    }
  });

  const abortReq: AbortRequest = {
    h_contract: download.contractTermsHash,
    coins: abortingCoins,
  };

  logger.trace(`making order abort request to ${requestUrl.href}`);

  const abortHttpResp = await cancelableFetch(wex, requestUrl, {
    method: "POST",
    body: abortReq,
  });

  logger.trace(`abort response status: ${j2s(abortHttpResp.status)}`);

  if (abortHttpResp.status === HttpStatusCode.NotFound) {
    const err = await readTalerErrorResponse(abortHttpResp);
    if (
      err.code ===
      TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
    ) {
      const ctx = new PayMerchantTransactionContext(wex, proposalId);
      await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
        const [rec, h] = await ctx.getRecordHandle(tx);
        if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) {
          return;
        }
        rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
        await h.update(rec);
      });
    }
  }

  const abortResp = await readSuccessResponseJsonOrThrow(
    abortHttpResp,
    codecForAbortResponse(),
  );

  if (logger.shouldLogTrace()) {
    logger.trace(`abort response: ${j2s(abortResp)}`);
  }

  const refunds: MerchantCoinRefundStatus[] = [];

  if (abortResp.refunds.length != abortingCoins.length) {
    // FIXME: define error code!
    throw Error("invalid order abort response");
  }

  for (let i = 0; i < abortResp.refunds.length; i++) {
    const r = abortResp.refunds[i];
    if (r.type === "undeposited") {
      continue;
    }
    refunds.push({
      ...r,
      coin_pub: payCoinSelection.coinPubs[i],
      refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
      rtransaction_id: 0,
      execution_time: AbsoluteTime.toProtocolTimestamp(
        AbsoluteTime.addDuration(
          AbsoluteTime.fromProtocolTimestamp(download.contractTerms.timestamp),
          Duration.fromSpec({ seconds: 1 }),
        ),
      ),
    });
  }
  return await storeRefunds(wex, purchase, refunds, RefundReason.AbortRefund);
}

async function processPurchaseQueryRefund(
  wex: WalletExecutionContext,
  purchase: PurchaseRecord,
): Promise<TaskRunResult> {
  const proposalId = purchase.proposalId;
  logger.trace(`processing query-refund for proposal ${proposalId}`);

  const download = await expectProposalDownload(wex, purchase);

  const requestUrl = new URL(
    `orders/${download.contractTerms.order_id}`,
    download.contractTerms.merchant_base_url,
  );
  requestUrl.searchParams.set("h_contract", download.contractTermsHash);

  const resp = await cancelableFetch(wex, requestUrl);
  const orderStatus = await readSuccessResponseJsonOrThrow(
    resp,
    codecForMerchantOrderStatusPaid(),
  );

  const ctx = new PayMerchantTransactionContext(wex, proposalId);

  if (!orderStatus.refund_pending) {
    await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
      const [p, h] = await ctx.getRecordHandle(tx);
      if (p?.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
        return;
      }
      p.purchaseStatus = PurchaseStatus.Done;
      p.refundAmountAwaiting = undefined;
      await h.update(p);
    });
  } else {
    const refundAwaiting = Amounts.sub(
      Amounts.parseOrThrow(orderStatus.refund_amount),
      Amounts.parseOrThrow(orderStatus.refund_taken),
    ).amount;

    await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
      const [p, h] = await ctx.getRecordHandle(tx);
      if (p?.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
        return;
      }
      p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
      p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
      await h.update(p);
    });
  }
  return TaskRunResult.progress();
}

async function processPurchaseAcceptRefund(
  wex: WalletExecutionContext,
  purchase: PurchaseRecord,
): Promise<TaskRunResult> {
  const download = await expectProposalDownload(wex, purchase);

  const requestUrl = new URL(
    `orders/${download.contractTerms.order_id}/refund`,
    download.contractTerms.merchant_base_url,
  );

  logger.trace(`making refund request to ${requestUrl.href}`);

  const request = await cancelableFetch(wex, requestUrl, {
    method: "POST",
    body: {
      h_contract: download.contractTermsHash,
    },
  });

  const refundResponse = await readSuccessResponseJsonOrThrow(
    request,
    codecForWalletRefundResponse(),
  );
  return await storeRefunds(
    wex,
    purchase,
    refundResponse.refunds,
    RefundReason.AbortRefund,
  );
}

export async function startRefundQueryForUri(
  wex: WalletExecutionContext,
  talerUri: string,
): Promise<StartRefundQueryForUriResponse> {
  const parsedUri = parseTalerUri(talerUri);
  if (!parsedUri) {
    throw Error("invalid taler:// URI");
  }
  if (parsedUri.type !== TalerUriAction.Refund) {
    throw Error("expected taler://refund URI");
  }
  const purchaseRecord = await wex.db.runReadOnlyTx(
    { storeNames: ["purchases"] },
    async (tx) => {
      return tx.purchases.indexes.byUrlAndOrderId.get([
        parsedUri.merchantBaseUrl,
        parsedUri.orderId,
      ]);
    },
  );
  if (!purchaseRecord) {
    logger.error(
      `no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`,
    );
    throw Error("no purchase found, can't refund");
  }
  const proposalId = purchaseRecord.proposalId;
  const transactionId = constructTransactionIdentifier({
    tag: TransactionType.Payment,
    proposalId,
  });
  await startQueryRefund(wex, proposalId);
  return {
    transactionId,
  };
}

export async function startQueryRefund(
  wex: WalletExecutionContext,
  proposalId: string,
): Promise<void> {
  const ctx = new PayMerchantTransactionContext(wex, proposalId);
  await wex.db.runReadWriteTx(
    { storeNames: ["purchases", "transactionsMeta"] },
    async (tx) => {
      const p = await tx.purchases.get(proposalId);
      if (!p) {
        logger.warn(`purchase ${proposalId} does not exist anymore`);
        return;
      }
      if (p.purchaseStatus !== PurchaseStatus.Done) {
        return;
      }
      const oldTxState = computePayMerchantTransactionState(p);
      const oldStId = p.purchaseStatus;
      p.purchaseStatus = PurchaseStatus.PendingQueryingRefund;
      const newTxState = computePayMerchantTransactionState(p);
      const newStId = p.purchaseStatus;
      await tx.purchases.put(p);
      await ctx.updateTransactionMeta(tx);
      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        newStId,
        oldStId,
      });
    },
  );
  wex.taskScheduler.startShepherdTask(ctx.taskId);
}

async function computeRefreshRequest(
  wex: WalletExecutionContext,
  tx: WalletDbReadWriteTransaction<["coins", "denominations"]>,
  items: RefundItemRecord[],
): Promise<CoinRefreshRequest[]> {
  const refreshCoins: CoinRefreshRequest[] = [];
  for (const item of items) {
    const coin = await tx.coins.get(item.coinPub);
    if (!coin) {
      throw Error("coin not found");
    }
    const denomInfo = await getDenomInfo(
      wex,
      tx,
      coin.exchangeBaseUrl,
      coin.denomPubHash,
    );
    if (!denomInfo) {
      throw Error("denom not found");
    }
    if (item.status === RefundItemStatus.Done) {
      const refundedAmount = Amounts.sub(
        item.refundAmount,
        denomInfo.feeRefund,
      ).amount;
      refreshCoins.push({
        amount: Amounts.stringify(refundedAmount),
        coinPub: item.coinPub,
      });
    }
  }
  return refreshCoins;
}

/**
 * Compute the refund item status based on the merchant's response.
 */
function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
  if (rf.type === "success") {
    return RefundItemStatus.Done;
  } else {
    if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
      return RefundItemStatus.Pending;
    } else {
      return RefundItemStatus.Failed;
    }
  }
}

/**
 * Store refunds, possibly creating a new refund group.
 */
async function storeRefunds(
  wex: WalletExecutionContext,
  purchase: PurchaseRecord,
  refunds: MerchantCoinRefundStatus[],
  reason: RefundReason,
): Promise<TaskRunResult> {
  logger.info(`storing refunds: ${j2s(refunds)}`);

  const ctx = new PayMerchantTransactionContext(wex, purchase.proposalId);

  const newRefundGroupId = encodeCrock(randomBytes(32));
  const now = TalerPreciseTimestamp.now();

  const download = await expectProposalDownload(wex, purchase);
  const { available, amountRaw } = ContractTermsUtil.extractAmounts(
    download.contractTerms,
    purchase.choiceIndex,
  );
  if (!available) {
    throw Error("choice index not specified for contract v1");
  }

  const currency = Amounts.currencyOf(amountRaw);

  const result = await wex.db.runReadWriteTx(
    {
      storeNames: [
        "coinAvailability",
        "coinHistory",
        "coins",
        "coins",
        "denominations",
        "denominations",
        "purchases",
        "refreshGroups",
        "refreshSessions",
        "refundGroups",
        "refundItems",
        "transactionsMeta",
      ],
    },
    async (tx) => {
      const myPurchase = await tx.purchases.get(purchase.proposalId);
      if (!myPurchase) {
        logger.warn("purchase group not found anymore");
        return;
      }
      let isAborting: boolean;
      switch (myPurchase.purchaseStatus) {
        case PurchaseStatus.PendingAcceptRefund:
          isAborting = false;
          break;
        case PurchaseStatus.AbortingWithRefund:
          isAborting = true;
          break;
        default:
          logger.warn("wrong state, not accepting refund");
          return;
      }

      let newGroup: RefundGroupRecord | undefined = undefined;
      // Pending, but not part of an aborted refund group.
      let numPendingItemsTotal = 0;
      const newGroupRefunds: RefundItemRecord[] = [];

      for (const rf of refunds) {
        const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
          rf.coin_pub,
          rf.rtransaction_id,
        ]);
        let oldTxState: TransactionState | undefined = undefined;
        if (oldItem) {
          logger.info("already have refund in database");
          if (oldItem.status === RefundItemStatus.Done) {
            continue;
          }
          if (rf.type === "success") {
            oldItem.status = RefundItemStatus.Done;
          } else {
            if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
              oldItem.status = RefundItemStatus.Pending;
              numPendingItemsTotal += 1;
            } else {
              oldItem.status = RefundItemStatus.Failed;
            }
          }
          await tx.refundItems.put(oldItem);
        } else {
          // Put refund item into a new group!
          if (!newGroup) {
            newGroup = {
              proposalId: purchase.proposalId,
              refundGroupId: newRefundGroupId,
              status: RefundGroupStatus.Pending,
              timestampCreated: timestampPreciseToDb(now),
              amountEffective: Amounts.stringify(
                Amounts.zeroOfCurrency(currency),
              ),
              amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
            };
          }
          const status: RefundItemStatus = getItemStatus(rf);
          const newItem: RefundItemRecord = {
            coinPub: rf.coin_pub,
            executionTime: timestampProtocolToDb(rf.execution_time),
            obtainedTime: timestampPreciseToDb(now),
            refundAmount: rf.refund_amount,
            refundGroupId: newGroup.refundGroupId,
            rtxid: rf.rtransaction_id,
            status,
          };
          if (status === RefundItemStatus.Pending) {
            numPendingItemsTotal += 1;
          }
          newGroupRefunds.push(newItem);
          await tx.refundItems.put(newItem);
        }
      }

      // Now that we know all the refunds for the new refund group,
      // we can compute the raw/effective amounts.
      if (newGroup) {
        const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
        const refreshCoins = await computeRefreshRequest(
          wex,
          tx,
          newGroupRefunds,
        );
        const outInfo = await calculateRefreshOutput(
          wex,
          tx,
          currency,
          refreshCoins,
        );
        newGroup.amountEffective = Amounts.stringify(
          Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
        );
        newGroup.amountRaw = Amounts.stringify(
          Amounts.sumOrZero(currency, amountsRaw).amount,
        );
        const refundCtx = new RefundTransactionContext(
          wex,
          newGroup.refundGroupId,
        );
        await tx.refundGroups.put(newGroup);
        await refundCtx.updateTransactionMeta(tx);
        applyNotifyTransition(tx.notify, refundCtx.transactionId, {
          oldTxState: { major: TransactionMajorState.None },
          newTxState: computeRefundTransactionState(newGroup),
          balanceEffect: BalanceEffect.Any,
          newStId: newGroup.status,
          oldStId: 0,
        });
      }

      const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
        myPurchase.proposalId,
      );

      for (const refundGroup of refundGroups) {
        const refundCtx = new RefundTransactionContext(
          wex,
          refundGroup.refundGroupId,
        );
        switch (refundGroup.status) {
          case RefundGroupStatus.Aborted:
          case RefundGroupStatus.Expired:
          case RefundGroupStatus.Failed:
          case RefundGroupStatus.Done:
            continue;
          case RefundGroupStatus.Pending:
            break;
          default:
            assertUnreachable(refundGroup.status);
        }
        const items = await tx.refundItems.indexes.byRefundGroupId.getAll([
          refundGroup.refundGroupId,
        ]);
        let numPending = 0;
        let numFailed = 0;
        for (const item of items) {
          if (item.status === RefundItemStatus.Pending) {
            numPending++;
          }
          if (item.status === RefundItemStatus.Failed) {
            numFailed++;
          }
        }
        const oldTxState: TransactionState =
          computeRefundTransactionState(refundGroup);
        const oldStId = refundGroup.status;
        if (numPending === 0) {
          // We're done for this refund group!
          if (numFailed === 0) {
            refundGroup.status = RefundGroupStatus.Done;
          } else {
            refundGroup.status = RefundGroupStatus.Failed;
          }
          await tx.refundGroups.put(refundGroup);
          await refundCtx.updateTransactionMeta(tx);
          const refreshCoins = await computeRefreshRequest(wex, tx, items);
          const newTxState: TransactionState =
            computeRefundTransactionState(refundGroup);
          const newStId = refundGroup.status;
          applyNotifyTransition(tx.notify, refundCtx.transactionId, {
            oldTxState,
            newTxState,
            balanceEffect: BalanceEffect.Any,
            newStId,
            oldStId,
          });
          await createRefreshGroup(
            wex,
            tx,
            Amounts.currencyOf(amountRaw),
            refreshCoins,
            RefreshReason.Refund,
            // Since refunds are really just pseudo-transactions,
            // the originating transaction for the refresh is the payment transaction.
            constructTransactionIdentifier({
              tag: TransactionType.Payment,
              proposalId: myPurchase.proposalId,
            }),
          );
        }
      }

      const oldTxState = computePayMerchantTransactionState(myPurchase);
      const oldStId = myPurchase.purchaseStatus;

      const shouldCheckAutoRefund =
        myPurchase.autoRefundDeadline &&
        !AbsoluteTime.isExpired(
          AbsoluteTime.fromProtocolTimestamp(
            timestampProtocolFromDb(myPurchase.autoRefundDeadline),
          ),
        );

      if (numPendingItemsTotal === 0) {
        if (isAborting) {
          myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
        } else if (shouldCheckAutoRefund) {
          myPurchase.purchaseStatus =
            PurchaseStatus.FinalizingQueryingAutoRefund;
        } else {
          myPurchase.purchaseStatus = PurchaseStatus.Done;
        }
        myPurchase.refundAmountAwaiting = undefined;
      }
      await tx.purchases.put(myPurchase);
      await ctx.updateTransactionMeta(tx);
      const newTxState = computePayMerchantTransactionState(myPurchase);

      applyNotifyTransition(tx.notify, ctx.transactionId, {
        oldTxState,
        newTxState,
        balanceEffect: BalanceEffect.Any,
        newStId: myPurchase.purchaseStatus,
        oldStId,
      });

      return {
        numPendingItemsTotal,
      };
    },
  );

  if (!result) {
    return TaskRunResult.finished();
  }

  if (result.numPendingItemsTotal > 0) {
    return TaskRunResult.backoff();
  } else {
    return TaskRunResult.progress();
  }
}

export function computeRefundTransactionState(
  refundGroupRecord: RefundGroupRecord,
): TransactionState {
  switch (refundGroupRecord.status) {
    case RefundGroupStatus.Aborted:
      return {
        major: TransactionMajorState.Aborted,
      };
    case RefundGroupStatus.Done:
      return {
        major: TransactionMajorState.Done,
      };
    case RefundGroupStatus.Failed:
      return {
        major: TransactionMajorState.Failed,
      };
    case RefundGroupStatus.Pending:
      return {
        major: TransactionMajorState.Pending,
      };
    case RefundGroupStatus.Expired:
      return {
        major: TransactionMajorState.Expired,
      };
  }
}
