gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-wallet-core] 01/03: wallet-core: Clean up merchant payments DB sc


From: gnunet
Subject: [taler-wallet-core] 01/03: wallet-core: Clean up merchant payments DB schema
Date: Sat, 08 Oct 2022 23:45:54 +0200

This is an automated email from the git hooks/post-receive script.

dold pushed a commit to branch master
in repository wallet-core.

commit 526f4eba9554f27e33afb0e02d19d870825b038c
Author: Florian Dold <florian@dold.me>
AuthorDate: Sat Oct 8 20:56:57 2022 +0200

    wallet-core: Clean up merchant payments DB schema
---
 .vscode/settings.json                              |  104 +-
 packages/idb-bridge/src/MemoryBackend.ts           |   26 +-
 packages/taler-util/src/backupTypes.ts             |  166 ++-
 .../src/integrationtests/test-denom-unoffered.ts   |    7 +-
 .../integrationtests/test-payment-idempotency.ts   |   29 +-
 packages/taler-wallet-core/src/bank-api-client.ts  |    6 +-
 packages/taler-wallet-core/src/db.ts               |  277 +++--
 packages/taler-wallet-core/src/dbless.ts           |   28 +-
 packages/taler-wallet-core/src/index.ts            |    1 -
 .../taler-wallet-core/src/internal-wallet-state.ts |   18 +
 .../src/operations/backup/export.ts                |   95 +-
 .../src/operations/backup/import.ts                |  201 +---
 .../src/operations/backup/index.ts                 |   11 +-
 .../taler-wallet-core/src/operations/common.ts     |  292 ++++-
 .../taler-wallet-core/src/operations/deposits.ts   |    5 +-
 .../taler-wallet-core/src/operations/exchanges.ts  |    3 -
 .../taler-wallet-core/src/operations/merchants.ts  |    4 +-
 .../src/operations/{pay.ts => pay-merchant.ts}     | 1225 +++++++++++++++++---
 .../operations/{peer-to-peer.ts => pay-peer.ts}    |    3 +-
 .../taler-wallet-core/src/operations/pending.ts    |   85 +-
 .../taler-wallet-core/src/operations/recoup.ts     |    7 +-
 .../taler-wallet-core/src/operations/refresh.ts    |    2 +-
 .../taler-wallet-core/src/operations/refund.ts     |  815 -------------
 .../taler-wallet-core/src/operations/testing.ts    |    5 +-
 packages/taler-wallet-core/src/operations/tip.ts   |    5 +-
 .../src/operations/transactions.ts                 |  147 +--
 .../taler-wallet-core/src/operations/withdraw.ts   |   14 +-
 packages/taler-wallet-core/src/pending-types.ts    |   44 +-
 packages/taler-wallet-core/src/util/retries.ts     |   11 +-
 packages/taler-wallet-core/src/wallet.ts           |  273 +----
 30 files changed, 1833 insertions(+), 2076 deletions(-)

diff --git a/.vscode/settings.json b/.vscode/settings.json
index d8e616936..97596d26c 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,53 +1,59 @@
 // Place your settings in this file to overwrite default and user settings.
 {
-    // Use latest language servicesu
-    "typescript.tsdk": "./node_modules/typescript/lib",
-    // Defines space handling after a comma delimiter
-    "typescript.format.insertSpaceAfterCommaDelimiter": true,
-    //  Defines space handling after a semicolon in a for statement
-    "typescript.format.insertSpaceAfterSemicolonInForStatements": true,
-    // Defines space handling after a binary operator
-    "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": true,
-    // Defines space handling after keywords in control flow statement
-    "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": true,
-    // Defines space handling after function keyword for anonymous functions
-    "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": 
true,
-    // Defines space handling after opening and before closing non empty 
parenthesis
-    
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": 
false,
-    // Defines space handling after opening and before closing non empty 
brackets
-    
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": 
false,
-    // Defines whether an open brace is put onto a new line for functions or 
not
-    "typescript.format.placeOpenBraceOnNewLineForFunctions": false,
-    // Defines whether an open brace is put onto a new line for control blocks 
or not
-    "typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
-    // Files hidden in the explorer
-    "files.exclude": {
-        // include the defaults from VS Code
-        "**/.git": true,
-        "**/.DS_Store": true,
-        // exclude .js and .js.map files, when in a TypeScript project
-        "**/*.js": {
-            "when": "$(basename).ts"
-        },
-        "**/*?.js": {
-            "when": "$(basename).tsx"
-        },
-        "**/*.js.map": true
+  // Use latest language servicesu
+  "typescript.tsdk": "./node_modules/typescript/lib",
+  // Defines space handling after a comma delimiter
+  "typescript.format.insertSpaceAfterCommaDelimiter": true,
+  //  Defines space handling after a semicolon in a for statement
+  "typescript.format.insertSpaceAfterSemicolonInForStatements": true,
+  // Defines space handling after a binary operator
+  "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": true,
+  // Defines space handling after keywords in control flow statement
+  "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": true,
+  // Defines space handling after function keyword for anonymous functions
+  "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": 
true,
+  // Defines space handling after opening and before closing non empty 
parenthesis
+  
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": 
false,
+  // Defines space handling after opening and before closing non empty brackets
+  "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": 
false,
+  // Defines whether an open brace is put onto a new line for functions or not
+  "typescript.format.placeOpenBraceOnNewLineForFunctions": false,
+  // Defines whether an open brace is put onto a new line for control blocks 
or not
+  "typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
+  "typescript.preferences.autoImportFileExcludePatterns": [
+    "index.js",
+    "index.*.js",
+    "index.ts",
+    "index.*.ts"
+  ],
+  // Files hidden in the explorer
+  "files.exclude": {
+    // include the defaults from VS Code
+    "**/.git": true,
+    "**/.DS_Store": true,
+    // exclude .js and .js.map files, when in a TypeScript project
+    "**/*.js": {
+      "when": "$(basename).ts"
     },
-    "editor.wrappingIndent": "same",
-    "editor.tabSize": 2,
-    "search.exclude": {
-        "dist": true,
-        "prebuilt": true,
-        "src/i18n/*.po": true,
-        "vendor": true
+    "**/*?.js": {
+      "when": "$(basename).tsx"
     },
-    "search.collapseResults": "auto",
-    "files.associations": {
-        "api-extractor.json": "jsonc"
-    },
-    "typescript.preferences.importModuleSpecifierEnding": "js",
-    "typescript.preferences.importModuleSpecifier": "project-relative",
-    "javascript.preferences.importModuleSpecifier": "project-relative",
-    "javascript.preferences.importModuleSpecifierEnding": "js"
-}
\ No newline at end of file
+    "**/*.js.map": true
+  },
+  "editor.wrappingIndent": "same",
+  "editor.tabSize": 2,
+  "search.exclude": {
+    "dist": true,
+    "prebuilt": true,
+    "src/i18n/*.po": true,
+    "vendor": true
+  },
+  "search.collapseResults": "auto",
+  "files.associations": {
+    "api-extractor.json": "jsonc"
+  },
+  "typescript.preferences.importModuleSpecifierEnding": "js",
+  "typescript.preferences.importModuleSpecifier": "project-relative",
+  "javascript.preferences.importModuleSpecifier": "project-relative",
+  "javascript.preferences.importModuleSpecifierEnding": "js"
+}
diff --git a/packages/idb-bridge/src/MemoryBackend.ts 
b/packages/idb-bridge/src/MemoryBackend.ts
index 3919cdf97..f40f1c98b 100644
--- a/packages/idb-bridge/src/MemoryBackend.ts
+++ b/packages/idb-bridge/src/MemoryBackend.ts
@@ -378,9 +378,9 @@ export class MemoryBackend implements Backend {
     }
   }
 
-  private makeObjectStoreMap(
-    database: Database,
-  ): { [currentName: string]: ObjectStoreMapEntry } {
+  private makeObjectStoreMap(database: Database): {
+    [currentName: string]: ObjectStoreMapEntry;
+  } {
     let map: { [currentName: string]: ObjectStoreMapEntry } = {};
     for (let objectStoreName in database.committedObjectStores) {
       const store = database.committedObjectStores[objectStoreName];
@@ -1088,9 +1088,8 @@ export class MemoryBackend implements Backend {
       if (!existingIndexRecord) {
         throw Error("db inconsistent: expected index entry missing");
       }
-      const newPrimaryKeys = existingIndexRecord.primaryKeys.without(
-        primaryKey,
-      );
+      const newPrimaryKeys =
+        existingIndexRecord.primaryKeys.without(primaryKey);
       if (newPrimaryKeys.size === 0) {
         index.modifiedData = indexData.without(indexKey);
       } else {
@@ -1357,7 +1356,20 @@ export class MemoryBackend implements Backend {
 
       // Remove old index entry first!
       if (oldStoreRecord) {
-        this.deleteFromIndex(index, key, oldStoreRecord.value, 
indexProperties);
+        try {
+          this.deleteFromIndex(
+            index,
+            key,
+            oldStoreRecord.value,
+            indexProperties,
+          );
+        } catch (e) {
+          if (e instanceof DataError) {
+            // Do nothing
+          } else {
+            throw e;
+          }
+        }
       }
       try {
         this.insertIntoIndex(index, key, value, indexProperties);
diff --git a/packages/taler-util/src/backupTypes.ts 
b/packages/taler-util/src/backupTypes.ts
index 19d478178..777086599 100644
--- a/packages/taler-util/src/backupTypes.ts
+++ b/packages/taler-util/src/backupTypes.ts
@@ -180,15 +180,6 @@ export interface WalletBackupContentV1 {
    */
   tips: BackupTip[];
 
-  /**
-   * Proposals from merchants.  The proposal may
-   * be deleted as soon as it has been accepted (and thus
-   * turned into a purchase).
-   *
-   * Sorted by the proposal ID.
-   */
-  proposals: BackupProposal[];
-
   /**
    * Accepted purchases.
    *
@@ -838,29 +829,10 @@ export type BackupRefundItem =
   | BackupRefundPendingItem
   | BackupRefundAppliedItem;
 
-export interface BackupPurchase {
-  /**
-   * Proposal ID for this purchase.  Uniquely identifies the
-   * purchase and the proposal.
-   */
-  proposal_id: string;
-
-  /**
-   * Contract terms we got from the merchant.
-   */
-  contract_terms_raw: RawContractTerms;
-
-  /**
-   * Signature on the contract terms.
-   */
-  merchant_sig: string;
-
-  /**
-   * Private key for the nonce.  Might eventually be used
-   * to prove ownership of the contract.
-   */
-  nonce_priv: string;
-
+/**
+ * Data we store when the payment was accepted.
+ */
+export interface BackupPayInfo {
   pay_coins: {
     /**
      * Public keys of the coins that were selected.
@@ -890,6 +862,63 @@ export interface BackupPurchase {
    * We might show adjustments to this later, but currently we don't do so.
    */
   total_pay_cost: BackupAmountString;
+}
+
+export interface BackupPurchase {
+  /**
+   * Proposal ID for this purchase.  Uniquely identifies the
+   * purchase and the proposal.
+   */
+  proposal_id: string;
+
+  /**
+   * Status of the proposal.
+   */
+  proposal_status: BackupProposalStatus;
+
+  /**
+   * Proposal that this one got "redirected" to as part of
+   * the repurchase detection.
+   */
+  repurchase_proposal_id: string | undefined;
+
+  /**
+   * Session ID we got when downloading the contract.
+   */
+  download_session_id?: string;
+
+  /**
+   * Merchant-assigned order ID of the proposal.
+   */
+  order_id: string;
+
+  /**
+   * Base URL of the merchant that proposed the purchase.
+   */
+  merchant_base_url: string;
+
+  /**
+   * Claim token initially given by the merchant.
+   */
+  claim_token: string | undefined;
+
+  /**
+   * Contract terms we got from the merchant.
+   */
+  contract_terms_raw?: RawContractTerms;
+
+  /**
+   * Signature on the contract terms.
+   */
+  merchant_sig?: string;
+
+  /**
+   * Private key for the nonce.  Might eventually be used
+   * to prove ownership of the contract.
+   */
+  nonce_priv: string;
+
+  pay_info: BackupPayInfo | undefined;
 
   /**
    * Timestamp of the first time that sending a payment to the merchant
@@ -902,11 +931,13 @@ export interface BackupPurchase {
    */
   merchant_pay_sig: string | undefined;
 
+  timestamp_proposed: TalerProtocolTimestamp;
+
   /**
    * When was the purchase made?
    * Refers to the time that the user accepted.
    */
-  timestamp_accept: TalerProtocolTimestamp;
+  timestamp_accepted: TalerProtocolTimestamp | undefined;
 
   /**
    * Pending refunds for the purchase.  A refund is pending
@@ -914,11 +945,6 @@ export interface BackupPurchase {
    */
   refunds: BackupRefundItem[];
 
-  /**
-   * Abort status of the payment.
-   */
-  abort_status?: "abort-refund" | "abort-finished";
-
   /**
    * Continue querying the refund status until this deadline has expired.
    */
@@ -1218,70 +1244,8 @@ export enum BackupProposalStatus {
    * Downloaded proposal was detected as a re-purchase.
    */
   Repurchase = "repurchase",
-}
-
-/**
- * Proposal by a merchant.
- */
-export interface BackupProposal {
-  /**
-   * Base URL of the merchant that proposed the purchase.
-   */
-  merchant_base_url: string;
-
-  /**
-   * Downloaded data from the merchant.
-   */
-  contract_terms_raw?: RawContractTerms;
-
-  /**
-   * Signature on the contract terms.
-   *
-   * Must be present if contract_terms_raw is present.
-   */
-  merchant_sig?: string;
-
-  /**
-   * Unique ID when the order is stored in the wallet DB.
-   */
-  proposal_id: string;
-
-  /**
-   * Merchant-assigned order ID of the proposal.
-   */
-  order_id: string;
-
-  /**
-   * Timestamp of when the record
-   * was created.
-   */
-  timestamp: TalerProtocolTimestamp;
-
-  /**
-   * Private key for the nonce.
-   */
-  nonce_priv: string;
-
-  /**
-   * Claim token initially given by the merchant.
-   */
-  claim_token: string | undefined;
-
-  /**
-   * Status of the proposal.
-   */
-  proposal_status: BackupProposalStatus;
-
-  /**
-   * Proposal that this one got "redirected" to as part of
-   * the repurchase detection.
-   */
-  repurchase_proposal_id: string | undefined;
 
-  /**
-   * Session ID we got when downloading the contract.
-   */
-  download_session_id?: string;
+  Paid = "paid",
 }
 
 export interface BackupRecovery {
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts
index ec1d9f64b..b5ecbee4a 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts
@@ -17,13 +17,8 @@
 /**
  * Imports.
  */
-import {
-  PreparePayResultType,
-  TalerErrorCode,
-  TalerErrorDetail,
-} from "@gnu-taler/taler-util";
+import { PreparePayResultType, TalerErrorCode } from "@gnu-taler/taler-util";
 import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { makeEventId } from "@gnu-taler/taler-wallet-core";
 import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
 import {
   createSimpleTestkudosEnvironment,
diff --git 
a/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts 
b/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts
index 9378465a0..1099a8188 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts
+++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts
@@ -18,7 +18,10 @@
  * Imports.
  */
 import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from 
"../harness/helpers.js";
+import {
+  createSimpleTestkudosEnvironment,
+  withdrawViaBank,
+} from "../harness/helpers.js";
 import { PreparePayResultType } from "@gnu-taler/taler-util";
 import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
 
@@ -29,12 +32,8 @@ import { WalletApiOperation } from 
"@gnu-taler/taler-wallet-core";
 export async function runPaymentIdempotencyTest(t: GlobalTestState) {
   // Set up test environment
 
-  const {
-    wallet,
-    bank,
-    exchange,
-    merchant,
-  } = await createSimpleTestkudosEnvironment(t);
+  const { wallet, bank, exchange, merchant } =
+    await createSimpleTestkudosEnvironment(t);
 
   // Withdraw digital cash into the wallet.
 
@@ -83,10 +82,16 @@ export async function runPaymentIdempotencyTest(t: 
GlobalTestState) {
 
   const proposalId = preparePayResult.proposalId;
 
-  await wallet.client.call(WalletApiOperation.ConfirmPay, {
-    // FIXME: should be validated, don't cast!
-    proposalId: proposalId,
-  });
+  const confirmPayResult = await wallet.client.call(
+    WalletApiOperation.ConfirmPay,
+    {
+      proposalId: proposalId,
+    },
+  );
+
+  console.log("confirm pay result", confirmPayResult);
+
+  await wallet.runUntilDone();
 
   // Check if payment was successful.
 
@@ -103,6 +108,8 @@ export async function runPaymentIdempotencyTest(t: 
GlobalTestState) {
     },
   );
 
+  console.log("result after:", preparePayResultAfter);
+
   t.assertTrue(
     preparePayResultAfter.status === PreparePayResultType.AlreadyConfirmed,
   );
diff --git a/packages/taler-wallet-core/src/bank-api-client.ts 
b/packages/taler-wallet-core/src/bank-api-client.ts
index 8f82d7e7f..557b8c315 100644
--- a/packages/taler-wallet-core/src/bank-api-client.ts
+++ b/packages/taler-wallet-core/src/bank-api-client.ts
@@ -34,11 +34,7 @@ import {
   TalerErrorCode,
 } from "@gnu-taler/taler-util";
 import { TalerError } from "./errors.js";
-import {
-  HttpRequestLibrary,
-  readSuccessResponseJsonOrErrorCode,
-  readSuccessResponseJsonOrThrow,
-} from "./index.browser.js";
+import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from 
"./util/http.js";
 
 const logger = new Logger("bank-api-client.ts");
 
diff --git a/packages/taler-wallet-core/src/db.ts 
b/packages/taler-wallet-core/src/db.ts
index 16ae2cf8d..5d344319f 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -98,11 +98,11 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
  */
 export const WALLET_DB_MINOR_VERSION = 2;
 
-export namespace OperationStatusRange {
-  export const ACTIVE_START = 10;
-  export const ACTIVE_END = 29;
-  export const DORMANT_START = 50;
-  export const DORMANT_END = 69;
+export enum OperationStatusRange {
+  ACTIVE_START = 10,
+  ACTIVE_END = 29,
+  DORMANT_START = 50,
+  DORMANT_END = 69,
 }
 
 /**
@@ -741,93 +741,6 @@ export interface CoinAllocation {
   amount: AmountString;
 }
 
-export enum ProposalStatus {
-  /**
-   * Not downloaded yet.
-   */
-  Downloading = "downloading",
-  /**
-   * Proposal downloaded, but the user needs to accept/reject it.
-   */
-  Proposed = "proposed",
-  /**
-   * The user has accepted the proposal.
-   */
-  Accepted = "accepted",
-  /**
-   * The user has rejected the proposal.
-   */
-  Refused = "refused",
-  /**
-   * Downloading or processing the proposal has failed permanently.
-   */
-  PermanentlyFailed = "permanently-failed",
-  /**
-   * Downloaded proposal was detected as a re-purchase.
-   */
-  Repurchase = "repurchase",
-}
-
-export interface ProposalDownload {
-  /**
-   * The contract that was offered by the merchant.
-   */
-  contractTermsRaw: any;
-
-  /**
-   * Extracted / parsed data from the contract terms.
-   *
-   * FIXME: Do we need to store *all* that data in duplicate?
-   */
-  contractData: WalletContractData;
-}
-
-/**
- * Record for a downloaded order, stored in the wallet's database.
- */
-export interface ProposalRecord {
-  orderId: string;
-
-  merchantBaseUrl: string;
-
-  /**
-   * Downloaded data from the merchant.
-   */
-  download: ProposalDownload | undefined;
-
-  /**
-   * Unique ID when the order is stored in the wallet DB.
-   */
-  proposalId: string;
-
-  /**
-   * Timestamp (in ms) of when the record
-   * was created.
-   */
-  timestamp: TalerProtocolTimestamp;
-
-  /**
-   * Private key for the nonce.
-   */
-  noncePriv: string;
-
-  /**
-   * Public key for the nonce.
-   */
-  noncePub: string;
-
-  claimToken: string | undefined;
-
-  proposalStatus: ProposalStatus;
-
-  repurchaseProposalId: string | undefined;
-
-  /**
-   * Session ID we got when downloading the contract.
-   */
-  downloadSessionId: string | undefined;
-}
-
 /**
  * Status of a tip we got from a merchant.
  */
@@ -1113,23 +1026,132 @@ export interface WalletContractData {
   deliveryLocation: Location | undefined;
 }
 
-export enum AbortStatus {
-  None = "none",
-  AbortRefund = "abort-refund",
-  AbortFinished = "abort-finished",
+export enum ProposalStatus {
+  /**
+   * Not downloaded yet.
+   */
+  DownloadingProposal = OperationStatusRange.ACTIVE_START,
+
+  /**
+   * The user has accepted the proposal.
+   */
+  Paying = OperationStatusRange.ACTIVE_START + 1,
+
+  AbortingWithRefund = OperationStatusRange.ACTIVE_START + 2,
+
+  /**
+   * Paying a second time, likely with different session ID
+   */
+  PayingReplay = OperationStatusRange.ACTIVE_START + 3,
+
+  /**
+   * Query for refunds (until query succeeds).
+   */
+  QueryingRefund = OperationStatusRange.ACTIVE_START + 4,
+
+  /**
+   * Query for refund (until auto-refund deadline is reached).
+   */
+  QueryingAutoRefund = OperationStatusRange.ACTIVE_START + 5,
+
+  /**
+   * Proposal downloaded, but the user needs to accept/reject it.
+   */
+  Proposed = OperationStatusRange.DORMANT_START,
+
+  /**
+   * The user has rejected the proposal.
+   */
+  ProposalRefused = OperationStatusRange.DORMANT_START + 1,
+
+  /**
+   * Downloading or processing the proposal has failed permanently.
+   */
+  ProposalDownloadFailed = OperationStatusRange.DORMANT_START + 2,
+
+  /**
+   * Downloaded proposal was detected as a re-purchase.
+   */
+  RepurchaseDetected = OperationStatusRange.DORMANT_START + 3,
+
+  /**
+   * The payment has been aborted.
+   */
+  PaymentAbortFinished = OperationStatusRange.DORMANT_START + 4,
+
+  /**
+   * Payment was successful.
+   */
+  Paid = OperationStatusRange.DORMANT_START + 5,
+}
+
+export interface ProposalDownload {
+  /**
+   * The contract that was offered by the merchant.
+   */
+  contractTermsRaw: any;
+
+  /**
+   * Extracted / parsed data from the contract terms.
+   *
+   * FIXME: Do we need to store *all* that data in duplicate?
+   */
+  contractData: WalletContractData;
+}
+
+export interface PurchasePayInfo {
+  payCoinSelection: PayCoinSelection;
+  totalPayCost: AmountJson;
+  payCoinSelectionUid: string;
+
+  /**
+   * Deposit permissions, available once the user has accepted the payment.
+   *
+   * This value is cached and derived from payCoinSelection.
+   *
+   * FIXME: Should probably be cached somewhere else, maybe not even in DB!
+   */
+  coinDepositPermissions: CoinDepositPermission[] | undefined;
 }
 
 /**
  * Record that stores status information about one purchase, starting from when
  * the customer accepts a proposal.  Includes refund status if applicable.
+ *
+ * FIXME: Should have a single "status" field.
  */
 export interface PurchaseRecord {
   /**
    * Proposal ID for this purchase.  Uniquely identifies the
    * purchase and the proposal.
+   * Assigned by the wallet.
    */
   proposalId: string;
 
+  /**
+   * Order ID, assigned by the merchant.
+   */
+  orderId: string;
+
+  merchantBaseUrl: string;
+
+  /**
+   * Claim token used when downloading the contract terms.
+   */
+  claimToken: string | undefined;
+
+  /**
+   * Session ID we got when downloading the contract.
+   */
+  downloadSessionId: string | undefined;
+
+  /**
+   * If this purchase is a repurchase, this field identifies the original 
purchase.
+   */
+  repurchaseProposalId: string | undefined;
+
+  status: ProposalStatus;
+
   /**
    * Private key for the nonce.
    */
@@ -1146,18 +1168,9 @@ export interface PurchaseRecord {
    * FIXME:  Move this into another object store,
    * to improve read/write perf on purchases.
    */
-  download: ProposalDownload;
-
-  /**
-   * Deposit permissions, available once the user has accepted the payment.
-   *
-   * This value is cached and derived from payCoinSelection.
-   */
-  coinDepositPermissions: CoinDepositPermission[] | undefined;
-
-  payCoinSelection: PayCoinSelection;
+  download: ProposalDownload | undefined;
 
-  payCoinSelectionUid: string;
+  payInfo: PurchasePayInfo | undefined;
 
   /**
    * Pending removals from pay coin selection.
@@ -1169,8 +1182,6 @@ export interface PurchaseRecord {
    */
   pendingRemovedCoinPubs?: string[];
 
-  totalPayCost: AmountJson;
-
   /**
    * Timestamp of the first time that sending a payment to the merchant
    * for this purchase was successful.
@@ -1181,11 +1192,16 @@ export interface PurchaseRecord {
 
   merchantPaySig: string | undefined;
 
+  /**
+   * When was the purchase record created?
+   */
+  timestamp: TalerProtocolTimestamp;
+
   /**
    * When was the purchase made?
    * Refers to the time that the user accepted.
    */
-  timestampAccept: TalerProtocolTimestamp;
+  timestampAccept: TalerProtocolTimestamp | undefined;
 
   /**
    * Pending refunds for the purchase.  A refund is pending
@@ -1206,18 +1222,6 @@ export interface PurchaseRecord {
    */
   lastSessionId: string | undefined;
 
-  /**
-   * Do we still need to post the deposit permissions to the merchant?
-   * Set for the first payment, or on re-plays.
-   */
-  paymentSubmitPending: boolean;
-
-  /**
-   * Do we need to query the merchant for the refund status
-   * of the payment?
-   */
-  refundQueryRequested: boolean;
-
   /**
    * Continue querying the refund status until this deadline has expired.
    */
@@ -1227,18 +1231,7 @@ export interface PurchaseRecord {
    * How much merchant has refund to be taken but the wallet
    * did not picked up yet
    */
-  refundAwaiting: AmountJson | undefined;
-
-  /**
-   * Is the payment frozen?  I.e. did we encounter
-   * an error where it doesn't make sense to retry.
-   */
-  payFrozen?: boolean;
-
-  /**
-   * FIXME: How does this interact with payFrozen?
-   */
-  abortStatus: AbortStatus;
+  refundAmountAwaiting: AmountJson | undefined;
 }
 
 export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
@@ -1923,16 +1916,6 @@ export const WalletStoresV1 = {
     }),
     {},
   ),
-  proposals: describeStore(
-    "proposals",
-    describeContents<ProposalRecord>({ keyPath: "proposalId" }),
-    {
-      byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
-        "merchantBaseUrl",
-        "orderId",
-      ]),
-    },
-  ),
   refreshGroups: describeStore(
     "refreshGroups",
     describeContents<RefreshGroupRecord>({
@@ -1953,14 +1936,20 @@ export const WalletStoresV1 = {
     "purchases",
     describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
     {
+      byStatus: describeIndex("byStatus", "operationStatus"),
       byFulfillmentUrl: describeIndex(
         "byFulfillmentUrl",
         "download.contractData.fulfillmentUrl",
       ),
+      // FIXME: Deduplicate!
       byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [
         "download.contractData.merchantBaseUrl",
         "download.contractData.orderId",
       ]),
+      byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
+        "merchantBaseUrl",
+        "orderId",
+      ]),
     },
   ),
   tips: describeStore(
diff --git a/packages/taler-wallet-core/src/dbless.ts 
b/packages/taler-wallet-core/src/dbless.ts
index ff7870435..9d709e8e3 100644
--- a/packages/taler-wallet-core/src/dbless.ts
+++ b/packages/taler-wallet-core/src/dbless.ts
@@ -26,6 +26,9 @@
  * Imports.
  */
 import {
+  AbsoluteTime,
+  AgeRestriction,
+  AmountJson,
   Amounts,
   AmountString,
   codecForAny,
@@ -35,7 +38,6 @@ import {
   codecForExchangeRevealResponse,
   codecForWithdrawResponse,
   DenominationPubKey,
-  eddsaGetPublic,
   encodeCrock,
   ExchangeMeltRequest,
   ExchangeProtocolVersion,
@@ -44,29 +46,15 @@ import {
   hashWire,
   Logger,
   parsePaytoUri,
-  AbsoluteTime,
   UnblindedSignature,
-  BankWithdrawDetails,
-  parseWithdrawUri,
-  AmountJson,
-  AgeRestriction,
 } from "@gnu-taler/taler-util";
 import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
 import { DenominationRecord } from "./db.js";
-import {
-  assembleRefreshRevealRequest,
-  ExchangeInfo,
-  getBankWithdrawalInfo,
-  HttpRequestLibrary,
-  isWithdrawableDenom,
-  readSuccessResponseJsonOrThrow,
-} from "./index.browser.js";
-import {
-  BankAccessApi,
-  BankApi,
-  BankServiceHandle,
-  getBankStatusUrl,
-} from "./index.js";
+import { BankAccessApi, BankApi, BankServiceHandle } from 
"./bank-api-client.js";
+import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from 
"./util/http.js";
+import { getBankStatusUrl, getBankWithdrawalInfo, isWithdrawableDenom } from 
"./operations/withdraw.js";
+import { ExchangeInfo } from "./operations/exchanges.js";
+import { assembleRefreshRevealRequest } from "./operations/refresh.js";
 
 const logger = new Logger("dbless.ts");
 
diff --git a/packages/taler-wallet-core/src/index.ts 
b/packages/taler-wallet-core/src/index.ts
index 4e419503b..0e01e3517 100644
--- a/packages/taler-wallet-core/src/index.ts
+++ b/packages/taler-wallet-core/src/index.ts
@@ -47,7 +47,6 @@ export * from "./wallet-api-types.js";
 export * from "./wallet.js";
 
 export * from "./operations/backup/index.js";
-export { makeEventId } from "./operations/transactions.js";
 
 export * from "./operations/exchanges.js";
 
diff --git a/packages/taler-wallet-core/src/internal-wallet-state.ts 
b/packages/taler-wallet-core/src/internal-wallet-state.ts
index b8415a469..6c7d943cb 100644
--- a/packages/taler-wallet-core/src/internal-wallet-state.ts
+++ b/packages/taler-wallet-core/src/internal-wallet-state.ts
@@ -37,6 +37,9 @@ import {
   TalerProtocolTimestamp,
   CancellationToken,
   DenominationInfo,
+  RefreshGroupId,
+  CoinPublicKey,
+  RefreshReason,
 } from "@gnu-taler/taler-util";
 import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js";
 import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
@@ -74,6 +77,20 @@ export interface MerchantOperations {
   ): Promise<MerchantInfo>;
 }
 
+export interface RefreshOperations {
+  createRefreshGroup(
+    ws: InternalWalletState,
+    tx: GetReadWriteAccess<{
+      denominations: typeof WalletStoresV1.denominations;
+      coins: typeof WalletStoresV1.coins;
+      refreshGroups: typeof WalletStoresV1.refreshGroups;
+      coinAvailability: typeof WalletStoresV1.coinAvailability;
+    }>,
+    oldCoinPubs: CoinPublicKey[],
+    reason: RefreshReason,
+  ): Promise<RefreshGroupId>;
+}
+
 /**
  * Interface for exchange-related operations.
  */
@@ -172,6 +189,7 @@ export interface InternalWalletState {
   exchangeOps: ExchangeOperations;
   recoupOps: RecoupOperations;
   merchantOps: MerchantOperations;
+  refreshOps: RefreshOperations;
 
   getDenomInfo(
     ws: InternalWalletState,
diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts 
b/packages/taler-wallet-core/src/operations/backup/export.ts
index c8454a62f..04fac7d38 100644
--- a/packages/taler-wallet-core/src/operations/backup/export.ts
+++ b/packages/taler-wallet-core/src/operations/backup/export.ts
@@ -37,7 +37,7 @@ import {
   BackupExchangeDetails,
   BackupExchangeWireFee,
   BackupOperationStatus,
-  BackupProposal,
+  BackupPayInfo,
   BackupProposalStatus,
   BackupPurchase,
   BackupRecoupGroup,
@@ -62,11 +62,9 @@ import {
   WalletBackupContentV1,
 } from "@gnu-taler/taler-util";
 import {
-  AbortStatus,
   CoinSourceType,
   CoinStatus,
   DenominationRecord,
-  OperationStatus,
   ProposalStatus,
   RefreshCoinStatus,
   RefundState,
@@ -92,7 +90,6 @@ export async function exportBackup(
       x.coins,
       x.denominations,
       x.purchases,
-      x.proposals,
       x.refreshGroups,
       x.backupProviders,
       x.tips,
@@ -109,7 +106,6 @@ export async function exportBackup(
         [url: string]: BackupDenomination[];
       } = {};
       const backupPurchases: BackupPurchase[] = [];
-      const backupProposals: BackupProposal[] = [];
       const backupRefreshGroups: BackupRefreshGroup[] = [];
       const backupBackupProviders: BackupBackupProvider[] = [];
       const backupTips: BackupTip[] = [];
@@ -385,65 +381,61 @@ export async function exportBackup(
           }
         }
 
-        backupPurchases.push({
-          contract_terms_raw: purch.download.contractTermsRaw,
-          auto_refund_deadline: purch.autoRefundDeadline,
-          merchant_pay_sig: purch.merchantPaySig,
-          pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({
-            coin_pub: x,
-            contribution: Amounts.stringify(
-              purch.payCoinSelection.coinContributions[i],
-            ),
-          })),
-          proposal_id: purch.proposalId,
-          refunds,
-          timestamp_accept: purch.timestampAccept,
-          timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
-          abort_status:
-            purch.abortStatus === AbortStatus.None
-              ? undefined
-              : purch.abortStatus,
-          nonce_priv: purch.noncePriv,
-          merchant_sig: purch.download.contractData.merchantSig,
-          total_pay_cost: Amounts.stringify(purch.totalPayCost),
-          pay_coins_uid: purch.payCoinSelectionUid,
-        });
-      });
-
-      await tx.proposals.iter().forEach((prop) => {
-        if (purchaseProposalIdSet.has(prop.proposalId)) {
-          return;
-        }
         let propStatus: BackupProposalStatus;
-        switch (prop.proposalStatus) {
-          case ProposalStatus.Accepted:
+        switch (purch.status) {
+          case ProposalStatus.Paid:
+            propStatus = BackupProposalStatus.Paid;
             return;
-          case ProposalStatus.Downloading:
+          case ProposalStatus.DownloadingProposal:
           case ProposalStatus.Proposed:
             propStatus = BackupProposalStatus.Proposed;
             break;
-          case ProposalStatus.PermanentlyFailed:
+          case ProposalStatus.ProposalDownloadFailed:
             propStatus = BackupProposalStatus.PermanentlyFailed;
             break;
-          case ProposalStatus.Refused:
+          case ProposalStatus.ProposalRefused:
             propStatus = BackupProposalStatus.Refused;
             break;
-          case ProposalStatus.Repurchase:
+          case ProposalStatus.RepurchaseDetected:
             propStatus = BackupProposalStatus.Repurchase;
             break;
+          default:
+            throw Error();
         }
-        backupProposals.push({
-          claim_token: prop.claimToken,
-          nonce_priv: prop.noncePriv,
-          proposal_id: prop.noncePriv,
+
+        const payInfo = purch.payInfo;
+        let backupPayInfo: BackupPayInfo | undefined = undefined;
+        if (payInfo) {
+          backupPayInfo = {
+            pay_coins: payInfo.payCoinSelection.coinPubs.map((x, i) => ({
+              coin_pub: x,
+              contribution: Amounts.stringify(
+                payInfo.payCoinSelection.coinContributions[i],
+              ),
+            })),
+            total_pay_cost: Amounts.stringify(payInfo.totalPayCost),
+            pay_coins_uid: payInfo.payCoinSelectionUid,
+          };
+        }
+
+        backupPurchases.push({
+          contract_terms_raw: purch.download?.contractTermsRaw,
+          auto_refund_deadline: purch.autoRefundDeadline,
+          merchant_pay_sig: purch.merchantPaySig,
+          pay_info: backupPayInfo,
+          proposal_id: purch.proposalId,
+          refunds,
+          timestamp_accepted: purch.timestampAccept,
+          timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
+          nonce_priv: purch.noncePriv,
+          merchant_sig: purch.download?.contractData.merchantSig,
+          claim_token: purch.claimToken,
+          merchant_base_url: purch.merchantBaseUrl,
+          order_id: purch.orderId,
           proposal_status: propStatus,
-          repurchase_proposal_id: prop.repurchaseProposalId,
-          timestamp: prop.timestamp,
-          contract_terms_raw: prop.download?.contractTermsRaw,
-          download_session_id: prop.downloadSessionId,
-          merchant_base_url: prop.merchantBaseUrl,
-          order_id: prop.orderId,
-          merchant_sig: prop.download?.contractData.merchantSig,
+          repurchase_proposal_id: purch.repurchaseProposalId,
+          download_session_id: purch.downloadSessionId,
+          timestamp_proposed: purch.timestamp,
         });
       });
 
@@ -498,7 +490,6 @@ export async function exportBackup(
         wallet_root_pub: bs.walletRootPub,
         backup_providers: backupBackupProviders,
         current_device_id: bs.deviceId,
-        proposals: backupProposals,
         purchases: backupPurchases,
         recoup_groups: backupRecoupGroups,
         refresh_groups: backupRefreshGroups,
diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts 
b/packages/taler-wallet-core/src/operations/backup/import.ts
index fb747ef1c..00dbf6fa8 100644
--- a/packages/taler-wallet-core/src/operations/backup/import.ts
+++ b/packages/taler-wallet-core/src/operations/backup/import.ts
@@ -21,8 +21,8 @@ import {
   BackupCoin,
   BackupCoinSourceType,
   BackupDenomSel,
+  BackupPayInfo,
   BackupProposalStatus,
-  BackupPurchase,
   BackupRefreshReason,
   BackupRefundState,
   BackupWgType,
@@ -37,7 +37,6 @@ import {
   WireInfo,
 } from "@gnu-taler/taler-util";
 import {
-  AbortStatus,
   CoinRecord,
   CoinSource,
   CoinSourceType,
@@ -48,28 +47,23 @@ import {
   OperationStatus,
   ProposalDownload,
   ProposalStatus,
+  PurchasePayInfo,
   RefreshCoinStatus,
   RefreshSessionRecord,
   RefundState,
-  ReserveBankInfo,
-  WithdrawalGroupStatus,
   WalletContractData,
   WalletRefundItem,
   WalletStoresV1,
   WgInfo,
+  WithdrawalGroupStatus,
   WithdrawalRecordType,
 } from "../../db.js";
 import { InternalWalletState } from "../../internal-wallet-state.js";
 import { assertUnreachable } from "../../util/assertUnreachable.js";
-import {
-  checkDbInvariant,
-  checkLogicInvariant,
-} from "../../util/invariants.js";
+import { checkLogicInvariant } from "../../util/invariants.js";
 import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
-import { RetryInfo } from "../../util/retries.js";
-import { makeCoinAvailable } from "../../wallet.js";
+import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js";
 import { getExchangeDetails } from "../exchanges.js";
-import { makeEventId, TombstoneTag } from "../transactions.js";
 import { provideBackupState } from "./state.js";
 
 const logger = new Logger("operations/backup/import.ts");
@@ -95,10 +89,10 @@ async function recoverPayCoinSelection(
     denominations: typeof WalletStoresV1.denominations;
   }>,
   contractData: WalletContractData,
-  backupPurchase: BackupPurchase,
+  payInfo: BackupPayInfo,
 ): Promise<PayCoinSelection> {
-  const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
-  const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
+  const coinPubs: string[] = payInfo.pay_coins.map((x) => x.coin_pub);
+  const coinContributions: AmountJson[] = payInfo.pay_coins.map((x) =>
     Amounts.parseOrThrow(x.contribution),
   );
 
@@ -316,7 +310,6 @@ export async function importBackup(
       x.coinAvailability,
       x.denominations,
       x.purchases,
-      x.proposals,
       x.refreshGroups,
       x.backupProviders,
       x.tips,
@@ -560,113 +553,6 @@ export async function importBackup(
         }
       }
 
-      for (const backupProposal of backupBlob.proposals) {
-        const ts = makeEventId(
-          TombstoneTag.DeletePayment,
-          backupProposal.proposal_id,
-        );
-        if (tombstoneSet.has(ts)) {
-          continue;
-        }
-        const existingProposal = await tx.proposals.get(
-          backupProposal.proposal_id,
-        );
-        if (!existingProposal) {
-          let download: ProposalDownload | undefined;
-          let proposalStatus: ProposalStatus;
-          switch (backupProposal.proposal_status) {
-            case BackupProposalStatus.Proposed:
-              if (backupProposal.contract_terms_raw) {
-                proposalStatus = ProposalStatus.Proposed;
-              } else {
-                proposalStatus = ProposalStatus.Downloading;
-              }
-              break;
-            case BackupProposalStatus.Refused:
-              proposalStatus = ProposalStatus.Refused;
-              break;
-            case BackupProposalStatus.Repurchase:
-              proposalStatus = ProposalStatus.Repurchase;
-              break;
-            case BackupProposalStatus.PermanentlyFailed:
-              proposalStatus = ProposalStatus.PermanentlyFailed;
-              break;
-          }
-          if (backupProposal.contract_terms_raw) {
-            checkDbInvariant(!!backupProposal.merchant_sig);
-            const parsedContractTerms = codecForContractTerms().decode(
-              backupProposal.contract_terms_raw,
-            );
-            const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
-            const contractTermsHash =
-              cryptoComp.proposalIdToContractTermsHash[
-                backupProposal.proposal_id
-              ];
-            let maxWireFee: AmountJson;
-            if (parsedContractTerms.max_wire_fee) {
-              maxWireFee = Amounts.parseOrThrow(
-                parsedContractTerms.max_wire_fee,
-              );
-            } else {
-              maxWireFee = Amounts.getZero(amount.currency);
-            }
-            download = {
-              contractData: {
-                amount,
-                contractTermsHash: contractTermsHash,
-                fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
-                merchantBaseUrl: parsedContractTerms.merchant_base_url,
-                merchantPub: parsedContractTerms.merchant_pub,
-                merchantSig: backupProposal.merchant_sig,
-                orderId: parsedContractTerms.order_id,
-                summary: parsedContractTerms.summary,
-                autoRefund: parsedContractTerms.auto_refund,
-                maxWireFee,
-                payDeadline: parsedContractTerms.pay_deadline,
-                refundDeadline: parsedContractTerms.refund_deadline,
-                wireFeeAmortization:
-                  parsedContractTerms.wire_fee_amortization || 1,
-                allowedAuditors: parsedContractTerms.auditors.map((x) => ({
-                  auditorBaseUrl: x.url,
-                  auditorPub: x.auditor_pub,
-                })),
-                allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
-                  exchangeBaseUrl: x.url,
-                  exchangePub: x.master_pub,
-                })),
-                timestamp: parsedContractTerms.timestamp,
-                wireMethod: parsedContractTerms.wire_method,
-                wireInfoHash: parsedContractTerms.h_wire,
-                maxDepositFee: Amounts.parseOrThrow(
-                  parsedContractTerms.max_fee,
-                ),
-                merchant: parsedContractTerms.merchant,
-                products: parsedContractTerms.products,
-                summaryI18n: parsedContractTerms.summary_i18n,
-                deliveryDate: parsedContractTerms.delivery_date,
-                deliveryLocation: parsedContractTerms.delivery_location,
-              },
-              contractTermsRaw: backupProposal.contract_terms_raw,
-            };
-          }
-          await tx.proposals.put({
-            claimToken: backupProposal.claim_token,
-            merchantBaseUrl: backupProposal.merchant_base_url,
-            timestamp: backupProposal.timestamp,
-            orderId: backupProposal.order_id,
-            noncePriv: backupProposal.nonce_priv,
-            noncePub:
-              cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
-            proposalId: backupProposal.proposal_id,
-            repurchaseProposalId: backupProposal.repurchase_proposal_id,
-            download,
-            proposalStatus,
-            // FIXME!
-            downloadSessionId: undefined,
-          });
-        }
-      }
-
       for (const backupPurchase of backupBlob.purchases) {
         const ts = makeEventId(
           TombstoneTag.DeletePayment,
@@ -678,6 +564,14 @@ export async function importBackup(
         const existingPurchase = await tx.purchases.get(
           backupPurchase.proposal_id,
         );
+        let proposalStatus: ProposalStatus;
+        switch (backupPurchase.proposal_status) {
+          case BackupProposalStatus.Paid:
+            proposalStatus = ProposalStatus.Paid;
+            break;
+          default:
+            throw Error();
+        }
         if (!existingPurchase) {
           const refunds: { [refundKey: string]: WalletRefundItem } = {};
           for (const backupRefund of backupPurchase.refunds) {
@@ -721,25 +615,6 @@ export async function importBackup(
                 break;
             }
           }
-          let abortStatus: AbortStatus;
-          switch (backupPurchase.abort_status) {
-            case "abort-finished":
-              abortStatus = AbortStatus.AbortFinished;
-              break;
-            case "abort-refund":
-              abortStatus = AbortStatus.AbortRefund;
-              break;
-            case undefined:
-              abortStatus = AbortStatus.None;
-              break;
-            default:
-              logger.warn(
-                `got backup purchase abort_status ${j2s(
-                  backupPurchase.abort_status,
-                )}`,
-              );
-              throw Error("not reachable");
-          }
           const parsedContractTerms = codecForContractTerms().decode(
             backupPurchase.contract_terms_raw,
           );
@@ -761,7 +636,7 @@ export async function importBackup(
               fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
               merchantBaseUrl: parsedContractTerms.merchant_base_url,
               merchantPub: parsedContractTerms.merchant_pub,
-              merchantSig: backupPurchase.merchant_sig,
+              merchantSig: backupPurchase.merchant_sig!,
               orderId: parsedContractTerms.order_id,
               summary: parsedContractTerms.summary,
               autoRefund: parsedContractTerms.auto_refund,
@@ -790,33 +665,46 @@ export async function importBackup(
             },
             contractTermsRaw: backupPurchase.contract_terms_raw,
           };
+
+          let payInfo: PurchasePayInfo | undefined = undefined;
+          if (backupPurchase.pay_info) {
+            payInfo = {
+              coinDepositPermissions: undefined,
+              payCoinSelection: await recoverPayCoinSelection(
+                tx,
+                download.contractData,
+                backupPurchase.pay_info,
+              ),
+              payCoinSelectionUid: backupPurchase.pay_info.pay_coins_uid,
+              totalPayCost: Amounts.parseOrThrow(
+                backupPurchase.pay_info.total_pay_cost,
+              ),
+            };
+          }
+
           await tx.purchases.put({
             proposalId: backupPurchase.proposal_id,
             noncePriv: backupPurchase.nonce_priv,
             noncePub:
               cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
             autoRefundDeadline: TalerProtocolTimestamp.never(),
-            refundAwaiting: undefined,
-            timestampAccept: backupPurchase.timestamp_accept,
+            timestampAccept: backupPurchase.timestamp_accepted,
             timestampFirstSuccessfulPay:
               backupPurchase.timestamp_first_successful_pay,
             timestampLastRefundStatus: undefined,
             merchantPaySig: backupPurchase.merchant_pay_sig,
             lastSessionId: undefined,
-            abortStatus,
             download,
-            paymentSubmitPending:
-              !backupPurchase.timestamp_first_successful_pay,
-            refundQueryRequested: false,
-            payCoinSelection: await recoverPayCoinSelection(
-              tx,
-              download.contractData,
-              backupPurchase,
-            ),
-            coinDepositPermissions: undefined,
-            totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost),
             refunds,
-            payCoinSelectionUid: backupPurchase.pay_coins_uid,
+            claimToken: backupPurchase.claim_token,
+            downloadSessionId: backupPurchase.download_session_id,
+            merchantBaseUrl: backupPurchase.merchant_base_url,
+            orderId: backupPurchase.order_id,
+            payInfo,
+            refundAmountAwaiting: undefined,
+            repurchaseProposalId: backupPurchase.repurchase_proposal_id,
+            status: proposalStatus,
+            timestamp: backupPurchase.timestamp_proposed,
           });
         }
       }
@@ -948,7 +836,6 @@ export async function importBackup(
           await tx.depositGroups.delete(rest[0]);
         } else if (type === TombstoneTag.DeletePayment) {
           await tx.purchases.delete(rest[0]);
-          await tx.proposals.delete(rest[0]);
         } else if (type === TombstoneTag.DeleteRefreshGroup) {
           await tx.refreshGroups.delete(rest[0]);
         } else if (type === TombstoneTag.DeleteRefund) {
diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts 
b/packages/taler-wallet-core/src/operations/backup/index.ts
index fc84ce4ef..3d3ebf04a 100644
--- a/packages/taler-wallet-core/src/operations/backup/index.ts
+++ b/packages/taler-wallet-core/src/operations/backup/index.ts
@@ -96,7 +96,7 @@ import {
   checkPaymentByProposalId,
   confirmPay,
   preparePayForUri,
-} from "../pay.js";
+} from "../pay-merchant.js";
 import { exportBackup } from "./export.js";
 import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
 import { getWalletBackupState, provideBackupState } from "./state.js";
@@ -193,15 +193,6 @@ async function computeBackupCryptoData(
       eddsaGetPublic(decodeCrock(backupWg.reserve_priv)),
     );
   }
-  for (const prop of backupContent.proposals) {
-    const { h: contractTermsHash } = await cryptoApi.hashString({
-      str: canonicalJson(prop.contract_terms_raw),
-    });
-    const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv)));
-    cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub;
-    cryptoData.proposalIdToContractTermsHash[prop.proposal_id] =
-      contractTermsHash;
-  }
   for (const purch of backupContent.purchases) {
     const { h: contractTermsHash } = await cryptoApi.hashString({
       str: canonicalJson(purch.contract_terms_raw),
diff --git a/packages/taler-wallet-core/src/operations/common.ts 
b/packages/taler-wallet-core/src/operations/common.ts
index 6d54503a1..9f235c9b4 100644
--- a/packages/taler-wallet-core/src/operations/common.ts
+++ b/packages/taler-wallet-core/src/operations/common.ts
@@ -17,38 +17,272 @@
 /**
  * Imports.
  */
-import { TalerErrorDetail, TalerErrorCode } from "@gnu-taler/taler-util";
-import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js";
-import { TalerError, getErrorDetailFromException } from "../errors.js";
+import {
+  AmountJson,
+  Amounts,
+  j2s,
+  Logger,
+  RefreshReason,
+  TalerErrorCode,
+  TalerErrorDetail,
+  TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletStoresV1, CoinStatus, CoinRecord } from "../db.js";
+import { makeErrorDetail, TalerError } from "../errors.js";
+import { InternalWalletState } from "../internal-wallet-state.js";
+import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
+import { GetReadWriteAccess } from "../util/query.js";
+import {
+  OperationAttemptResult,
+  OperationAttemptResultType,
+  RetryInfo,
+} from "../util/retries.js";
+import { createRefreshGroup } from "./refresh.js";
 
-/**
- * Run an operation and call the onOpError callback
- * when there was an exception or operation error that must be reported.
- * The cause will be re-thrown to the caller.
- */
-export async function guardOperationException<T>(
-  op: () => Promise<T>,
-  onOpError: (e: TalerErrorDetail) => Promise<void>,
-): Promise<T> {
+const logger = new Logger("operations/common.ts");
+
+export interface CoinsSpendInfo {
+  coinPubs: string[];
+  contributions: AmountJson[];
+  refreshReason: RefreshReason;
+  /**
+   * Identifier for what the coin has been spent for.
+   */
+  allocationId: string;
+}
+
+export async function makeCoinAvailable(
+  ws: InternalWalletState,
+  tx: GetReadWriteAccess<{
+    coins: typeof WalletStoresV1.coins;
+    coinAvailability: typeof WalletStoresV1.coinAvailability;
+    denominations: typeof WalletStoresV1.denominations;
+  }>,
+  coinRecord: CoinRecord,
+): Promise<void> {
+  checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
+  const existingCoin = await tx.coins.get(coinRecord.coinPub);
+  if (existingCoin) {
+    return;
+  }
+  const denom = await tx.denominations.get([
+    coinRecord.exchangeBaseUrl,
+    coinRecord.denomPubHash,
+  ]);
+  checkDbInvariant(!!denom);
+  const ageRestriction = coinRecord.maxAge;
+  let car = await tx.coinAvailability.get([
+    coinRecord.exchangeBaseUrl,
+    coinRecord.denomPubHash,
+    ageRestriction,
+  ]);
+  if (!car) {
+    car = {
+      maxAge: ageRestriction,
+      amountFrac: denom.amountFrac,
+      amountVal: denom.amountVal,
+      currency: denom.currency,
+      denomPubHash: denom.denomPubHash,
+      exchangeBaseUrl: denom.exchangeBaseUrl,
+      freshCoinCount: 0,
+    };
+  }
+  car.freshCoinCount++;
+  await tx.coins.put(coinRecord);
+  await tx.coinAvailability.put(car);
+}
+
+export async function spendCoins(
+  ws: InternalWalletState,
+  tx: GetReadWriteAccess<{
+    coins: typeof WalletStoresV1.coins;
+    coinAvailability: typeof WalletStoresV1.coinAvailability;
+    refreshGroups: typeof WalletStoresV1.refreshGroups;
+    denominations: typeof WalletStoresV1.denominations;
+  }>,
+  csi: CoinsSpendInfo,
+): Promise<void> {
+  for (let i = 0; i < csi.coinPubs.length; i++) {
+    const coin = await tx.coins.get(csi.coinPubs[i]);
+    if (!coin) {
+      throw Error("coin allocated for payment doesn't exist anymore");
+    }
+    const coinAvailability = await tx.coinAvailability.get([
+      coin.exchangeBaseUrl,
+      coin.denomPubHash,
+      coin.maxAge,
+    ]);
+    checkDbInvariant(!!coinAvailability);
+    const contrib = csi.contributions[i];
+    if (coin.status !== CoinStatus.Fresh) {
+      const alloc = coin.allocation;
+      if (!alloc) {
+        continue;
+      }
+      if (alloc.id !== csi.allocationId) {
+        // FIXME: assign error code
+        throw Error("conflicting coin allocation (id)");
+      }
+      if (0 !== Amounts.cmp(alloc.amount, contrib)) {
+        // FIXME: assign error code
+        throw Error("conflicting coin allocation (contrib)");
+      }
+      continue;
+    }
+    coin.status = CoinStatus.Dormant;
+    coin.allocation = {
+      id: csi.allocationId,
+      amount: Amounts.stringify(contrib),
+    };
+    const remaining = Amounts.sub(coin.currentAmount, contrib);
+    if (remaining.saturated) {
+      throw Error("not enough remaining balance on coin for payment");
+    }
+    coin.currentAmount = remaining.amount;
+    checkDbInvariant(!!coinAvailability);
+    if (coinAvailability.freshCoinCount === 0) {
+      throw Error(
+        `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
+      );
+    }
+    coinAvailability.freshCoinCount--;
+    await tx.coins.put(coin);
+    await tx.coinAvailability.put(coinAvailability);
+  }
+  const refreshCoinPubs = csi.coinPubs.map((x) => ({
+    coinPub: x,
+  }));
+  await ws.refreshOps.createRefreshGroup(
+    ws,
+    tx,
+    refreshCoinPubs,
+    RefreshReason.PayMerchant,
+  );
+}
+
+export async function storeOperationError(
+  ws: InternalWalletState,
+  pendingTaskId: string,
+  e: TalerErrorDetail,
+): Promise<void> {
+  await ws.db
+    .mktx((x) => [x.operationRetries])
+    .runReadWrite(async (tx) => {
+      let retryRecord = await tx.operationRetries.get(pendingTaskId);
+      if (!retryRecord) {
+        retryRecord = {
+          id: pendingTaskId,
+          lastError: e,
+          retryInfo: RetryInfo.reset(),
+        };
+      } else {
+        retryRecord.lastError = e;
+        retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
+      }
+      await tx.operationRetries.put(retryRecord);
+    });
+}
+
+export async function storeOperationPending(
+  ws: InternalWalletState,
+  pendingTaskId: string,
+): Promise<void> {
+  await ws.db
+    .mktx((x) => [x.operationRetries])
+    .runReadWrite(async (tx) => {
+      let retryRecord = await tx.operationRetries.get(pendingTaskId);
+      if (!retryRecord) {
+        retryRecord = {
+          id: pendingTaskId,
+          retryInfo: RetryInfo.reset(),
+        };
+      } else {
+        delete retryRecord.lastError;
+        retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
+      }
+      await tx.operationRetries.put(retryRecord);
+    });
+}
+
+export async function runOperationWithErrorReporting(
+  ws: InternalWalletState,
+  opId: string,
+  f: () => Promise<OperationAttemptResult>,
+): Promise<void> {
+  let maybeError: TalerErrorDetail | undefined;
   try {
-    return await op();
-  } catch (e: any) {
-    if (e instanceof CryptoApiStoppedError) {
-      throw e;
+    const resp = await f();
+    switch (resp.type) {
+      case OperationAttemptResultType.Error:
+        return await storeOperationError(ws, opId, resp.errorDetail);
+      case OperationAttemptResultType.Finished:
+        return await storeOperationFinished(ws, opId);
+      case OperationAttemptResultType.Pending:
+        return await storeOperationPending(ws, opId);
+      case OperationAttemptResultType.Longpoll:
+        break;
     }
-    if (
-      e instanceof TalerError &&
-      e.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED)
-    ) {
-      throw e;
+  } catch (e) {
+    if (e instanceof TalerError) {
+      logger.warn("operation processed resulted in error");
+      logger.warn(`error was: ${j2s(e.errorDetail)}`);
+      maybeError = e.errorDetail;
+      return await storeOperationError(ws, opId, maybeError!);
+    } else if (e instanceof Error) {
+      // This is a bug, as we expect pending operations to always
+      // do their own error handling and only throw 
WALLET_PENDING_OPERATION_FAILED
+      // or return something.
+      logger.error(`Uncaught exception: ${e.message}`);
+      logger.error(`Stack: ${e.stack}`);
+      maybeError = makeErrorDetail(
+        TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+        {
+          stack: e.stack,
+        },
+        `unexpected exception (message: ${e.message})`,
+      );
+      return await storeOperationError(ws, opId, maybeError);
+    } else {
+      logger.error("Uncaught exception, value is not even an error.");
+      maybeError = makeErrorDetail(
+        TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+        {},
+        `unexpected exception (not even an error)`,
+      );
+      return await storeOperationError(ws, opId, maybeError);
     }
-    const opErr = getErrorDetailFromException(e);
-    await onOpError(opErr);
-    throw TalerError.fromDetail(
-      TalerErrorCode.WALLET_PENDING_OPERATION_FAILED,
-      {
-        innerError: opErr,
-      },
-    );
   }
 }
+
+export async function storeOperationFinished(
+  ws: InternalWalletState,
+  pendingTaskId: string,
+): Promise<void> {
+  await ws.db
+    .mktx((x) => [x.operationRetries])
+    .runReadWrite(async (tx) => {
+      await tx.operationRetries.delete(pendingTaskId);
+    });
+}
+
+export enum TombstoneTag {
+  DeleteWithdrawalGroup = "delete-withdrawal-group",
+  DeleteReserve = "delete-reserve",
+  DeletePayment = "delete-payment",
+  DeleteTip = "delete-tip",
+  DeleteRefreshGroup = "delete-refresh-group",
+  DeleteDepositGroup = "delete-deposit-group",
+  DeleteRefund = "delete-refund",
+  DeletePeerPullDebit = "delete-peer-pull-debit",
+  DeletePeerPushDebit = "delete-peer-push-debit",
+}
+
+/**
+ * Create an event ID from the type and the primary key for the event.
+ */
+export function makeEventId(
+  type: TransactionType | TombstoneTag,
+  ...args: string[]
+): string {
+  return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
+}
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts 
b/packages/taler-wallet-core/src/operations/deposits.ts
index 625bc0828..1f7d05d29 100644
--- a/packages/taler-wallet-core/src/operations/deposits.ts
+++ b/packages/taler-wallet-core/src/operations/deposits.ts
@@ -53,16 +53,15 @@ import {
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { readSuccessResponseJsonOrThrow } from "../util/http.js";
 import { OperationAttemptResult } from "../util/retries.js";
-import { spendCoins } from "../wallet.js";
+import { makeEventId, spendCoins } from "./common.js";
 import { getExchangeDetails } from "./exchanges.js";
 import {
   extractContractData,
   generateDepositPermissions,
   getTotalPaymentCost,
   selectPayCoinsNew,
-} from "./pay.js";
+} from "./pay-merchant.js";
 import { getTotalRefreshCost } from "./refresh.js";
-import { makeEventId } from "./transactions.js";
 
 /**
  * Logger.
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts 
b/packages/taler-wallet-core/src/operations/exchanges.ts
index 1dd8660b5..9a6c72577 100644
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ b/packages/taler-wallet-core/src/operations/exchanges.ts
@@ -40,7 +40,6 @@ import {
   parsePaytoUri,
   Recoup,
   TalerErrorCode,
-  TalerErrorDetail,
   TalerProtocolDuration,
   TalerProtocolTimestamp,
   URL,
@@ -71,11 +70,9 @@ import {
 import {
   OperationAttemptResult,
   OperationAttemptResultType,
-  RetryInfo,
   runOperationHandlerForResult,
 } from "../util/retries.js";
 import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
-import { guardOperationException } from "./common.js";
 
 const logger = new Logger("exchanges.ts");
 
diff --git a/packages/taler-wallet-core/src/operations/merchants.ts 
b/packages/taler-wallet-core/src/operations/merchants.ts
index 614478715..f5b3ca38c 100644
--- a/packages/taler-wallet-core/src/operations/merchants.ts
+++ b/packages/taler-wallet-core/src/operations/merchants.ts
@@ -25,7 +25,7 @@ import {
   LibtoolVersion,
 } from "@gnu-taler/taler-util";
 import { InternalWalletState, MerchantInfo } from 
"../internal-wallet-state.js";
-import { readSuccessResponseJsonOrThrow } from "../index.js";
+import { readSuccessResponseJsonOrThrow } from "../util/http.js";
 
 const logger = new Logger("taler-wallet-core:merchants.ts");
 
@@ -40,7 +40,7 @@ export async function getMerchantInfo(
     return existingInfo;
   }
 
-  const configUrl = new URL("config", canonBaseUrl);
+const configUrl = new URL("config", canonBaseUrl);
   const resp = await ws.http.get(configUrl.href);
 
   const configResp = await readSuccessResponseJsonOrThrow(
diff --git a/packages/taler-wallet-core/src/operations/pay.ts 
b/packages/taler-wallet-core/src/operations/pay-merchant.ts
similarity index 60%
rename from packages/taler-wallet-core/src/operations/pay.ts
rename to packages/taler-wallet-core/src/operations/pay-merchant.ts
index 6757b79b4..97901c71e 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts
@@ -26,14 +26,21 @@
  */
 import { GlobalIDB } from "@gnu-taler/idb-bridge";
 import {
+  AbortingCoin,
+  AbortRequest,
   AbsoluteTime,
   AgeRestriction,
   AmountJson,
   Amounts,
+  ApplyRefundResponse,
+  codecForAbortResponse,
   codecForContractTerms,
+  codecForMerchantOrderRefundPickupResponse,
+  codecForMerchantOrderStatusPaid,
   codecForMerchantPayResponse,
   codecForProposal,
   CoinDepositPermission,
+  CoinPublicKey,
   ConfirmPayResult,
   ConfirmPayResultType,
   ContractTerms,
@@ -46,12 +53,17 @@ import {
   HttpStatusCode,
   j2s,
   Logger,
+  MerchantCoinRefundFailureStatus,
+  MerchantCoinRefundStatus,
+  MerchantCoinRefundSuccessStatus,
   NotificationType,
   parsePaytoUri,
   parsePayUri,
+  parseRefundUri,
   PayCoinSelection,
   PreparePayResult,
   PreparePayResultType,
+  PrepareRefundResult,
   RefreshReason,
   strcmp,
   TalerErrorCode,
@@ -62,17 +74,19 @@ import {
 } from "@gnu-taler/taler-util";
 import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
 import {
-  AbortStatus,
   AllowedAuditorInfo,
   AllowedExchangeInfo,
   BackupProviderStateTag,
   CoinRecord,
   CoinStatus,
   DenominationRecord,
-  ProposalRecord,
+  ProposalDownload,
   ProposalStatus,
   PurchaseRecord,
+  RefundReason,
+  RefundState,
   WalletContractData,
+  WalletStoresV1,
 } from "../db.js";
 import {
   makeErrorDetail,
@@ -80,6 +94,7 @@ import {
   TalerError,
   TalerProtocolViolationError,
 } from "../errors.js";
+import { GetReadWriteAccess } from "../index.browser.js";
 import {
   EXCHANGE_COINS_LOCK,
   InternalWalletState,
@@ -109,12 +124,12 @@ import {
 } from "../util/retries.js";
 import {
   spendCoins,
-  storeOperationError,
   storeOperationPending,
-} from "../wallet.js";
+  storeOperationError,
+  makeEventId,
+} from "./common.js";
 import { getExchangeDetails } from "./exchanges.js";
-import { getTotalRefreshCost } from "./refresh.js";
-import { makeEventId } from "./transactions.js";
+import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
 
 /**
  * Logger.
@@ -203,98 +218,20 @@ export interface CoinSelectionRequest {
   minimumAge?: number;
 }
 
-/**
- * Record all information that is necessary to
- * pay for a proposal in the wallet's database.
- */
-async function recordConfirmPay(
-  ws: InternalWalletState,
-  proposal: ProposalRecord,
-  coinSelection: PayCoinSelection,
-  coinDepositPermissions: CoinDepositPermission[],
-  sessionIdOverride: string | undefined,
-): Promise<PurchaseRecord> {
-  const d = proposal.download;
-  if (!d) {
-    throw Error("proposal is in invalid state");
-  }
-  let sessionId;
-  if (sessionIdOverride) {
-    sessionId = sessionIdOverride;
-  } else {
-    sessionId = proposal.downloadSessionId;
-  }
-  logger.trace(
-    `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
-  );
-  const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
-  const t: PurchaseRecord = {
-    abortStatus: AbortStatus.None,
-    download: d,
-    lastSessionId: sessionId,
-    payCoinSelection: coinSelection,
-    payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
-    totalPayCost: payCostInfo,
-    coinDepositPermissions,
-    timestampAccept: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
-    timestampLastRefundStatus: undefined,
-    proposalId: proposal.proposalId,
-    refundQueryRequested: false,
-    timestampFirstSuccessfulPay: undefined,
-    autoRefundDeadline: undefined,
-    refundAwaiting: undefined,
-    paymentSubmitPending: true,
-    refunds: {},
-    merchantPaySig: undefined,
-    noncePriv: proposal.noncePriv,
-    noncePub: proposal.noncePub,
-  };
-
-  await ws.db
-    .mktx((x) => [
-      x.proposals,
-      x.purchases,
-      x.coins,
-      x.refreshGroups,
-      x.denominations,
-      x.coinAvailability,
-    ])
-    .runReadWrite(async (tx) => {
-      const p = await tx.proposals.get(proposal.proposalId);
-      if (p) {
-        p.proposalStatus = ProposalStatus.Accepted;
-        await tx.proposals.put(p);
-      }
-      await tx.purchases.put(t);
-      await spendCoins(ws, tx, {
-        allocationId: `proposal:${t.proposalId}`,
-        coinPubs: coinSelection.coinPubs,
-        contributions: coinSelection.coinContributions,
-        refreshReason: RefreshReason.PayMerchant,
-      });
-    });
-
-  ws.notify({
-    type: NotificationType.ProposalAccepted,
-    proposalId: proposal.proposalId,
-  });
-  return t;
-}
-
 async function failProposalPermanently(
   ws: InternalWalletState,
   proposalId: string,
   err: TalerErrorDetail,
 ): Promise<void> {
   await ws.db
-    .mktx((x) => [x.proposals])
+    .mktx((x) => [x.purchases])
     .runReadWrite(async (tx) => {
-      const p = await tx.proposals.get(proposalId);
+      const p = await tx.purchases.get(proposalId);
       if (!p) {
         return;
       }
-      p.proposalStatus = ProposalStatus.PermanentlyFailed;
-      await tx.proposals.put(p);
+      p.status = ProposalStatus.ProposalDownloadFailed;
+      await tx.purchases.put(p);
     });
 }
 
@@ -309,10 +246,24 @@ function getProposalRequestTimeout(retryInfo?: 
RetryInfo): Duration {
 function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
   return Duration.multiply(
     { d_ms: 15000 },
-    1 + purchase.payCoinSelection.coinPubs.length / 5,
+    1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
   );
 }
 
+/**
+ * Return the proposal download data for a purchase, throw if not available.
+ *
+ * (Async since in the future this will query the DB.)
+ */
+export async function expectProposalDownload(
+  p: PurchaseRecord,
+): Promise<ProposalDownload> {
+  if (!p.download) {
+    throw Error("expected proposal to be downloaded");
+  }
+  return p.download;
+}
+
 export function extractContractData(
   parsedContractTerms: ContractTerms,
   contractTermsHash: string,
@@ -366,9 +317,9 @@ export async function processDownloadProposal(
   options: object = {},
 ): Promise<OperationAttemptResult> {
   const proposal = await ws.db
-    .mktx((x) => [x.proposals])
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
-      return await tx.proposals.get(proposalId);
+      return await tx.purchases.get(proposalId);
     });
 
   if (!proposal) {
@@ -378,7 +329,7 @@ export async function processDownloadProposal(
     };
   }
 
-  if (proposal.proposalStatus != ProposalStatus.Downloading) {
+  if (proposal.status != ProposalStatus.DownloadingProposal) {
     return {
       type: OperationAttemptResultType.Finished,
       result: undefined,
@@ -401,7 +352,7 @@ export async function processDownloadProposal(
     requestBody.token = proposal.claimToken;
   }
 
-  const opId = RetryTags.forProposalClaim(proposal);
+  const opId = RetryTags.forPay(proposal);
   const retryRecord = await ws.db
     .mktx((x) => [x.operationRetries])
     .runReadOnly(async (tx) => {
@@ -543,13 +494,13 @@ export async function processDownloadProposal(
   logger.trace(`extracted contract data: ${j2s(contractData)}`);
 
   await ws.db
-    .mktx((x) => [x.purchases, x.proposals])
+    .mktx((x) => [x.purchases])
     .runReadWrite(async (tx) => {
-      const p = await tx.proposals.get(proposalId);
+      const p = await tx.purchases.get(proposalId);
       if (!p) {
         return;
       }
-      if (p.proposalStatus !== ProposalStatus.Downloading) {
+      if (p.status !== ProposalStatus.DownloadingProposal) {
         return;
       }
       p.download = {
@@ -565,14 +516,14 @@ export async function processDownloadProposal(
           await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
         if (differentPurchase) {
           logger.warn("repurchase detected");
-          p.proposalStatus = ProposalStatus.Repurchase;
+          p.status = ProposalStatus.RepurchaseDetected;
           p.repurchaseProposalId = differentPurchase.proposalId;
-          await tx.proposals.put(p);
+          await tx.purchases.put(p);
           return;
         }
       }
-      p.proposalStatus = ProposalStatus.Proposed;
-      await tx.proposals.put(p);
+      p.status = ProposalStatus.Proposed;
+      await tx.purchases.put(p);
     });
 
   ws.notify({
@@ -602,9 +553,9 @@ async function startDownloadProposal(
   noncePriv: string | undefined,
 ): Promise<string> {
   const oldProposal = await ws.db
-    .mktx((x) => [x.proposals])
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
-      return tx.proposals.indexes.byUrlAndOrderId.get([
+      return tx.purchases.indexes.byUrlAndOrderId.get([
         merchantBaseUrl,
         orderId,
       ]);
@@ -635,7 +586,7 @@ async function startDownloadProposal(
   const { priv, pub } = noncePair;
   const proposalId = encodeCrock(getRandomBytes(32));
 
-  const proposalRecord: ProposalRecord = {
+  const proposalRecord: PurchaseRecord = {
     download: undefined,
     noncePriv: priv,
     noncePub: pub,
@@ -644,15 +595,25 @@ async function startDownloadProposal(
     merchantBaseUrl,
     orderId,
     proposalId: proposalId,
-    proposalStatus: ProposalStatus.Downloading,
+    status: ProposalStatus.DownloadingProposal,
     repurchaseProposalId: undefined,
     downloadSessionId: sessionId,
+    autoRefundDeadline: undefined,
+    lastSessionId: undefined,
+    merchantPaySig: undefined,
+    payInfo: undefined,
+    refundAmountAwaiting: undefined,
+    refunds: {},
+    timestampAccept: undefined,
+    timestampFirstSuccessfulPay: undefined,
+    timestampLastRefundStatus: undefined,
+    pendingRemovedCoinPubs: undefined,
   };
 
   await ws.db
-    .mktx((x) => [x.proposals])
+    .mktx((x) => [x.purchases])
     .runReadWrite(async (tx) => {
-      const existingRecord = await tx.proposals.indexes.byUrlAndOrderId.get([
+      const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([
         merchantBaseUrl,
         orderId,
       ]);
@@ -660,7 +621,7 @@ async function startDownloadProposal(
         // Created concurrently
         return;
       }
-      await tx.proposals.put(proposalRecord);
+      await tx.purchases.put(proposalRecord);
     });
 
   await processDownloadProposal(ws, proposalId);
@@ -688,15 +649,17 @@ async function storeFirstPaySuccess(
         logger.warn("payment success already stored");
         return;
       }
+      if (purchase.status === ProposalStatus.Paying) {
+        purchase.status = ProposalStatus.Paid;
+      }
       purchase.timestampFirstSuccessfulPay = now;
-      purchase.paymentSubmitPending = false;
       purchase.lastSessionId = sessionId;
       purchase.merchantPaySig = paySig;
-      const protoAr = purchase.download.contractData.autoRefund;
+      const protoAr = purchase.download!.contractData.autoRefund;
       if (protoAr) {
         const ar = Duration.fromTalerProtocolDuration(protoAr);
         logger.info("auto_refund present");
-        purchase.refundQueryRequested = true;
+        purchase.status = ProposalStatus.QueryingAutoRefund;
         purchase.autoRefundDeadline = AbsoluteTime.toTimestamp(
           AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
         );
@@ -723,7 +686,9 @@ async function storePayReplaySuccess(
       if (isFirst) {
         throw Error("invalid payment state");
       }
-      purchase.paymentSubmitPending = false;
+      if (purchase.status === ProposalStatus.Paying) {
+        purchase.status = ProposalStatus.Paid;
+      }
       purchase.lastSessionId = sessionId;
       await tx.purchases.put(purchase);
     });
@@ -774,19 +739,26 @@ async function handleInsufficientFunds(
     throw new TalerProtocolViolationError();
   }
 
-  const { contractData } = proposal.download;
+  const { contractData } = proposal.download!;
 
   const prevPayCoins: PreviousPayCoins = [];
 
+  const payInfo = proposal.payInfo;
+  if (!payInfo) {
+    return;
+  }
+
+  const payCoinSelection = payInfo.payCoinSelection;
+
   await ws.db
     .mktx((x) => [x.coins, x.denominations])
     .runReadOnly(async (tx) => {
-      for (let i = 0; i < proposal.payCoinSelection.coinPubs.length; i++) {
-        const coinPub = proposal.payCoinSelection.coinPubs[i];
+      for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+        const coinPub = payCoinSelection.coinPubs[i];
         if (coinPub === brokenCoinPub) {
           continue;
         }
-        const contrib = proposal.payCoinSelection.coinContributions[i];
+        const contrib = payCoinSelection.coinContributions[i];
         const coin = await tx.coins.get(coinPub);
         if (!coin) {
           continue;
@@ -839,14 +811,19 @@ async function handleInsufficientFunds(
       if (!p) {
         return;
       }
-      p.payCoinSelection = res;
-      p.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
-      p.coinDepositPermissions = undefined;
+      const payInfo = p.payInfo;
+      if (!payInfo) {
+        return;
+      }
+      payInfo.payCoinSelection = res;
+      payInfo.payCoinSelection = res;
+      payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+      payInfo.coinDepositPermissions = undefined;
       await tx.purchases.put(p);
       await spendCoins(ws, tx, {
         allocationId: `proposal:${p.proposalId}`,
-        coinPubs: p.payCoinSelection.coinPubs,
-        contributions: p.payCoinSelection.coinContributions,
+        coinPubs: payInfo.payCoinSelection.coinPubs,
+        contributions: payInfo.payCoinSelection.coinContributions,
         refreshReason: RefreshReason.PayMerchant,
       });
     });
@@ -1255,23 +1232,23 @@ export async function checkPaymentByProposalId(
   sessionId?: string,
 ): Promise<PreparePayResult> {
   let proposal = await ws.db
-    .mktx((x) => [x.proposals])
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
-      return tx.proposals.get(proposalId);
+      return tx.purchases.get(proposalId);
     });
   if (!proposal) {
     throw Error(`could not get proposal ${proposalId}`);
   }
-  if (proposal.proposalStatus === ProposalStatus.Repurchase) {
+  if (proposal.status === ProposalStatus.RepurchaseDetected) {
     const existingProposalId = proposal.repurchaseProposalId;
     if (!existingProposalId) {
       throw Error("invalid proposal state");
     }
     logger.trace("using existing purchase for same product");
     proposal = await ws.db
-      .mktx((x) => [x.proposals])
+      .mktx((x) => [x.purchases])
       .runReadOnly(async (tx) => {
-        return tx.proposals.get(existingProposalId);
+        return tx.purchases.get(existingProposalId);
       });
     if (!proposal) {
       throw Error("existing proposal is in wrong state");
@@ -1297,7 +1274,7 @@ export async function checkPaymentByProposalId(
       return tx.purchases.get(proposalId);
     });
 
-  if (!purchase) {
+  if (!purchase || purchase.status === ProposalStatus.Proposed) {
     // If not already paid, check if we could pay for it.
     const res = await selectPayCoinsNew(ws, {
       auditors: contractData.allowedAuditors,
@@ -1337,10 +1314,14 @@ export async function checkPaymentByProposalId(
     };
   }
 
-  if (purchase.lastSessionId !== sessionId) {
+  if (
+    purchase.status === ProposalStatus.Paid &&
+    purchase.lastSessionId !== sessionId
+  ) {
     logger.trace(
       "automatically re-submitting payment with different session ID",
     );
+    logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
     await ws.db
       .mktx((x) => [x.purchases])
       .runReadWrite(async (tx) => {
@@ -1349,7 +1330,7 @@ export async function checkPaymentByProposalId(
           return;
         }
         p.lastSessionId = sessionId;
-        p.paymentSubmitPending = true;
+        p.status = ProposalStatus.PayingReplay;
         await tx.purchases.put(p);
       });
     const r = await processPurchasePay(ws, proposalId, { forceNow: true });
@@ -1357,35 +1338,41 @@ export async function checkPaymentByProposalId(
       // FIXME: This does not surface the original error
       throw Error("submitting pay failed");
     }
+    const download = await expectProposalDownload(purchase);
     return {
       status: PreparePayResultType.AlreadyConfirmed,
-      contractTerms: purchase.download.contractTermsRaw,
-      contractTermsHash: purchase.download.contractData.contractTermsHash,
+      contractTerms: download.contractTermsRaw,
+      contractTermsHash: download.contractData.contractTermsHash,
       paid: true,
-      amountRaw: Amounts.stringify(purchase.download.contractData.amount),
-      amountEffective: Amounts.stringify(purchase.totalPayCost),
+      amountRaw: Amounts.stringify(download.contractData.amount),
+      amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
       proposalId,
     };
   } else if (!purchase.timestampFirstSuccessfulPay) {
+    const download = await expectProposalDownload(purchase);
     return {
       status: PreparePayResultType.AlreadyConfirmed,
-      contractTerms: purchase.download.contractTermsRaw,
-      contractTermsHash: purchase.download.contractData.contractTermsHash,
+      contractTerms: download.contractTermsRaw,
+      contractTermsHash: download.contractData.contractTermsHash,
       paid: false,
-      amountRaw: Amounts.stringify(purchase.download.contractData.amount),
-      amountEffective: Amounts.stringify(purchase.totalPayCost),
+      amountRaw: Amounts.stringify(download.contractData.amount),
+      amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
       proposalId,
     };
   } else {
-    const paid = !purchase.paymentSubmitPending;
+    const paid =
+      purchase.status === ProposalStatus.Paid ||
+      purchase.status === ProposalStatus.QueryingRefund ||
+      purchase.status === ProposalStatus.QueryingAutoRefund;
+    const download = await expectProposalDownload(purchase);
     return {
       status: PreparePayResultType.AlreadyConfirmed,
-      contractTerms: purchase.download.contractTermsRaw,
-      contractTermsHash: purchase.download.contractData.contractTermsHash,
+      contractTerms: download.contractTermsRaw,
+      contractTermsHash: download.contractData.contractTermsHash,
       paid,
-      amountRaw: Amounts.stringify(purchase.download.contractData.amount),
-      amountEffective: Amounts.stringify(purchase.totalPayCost),
-      ...(paid ? { nextUrl: purchase.download.contractData.orderId } : {}),
+      amountRaw: Amounts.stringify(download.contractData.amount),
+      amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
+      ...(paid ? { nextUrl: download.contractData.orderId } : {}),
       proposalId,
     };
   }
@@ -1396,9 +1383,9 @@ export async function getContractTermsDetails(
   proposalId: string,
 ): Promise<WalletContractData> {
   const proposal = await ws.db
-    .mktx((x) => [x.proposals])
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
-      return tx.proposals.get(proposalId);
+      return tx.purchases.get(proposalId);
     });
 
   if (!proposal) {
@@ -1574,7 +1561,10 @@ export async function runPayForConfirmPay(
       }
     }
     case OperationAttemptResultType.Pending:
-      await storeOperationPending(ws, `${PendingTaskType.Pay}:${proposalId}`);
+      await storeOperationPending(
+        ws,
+        `${PendingTaskType.Purchase}:${proposalId}`,
+      );
       return {
         type: ConfirmPayResultType.Pending,
         transactionId: makeEventId(TransactionType.Payment, proposalId),
@@ -1600,9 +1590,9 @@ export async function confirmPay(
     `executing confirmPay with proposalId ${proposalId} and sessionIdOverride 
${sessionIdOverride}`,
   );
   const proposal = await ws.db
-    .mktx((x) => [x.proposals])
+    .mktx((x) => [x.purchases])
     .runReadOnly(async (tx) => {
-      return tx.proposals.get(proposalId);
+      return tx.purchases.get(proposalId);
     });
 
   if (!proposal) {
@@ -1625,13 +1615,12 @@ export async function confirmPay(
       ) {
         logger.trace(`changing session ID to ${sessionIdOverride}`);
         purchase.lastSessionId = sessionIdOverride;
-        purchase.paymentSubmitPending = true;
         await tx.purchases.put(purchase);
       }
       return purchase;
     });
 
-  if (existingPurchase) {
+  if (existingPurchase && existingPurchase.payInfo) {
     logger.trace("confirmPay: submitting payment for existing purchase");
     return runPayForConfirmPay(ws, proposalId);
   }
@@ -1640,9 +1629,9 @@ export async function confirmPay(
 
   const contractData = d.contractData;
 
-  let res: PayCoinSelection | undefined = undefined;
+  let maybeCoinSelection: PayCoinSelection | undefined = undefined;
 
-  res = await selectPayCoinsNew(ws, {
+  maybeCoinSelection = await selectPayCoinsNew(ws, {
     auditors: contractData.allowedAuditors,
     exchanges: contractData.allowedExchanges,
     wireMethod: contractData.wireMethod,
@@ -1655,9 +1644,9 @@ export async function confirmPay(
     forcedSelection: forcedCoinSel,
   });
 
-  logger.trace("coin selection result", res);
+  logger.trace("coin selection result", maybeCoinSelection);
 
-  if (!res) {
+  if (!maybeCoinSelection) {
     // 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.
@@ -1665,23 +1654,121 @@ export async function confirmPay(
     throw Error("insufficient balance");
   }
 
+  const coinSelection = maybeCoinSelection;
+
   const depositPermissions = await generateDepositPermissions(
     ws,
-    res,
+    coinSelection,
     d.contractData,
   );
 
-  await recordConfirmPay(
-    ws,
-    proposal,
-    res,
-    depositPermissions,
-    sessionIdOverride,
+  const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
+
+  let sessionId: string | undefined;
+  if (sessionIdOverride) {
+    sessionId = sessionIdOverride;
+  } else {
+    sessionId = proposal.downloadSessionId;
+  }
+
+  logger.trace(
+    `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
   );
 
+  await ws.db
+    .mktx((x) => [
+      x.purchases,
+      x.coins,
+      x.refreshGroups,
+      x.denominations,
+      x.coinAvailability,
+    ])
+    .runReadWrite(async (tx) => {
+      const p = await tx.purchases.get(proposal.proposalId);
+      if (!p) {
+        return;
+      }
+      switch (p.status) {
+        case ProposalStatus.Proposed:
+          p.payInfo = {
+            payCoinSelection: coinSelection,
+            payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
+            totalPayCost: payCostInfo,
+            coinDepositPermissions: depositPermissions,
+          };
+          p.lastSessionId = sessionId;
+          p.timestampAccept = TalerProtocolTimestamp.now();
+          p.status = ProposalStatus.Paying;
+          await tx.purchases.put(p);
+          await spendCoins(ws, tx, {
+            allocationId: `proposal:${p.proposalId}`,
+            coinPubs: coinSelection.coinPubs,
+            contributions: coinSelection.coinContributions,
+            refreshReason: RefreshReason.PayMerchant,
+          });
+          break;
+        case ProposalStatus.Paid:
+        case ProposalStatus.Paying:
+        default:
+          break;
+      }
+    });
+
+  ws.notify({
+    type: NotificationType.ProposalAccepted,
+    proposalId: proposal.proposalId,
+  });
+
   return runPayForConfirmPay(ws, proposalId);
 }
 
+export async function processPurchase(
+  ws: InternalWalletState,
+  proposalId: string,
+  options: {
+    forceNow?: boolean;
+  } = {},
+): Promise<OperationAttemptResult> {
+  const purchase = await ws.db
+    .mktx((x) => [x.purchases])
+    .runReadOnly(async (tx) => {
+      return tx.purchases.get(proposalId);
+    });
+  if (!purchase) {
+    return {
+      type: OperationAttemptResultType.Error,
+      errorDetail: {
+        // FIXME: allocate more specific error code
+        code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+        hint: `trying to pay for purchase that is not in the database`,
+        proposalId: proposalId,
+      },
+    };
+  }
+
+  switch (purchase.status) {
+    case ProposalStatus.DownloadingProposal:
+      return processDownloadProposal(ws, proposalId, options);
+    case ProposalStatus.Paying:
+    case ProposalStatus.PayingReplay:
+      return processPurchasePay(ws, proposalId, options);
+    case ProposalStatus.QueryingAutoRefund:
+    case ProposalStatus.QueryingAutoRefund:
+    case ProposalStatus.AbortingWithRefund:
+      return processPurchaseQueryRefund(ws, proposalId, options);
+    case ProposalStatus.ProposalDownloadFailed:
+    case ProposalStatus.Paid:
+    case ProposalStatus.AbortingWithRefund:
+    case ProposalStatus.RepurchaseDetected:
+      return {
+        type: OperationAttemptResultType.Finished,
+        result: undefined,
+      };
+    default:
+      throw Error(`unexpected purchase status (${purchase.status})`);
+  }
+}
+
 export async function processPurchasePay(
   ws: InternalWalletState,
   proposalId: string,
@@ -1705,31 +1792,38 @@ export async function processPurchasePay(
       },
     };
   }
-  if (!purchase.paymentSubmitPending) {
-    OperationAttemptResult.finishedEmpty();
+  switch (purchase.status) {
+    case ProposalStatus.Paying:
+    case ProposalStatus.PayingReplay:
+      break;
+    default:
+      return OperationAttemptResult.finishedEmpty();
   }
   logger.trace(`processing purchase pay ${proposalId}`);
 
   const sessionId = purchase.lastSessionId;
 
-  logger.trace("paying with session ID", sessionId);
+  logger.trace(`paying with session ID ${sessionId}`);
+  const payInfo = purchase.payInfo;
+  checkDbInvariant(!!payInfo, "payInfo");
 
+  const download = await expectProposalDownload(purchase);
   if (!purchase.merchantPaySig) {
     const payUrl = new URL(
-      `orders/${purchase.download.contractData.orderId}/pay`,
-      purchase.download.contractData.merchantBaseUrl,
+      `orders/${download.contractData.orderId}/pay`,
+      download.contractData.merchantBaseUrl,
     ).href;
 
     let depositPermissions: CoinDepositPermission[];
 
-    if (purchase.coinDepositPermissions) {
-      depositPermissions = purchase.coinDepositPermissions;
+    if (purchase.payInfo?.coinDepositPermissions) {
+      depositPermissions = purchase.payInfo.coinDepositPermissions;
     } else {
       // FIXME: also cache!
       depositPermissions = await generateDepositPermissions(
         ws,
-        purchase.payCoinSelection,
-        purchase.download.contractData,
+        payInfo.payCoinSelection,
+        download.contractData,
       );
     }
 
@@ -1775,7 +1869,8 @@ export async function processPurchasePay(
           if (!purch) {
             return;
           }
-          purch.payFrozen = true;
+          // FIXME: Should be some "PayPermanentlyFailed" and error info 
should be stored
+          purch.status = ProposalStatus.PaymentAbortFinished;
           await tx.purchases.put(purch);
         });
       throw makePendingOperationFailedError(
@@ -1819,9 +1914,9 @@ export async function processPurchasePay(
 
     logger.trace("got success from pay URL", merchantResp);
 
-    const merchantPub = purchase.download.contractData.merchantPub;
+    const merchantPub = download.contractData.merchantPub;
     const { valid } = await ws.cryptoApi.isValidPaymentSignature({
-      contractHash: purchase.download.contractData.contractTermsHash,
+      contractHash: download.contractData.contractTermsHash,
       merchantPub,
       sig: merchantResp.sig,
     });
@@ -1836,17 +1931,19 @@ export async function processPurchasePay(
     await unblockBackup(ws, proposalId);
   } else {
     const payAgainUrl = new URL(
-      `orders/${purchase.download.contractData.orderId}/paid`,
-      purchase.download.contractData.merchantBaseUrl,
+      `orders/${download.contractData.orderId}/paid`,
+      download.contractData.merchantBaseUrl,
     ).href;
     const reqBody = {
       sig: purchase.merchantPaySig,
-      h_contract: purchase.download.contractData.contractTermsHash,
+      h_contract: download.contractData.contractTermsHash,
       session_id: sessionId ?? "",
     };
+    logger.trace(`/paid request body: ${j2s(reqBody)}`);
     const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
       ws.http.postJson(payAgainUrl, reqBody),
     );
+    logger.trace(`/paid response status: ${resp.status}`);
     if (resp.status !== 204) {
       throw TalerError.fromDetail(
         TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
@@ -1871,18 +1968,18 @@ export async function refuseProposal(
   proposalId: string,
 ): Promise<void> {
   const success = await ws.db
-    .mktx((x) => [x.proposals])
+    .mktx((x) => [x.purchases])
     .runReadWrite(async (tx) => {
-      const proposal = await tx.proposals.get(proposalId);
+      const proposal = await tx.purchases.get(proposalId);
       if (!proposal) {
         logger.trace(`proposal ${proposalId} not found, won't refuse 
proposal`);
         return false;
       }
-      if (proposal.proposalStatus !== ProposalStatus.Proposed) {
+      if (proposal.status !== ProposalStatus.Proposed) {
         return false;
       }
-      proposal.proposalStatus = ProposalStatus.Refused;
-      await tx.proposals.put(proposal);
+      proposal.status = ProposalStatus.ProposalRefused;
+      await tx.purchases.put(proposal);
       return true;
     });
   if (success) {
@@ -1891,3 +1988,771 @@ export async function refuseProposal(
     });
   }
 }
+
+export async function prepareRefund(
+  ws: InternalWalletState,
+  talerRefundUri: string,
+): Promise<PrepareRefundResult> {
+  const parseResult = parseRefundUri(talerRefundUri);
+
+  logger.trace("preparing refund offer", parseResult);
+
+  if (!parseResult) {
+    throw Error("invalid refund URI");
+  }
+
+  const purchase = await ws.db
+    .mktx((x) => [x.purchases])
+    .runReadOnly(async (tx) => {
+      return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
+        parseResult.merchantBaseUrl,
+        parseResult.orderId,
+      ]);
+    });
+
+  if (!purchase) {
+    throw Error(
+      `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
+    );
+  }
+
+  const awaiting = await queryAndSaveAwaitingRefund(ws, purchase);
+  const summary = await calculateRefundSummary(purchase);
+  const proposalId = purchase.proposalId;
+
+  const { contractData: c } = await expectProposalDownload(purchase);
+
+  return {
+    proposalId,
+    effectivePaid: Amounts.stringify(summary.amountEffectivePaid),
+    gone: Amounts.stringify(summary.amountRefundGone),
+    granted: Amounts.stringify(summary.amountRefundGranted),
+    pending: summary.pendingAtExchange,
+    awaiting: Amounts.stringify(awaiting),
+    info: {
+      contractTermsHash: c.contractTermsHash,
+      merchant: c.merchant,
+      orderId: c.orderId,
+      products: c.products,
+      summary: c.summary,
+      fulfillmentMessage: c.fulfillmentMessage,
+      summary_i18n: c.summaryI18n,
+      fulfillmentMessage_i18n: c.fulfillmentMessageI18n,
+    },
+  };
+}
+
+function getRefundKey(d: MerchantCoinRefundStatus): string {
+  return `${d.coin_pub}-${d.rtransaction_id}`;
+}
+
+async function applySuccessfulRefund(
+  tx: GetReadWriteAccess<{
+    coins: typeof WalletStoresV1.coins;
+    denominations: typeof WalletStoresV1.denominations;
+  }>,
+  p: PurchaseRecord,
+  refreshCoinsMap: Record<string, { coinPub: string }>,
+  r: MerchantCoinRefundSuccessStatus,
+): Promise<void> {
+  // FIXME: check signature before storing it as valid!
+
+  const refundKey = getRefundKey(r);
+  const coin = await tx.coins.get(r.coin_pub);
+  if (!coin) {
+    logger.warn("coin not found, can't apply refund");
+    return;
+  }
+  const denom = await tx.denominations.get([
+    coin.exchangeBaseUrl,
+    coin.denomPubHash,
+  ]);
+  if (!denom) {
+    throw Error("inconsistent database");
+  }
+  refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
+  const refundAmount = Amounts.parseOrThrow(r.refund_amount);
+  const refundFee = denom.fees.feeRefund;
+  coin.status = CoinStatus.Dormant;
+  coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
+  coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
+  logger.trace(`coin amount after is 
${Amounts.stringify(coin.currentAmount)}`);
+  await tx.coins.put(coin);
+
+  const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
+    .iter(coin.exchangeBaseUrl)
+    .toArray();
+
+  const amountLeft = Amounts.sub(
+    Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+      .amount,
+    denom.fees.feeRefund,
+  ).amount;
+
+  const totalRefreshCostBound = getTotalRefreshCost(
+    allDenoms,
+    DenominationRecord.toDenomInfo(denom),
+    amountLeft,
+  );
+
+  p.refunds[refundKey] = {
+    type: RefundState.Applied,
+    obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
+    executionTime: r.execution_time,
+    refundAmount: Amounts.parseOrThrow(r.refund_amount),
+    refundFee: denom.fees.feeRefund,
+    totalRefreshCostBound,
+    coinPub: r.coin_pub,
+    rtransactionId: r.rtransaction_id,
+  };
+}
+
+async function storePendingRefund(
+  tx: GetReadWriteAccess<{
+    denominations: typeof WalletStoresV1.denominations;
+    coins: typeof WalletStoresV1.coins;
+  }>,
+  p: PurchaseRecord,
+  r: MerchantCoinRefundFailureStatus,
+): Promise<void> {
+  const refundKey = getRefundKey(r);
+
+  const coin = await tx.coins.get(r.coin_pub);
+  if (!coin) {
+    logger.warn("coin not found, can't apply refund");
+    return;
+  }
+  const denom = await tx.denominations.get([
+    coin.exchangeBaseUrl,
+    coin.denomPubHash,
+  ]);
+
+  if (!denom) {
+    throw Error("inconsistent database");
+  }
+
+  const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
+    .iter(coin.exchangeBaseUrl)
+    .toArray();
+
+  const amountLeft = Amounts.sub(
+    Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+      .amount,
+    denom.fees.feeRefund,
+  ).amount;
+
+  const totalRefreshCostBound = getTotalRefreshCost(
+    allDenoms,
+    DenominationRecord.toDenomInfo(denom),
+    amountLeft,
+  );
+
+  p.refunds[refundKey] = {
+    type: RefundState.Pending,
+    obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
+    executionTime: r.execution_time,
+    refundAmount: Amounts.parseOrThrow(r.refund_amount),
+    refundFee: denom.fees.feeRefund,
+    totalRefreshCostBound,
+    coinPub: r.coin_pub,
+    rtransactionId: r.rtransaction_id,
+  };
+}
+
+async function storeFailedRefund(
+  tx: GetReadWriteAccess<{
+    coins: typeof WalletStoresV1.coins;
+    denominations: typeof WalletStoresV1.denominations;
+  }>,
+  p: PurchaseRecord,
+  refreshCoinsMap: Record<string, { coinPub: string }>,
+  r: MerchantCoinRefundFailureStatus,
+): Promise<void> {
+  const refundKey = getRefundKey(r);
+
+  const coin = await tx.coins.get(r.coin_pub);
+  if (!coin) {
+    logger.warn("coin not found, can't apply refund");
+    return;
+  }
+  const denom = await tx.denominations.get([
+    coin.exchangeBaseUrl,
+    coin.denomPubHash,
+  ]);
+
+  if (!denom) {
+    throw Error("inconsistent database");
+  }
+
+  const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
+    .iter(coin.exchangeBaseUrl)
+    .toArray();
+
+  const amountLeft = Amounts.sub(
+    Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+      .amount,
+    denom.fees.feeRefund,
+  ).amount;
+
+  const totalRefreshCostBound = getTotalRefreshCost(
+    allDenoms,
+    DenominationRecord.toDenomInfo(denom),
+    amountLeft,
+  );
+
+  p.refunds[refundKey] = {
+    type: RefundState.Failed,
+    obtainedTime: TalerProtocolTimestamp.now(),
+    executionTime: r.execution_time,
+    refundAmount: Amounts.parseOrThrow(r.refund_amount),
+    refundFee: denom.fees.feeRefund,
+    totalRefreshCostBound,
+    coinPub: r.coin_pub,
+    rtransactionId: r.rtransaction_id,
+  };
+
+  if (p.status === ProposalStatus.AbortingWithRefund) {
+    // Refund failed because the merchant didn't even try to deposit
+    // the coin yet, so we try to refresh.
+    if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
+      const coin = await tx.coins.get(r.coin_pub);
+      if (!coin) {
+        logger.warn("coin not found, can't apply refund");
+        return;
+      }
+      const denom = await tx.denominations.get([
+        coin.exchangeBaseUrl,
+        coin.denomPubHash,
+      ]);
+      if (!denom) {
+        logger.warn("denomination for coin missing");
+        return;
+      }
+      const payCoinSelection = p.payInfo?.payCoinSelection;
+      if (!payCoinSelection) {
+        logger.warn("no pay coin selection, can't apply refund");
+        return;
+      }
+      let contrib: AmountJson | undefined;
+      for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+        if (payCoinSelection.coinPubs[i] === r.coin_pub) {
+          contrib = payCoinSelection.coinContributions[i];
+        }
+      }
+      if (contrib) {
+        coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
+        coin.currentAmount = Amounts.sub(
+          coin.currentAmount,
+          denom.fees.feeRefund,
+        ).amount;
+      }
+      refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
+      await tx.coins.put(coin);
+    }
+  }
+}
+
+async function acceptRefunds(
+  ws: InternalWalletState,
+  proposalId: string,
+  refunds: MerchantCoinRefundStatus[],
+  reason: RefundReason,
+): Promise<void> {
+  logger.trace("handling refunds", refunds);
+  const now = TalerProtocolTimestamp.now();
+
+  await ws.db
+    .mktx((x) => [
+      x.purchases,
+      x.coins,
+      x.coinAvailability,
+      x.denominations,
+      x.refreshGroups,
+    ])
+    .runReadWrite(async (tx) => {
+      const p = await tx.purchases.get(proposalId);
+      if (!p) {
+        logger.error("purchase not found, not adding refunds");
+        return;
+      }
+
+      const refreshCoinsMap: Record<string, CoinPublicKey> = {};
+
+      for (const refundStatus of refunds) {
+        const refundKey = getRefundKey(refundStatus);
+        const existingRefundInfo = p.refunds[refundKey];
+
+        const isPermanentFailure =
+          refundStatus.type === "failure" &&
+          refundStatus.exchange_status >= 400 &&
+          refundStatus.exchange_status < 500;
+
+        // Already failed.
+        if (existingRefundInfo?.type === RefundState.Failed) {
+          continue;
+        }
+
+        // Already applied.
+        if (existingRefundInfo?.type === RefundState.Applied) {
+          continue;
+        }
+
+        // Still pending.
+        if (
+          refundStatus.type === "failure" &&
+          !isPermanentFailure &&
+          existingRefundInfo?.type === RefundState.Pending
+        ) {
+          continue;
+        }
+
+        // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
+
+        if (refundStatus.type === "success") {
+          await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
+        } else if (isPermanentFailure) {
+          await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
+        } else {
+          await storePendingRefund(tx, p, refundStatus);
+        }
+      }
+
+      const refreshCoinsPubs = Object.values(refreshCoinsMap);
+      if (refreshCoinsPubs.length > 0) {
+        await createRefreshGroup(
+          ws,
+          tx,
+          refreshCoinsPubs,
+          RefreshReason.Refund,
+        );
+      }
+
+      // Are we done with querying yet, or do we need to do another round
+      // after a retry delay?
+      let queryDone = true;
+
+      let numPendingRefunds = 0;
+      for (const ri of Object.values(p.refunds)) {
+        switch (ri.type) {
+          case RefundState.Pending:
+            numPendingRefunds++;
+            break;
+        }
+      }
+
+      if (numPendingRefunds > 0) {
+        queryDone = false;
+      }
+
+      if (queryDone) {
+        p.timestampLastRefundStatus = now;
+        if (p.status === ProposalStatus.AbortingWithRefund) {
+          p.status = ProposalStatus.PaymentAbortFinished;
+        } else if (p.status === ProposalStatus.QueryingAutoRefund) {
+          const autoRefundDeadline = p.autoRefundDeadline;
+          checkDbInvariant(!!autoRefundDeadline);
+          if (
+            AbsoluteTime.isExpired(
+              AbsoluteTime.fromTimestamp(autoRefundDeadline),
+            )
+          ) {
+            p.status = ProposalStatus.Paid;
+          }
+        } else if (p.status === ProposalStatus.QueryingRefund) {
+          p.status = ProposalStatus.Paid;
+        }
+        logger.trace("refund query done");
+      } else {
+        // No error, but we need to try again!
+        p.timestampLastRefundStatus = now;
+        logger.trace("refund query not done");
+      }
+
+      await tx.purchases.put(p);
+    });
+
+  ws.notify({
+    type: NotificationType.RefundQueried,
+  });
+}
+
+async function calculateRefundSummary(
+  p: PurchaseRecord,
+): Promise<RefundSummary> {
+  const download = await expectProposalDownload(p);
+  let amountRefundGranted = Amounts.getZero(
+    download.contractData.amount.currency,
+  );
+  let amountRefundGone = 
Amounts.getZero(download.contractData.amount.currency);
+
+  let pendingAtExchange = false;
+
+  const payInfo = p.payInfo;
+  if (!payInfo) {
+    throw Error("can't calculate refund summary without payInfo");
+  }
+
+  Object.keys(p.refunds).forEach((rk) => {
+    const refund = p.refunds[rk];
+    if (refund.type === RefundState.Pending) {
+      pendingAtExchange = true;
+    }
+    if (
+      refund.type === RefundState.Applied ||
+      refund.type === RefundState.Pending
+    ) {
+      amountRefundGranted = Amounts.add(
+        amountRefundGranted,
+        Amounts.sub(
+          refund.refundAmount,
+          refund.refundFee,
+          refund.totalRefreshCostBound,
+        ).amount,
+      ).amount;
+    } else {
+      amountRefundGone = Amounts.add(
+        amountRefundGone,
+        refund.refundAmount,
+      ).amount;
+    }
+  });
+  return {
+    amountEffectivePaid: payInfo.totalPayCost,
+    amountRefundGone,
+    amountRefundGranted,
+    pendingAtExchange,
+  };
+}
+
+/**
+ * Summary of the refund status of a purchase.
+ */
+export interface RefundSummary {
+  pendingAtExchange: boolean;
+  amountEffectivePaid: AmountJson;
+  amountRefundGranted: AmountJson;
+  amountRefundGone: AmountJson;
+}
+
+/**
+ * Accept a refund, return the contract hash for the contract
+ * that was involved in the refund.
+ */
+export async function applyRefund(
+  ws: InternalWalletState,
+  talerRefundUri: string,
+): Promise<ApplyRefundResponse> {
+  const parseResult = parseRefundUri(talerRefundUri);
+
+  logger.trace("applying refund", parseResult);
+
+  if (!parseResult) {
+    throw Error("invalid refund URI");
+  }
+
+  const purchase = await ws.db
+    .mktx((x) => [x.purchases])
+    .runReadOnly(async (tx) => {
+      return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
+        parseResult.merchantBaseUrl,
+        parseResult.orderId,
+      ]);
+    });
+
+  if (!purchase) {
+    throw Error(
+      `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
+    );
+  }
+
+  return applyRefundFromPurchaseId(ws, purchase.proposalId);
+}
+
+export async function applyRefundFromPurchaseId(
+  ws: InternalWalletState,
+  proposalId: string,
+): Promise<ApplyRefundResponse> {
+  logger.trace("applying refund for purchase", proposalId);
+
+  logger.info("processing purchase for refund");
+  const success = await ws.db
+    .mktx((x) => [x.purchases])
+    .runReadWrite(async (tx) => {
+      const p = await tx.purchases.get(proposalId);
+      if (!p) {
+        logger.error("no purchase found for refund URL");
+        return false;
+      }
+      if (p.status === ProposalStatus.Paid) {
+        p.status = ProposalStatus.QueryingRefund;
+      }
+      await tx.purchases.put(p);
+      return true;
+    });
+
+  if (success) {
+    ws.notify({
+      type: NotificationType.RefundStarted,
+    });
+    await processPurchaseQueryRefund(ws, proposalId, {
+      forceNow: true,
+      waitForAutoRefund: false,
+    });
+  }
+
+  const purchase = await ws.db
+    .mktx((x) => [x.purchases])
+    .runReadOnly(async (tx) => {
+      return tx.purchases.get(proposalId);
+    });
+
+  if (!purchase) {
+    throw Error("purchase no longer exists");
+  }
+
+  const summary = await calculateRefundSummary(purchase);
+  const download = await expectProposalDownload(purchase);
+
+  return {
+    contractTermsHash: download.contractData.contractTermsHash,
+    proposalId: purchase.proposalId,
+    transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: 
can we have the tx id of the refund
+    amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid),
+    amountRefundGone: Amounts.stringify(summary.amountRefundGone),
+    amountRefundGranted: Amounts.stringify(summary.amountRefundGranted),
+    pendingAtExchange: summary.pendingAtExchange,
+    info: {
+      contractTermsHash: download.contractData.contractTermsHash,
+      merchant: download.contractData.merchant,
+      orderId: download.contractData.orderId,
+      products: download.contractData.products,
+      summary: download.contractData.summary,
+      fulfillmentMessage: download.contractData.fulfillmentMessage,
+      summary_i18n: download.contractData.summaryI18n,
+      fulfillmentMessage_i18n: download.contractData.fulfillmentMessageI18n,
+    },
+  };
+}
+
+async function queryAndSaveAwaitingRefund(
+  ws: InternalWalletState,
+  purchase: PurchaseRecord,
+  waitForAutoRefund?: boolean,
+): Promise<AmountJson> {
+  const download = await expectProposalDownload(purchase);
+  const requestUrl = new URL(
+    `orders/${download.contractData.orderId}`,
+    download.contractData.merchantBaseUrl,
+  );
+  requestUrl.searchParams.set(
+    "h_contract",
+    download.contractData.contractTermsHash,
+  );
+  // Long-poll for one second
+  if (waitForAutoRefund) {
+    requestUrl.searchParams.set("timeout_ms", "1000");
+    requestUrl.searchParams.set("await_refund_obtained", "yes");
+    logger.trace("making long-polling request for auto-refund");
+  }
+  const resp = await ws.http.get(requestUrl.href);
+  const orderStatus = await readSuccessResponseJsonOrThrow(
+    resp,
+    codecForMerchantOrderStatusPaid(),
+  );
+  if (!orderStatus.refunded) {
+    // Wait for retry ...
+    return Amounts.getZero(download.contractData.amount.currency);
+  }
+
+  const refundAwaiting = Amounts.sub(
+    Amounts.parseOrThrow(orderStatus.refund_amount),
+    Amounts.parseOrThrow(orderStatus.refund_taken),
+  ).amount;
+
+  if (
+    purchase.refundAmountAwaiting === undefined ||
+    Amounts.cmp(refundAwaiting, purchase.refundAmountAwaiting) !== 0
+  ) {
+    await ws.db
+      .mktx((x) => [x.purchases])
+      .runReadWrite(async (tx) => {
+        const p = await tx.purchases.get(purchase.proposalId);
+        if (!p) {
+          logger.warn("purchase does not exist anymore");
+          return;
+        }
+        p.refundAmountAwaiting = refundAwaiting;
+        await tx.purchases.put(p);
+      });
+  }
+
+  return refundAwaiting;
+}
+
+export async function processPurchaseQueryRefund(
+  ws: InternalWalletState,
+  proposalId: string,
+  options: {
+    forceNow?: boolean;
+    waitForAutoRefund?: boolean;
+  } = {},
+): Promise<OperationAttemptResult> {
+  logger.trace(`processing refund query for proposal ${proposalId}`);
+  const waitForAutoRefund = options.waitForAutoRefund ?? false;
+  const purchase = await ws.db
+    .mktx((x) => [x.purchases])
+    .runReadOnly(async (tx) => {
+      return tx.purchases.get(proposalId);
+    });
+  if (!purchase) {
+    return OperationAttemptResult.finishedEmpty();
+  }
+
+  if (
+    !(
+      purchase.status === ProposalStatus.QueryingAutoRefund ||
+      purchase.status === ProposalStatus.QueryingRefund ||
+      purchase.status === ProposalStatus.AbortingWithRefund
+    )
+  ) {
+    return OperationAttemptResult.finishedEmpty();
+  }
+
+  const download = await expectProposalDownload(purchase);
+
+  if (purchase.timestampFirstSuccessfulPay) {
+    if (
+      !purchase.autoRefundDeadline ||
+      !AbsoluteTime.isExpired(
+        AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
+      )
+    ) {
+      const awaitingAmount = await queryAndSaveAwaitingRefund(
+        ws,
+        purchase,
+        waitForAutoRefund,
+      );
+      if (Amounts.isZero(awaitingAmount)) {
+        return OperationAttemptResult.finishedEmpty();
+      }
+    }
+
+    const requestUrl = new URL(
+      `orders/${download.contractData.orderId}/refund`,
+      download.contractData.merchantBaseUrl,
+    );
+
+    logger.trace(`making refund request to ${requestUrl.href}`);
+
+    const request = await ws.http.postJson(requestUrl.href, {
+      h_contract: download.contractData.contractTermsHash,
+    });
+
+    const refundResponse = await readSuccessResponseJsonOrThrow(
+      request,
+      codecForMerchantOrderRefundPickupResponse(),
+    );
+
+    await acceptRefunds(
+      ws,
+      proposalId,
+      refundResponse.refunds,
+      RefundReason.NormalRefund,
+    );
+  } else if (purchase.status === ProposalStatus.AbortingWithRefund) {
+    const requestUrl = new URL(
+      `orders/${download.contractData.orderId}/abort`,
+      download.contractData.merchantBaseUrl,
+    );
+
+    const abortingCoins: AbortingCoin[] = [];
+
+    const payCoinSelection = purchase.payInfo?.payCoinSelection;
+    if (!payCoinSelection) {
+      throw Error("can't abort, no coins selected");
+    }
+
+    await ws.db
+      .mktx((x) => [x.coins])
+      .runReadOnly(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, "expected coin to be present");
+          abortingCoins.push({
+            coin_pub: coinPub,
+            contribution: Amounts.stringify(
+              payCoinSelection.coinContributions[i],
+            ),
+            exchange_url: coin.exchangeBaseUrl,
+          });
+        }
+      });
+
+    const abortReq: AbortRequest = {
+      h_contract: download.contractData.contractTermsHash,
+      coins: abortingCoins,
+    };
+
+    logger.trace(`making order abort request to ${requestUrl.href}`);
+
+    const request = await ws.http.postJson(requestUrl.href, abortReq);
+    const abortResp = await readSuccessResponseJsonOrThrow(
+      request,
+      codecForAbortResponse(),
+    );
+
+    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];
+      refunds.push({
+        ...r,
+        coin_pub: payCoinSelection.coinPubs[i],
+        refund_amount: 
Amounts.stringify(payCoinSelection.coinContributions[i]),
+        rtransaction_id: 0,
+        execution_time: AbsoluteTime.toTimestamp(
+          AbsoluteTime.addDuration(
+            AbsoluteTime.fromTimestamp(download.contractData.timestamp),
+            Duration.fromSpec({ seconds: 1 }),
+          ),
+        ),
+      });
+    }
+    await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
+  }
+  return OperationAttemptResult.finishedEmpty();
+}
+
+export async function abortFailedPayWithRefund(
+  ws: InternalWalletState,
+  proposalId: string,
+): Promise<void> {
+  await ws.db
+    .mktx((x) => [x.purchases])
+    .runReadWrite(async (tx) => {
+      const purchase = await tx.purchases.get(proposalId);
+      if (!purchase) {
+        throw Error("purchase not found");
+      }
+      if (purchase.timestampFirstSuccessfulPay) {
+        // No point in aborting it.  We don't even report an error.
+        logger.warn(`tried to abort successful payment`);
+        return;
+      }
+      if (purchase.status === ProposalStatus.Paying) {
+        purchase.status = ProposalStatus.AbortingWithRefund;
+      }
+      await tx.purchases.put(purchase);
+    });
+  processPurchaseQueryRefund(ws, proposalId, {
+    forceNow: true,
+  }).catch((e) => {
+    logger.trace(`error during refund processing after abort pay: ${e}`);
+  });
+}
diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts 
b/packages/taler-wallet-core/src/operations/pay-peer.ts
similarity index 99%
rename from packages/taler-wallet-core/src/operations/peer-to-peer.ts
rename to packages/taler-wallet-core/src/operations/pay-peer.ts
index d30cb294d..e9185a9d4 100644
--- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts
+++ b/packages/taler-wallet-core/src/operations/pay-peer.ts
@@ -73,9 +73,8 @@ import { InternalWalletState } from 
"../internal-wallet-state.js";
 import { readSuccessResponseJsonOrThrow } from "../util/http.js";
 import { checkDbInvariant } from "../util/invariants.js";
 import { GetReadOnlyAccess } from "../util/query.js";
-import { spendCoins } from "../wallet.js";
+import { spendCoins, makeEventId } from "../operations/common.js";
 import { updateExchangeFromUrl } from "./exchanges.js";
-import { makeEventId } from "./transactions.js";
 import { internalCreateWithdrawalGroup } from "./withdraw.js";
 
 const logger = new Logger("operations/peer-to-peer.ts");
diff --git a/packages/taler-wallet-core/src/operations/pending.ts 
b/packages/taler-wallet-core/src/operations/pending.ts
index e4c270d85..db7a85432 100644
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ b/packages/taler-wallet-core/src/operations/pending.ts
@@ -23,7 +23,6 @@
  */
 import {
   ProposalStatus,
-  AbortStatus,
   WalletStoresV1,
   BackupProviderStateTag,
   RefreshCoinStatus,
@@ -38,7 +37,6 @@ import { AbsoluteTime } from "@gnu-taler/taler-util";
 import { InternalWalletState } from "../internal-wallet-state.js";
 import { GetReadOnlyAccess } from "../util/query.js";
 import { RetryTags } from "../util/retries.js";
-import { Wallet } from "../wallet.js";
 import { GlobalIDB } from "@gnu-taler/idb-bridge";
 
 function getPendingCommon(
@@ -184,38 +182,6 @@ async function gatherWithdrawalPending(
   }
 }
 
-async function gatherProposalPending(
-  ws: InternalWalletState,
-  tx: GetReadOnlyAccess<{
-    proposals: typeof WalletStoresV1.proposals;
-    operationRetries: typeof WalletStoresV1.operationRetries;
-  }>,
-  now: AbsoluteTime,
-  resp: PendingOperationsResponse,
-): Promise<void> {
-  await tx.proposals.iter().forEachAsync(async (proposal) => {
-    if (proposal.proposalStatus == ProposalStatus.Proposed) {
-      // Nothing to do, user needs to choose.
-    } else if (proposal.proposalStatus == ProposalStatus.Downloading) {
-      const opId = RetryTags.forProposalClaim(proposal);
-      const retryRecord = await tx.operationRetries.get(opId);
-      const timestampDue =
-        retryRecord?.retryInfo?.nextRetry ?? AbsoluteTime.now();
-      resp.pendingOperations.push({
-        type: PendingTaskType.ProposalDownload,
-        ...getPendingCommon(ws, opId, timestampDue),
-        givesLifeness: true,
-        merchantBaseUrl: proposal.merchantBaseUrl,
-        orderId: proposal.orderId,
-        proposalId: proposal.proposalId,
-        proposalTimestamp: proposal.timestamp,
-        lastError: retryRecord?.lastError,
-        retryInfo: retryRecord?.retryInfo,
-      });
-    }
-  });
-}
-
 async function gatherDepositPending(
   ws: InternalWalletState,
   tx: GetReadOnlyAccess<{
@@ -287,44 +253,27 @@ async function gatherPurchasePending(
   resp: PendingOperationsResponse,
 ): Promise<void> {
   // FIXME: Only iter purchases with some "active" flag!
-  await tx.purchases.iter().forEachAsync(async (pr) => {
-    if (
-      pr.paymentSubmitPending &&
-      pr.abortStatus === AbortStatus.None &&
-      !pr.payFrozen
-    ) {
-      const payOpId = RetryTags.forPay(pr);
-      const payRetryRecord = await tx.operationRetries.get(payOpId);
-
-      const timestampDue =
-        payRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
-      resp.pendingOperations.push({
-        type: PendingTaskType.Pay,
-        ...getPendingCommon(ws, payOpId, timestampDue),
-        givesLifeness: true,
-        isReplay: false,
-        proposalId: pr.proposalId,
-        retryInfo: payRetryRecord?.retryInfo,
-        lastError: payRetryRecord?.lastError,
-      });
-    }
-    if (pr.refundQueryRequested) {
-      const refundQueryOpId = RetryTags.forRefundQuery(pr);
-      const refundQueryRetryRecord = await tx.operationRetries.get(
-        refundQueryOpId,
-      );
+  const keyRange = GlobalIDB.KeyRange.bound(
+    OperationStatusRange.ACTIVE_START,
+    OperationStatusRange.ACTIVE_END,
+  );
+  await tx.purchases.indexes.byStatus
+    .iter(keyRange)
+    .forEachAsync(async (pr) => {
+      const opId = RetryTags.forPay(pr);
+      const retryRecord = await tx.operationRetries.get(opId);
       const timestampDue =
-        refundQueryRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
+        retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
       resp.pendingOperations.push({
-        type: PendingTaskType.RefundQuery,
-        ...getPendingCommon(ws, refundQueryOpId, timestampDue),
+        type: PendingTaskType.Purchase,
+        ...getPendingCommon(ws, opId, timestampDue),
         givesLifeness: true,
+        statusStr: ProposalStatus[pr.status],
         proposalId: pr.proposalId,
-        retryInfo: refundQueryRetryRecord?.retryInfo,
-        lastError: refundQueryRetryRecord?.lastError,
+        retryInfo: retryRecord?.retryInfo,
+        lastError: retryRecord?.lastError,
       });
-    }
-  });
+    });
 }
 
 async function gatherRecoupPending(
@@ -404,7 +353,6 @@ export async function getPendingOperations(
       x.refreshGroups,
       x.coins,
       x.withdrawalGroups,
-      x.proposals,
       x.tips,
       x.purchases,
       x.planchets,
@@ -419,7 +367,6 @@ export async function getPendingOperations(
       await gatherExchangePending(ws, tx, now, resp);
       await gatherRefreshPending(ws, tx, now, resp);
       await gatherWithdrawalPending(ws, tx, now, resp);
-      await gatherProposalPending(ws, tx, now, resp);
       await gatherDepositPending(ws, tx, now, resp);
       await gatherTipPending(ws, tx, now, resp);
       await gatherPurchasePending(ws, tx, now, resp);
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts 
b/packages/taler-wallet-core/src/operations/recoup.ts
index 2d92ff8ba..ff6bb4efc 100644
--- a/packages/taler-wallet-core/src/operations/recoup.ts
+++ b/packages/taler-wallet-core/src/operations/recoup.ts
@@ -27,16 +27,15 @@
 import {
   Amounts,
   codecForRecoupConfirmation,
+  codecForReserveStatus,
   encodeCrock,
   getRandomBytes,
   j2s,
   Logger,
   NotificationType,
   RefreshReason,
-  TalerErrorDetail,
   TalerProtocolTimestamp,
   URL,
-  codecForReserveStatus,
 } from "@gnu-taler/taler-util";
 import {
   CoinRecord,
@@ -44,8 +43,8 @@ import {
   CoinStatus,
   RecoupGroupRecord,
   RefreshCoinSource,
-  WithdrawalGroupStatus,
   WalletStoresV1,
+  WithdrawalGroupStatus,
   WithdrawalRecordType,
   WithdrawCoinSource,
 } from "../db.js";
@@ -54,10 +53,8 @@ import { readSuccessResponseJsonOrThrow } from 
"../util/http.js";
 import { GetReadWriteAccess } from "../util/query.js";
 import {
   OperationAttemptResult,
-  RetryInfo,
   runOperationHandlerForResult,
 } from "../util/retries.js";
-import { guardOperationException } from "./common.js";
 import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
 import { internalCreateWithdrawalGroup } from "./withdraw.js";
 
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts 
b/packages/taler-wallet-core/src/operations/refresh.ts
index 9fe2e6a8f..a5951ea53 100644
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ b/packages/taler-wallet-core/src/operations/refresh.ts
@@ -78,7 +78,7 @@ import {
   OperationAttemptResult,
   OperationAttemptResultType,
 } from "../util/retries.js";
-import { makeCoinAvailable } from "../wallet.js";
+import { makeCoinAvailable } from "./common.js";
 import { updateExchangeFromUrl } from "./exchanges.js";
 import {
   isWithdrawableDenom,
diff --git a/packages/taler-wallet-core/src/operations/refund.ts 
b/packages/taler-wallet-core/src/operations/refund.ts
deleted file mode 100644
index 0d86b92ab..000000000
--- a/packages/taler-wallet-core/src/operations/refund.ts
+++ /dev/null
@@ -1,815 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the refund operation.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import {
-  AbortingCoin,
-  AbortRequest,
-  AbsoluteTime,
-  AmountJson,
-  Amounts,
-  ApplyRefundResponse,
-  codecForAbortResponse,
-  codecForMerchantOrderRefundPickupResponse,
-  codecForMerchantOrderStatusPaid,
-  CoinPublicKey,
-  Duration,
-  Logger,
-  MerchantCoinRefundFailureStatus,
-  MerchantCoinRefundStatus,
-  MerchantCoinRefundSuccessStatus,
-  NotificationType,
-  parseRefundUri,
-  PrepareRefundResult,
-  RefreshReason,
-  TalerErrorCode,
-  TalerProtocolTimestamp,
-  TransactionType,
-  URL,
-} from "@gnu-taler/taler-util";
-import {
-  AbortStatus,
-  CoinStatus,
-  DenominationRecord,
-  PurchaseRecord,
-  RefundReason,
-  RefundState,
-  WalletStoresV1,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadWriteAccess } from "../util/query.js";
-import { OperationAttemptResult } from "../util/retries.js";
-import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
-import { makeEventId } from "./transactions.js";
-
-const logger = new Logger("refund.ts");
-
-export async function prepareRefund(
-  ws: InternalWalletState,
-  talerRefundUri: string,
-): Promise<PrepareRefundResult> {
-  const parseResult = parseRefundUri(talerRefundUri);
-
-  logger.trace("preparing refund offer", parseResult);
-
-  if (!parseResult) {
-    throw Error("invalid refund URI");
-  }
-
-  const purchase = await ws.db
-    .mktx((x) => [x.purchases])
-    .runReadOnly(async (tx) => {
-      return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
-        parseResult.merchantBaseUrl,
-        parseResult.orderId,
-      ]);
-    });
-
-  if (!purchase) {
-    throw Error(
-      `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
-    );
-  }
-
-  const awaiting = await queryAndSaveAwaitingRefund(ws, purchase);
-  const summary = calculateRefundSummary(purchase);
-  const proposalId = purchase.proposalId;
-
-  const { contractData: c } = purchase.download;
-
-  return {
-    proposalId,
-    effectivePaid: Amounts.stringify(summary.amountEffectivePaid),
-    gone: Amounts.stringify(summary.amountRefundGone),
-    granted: Amounts.stringify(summary.amountRefundGranted),
-    pending: summary.pendingAtExchange,
-    awaiting: Amounts.stringify(awaiting),
-    info: {
-      contractTermsHash: c.contractTermsHash,
-      merchant: c.merchant,
-      orderId: c.orderId,
-      products: c.products,
-      summary: c.summary,
-      fulfillmentMessage: c.fulfillmentMessage,
-      summary_i18n: c.summaryI18n,
-      fulfillmentMessage_i18n: c.fulfillmentMessageI18n,
-    },
-  };
-}
-
-function getRefundKey(d: MerchantCoinRefundStatus): string {
-  return `${d.coin_pub}-${d.rtransaction_id}`;
-}
-
-async function applySuccessfulRefund(
-  tx: GetReadWriteAccess<{
-    coins: typeof WalletStoresV1.coins;
-    denominations: typeof WalletStoresV1.denominations;
-  }>,
-  p: PurchaseRecord,
-  refreshCoinsMap: Record<string, { coinPub: string }>,
-  r: MerchantCoinRefundSuccessStatus,
-): Promise<void> {
-  // FIXME: check signature before storing it as valid!
-
-  const refundKey = getRefundKey(r);
-  const coin = await tx.coins.get(r.coin_pub);
-  if (!coin) {
-    logger.warn("coin not found, can't apply refund");
-    return;
-  }
-  const denom = await tx.denominations.get([
-    coin.exchangeBaseUrl,
-    coin.denomPubHash,
-  ]);
-  if (!denom) {
-    throw Error("inconsistent database");
-  }
-  refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
-  const refundAmount = Amounts.parseOrThrow(r.refund_amount);
-  const refundFee = denom.fees.feeRefund;
-  coin.status = CoinStatus.Dormant;
-  coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
-  coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
-  logger.trace(`coin amount after is 
${Amounts.stringify(coin.currentAmount)}`);
-  await tx.coins.put(coin);
-
-  const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
-    .iter(coin.exchangeBaseUrl)
-    .toArray();
-
-  const amountLeft = Amounts.sub(
-    Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
-      .amount,
-    denom.fees.feeRefund,
-  ).amount;
-
-  const totalRefreshCostBound = getTotalRefreshCost(
-    allDenoms,
-    DenominationRecord.toDenomInfo(denom),
-    amountLeft,
-  );
-
-  p.refunds[refundKey] = {
-    type: RefundState.Applied,
-    obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
-    executionTime: r.execution_time,
-    refundAmount: Amounts.parseOrThrow(r.refund_amount),
-    refundFee: denom.fees.feeRefund,
-    totalRefreshCostBound,
-    coinPub: r.coin_pub,
-    rtransactionId: r.rtransaction_id,
-  };
-}
-
-async function storePendingRefund(
-  tx: GetReadWriteAccess<{
-    denominations: typeof WalletStoresV1.denominations;
-    coins: typeof WalletStoresV1.coins;
-  }>,
-  p: PurchaseRecord,
-  r: MerchantCoinRefundFailureStatus,
-): Promise<void> {
-  const refundKey = getRefundKey(r);
-
-  const coin = await tx.coins.get(r.coin_pub);
-  if (!coin) {
-    logger.warn("coin not found, can't apply refund");
-    return;
-  }
-  const denom = await tx.denominations.get([
-    coin.exchangeBaseUrl,
-    coin.denomPubHash,
-  ]);
-
-  if (!denom) {
-    throw Error("inconsistent database");
-  }
-
-  const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
-    .iter(coin.exchangeBaseUrl)
-    .toArray();
-
-  const amountLeft = Amounts.sub(
-    Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
-      .amount,
-    denom.fees.feeRefund,
-  ).amount;
-
-  const totalRefreshCostBound = getTotalRefreshCost(
-    allDenoms,
-    DenominationRecord.toDenomInfo(denom),
-    amountLeft,
-  );
-
-  p.refunds[refundKey] = {
-    type: RefundState.Pending,
-    obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
-    executionTime: r.execution_time,
-    refundAmount: Amounts.parseOrThrow(r.refund_amount),
-    refundFee: denom.fees.feeRefund,
-    totalRefreshCostBound,
-    coinPub: r.coin_pub,
-    rtransactionId: r.rtransaction_id,
-  };
-}
-
-async function storeFailedRefund(
-  tx: GetReadWriteAccess<{
-    coins: typeof WalletStoresV1.coins;
-    denominations: typeof WalletStoresV1.denominations;
-  }>,
-  p: PurchaseRecord,
-  refreshCoinsMap: Record<string, { coinPub: string }>,
-  r: MerchantCoinRefundFailureStatus,
-): Promise<void> {
-  const refundKey = getRefundKey(r);
-
-  const coin = await tx.coins.get(r.coin_pub);
-  if (!coin) {
-    logger.warn("coin not found, can't apply refund");
-    return;
-  }
-  const denom = await tx.denominations.get([
-    coin.exchangeBaseUrl,
-    coin.denomPubHash,
-  ]);
-
-  if (!denom) {
-    throw Error("inconsistent database");
-  }
-
-  const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
-    .iter(coin.exchangeBaseUrl)
-    .toArray();
-
-  const amountLeft = Amounts.sub(
-    Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
-      .amount,
-    denom.fees.feeRefund,
-  ).amount;
-
-  const totalRefreshCostBound = getTotalRefreshCost(
-    allDenoms,
-    DenominationRecord.toDenomInfo(denom),
-    amountLeft,
-  );
-
-  p.refunds[refundKey] = {
-    type: RefundState.Failed,
-    obtainedTime: TalerProtocolTimestamp.now(),
-    executionTime: r.execution_time,
-    refundAmount: Amounts.parseOrThrow(r.refund_amount),
-    refundFee: denom.fees.feeRefund,
-    totalRefreshCostBound,
-    coinPub: r.coin_pub,
-    rtransactionId: r.rtransaction_id,
-  };
-
-  if (p.abortStatus === AbortStatus.AbortRefund) {
-    // Refund failed because the merchant didn't even try to deposit
-    // the coin yet, so we try to refresh.
-    if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
-      const coin = await tx.coins.get(r.coin_pub);
-      if (!coin) {
-        logger.warn("coin not found, can't apply refund");
-        return;
-      }
-      const denom = await tx.denominations.get([
-        coin.exchangeBaseUrl,
-        coin.denomPubHash,
-      ]);
-      if (!denom) {
-        logger.warn("denomination for coin missing");
-        return;
-      }
-      let contrib: AmountJson | undefined;
-      for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) {
-        if (p.payCoinSelection.coinPubs[i] === r.coin_pub) {
-          contrib = p.payCoinSelection.coinContributions[i];
-        }
-      }
-      if (contrib) {
-        coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
-        coin.currentAmount = Amounts.sub(
-          coin.currentAmount,
-          denom.fees.feeRefund,
-        ).amount;
-      }
-      refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
-      await tx.coins.put(coin);
-    }
-  }
-}
-
-async function acceptRefunds(
-  ws: InternalWalletState,
-  proposalId: string,
-  refunds: MerchantCoinRefundStatus[],
-  reason: RefundReason,
-): Promise<void> {
-  logger.trace("handling refunds", refunds);
-  const now = TalerProtocolTimestamp.now();
-
-  await ws.db
-    .mktx((x) => [
-      x.purchases,
-      x.coins,
-      x.coinAvailability,
-      x.denominations,
-      x.refreshGroups,
-    ])
-    .runReadWrite(async (tx) => {
-      const p = await tx.purchases.get(proposalId);
-      if (!p) {
-        logger.error("purchase not found, not adding refunds");
-        return;
-      }
-
-      const refreshCoinsMap: Record<string, CoinPublicKey> = {};
-
-      for (const refundStatus of refunds) {
-        const refundKey = getRefundKey(refundStatus);
-        const existingRefundInfo = p.refunds[refundKey];
-
-        const isPermanentFailure =
-          refundStatus.type === "failure" &&
-          refundStatus.exchange_status >= 400 &&
-          refundStatus.exchange_status < 500;
-
-        // Already failed.
-        if (existingRefundInfo?.type === RefundState.Failed) {
-          continue;
-        }
-
-        // Already applied.
-        if (existingRefundInfo?.type === RefundState.Applied) {
-          continue;
-        }
-
-        // Still pending.
-        if (
-          refundStatus.type === "failure" &&
-          !isPermanentFailure &&
-          existingRefundInfo?.type === RefundState.Pending
-        ) {
-          continue;
-        }
-
-        // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
-
-        if (refundStatus.type === "success") {
-          await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
-        } else if (isPermanentFailure) {
-          await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
-        } else {
-          await storePendingRefund(tx, p, refundStatus);
-        }
-      }
-
-      const refreshCoinsPubs = Object.values(refreshCoinsMap);
-      if (refreshCoinsPubs.length > 0) {
-        await createRefreshGroup(
-          ws,
-          tx,
-          refreshCoinsPubs,
-          RefreshReason.Refund,
-        );
-      }
-
-      // Are we done with querying yet, or do we need to do another round
-      // after a retry delay?
-      let queryDone = true;
-
-      if (
-        p.timestampFirstSuccessfulPay &&
-        p.autoRefundDeadline &&
-        AbsoluteTime.cmp(
-          AbsoluteTime.fromTimestamp(p.autoRefundDeadline),
-          AbsoluteTime.fromTimestamp(now),
-        ) > 0
-      ) {
-        queryDone = false;
-      }
-
-      let numPendingRefunds = 0;
-      for (const ri of Object.values(p.refunds)) {
-        switch (ri.type) {
-          case RefundState.Pending:
-            numPendingRefunds++;
-            break;
-        }
-      }
-
-      if (numPendingRefunds > 0) {
-        queryDone = false;
-      }
-
-      if (queryDone) {
-        p.timestampLastRefundStatus = now;
-        p.refundQueryRequested = false;
-        if (p.abortStatus === AbortStatus.AbortRefund) {
-          p.abortStatus = AbortStatus.AbortFinished;
-        }
-        logger.trace("refund query done");
-      } else {
-        // No error, but we need to try again!
-        p.timestampLastRefundStatus = now;
-        logger.trace("refund query not done");
-      }
-
-      await tx.purchases.put(p);
-    });
-
-  ws.notify({
-    type: NotificationType.RefundQueried,
-  });
-}
-
-function calculateRefundSummary(p: PurchaseRecord): RefundSummary {
-  let amountRefundGranted = Amounts.getZero(
-    p.download.contractData.amount.currency,
-  );
-  let amountRefundGone = Amounts.getZero(
-    p.download.contractData.amount.currency,
-  );
-
-  let pendingAtExchange = false;
-
-  Object.keys(p.refunds).forEach((rk) => {
-    const refund = p.refunds[rk];
-    if (refund.type === RefundState.Pending) {
-      pendingAtExchange = true;
-    }
-    if (
-      refund.type === RefundState.Applied ||
-      refund.type === RefundState.Pending
-    ) {
-      amountRefundGranted = Amounts.add(
-        amountRefundGranted,
-        Amounts.sub(
-          refund.refundAmount,
-          refund.refundFee,
-          refund.totalRefreshCostBound,
-        ).amount,
-      ).amount;
-    } else {
-      amountRefundGone = Amounts.add(
-        amountRefundGone,
-        refund.refundAmount,
-      ).amount;
-    }
-  });
-  return {
-    amountEffectivePaid: p.totalPayCost,
-    amountRefundGone,
-    amountRefundGranted,
-    pendingAtExchange,
-  };
-}
-
-/**
- * Summary of the refund status of a purchase.
- */
-export interface RefundSummary {
-  pendingAtExchange: boolean;
-  amountEffectivePaid: AmountJson;
-  amountRefundGranted: AmountJson;
-  amountRefundGone: AmountJson;
-}
-
-/**
- * Accept a refund, return the contract hash for the contract
- * that was involved in the refund.
- */
-export async function applyRefund(
-  ws: InternalWalletState,
-  talerRefundUri: string,
-): Promise<ApplyRefundResponse> {
-  const parseResult = parseRefundUri(talerRefundUri);
-
-  logger.trace("applying refund", parseResult);
-
-  if (!parseResult) {
-    throw Error("invalid refund URI");
-  }
-
-  const purchase = await ws.db
-    .mktx((x) => [x.purchases])
-    .runReadOnly(async (tx) => {
-      return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
-        parseResult.merchantBaseUrl,
-        parseResult.orderId,
-      ]);
-    });
-
-  if (!purchase) {
-    throw Error(
-      `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
-    );
-  }
-
-  return applyRefundFromPurchaseId(ws, purchase.proposalId);
-}
-
-export async function applyRefundFromPurchaseId(
-  ws: InternalWalletState,
-  proposalId: string,
-): Promise<ApplyRefundResponse> {
-  logger.trace("applying refund for purchase", proposalId);
-
-  logger.info("processing purchase for refund");
-  const success = await ws.db
-    .mktx((x) => [x.purchases])
-    .runReadWrite(async (tx) => {
-      const p = await tx.purchases.get(proposalId);
-      if (!p) {
-        logger.error("no purchase found for refund URL");
-        return false;
-      }
-      p.refundQueryRequested = true;
-      await tx.purchases.put(p);
-      return true;
-    });
-
-  if (success) {
-    ws.notify({
-      type: NotificationType.RefundStarted,
-    });
-    await processPurchaseQueryRefund(ws, proposalId, {
-      forceNow: true,
-      waitForAutoRefund: false,
-    });
-  }
-
-  const purchase = await ws.db
-    .mktx((x) => [x.purchases])
-    .runReadOnly(async (tx) => {
-      return tx.purchases.get(proposalId);
-    });
-
-  if (!purchase) {
-    throw Error("purchase no longer exists");
-  }
-
-  const summary = calculateRefundSummary(purchase);
-
-  return {
-    contractTermsHash: purchase.download.contractData.contractTermsHash,
-    proposalId: purchase.proposalId,
-    transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: 
can we have the tx id of the refund
-    amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid),
-    amountRefundGone: Amounts.stringify(summary.amountRefundGone),
-    amountRefundGranted: Amounts.stringify(summary.amountRefundGranted),
-    pendingAtExchange: summary.pendingAtExchange,
-    info: {
-      contractTermsHash: purchase.download.contractData.contractTermsHash,
-      merchant: purchase.download.contractData.merchant,
-      orderId: purchase.download.contractData.orderId,
-      products: purchase.download.contractData.products,
-      summary: purchase.download.contractData.summary,
-      fulfillmentMessage: purchase.download.contractData.fulfillmentMessage,
-      summary_i18n: purchase.download.contractData.summaryI18n,
-      fulfillmentMessage_i18n:
-        purchase.download.contractData.fulfillmentMessageI18n,
-    },
-  };
-}
-
-async function queryAndSaveAwaitingRefund(
-  ws: InternalWalletState,
-  purchase: PurchaseRecord,
-  waitForAutoRefund?: boolean,
-): Promise<AmountJson> {
-  const requestUrl = new URL(
-    `orders/${purchase.download.contractData.orderId}`,
-    purchase.download.contractData.merchantBaseUrl,
-  );
-  requestUrl.searchParams.set(
-    "h_contract",
-    purchase.download.contractData.contractTermsHash,
-  );
-  // Long-poll for one second
-  if (waitForAutoRefund) {
-    requestUrl.searchParams.set("timeout_ms", "1000");
-    requestUrl.searchParams.set("await_refund_obtained", "yes");
-    logger.trace("making long-polling request for auto-refund");
-  }
-  const resp = await ws.http.get(requestUrl.href);
-  const orderStatus = await readSuccessResponseJsonOrThrow(
-    resp,
-    codecForMerchantOrderStatusPaid(),
-  );
-  if (!orderStatus.refunded) {
-    // Wait for retry ...
-    return Amounts.getZero(purchase.totalPayCost.currency);
-  }
-
-  const refundAwaiting = Amounts.sub(
-    Amounts.parseOrThrow(orderStatus.refund_amount),
-    Amounts.parseOrThrow(orderStatus.refund_taken),
-  ).amount;
-
-  if (
-    purchase.refundAwaiting === undefined ||
-    Amounts.cmp(refundAwaiting, purchase.refundAwaiting) !== 0
-  ) {
-    await ws.db
-      .mktx((x) => [x.purchases])
-      .runReadWrite(async (tx) => {
-        const p = await tx.purchases.get(purchase.proposalId);
-        if (!p) {
-          logger.warn("purchase does not exist anymore");
-          return;
-        }
-        p.refundAwaiting = refundAwaiting;
-        await tx.purchases.put(p);
-      });
-  }
-
-  return refundAwaiting;
-}
-
-export async function processPurchaseQueryRefund(
-  ws: InternalWalletState,
-  proposalId: string,
-  options: {
-    forceNow?: boolean;
-    waitForAutoRefund?: boolean;
-  } = {},
-): Promise<OperationAttemptResult> {
-  const waitForAutoRefund = options.waitForAutoRefund ?? false;
-  const purchase = await ws.db
-    .mktx((x) => [x.purchases])
-    .runReadOnly(async (tx) => {
-      return tx.purchases.get(proposalId);
-    });
-  if (!purchase) {
-    return OperationAttemptResult.finishedEmpty();
-  }
-
-  if (!purchase.refundQueryRequested) {
-    return OperationAttemptResult.finishedEmpty();
-  }
-
-  if (purchase.timestampFirstSuccessfulPay) {
-    if (
-      !purchase.autoRefundDeadline ||
-      !AbsoluteTime.isExpired(
-        AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
-      )
-    ) {
-      const awaitingAmount = await queryAndSaveAwaitingRefund(
-        ws,
-        purchase,
-        waitForAutoRefund,
-      );
-      if (Amounts.isZero(awaitingAmount)) {
-        return OperationAttemptResult.finishedEmpty();
-      }
-    }
-
-    const requestUrl = new URL(
-      `orders/${purchase.download.contractData.orderId}/refund`,
-      purchase.download.contractData.merchantBaseUrl,
-    );
-
-    logger.trace(`making refund request to ${requestUrl.href}`);
-
-    const request = await ws.http.postJson(requestUrl.href, {
-      h_contract: purchase.download.contractData.contractTermsHash,
-    });
-
-    const refundResponse = await readSuccessResponseJsonOrThrow(
-      request,
-      codecForMerchantOrderRefundPickupResponse(),
-    );
-
-    await acceptRefunds(
-      ws,
-      proposalId,
-      refundResponse.refunds,
-      RefundReason.NormalRefund,
-    );
-  } else if (purchase.abortStatus === AbortStatus.AbortRefund) {
-    const requestUrl = new URL(
-      `orders/${purchase.download.contractData.orderId}/abort`,
-      purchase.download.contractData.merchantBaseUrl,
-    );
-
-    const abortingCoins: AbortingCoin[] = [];
-
-    await ws.db
-      .mktx((x) => [x.coins])
-      .runReadOnly(async (tx) => {
-        for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) {
-          const coinPub = purchase.payCoinSelection.coinPubs[i];
-          const coin = await tx.coins.get(coinPub);
-          checkDbInvariant(!!coin, "expected coin to be present");
-          abortingCoins.push({
-            coin_pub: coinPub,
-            contribution: Amounts.stringify(
-              purchase.payCoinSelection.coinContributions[i],
-            ),
-            exchange_url: coin.exchangeBaseUrl,
-          });
-        }
-      });
-
-    const abortReq: AbortRequest = {
-      h_contract: purchase.download.contractData.contractTermsHash,
-      coins: abortingCoins,
-    };
-
-    logger.trace(`making order abort request to ${requestUrl.href}`);
-
-    const request = await ws.http.postJson(requestUrl.href, abortReq);
-    const abortResp = await readSuccessResponseJsonOrThrow(
-      request,
-      codecForAbortResponse(),
-    );
-
-    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];
-      refunds.push({
-        ...r,
-        coin_pub: purchase.payCoinSelection.coinPubs[i],
-        refund_amount: Amounts.stringify(
-          purchase.payCoinSelection.coinContributions[i],
-        ),
-        rtransaction_id: 0,
-        execution_time: AbsoluteTime.toTimestamp(
-          AbsoluteTime.addDuration(
-            AbsoluteTime.fromTimestamp(
-              purchase.download.contractData.timestamp,
-            ),
-            Duration.fromSpec({ seconds: 1 }),
-          ),
-        ),
-      });
-    }
-    await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
-  }
-  return OperationAttemptResult.finishedEmpty();
-}
-
-export async function abortFailedPayWithRefund(
-  ws: InternalWalletState,
-  proposalId: string,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => [x.purchases])
-    .runReadWrite(async (tx) => {
-      const purchase = await tx.purchases.get(proposalId);
-      if (!purchase) {
-        throw Error("purchase not found");
-      }
-      if (purchase.timestampFirstSuccessfulPay) {
-        // No point in aborting it.  We don't even report an error.
-        logger.warn(`tried to abort successful payment`);
-        return;
-      }
-      if (purchase.abortStatus !== AbortStatus.None) {
-        return;
-      }
-      purchase.refundQueryRequested = true;
-      purchase.paymentSubmitPending = false;
-      purchase.abortStatus = AbortStatus.AbortRefund;
-      await tx.purchases.put(purchase);
-    });
-  processPurchaseQueryRefund(ws, proposalId, {
-    forceNow: true,
-  }).catch((e) => {
-    logger.trace(`error during refund processing after abort pay: ${e}`);
-  });
-}
diff --git a/packages/taler-wallet-core/src/operations/testing.ts 
b/packages/taler-wallet-core/src/operations/testing.ts
index 598a88502..9a11af8bb 100644
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ b/packages/taler-wallet-core/src/operations/testing.ts
@@ -40,9 +40,8 @@ import {
   PreparePayResultType,
 } from "@gnu-taler/taler-util";
 import { InternalWalletState } from "../internal-wallet-state.js";
-import { confirmPay, preparePayForUri } from "./pay.js";
+import { applyRefund, confirmPay, preparePayForUri } from "./pay-merchant.js";
 import { getBalances } from "./balance.js";
-import { applyRefund } from "./refund.js";
 import { checkLogicInvariant } from "../util/invariants.js";
 import { acceptWithdrawalFromUri } from "./withdraw.js";
 
@@ -471,6 +470,6 @@ export async function testPay(
     });
   checkLogicInvariant(!!purchase);
   return {
-    payCoinSelection: purchase.payCoinSelection,
+    payCoinSelection: purchase.payInfo?.payCoinSelection!,
   };
 }
diff --git a/packages/taler-wallet-core/src/operations/tip.ts 
b/packages/taler-wallet-core/src/operations/tip.ts
index bd5ff51e7..a83867f55 100644
--- a/packages/taler-wallet-core/src/operations/tip.ts
+++ b/packages/taler-wallet-core/src/operations/tip.ts
@@ -18,8 +18,8 @@
  * Imports.
  */
 import {
-  AgeRestriction,
   AcceptTipResponse,
+  AgeRestriction,
   Amounts,
   BlindedDenominationSignature,
   codecForMerchantTipResponseV2,
@@ -56,9 +56,8 @@ import {
   OperationAttemptResult,
   OperationAttemptResultType,
 } from "../util/retries.js";
-import { makeCoinAvailable } from "../wallet.js";
+import { makeCoinAvailable, makeEventId } from "./common.js";
 import { updateExchangeFromUrl } from "./exchanges.js";
-import { makeEventId } from "./transactions.js";
 import {
   getCandidateWithdrawalDenoms,
   getExchangeWithdrawalInfo,
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts 
b/packages/taler-wallet-core/src/operations/transactions.ts
index 4086fc9b3..6ddf14f98 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -36,12 +36,12 @@ import {
   WithdrawalType,
 } from "@gnu-taler/taler-util";
 import {
-  AbortStatus,
   DepositGroupRecord,
   ExchangeDetailsRecord,
   OperationRetryRecord,
   PeerPullPaymentIncomingRecord,
   PeerPushPaymentInitiationRecord,
+  ProposalStatus,
   PurchaseRecord,
   RefundState,
   TipRecord,
@@ -50,10 +50,12 @@ import {
   WithdrawalRecordType,
 } from "../db.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
+import { checkDbInvariant } from "../util/invariants.js";
 import { RetryTags } from "../util/retries.js";
+import { makeEventId, TombstoneTag } from "./common.js";
 import { processDepositGroup } from "./deposits.js";
 import { getExchangeDetails } from "./exchanges.js";
-import { processPurchasePay } from "./pay.js";
+import { expectProposalDownload, processPurchasePay } from "./pay-merchant.js";
 import { processRefreshGroup } from "./refresh.js";
 import { processTip } from "./tip.js";
 import {
@@ -63,28 +65,6 @@ import {
 
 const logger = new Logger("taler-wallet-core:transactions.ts");
 
-export enum TombstoneTag {
-  DeleteWithdrawalGroup = "delete-withdrawal-group",
-  DeleteReserve = "delete-reserve",
-  DeletePayment = "delete-payment",
-  DeleteTip = "delete-tip",
-  DeleteRefreshGroup = "delete-refresh-group",
-  DeleteDepositGroup = "delete-deposit-group",
-  DeleteRefund = "delete-refund",
-  DeletePeerPullDebit = "delete-peer-pull-debit",
-  DeletePeerPushDebit = "delete-peer-push-debit",
-}
-
-/**
- * Create an event ID from the type and the primary key for the event.
- */
-export function makeEventId(
-  type: TransactionType | TombstoneTag,
-  ...args: string[]
-): string {
-  return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
-}
-
 function shouldSkipCurrency(
   transactionsRequest: TransactionsRequest | undefined,
   currency: string,
@@ -219,29 +199,22 @@ export async function getTransactionById(
           }),
         );
 
+        const download = await expectProposalDownload(purchase);
+
         const cleanRefunds = filteredRefunds.filter(
           (x): x is WalletRefundItem => !!x,
         );
 
-        const contractData = purchase.download.contractData;
+        const contractData = download.contractData;
         const refunds = mergeRefundByExecutionTime(
           cleanRefunds,
           Amounts.getZero(contractData.amount.currency),
         );
 
         const payOpId = RetryTags.forPay(purchase);
-        const refundQueryOpId = RetryTags.forRefundQuery(purchase);
         const payRetryRecord = await tx.operationRetries.get(payOpId);
-        const refundQueryRetryRecord = await tx.operationRetries.get(
-          refundQueryOpId,
-        );
-
-        const err =
-          payRetryRecord !== undefined
-            ? payRetryRecord
-            : refundQueryRetryRecord;
 
-        return buildTransactionForPurchase(purchase, refunds, err);
+        return buildTransactionForPurchase(purchase, refunds, payRetryRecord);
       });
   } else if (type === TransactionType.Refresh) {
     const refreshGroupId = rest[0];
@@ -295,23 +268,14 @@ export async function getTransactionById(
           ),
         );
         if (t) throw Error("deleted");
-
-        const contractData = purchase.download.contractData;
+        const download = await expectProposalDownload(purchase);
+        const contractData = download.contractData;
         const refunds = mergeRefundByExecutionTime(
           [theRefund],
           Amounts.getZero(contractData.amount.currency),
         );
 
-        const refundQueryOpId = RetryTags.forRefundQuery(purchase);
-        const refundQueryRetryRecord = await tx.operationRetries.get(
-          refundQueryOpId,
-        );
-
-        return buildTransactionForRefund(
-          purchase,
-          refunds[0],
-          refundQueryRetryRecord,
-        );
+        return buildTransactionForRefund(purchase, refunds[0], undefined);
       });
   } else if (type === TransactionType.PeerPullDebit) {
     const peerPullPaymentIncomingId = rest[0];
@@ -606,12 +570,13 @@ function mergeRefundByExecutionTime(
   return Array.from(refundByExecTime.values());
 }
 
-function buildTransactionForRefund(
+async function buildTransactionForRefund(
   purchaseRecord: PurchaseRecord,
   refundInfo: MergedRefundInfo,
   ort?: OperationRetryRecord,
-): Transaction {
-  const contractData = purchaseRecord.download.contractData;
+): Promise<Transaction> {
+  const download = await expectProposalDownload(purchaseRecord);
+  const contractData = download.contractData;
 
   const info: OrderShortInfo = {
     merchant: contractData.merchant,
@@ -641,21 +606,22 @@ function buildTransactionForRefund(
     amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective),
     amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw),
     refundPending:
-      purchaseRecord.refundAwaiting === undefined
+      purchaseRecord.refundAmountAwaiting === undefined
         ? undefined
-        : Amounts.stringify(purchaseRecord.refundAwaiting),
+        : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
     pending: false,
     frozen: false,
     ...(ort?.lastError ? { error: ort.lastError } : {}),
   };
 }
 
-function buildTransactionForPurchase(
+async function buildTransactionForPurchase(
   purchaseRecord: PurchaseRecord,
   refundsInfo: MergedRefundInfo[],
   ort?: OperationRetryRecord,
-): Transaction {
-  const contractData = purchaseRecord.download.contractData;
+): Promise<Transaction> {
+  const download = await expectProposalDownload(purchaseRecord);
+  const contractData = download.contractData;
   const zero = Amounts.getZero(contractData.amount.currency);
 
   const info: OrderShortInfo = {
@@ -696,31 +662,34 @@ function buildTransactionForPurchase(
     ),
   }));
 
+  const timestamp = purchaseRecord.timestampAccept;
+  checkDbInvariant(!!timestamp);
+  checkDbInvariant(!!purchaseRecord.payInfo);
+
   return {
     type: TransactionType.Payment,
     amountRaw: Amounts.stringify(contractData.amount),
-    amountEffective: Amounts.stringify(purchaseRecord.totalPayCost),
+    amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
     totalRefundRaw: Amounts.stringify(totalRefund.raw),
     totalRefundEffective: Amounts.stringify(totalRefund.effective),
     refundPending:
-      purchaseRecord.refundAwaiting === undefined
+      purchaseRecord.refundAmountAwaiting === undefined
         ? undefined
-        : Amounts.stringify(purchaseRecord.refundAwaiting),
+        : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
     status: purchaseRecord.timestampFirstSuccessfulPay
       ? PaymentStatus.Paid
       : PaymentStatus.Accepted,
-    pending:
-      !purchaseRecord.timestampFirstSuccessfulPay &&
-      purchaseRecord.abortStatus === AbortStatus.None,
+    pending: purchaseRecord.status === ProposalStatus.Paying,
     refunds,
-    timestamp: purchaseRecord.timestampAccept,
+    timestamp,
     transactionId: makeEventId(
       TransactionType.Payment,
       purchaseRecord.proposalId,
     ),
     proposalId: purchaseRecord.proposalId,
     info,
-    frozen: purchaseRecord.payFrozen ?? false,
+    frozen:
+      purchaseRecord.status === ProposalStatus.PaymentAbortFinished ?? false,
     ...(ort?.lastError ? { error: ort.lastError } : {}),
   };
 }
@@ -745,7 +714,6 @@ export async function getTransactions(
       x.peerPullPaymentIncoming,
       x.peerPushPaymentInitiations,
       x.planchets,
-      x.proposals,
       x.purchases,
       x.recoupGroups,
       x.tips,
@@ -838,30 +806,33 @@ export async function getTransactions(
         transactions.push(buildTransactionForDeposit(dg, retryRecord));
       });
 
-      tx.purchases.iter().forEachAsync(async (pr) => {
+      tx.purchases.iter().forEachAsync(async (purchase) => {
+        const download = purchase.download;
+        if (!download) {
+          return;
+        }
+        if (!purchase.payInfo) {
+          return;
+        }
         if (
           shouldSkipCurrency(
             transactionsRequest,
-            pr.download.contractData.amount.currency,
+            download.contractData.amount.currency,
           )
         ) {
           return;
         }
-        const contractData = pr.download.contractData;
+        const contractData = download.contractData;
         if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
           return;
         }
-        const proposal = await tx.proposals.get(pr.proposalId);
-        if (!proposal) {
-          return;
-        }
 
         const filteredRefunds = await Promise.all(
-          Object.values(pr.refunds).map(async (r) => {
+          Object.values(purchase.refunds).map(async (r) => {
             const t = await tx.tombstones.get(
               makeEventId(
                 TombstoneTag.DeleteRefund,
-                pr.proposalId,
+                purchase.proposalId,
                 `${r.executionTime.t_s}`,
               ),
             );
@@ -880,29 +851,16 @@ export async function getTransactions(
         );
 
         refunds.forEach(async (refundInfo) => {
-          const refundQueryOpId = RetryTags.forRefundQuery(pr);
-          const refundQueryRetryRecord = await tx.operationRetries.get(
-            refundQueryOpId,
-          );
-
           transactions.push(
-            buildTransactionForRefund(pr, refundInfo, refundQueryRetryRecord),
+            await buildTransactionForRefund(purchase, refundInfo, undefined),
           );
         });
 
-        const payOpId = RetryTags.forPay(pr);
-        const refundQueryOpId = RetryTags.forRefundQuery(pr);
+        const payOpId = RetryTags.forPay(purchase);
         const payRetryRecord = await tx.operationRetries.get(payOpId);
-        const refundQueryRetryRecord = await tx.operationRetries.get(
-          refundQueryOpId,
+        transactions.push(
+          await buildTransactionForPurchase(purchase, refunds, payRetryRecord),
         );
-
-        const err =
-          payRetryRecord !== undefined
-            ? payRetryRecord
-            : refundQueryRetryRecord;
-
-        transactions.push(buildTransactionForPurchase(pr, refunds, err));
       });
 
       tx.tips.iter().forEachAsync(async (tipRecord) => {
@@ -1020,14 +978,9 @@ export async function deleteTransaction(
   } else if (type === TransactionType.Payment) {
     const proposalId = rest[0];
     await ws.db
-      .mktx((x) => [x.proposals, x.purchases, x.tombstones])
+      .mktx((x) => [x.purchases, x.tombstones])
       .runReadWrite(async (tx) => {
         let found = false;
-        const proposal = await tx.proposals.get(proposalId);
-        if (proposal) {
-          found = true;
-          await tx.proposals.delete(proposalId);
-        }
         const purchase = await tx.purchases.get(proposalId);
         if (purchase) {
           found = true;
@@ -1083,7 +1036,7 @@ export async function deleteTransaction(
     const executionTimeStr = rest[1];
 
     await ws.db
-      .mktx((x) => [x.proposals, x.purchases, x.tombstones])
+      .mktx((x) => [x.purchases, x.tombstones])
       .runReadWrite(async (tx) => {
         const purchase = await tx.purchases.get(proposalId);
         if (purchase) {
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts 
b/packages/taler-wallet-core/src/operations/withdraw.ts
index fb5e2c70a..3c2541e9a 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -70,12 +70,11 @@ import {
   DenomSelectionState,
   ExchangeDetailsRecord,
   ExchangeRecord,
-  OperationStatus,
   PlanchetRecord,
-  WithdrawalGroupStatus,
   WalletStoresV1,
   WgInfo,
   WithdrawalGroupRecord,
+  WithdrawalGroupStatus,
   WithdrawalRecordType,
 } from "../db.js";
 import {
@@ -84,7 +83,10 @@ import {
   TalerError,
 } from "../errors.js";
 import { InternalWalletState } from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
+import {
+  makeCoinAvailable,
+  runOperationWithErrorReporting,
+} from "../operations/common.js";
 import { walletCoreDebugFlags } from "../util/debugFlags.js";
 import {
   HttpRequestLibrary,
@@ -108,18 +110,16 @@ import {
   WALLET_EXCHANGE_PROTOCOL_VERSION,
 } from "../versions.js";
 import {
-  makeCoinAvailable,
-  runOperationWithErrorReporting,
+  makeEventId,
   storeOperationError,
   storeOperationPending,
-} from "../wallet.js";
+} from "./common.js";
 import {
   getExchangeDetails,
   getExchangePaytoUri,
   getExchangeTrust,
   updateExchangeFromUrl,
 } from "./exchanges.js";
-import { makeEventId } from "./transactions.js";
 
 /**
  * Logger for this file.
diff --git a/packages/taler-wallet-core/src/pending-types.ts 
b/packages/taler-wallet-core/src/pending-types.ts
index 5e0000b53..862bbf4f9 100644
--- a/packages/taler-wallet-core/src/pending-types.ts
+++ b/packages/taler-wallet-core/src/pending-types.ts
@@ -34,11 +34,9 @@ import { RetryInfo } from "./util/retries.js";
 export enum PendingTaskType {
   ExchangeUpdate = "exchange-update",
   ExchangeCheckRefresh = "exchange-check-refresh",
-  Pay = "pay",
-  ProposalDownload = "proposal-download",
+  Purchase = "purchase",
   Refresh = "refresh",
   Recoup = "recoup",
-  RefundQuery = "refund-query",
   TipPickup = "tip-pickup",
   Withdraw = "withdraw",
   Deposit = "deposit",
@@ -52,10 +50,8 @@ export type PendingTaskInfo = PendingTaskInfoCommon &
   (
     | PendingExchangeUpdateTask
     | PendingExchangeCheckRefreshTask
-    | PendingPayTask
-    | PendingProposalDownloadTask
+    | PendingPurchaseTask
     | PendingRefreshTask
-    | PendingRefundQueryTask
     | PendingTipPickupTask
     | PendingWithdrawTask
     | PendingRecoupTask
@@ -109,19 +105,6 @@ export interface PendingRefreshTask {
   retryInfo?: RetryInfo;
 }
 
-/**
- * Status of downloading signed contract terms from a merchant.
- */
-export interface PendingProposalDownloadTask {
-  type: PendingTaskType.ProposalDownload;
-  merchantBaseUrl: string;
-  proposalTimestamp: TalerProtocolTimestamp;
-  proposalId: string;
-  orderId: string;
-  lastError?: TalerErrorDetail;
-  retryInfo?: RetryInfo;
-}
-
 /**
  * The wallet is picking up a tip that the user has accepted.
  */
@@ -133,25 +116,16 @@ export interface PendingTipPickupTask {
 }
 
 /**
- * The wallet is signing coins and then sending them to
- * the merchant.
+ * A purchase needs to be processed (i.e. for download / payment / refund).
  */
-export interface PendingPayTask {
-  type: PendingTaskType.Pay;
-  proposalId: string;
-  isReplay: boolean;
-  retryInfo?: RetryInfo;
-  lastError: TalerErrorDetail | undefined;
-}
-
-/**
- * The wallet is querying the merchant about whether any refund
- * permissions are available for a purchase.
- */
-export interface PendingRefundQueryTask {
-  type: PendingTaskType.RefundQuery;
+export interface PendingPurchaseTask {
+  type: PendingTaskType.Purchase;
   proposalId: string;
   retryInfo?: RetryInfo;
+  /**
+   * Status of the payment as string, used only for debugging.
+   */
+  statusStr: string;
   lastError: TalerErrorDetail | undefined;
 }
 
diff --git a/packages/taler-wallet-core/src/util/retries.ts 
b/packages/taler-wallet-core/src/util/retries.ts
index cef9e072c..697d6531e 100644
--- a/packages/taler-wallet-core/src/util/retries.ts
+++ b/packages/taler-wallet-core/src/util/retries.ts
@@ -30,7 +30,6 @@ import {
   BackupProviderRecord,
   DepositGroupRecord,
   ExchangeRecord,
-  ProposalRecord,
   PurchaseRecord,
   RecoupGroupRecord,
   RefreshGroupRecord,
@@ -181,9 +180,6 @@ export namespace RetryTags {
   export function forExchangeCheckRefresh(exch: ExchangeRecord): string {
     return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}`;
   }
-  export function forProposalClaim(pr: ProposalRecord): string {
-    return `${PendingTaskType.ProposalDownload}:${pr.proposalId}`;
-  }
   export function forTipPickup(tipRecord: TipRecord): string {
     return `${PendingTaskType.TipPickup}:${tipRecord.walletTipId}`;
   }
@@ -191,10 +187,7 @@ export namespace RetryTags {
     return `${PendingTaskType.TipPickup}:${refreshGroupRecord.refreshGroupId}`;
   }
   export function forPay(purchaseRecord: PurchaseRecord): string {
-    return `${PendingTaskType.Pay}:${purchaseRecord.proposalId}`;
-  }
-  export function forRefundQuery(purchaseRecord: PurchaseRecord): string {
-    return `${PendingTaskType.RefundQuery}:${purchaseRecord.proposalId}`;
+    return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}`;
   }
   export function forRecoup(recoupRecord: RecoupGroupRecord): string {
     return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}`;
@@ -206,7 +199,7 @@ export namespace RetryTags {
     return `${PendingTaskType.Backup}:${backupRecord.baseUrl}`;
   }
   export function byPaymentProposalId(proposalId: string): string {
-    return `${PendingTaskType.Pay}:${proposalId}`;
+    return `${PendingTaskType.Purchase}:${proposalId}`;
   }
 }
 
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index aa3810035..07dd1fcda 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -26,7 +26,6 @@ import {
   AbsoluteTime,
   AmountJson,
   Amounts,
-  BalancesResponse,
   codecForAbortPayWithRefundRequest,
   codecForAcceptBankIntegratedWithdrawalRequest,
   codecForAcceptExchangeTosRequest,
@@ -35,6 +34,7 @@ import {
   codecForAcceptPeerPushPaymentRequest,
   codecForAcceptTipRequest,
   codecForAddExchangeRequest,
+  codecForAddKnownBankAccounts,
   codecForAny,
   codecForApplyRefundFromPurchaseIdRequest,
   codecForApplyRefundRequest,
@@ -44,6 +44,7 @@ import {
   codecForCreateDepositGroupRequest,
   codecForDeleteTransactionRequest,
   codecForForceRefreshRequest,
+  codecForForgetKnownBankAccounts,
   codecForGetContractTermsDetails,
   codecForGetExchangeTosRequest,
   codecForGetExchangeWithdrawalInfo,
@@ -81,6 +82,7 @@ import {
   GetExchangeTosResult,
   j2s,
   KnownBankAccounts,
+  KnownBankAccountsInfo,
   Logger,
   ManualWithdrawalDetails,
   NotificationType,
@@ -89,9 +91,6 @@ import {
   RefreshReason,
   TalerErrorCode,
   TalerErrorDetail,
-  KnownBankAccountsInfo,
-  codecForAddKnownBankAccounts,
-  codecForForgetKnownBankAccounts,
   URL,
   WalletCoreVersion,
   WalletNotification,
@@ -125,6 +124,7 @@ import {
   MerchantOperations,
   NotificationListener,
   RecoupOperations,
+  RefreshOperations,
 } from "./internal-wallet-state.js";
 import { exportBackup } from "./operations/backup/export.js";
 import {
@@ -142,6 +142,11 @@ import {
 } from "./operations/backup/index.js";
 import { setWalletDeviceId } from "./operations/backup/state.js";
 import { getBalances } from "./operations/balance.js";
+import {
+  runOperationWithErrorReporting,
+  storeOperationError,
+  storeOperationPending,
+} from "./operations/common.js";
 import {
   createDepositGroup,
   getFeeForDeposit,
@@ -162,12 +167,15 @@ import {
 } from "./operations/exchanges.js";
 import { getMerchantInfo } from "./operations/merchants.js";
 import {
+  abortFailedPayWithRefund,
+  applyRefund,
+  applyRefundFromPurchaseId,
   confirmPay,
   getContractTermsDetails,
   preparePayForUri,
-  processDownloadProposal,
-  processPurchasePay,
-} from "./operations/pay.js";
+  prepareRefund,
+  processPurchase,
+} from "./operations/pay-merchant.js";
 import {
   acceptPeerPullPayment,
   acceptPeerPushPayment,
@@ -175,7 +183,7 @@ import {
   checkPeerPushPayment,
   initiatePeerRequestForPay,
   initiatePeerToPeerPush,
-} from "./operations/peer-to-peer.js";
+} from "./operations/pay-peer.js";
 import { getPendingOperations } from "./operations/pending.js";
 import {
   createRecoupGroup,
@@ -187,13 +195,6 @@ import {
   createRefreshGroup,
   processRefreshGroup,
 } from "./operations/refresh.js";
-import {
-  abortFailedPayWithRefund,
-  applyRefund,
-  applyRefundFromPurchaseId,
-  prepareRefund,
-  processPurchaseQueryRefund,
-} from "./operations/refund.js";
 import {
   runIntegrationTest,
   testPay,
@@ -213,13 +214,8 @@ import {
   getWithdrawalDetailsForUri,
   processWithdrawalGroup,
 } from "./operations/withdraw.js";
-import {
-  PendingOperationsResponse,
-  PendingTaskInfo,
-  PendingTaskType,
-} from "./pending-types.js";
+import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
 import { assertUnreachable } from "./util/assertUnreachable.js";
-import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
 import { createDenominationTimeline } from "./util/denominations.js";
 import {
   HttpRequestLibrary,
@@ -306,18 +302,10 @@ async function callOperationHandler(
       return await processWithdrawalGroup(ws, pending.withdrawalGroupId, {
         forceNow,
       });
-    case PendingTaskType.ProposalDownload:
-      return await processDownloadProposal(ws, pending.proposalId, {
-        forceNow,
-      });
     case PendingTaskType.TipPickup:
       return await processTip(ws, pending.tipId, { forceNow });
-    case PendingTaskType.Pay:
-      return await processPurchasePay(ws, pending.proposalId, { forceNow });
-    case PendingTaskType.RefundQuery:
-      return await processPurchaseQueryRefund(ws, pending.proposalId, {
-        forceNow,
-      });
+    case PendingTaskType.Purchase:
+      return await processPurchase(ws, pending.proposalId, { forceNow });
     case PendingTaskType.Recoup:
       return await processRecoupGroupHandler(ws, pending.recoupGroupId, {
         forceNow,
@@ -337,111 +325,6 @@ async function callOperationHandler(
   throw Error(`not reached ${pending.type}`);
 }
 
-export async function storeOperationError(
-  ws: InternalWalletState,
-  pendingTaskId: string,
-  e: TalerErrorDetail,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => [x.operationRetries])
-    .runReadWrite(async (tx) => {
-      let retryRecord = await tx.operationRetries.get(pendingTaskId);
-      if (!retryRecord) {
-        retryRecord = {
-          id: pendingTaskId,
-          lastError: e,
-          retryInfo: RetryInfo.reset(),
-        };
-      } else {
-        retryRecord.lastError = e;
-        retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
-      }
-      await tx.operationRetries.put(retryRecord);
-    });
-}
-
-export async function storeOperationFinished(
-  ws: InternalWalletState,
-  pendingTaskId: string,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => [x.operationRetries])
-    .runReadWrite(async (tx) => {
-      await tx.operationRetries.delete(pendingTaskId);
-    });
-}
-
-export async function storeOperationPending(
-  ws: InternalWalletState,
-  pendingTaskId: string,
-): Promise<void> {
-  await ws.db
-    .mktx((x) => [x.operationRetries])
-    .runReadWrite(async (tx) => {
-      let retryRecord = await tx.operationRetries.get(pendingTaskId);
-      if (!retryRecord) {
-        retryRecord = {
-          id: pendingTaskId,
-          retryInfo: RetryInfo.reset(),
-        };
-      } else {
-        delete retryRecord.lastError;
-        retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
-      }
-      await tx.operationRetries.put(retryRecord);
-    });
-}
-
-export async function runOperationWithErrorReporting(
-  ws: InternalWalletState,
-  opId: string,
-  f: () => Promise<OperationAttemptResult>,
-): Promise<void> {
-  let maybeError: TalerErrorDetail | undefined;
-  try {
-    const resp = await f();
-    switch (resp.type) {
-      case OperationAttemptResultType.Error:
-        return await storeOperationError(ws, opId, resp.errorDetail);
-      case OperationAttemptResultType.Finished:
-        return await storeOperationFinished(ws, opId);
-      case OperationAttemptResultType.Pending:
-        return await storeOperationPending(ws, opId);
-      case OperationAttemptResultType.Longpoll:
-        break;
-    }
-  } catch (e) {
-    if (e instanceof TalerError) {
-      logger.warn("operation processed resulted in error");
-      logger.warn(`error was: ${j2s(e.errorDetail)}`);
-      maybeError = e.errorDetail;
-      return await storeOperationError(ws, opId, maybeError!);
-    } else if (e instanceof Error) {
-      // This is a bug, as we expect pending operations to always
-      // do their own error handling and only throw 
WALLET_PENDING_OPERATION_FAILED
-      // or return something.
-      logger.error(`Uncaught exception: ${e.message}`);
-      logger.error(`Stack: ${e.stack}`);
-      maybeError = makeErrorDetail(
-        TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
-        {
-          stack: e.stack,
-        },
-        `unexpected exception (message: ${e.message})`,
-      );
-      return await storeOperationError(ws, opId, maybeError);
-    } else {
-      logger.error("Uncaught exception, value is not even an error.");
-      maybeError = makeErrorDetail(
-        TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
-        {},
-        `unexpected exception (not even an error)`,
-      );
-      return await storeOperationError(ws, opId, maybeError);
-    }
-  }
-}
-
 /**
  * Process pending operations.
  */
@@ -857,120 +740,6 @@ async function getExchangeDetailedInfo(
   };
 }
 
-export async function makeCoinAvailable(
-  ws: InternalWalletState,
-  tx: GetReadWriteAccess<{
-    coins: typeof WalletStoresV1.coins;
-    coinAvailability: typeof WalletStoresV1.coinAvailability;
-    denominations: typeof WalletStoresV1.denominations;
-  }>,
-  coinRecord: CoinRecord,
-): Promise<void> {
-  checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
-  const existingCoin = await tx.coins.get(coinRecord.coinPub);
-  if (existingCoin) {
-    return;
-  }
-  const denom = await tx.denominations.get([
-    coinRecord.exchangeBaseUrl,
-    coinRecord.denomPubHash,
-  ]);
-  checkDbInvariant(!!denom);
-  const ageRestriction = coinRecord.maxAge;
-  let car = await tx.coinAvailability.get([
-    coinRecord.exchangeBaseUrl,
-    coinRecord.denomPubHash,
-    ageRestriction,
-  ]);
-  if (!car) {
-    car = {
-      maxAge: ageRestriction,
-      amountFrac: denom.amountFrac,
-      amountVal: denom.amountVal,
-      currency: denom.currency,
-      denomPubHash: denom.denomPubHash,
-      exchangeBaseUrl: denom.exchangeBaseUrl,
-      freshCoinCount: 0,
-    };
-  }
-  car.freshCoinCount++;
-  await tx.coins.put(coinRecord);
-  await tx.coinAvailability.put(car);
-}
-
-export interface CoinsSpendInfo {
-  coinPubs: string[];
-  contributions: AmountJson[];
-  refreshReason: RefreshReason;
-  /**
-   * Identifier for what the coin has been spent for.
-   */
-  allocationId: string;
-}
-
-export async function spendCoins(
-  ws: InternalWalletState,
-  tx: GetReadWriteAccess<{
-    coins: typeof WalletStoresV1.coins;
-    coinAvailability: typeof WalletStoresV1.coinAvailability;
-    refreshGroups: typeof WalletStoresV1.refreshGroups;
-    denominations: typeof WalletStoresV1.denominations;
-  }>,
-  csi: CoinsSpendInfo,
-): Promise<void> {
-  for (let i = 0; i < csi.coinPubs.length; i++) {
-    const coin = await tx.coins.get(csi.coinPubs[i]);
-    if (!coin) {
-      throw Error("coin allocated for payment doesn't exist anymore");
-    }
-    const coinAvailability = await tx.coinAvailability.get([
-      coin.exchangeBaseUrl,
-      coin.denomPubHash,
-      coin.maxAge,
-    ]);
-    checkDbInvariant(!!coinAvailability);
-    const contrib = csi.contributions[i];
-    if (coin.status !== CoinStatus.Fresh) {
-      const alloc = coin.allocation;
-      if (!alloc) {
-        continue;
-      }
-      if (alloc.id !== csi.allocationId) {
-        // FIXME: assign error code
-        throw Error("conflicting coin allocation (id)");
-      }
-      if (0 !== Amounts.cmp(alloc.amount, contrib)) {
-        // FIXME: assign error code
-        throw Error("conflicting coin allocation (contrib)");
-      }
-      continue;
-    }
-    coin.status = CoinStatus.Dormant;
-    coin.allocation = {
-      id: csi.allocationId,
-      amount: Amounts.stringify(contrib),
-    };
-    const remaining = Amounts.sub(coin.currentAmount, contrib);
-    if (remaining.saturated) {
-      throw Error("not enough remaining balance on coin for payment");
-    }
-    coin.currentAmount = remaining.amount;
-    checkDbInvariant(!!coinAvailability);
-    if (coinAvailability.freshCoinCount === 0) {
-      throw Error(
-        `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
-      );
-    }
-    coinAvailability.freshCoinCount--;
-    await tx.coins.put(coin);
-    await tx.coinAvailability.put(coinAvailability);
-  }
-  const refreshCoinPubs = csi.coinPubs.map((x) => ({
-    coinPub: x,
-  }));
-  await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.PayMerchant);
-}
-
 async function setCoinSuspended(
   ws: InternalWalletState,
   coinPub: string,
@@ -1649,6 +1418,10 @@ class InternalWalletStateImpl implements 
InternalWalletState {
     getMerchantInfo,
   };
 
+  refreshOps: RefreshOperations = {
+    createRefreshGroup,
+  };
+
   // FIXME: Use an LRU cache here.
   private denomCache: Record<string, DenominationInfo> = {};
 

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]