gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] 01/03: implement payment aborts with integration tes


From: gnunet
Subject: [taler-wallet-core] 01/03: implement payment aborts with integration test
Date: Tue, 08 Sep 2020 22:58:08 +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 67df550b4f6d67f8de346985df26133dc8da5c05
Author: Florian Dold <florian.dold@gmail.com>
AuthorDate: Wed Sep 9 02:18:03 2020 +0530

    implement payment aborts with integration test
---
 .../taler-integrationtests/src/faultInjection.ts   |   2 +-
 packages/taler-integrationtests/src/harness.ts     |  10 +
 packages/taler-integrationtests/src/helpers.ts     |   5 +-
 .../taler-integrationtests/src/test-tipping.ts     |   1 -
 packages/taler-wallet-core/src/operations/pay.ts   | 138 +++++++-------
 .../taler-wallet-core/src/operations/pending.ts    |   5 +-
 .../taler-wallet-core/src/operations/refund.ts     | 201 ++++++++++++++++++---
 .../src/operations/transactions.ts                 |  12 +-
 packages/taler-wallet-core/src/types/dbTypes.ts    |  18 +-
 packages/taler-wallet-core/src/types/talerTypes.ts | 108 ++++++++++-
 .../taler-wallet-core/src/types/walletTypes.ts     |   8 +
 packages/taler-wallet-core/src/wallet.ts           |  17 +-
 12 files changed, 405 insertions(+), 120 deletions(-)

diff --git a/packages/taler-integrationtests/src/faultInjection.ts 
b/packages/taler-integrationtests/src/faultInjection.ts
index a85b1dd7..a2d4836d 100644
--- a/packages/taler-integrationtests/src/faultInjection.ts
+++ b/packages/taler-integrationtests/src/faultInjection.ts
@@ -80,7 +80,7 @@ export class FaultProxy {
   start() {
     const server = http.createServer((req, res) => {
       const requestChunks: Buffer[] = [];
-      const requestUrl = 
`http://locahost:${this.faultProxyConfig.inboundPort}${req.url}`;
+      const requestUrl = 
`http://localhost:${this.faultProxyConfig.inboundPort}${req.url}`;
       console.log("request for", new URL(requestUrl));
       req.on("data", (chunk) => {
         requestChunks.push(chunk);
diff --git a/packages/taler-integrationtests/src/harness.ts 
b/packages/taler-integrationtests/src/harness.ts
index b71fe410..a25ee90b 100644
--- a/packages/taler-integrationtests/src/harness.ts
+++ b/packages/taler-integrationtests/src/harness.ts
@@ -76,6 +76,7 @@ import {
   PrepareTipRequest,
   codecForPrepareTipResult,
   AcceptTipRequest,
+  AbortPayWithRefundRequest,
 } from "taler-wallet-core";
 import { URL } from "url";
 import axios, { AxiosError } from "axios";
@@ -1538,6 +1539,15 @@ export class WalletCli {
     throw new OperationFailedError(resp.error);
   }
 
+
+  async abortFailedPayWithRefund(req: AbortPayWithRefundRequest): 
Promise<void> {
+    const resp = await this.apiRequest("abortFailedPayWithRefund", req);
+    if (resp.type === "response") {
+      return;
+    }
+    throw new OperationFailedError(resp.error);
+  }
+
   async confirmPay(req: ConfirmPayRequest): Promise<ConfirmPayResult> {
     const resp = await this.apiRequest("confirmPay", req);
     if (resp.type === "response") {
diff --git a/packages/taler-integrationtests/src/helpers.ts 
b/packages/taler-integrationtests/src/helpers.ts
index bdccdba8..f633ea82 100644
--- a/packages/taler-integrationtests/src/helpers.ts
+++ b/packages/taler-integrationtests/src/helpers.ts
@@ -36,6 +36,7 @@ import {
   BankApi,
   BankAccessApi,
   MerchantPrivateApi,
+  ExchangeServiceInterface,
 } from "./harness";
 import {
   AmountString,
@@ -233,7 +234,7 @@ export async function startWithdrawViaBank(
   p: {
     wallet: WalletCli;
     bank: BankService;
-    exchange: ExchangeService;
+    exchange: ExchangeServiceInterface;
     amount: AmountString;
   },
 ): Promise<void> {
@@ -272,7 +273,7 @@ export async function withdrawViaBank(
   p: {
     wallet: WalletCli;
     bank: BankService;
-    exchange: ExchangeService;
+    exchange: ExchangeServiceInterface;
     amount: AmountString;
   },
 ): Promise<void> {
diff --git a/packages/taler-integrationtests/src/test-tipping.ts 
b/packages/taler-integrationtests/src/test-tipping.ts
index 4c080293..6703ab4b 100644
--- a/packages/taler-integrationtests/src/test-tipping.ts
+++ b/packages/taler-integrationtests/src/test-tipping.ts
@@ -21,7 +21,6 @@ import {
   runTest,
   GlobalTestState,
   MerchantPrivateApi,
-  BankAccessApi,
   BankApi,
 } from "./harness";
 import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
diff --git a/packages/taler-wallet-core/src/operations/pay.ts 
b/packages/taler-wallet-core/src/operations/pay.ts
index 3dc5e160..8dbc2af4 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay.ts
@@ -35,6 +35,7 @@ import {
   CoinRecord,
   DenominationRecord,
   PayCoinSelection,
+  AbortStatus,
 } from "../types/dbTypes";
 import { NotificationType } from "../types/notifications";
 import {
@@ -77,7 +78,11 @@ import {
 } from "../util/http";
 import { TalerErrorCode } from "../TalerErrorCode";
 import { URL } from "../util/url";
-import { initRetryInfo, updateRetryInfoTimeout, getRetryDuration } from 
"../util/retries";
+import {
+  initRetryInfo,
+  updateRetryInfoTimeout,
+  getRetryDuration,
+} from "../util/retries";
 
 /**
  * Logger.
@@ -111,7 +116,6 @@ export interface AvailableCoinInfo {
   feeDeposit: AmountJson;
 }
 
-
 /**
  * Compute the total cost of a payment to the customer.
  *
@@ -429,8 +433,7 @@ async function recordConfirmPay(
   logger.trace(`recording payment with session ID ${sessionId}`);
   const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
   const t: PurchaseRecord = {
-    abortDone: false,
-    abortRequested: false,
+    abortStatus: AbortStatus.None,
     contractTermsRaw: d.contractTermsRaw,
     contractData: d.contractData,
     lastSessionId: sessionId,
@@ -444,7 +447,7 @@ async function recordConfirmPay(
     lastRefundStatusError: undefined,
     payRetryInfo: initRetryInfo(),
     refundStatusRetryInfo: initRetryInfo(),
-    refundStatusRequested: false,
+    refundQueryRequested: false,
     timestampFirstSuccessfulPay: undefined,
     autoRefundDeadline: undefined,
     paymentSubmitPending: true,
@@ -522,6 +525,10 @@ async function incrementProposalRetry(
   }
 }
 
+/**
+ * FIXME: currently pay operations aren't ever automatically retried.
+ * But we still keep a payRetryInfo around in the database.
+ */
 async function incrementPurchasePayRetry(
   ws: InternalWalletState,
   proposalId: string,
@@ -579,7 +586,10 @@ function getProposalRequestTimeout(proposal: 
ProposalRecord): Duration {
 }
 
 function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
-  return durationMul({ d_ms: 5000 }, 1 + 
purchase.payCoinSelection.coinPubs.length / 20);
+  return durationMul(
+    { d_ms: 5000 },
+    1 + purchase.payCoinSelection.coinPubs.length / 20,
+  );
 }
 
 async function processDownloadProposalImpl(
@@ -794,40 +804,37 @@ async function storeFirstPaySuccess(
   paySig: string,
 ): Promise<void> {
   const now = getTimestampNow();
-  await ws.db.runWithWriteTransaction(
-    [Stores.purchases],
-    async (tx) => {
-      const purchase = await tx.get(Stores.purchases, proposalId);
+  await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
+    const purchase = await tx.get(Stores.purchases, proposalId);
 
-      if (!purchase) {
-        logger.warn("purchase does not exist anymore");
-        return;
-      }
-      const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
-      if (!isFirst) {
-        logger.warn("payment success already stored");
-        return;
-      }
-      purchase.timestampFirstSuccessfulPay = now;
-      purchase.paymentSubmitPending = false;
-      purchase.lastPayError = undefined;
-      purchase.lastSessionId = sessionId;
-      purchase.payRetryInfo = initRetryInfo(false);
-      purchase.merchantPaySig = paySig;
-      if (isFirst) {
-        const ar = purchase.contractData.autoRefund;
-        if (ar) {
-          logger.info("auto_refund present");
-          purchase.refundStatusRequested = true;
-          purchase.refundStatusRetryInfo = initRetryInfo();
-          purchase.lastRefundStatusError = undefined;
-          purchase.autoRefundDeadline = timestampAddDuration(now, ar);
-        }
+    if (!purchase) {
+      logger.warn("purchase does not exist anymore");
+      return;
+    }
+    const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+    if (!isFirst) {
+      logger.warn("payment success already stored");
+      return;
+    }
+    purchase.timestampFirstSuccessfulPay = now;
+    purchase.paymentSubmitPending = false;
+    purchase.lastPayError = undefined;
+    purchase.lastSessionId = sessionId;
+    purchase.payRetryInfo = initRetryInfo(false);
+    purchase.merchantPaySig = paySig;
+    if (isFirst) {
+      const ar = purchase.contractData.autoRefund;
+      if (ar) {
+        logger.info("auto_refund present");
+        purchase.refundQueryRequested = true;
+        purchase.refundStatusRetryInfo = initRetryInfo();
+        purchase.lastRefundStatusError = undefined;
+        purchase.autoRefundDeadline = timestampAddDuration(now, ar);
       }
+    }
 
-      await tx.put(Stores.purchases, purchase);
-    },
-  );
+    await tx.put(Stores.purchases, purchase);
+  });
 }
 
 async function storePayReplaySuccess(
@@ -835,26 +842,23 @@ async function storePayReplaySuccess(
   proposalId: string,
   sessionId: string | undefined,
 ): Promise<void> {
-  await ws.db.runWithWriteTransaction(
-    [Stores.purchases],
-    async (tx) => {
-      const purchase = await tx.get(Stores.purchases, proposalId);
+  await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
+    const purchase = await tx.get(Stores.purchases, proposalId);
 
-      if (!purchase) {
-        logger.warn("purchase does not exist anymore");
-        return;
-      }
-      const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
-      if (isFirst) {
-        throw Error("invalid payment state");
-      }
-      purchase.paymentSubmitPending = false;
-      purchase.lastPayError = undefined;
-      purchase.payRetryInfo = initRetryInfo(false);
-      purchase.lastSessionId = sessionId;
-      await tx.put(Stores.purchases, purchase);
-    },
-  );
+    if (!purchase) {
+      logger.warn("purchase does not exist anymore");
+      return;
+    }
+    const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+    if (isFirst) {
+      throw Error("invalid payment state");
+    }
+    purchase.paymentSubmitPending = false;
+    purchase.lastPayError = undefined;
+    purchase.payRetryInfo = initRetryInfo(false);
+    purchase.lastSessionId = sessionId;
+    await tx.put(Stores.purchases, purchase);
+  });
 }
 
 /**
@@ -863,7 +867,7 @@ async function storePayReplaySuccess(
  * If the wallet has previously paid, it just transmits the merchant's
  * own signature certifying that the wallet has previously paid.
  */
-export async function submitPay(
+async function submitPay(
   ws: InternalWalletState,
   proposalId: string,
 ): Promise<ConfirmPayResult> {
@@ -871,7 +875,7 @@ export async function submitPay(
   if (!purchase) {
     throw Error("Purchase not found: " + proposalId);
   }
-  if (purchase.abortRequested) {
+  if (purchase.abortStatus !== AbortStatus.None) {
     throw Error("not submitting payment for aborted purchase");
   }
   const sessionId = purchase.lastSessionId;
@@ -1047,7 +1051,11 @@ export async function preparePayForUri(
       p.lastSessionId = uriResult.sessionId;
       await tx.put(Stores.purchases, p);
     });
-    const r = await submitPay(ws, proposalId);
+    const r = await guardOperationException(
+      () => submitPay(ws, proposalId),
+      (e: TalerErrorDetails): Promise<void> =>
+        incrementPurchasePayRetry(ws, proposalId, e),
+    );
     if (r.type !== ConfirmPayResultType.Done) {
       throw Error("submitting pay failed");
     }
@@ -1125,7 +1133,11 @@ export async function confirmPay(
       });
     }
     logger.trace("confirmPay: submitting payment for existing purchase");
-    return submitPay(ws, proposalId);
+    return await guardOperationException(
+      () => submitPay(ws, proposalId),
+      (e: TalerErrorDetails): Promise<void> =>
+        incrementPurchasePayRetry(ws, proposalId, e),
+    );
   }
 
   logger.trace("confirmPay: purchase record does not exist yet");
@@ -1179,7 +1191,11 @@ export async function confirmPay(
     sessionIdOverride,
   );
 
-  return submitPay(ws, proposalId);
+  return await guardOperationException(
+    () => submitPay(ws, proposalId),
+    (e: TalerErrorDetails): Promise<void> =>
+      incrementPurchasePayRetry(ws, proposalId, e),
+  );
 }
 
 export async function processPurchasePay(
diff --git a/packages/taler-wallet-core/src/operations/pending.ts 
b/packages/taler-wallet-core/src/operations/pending.ts
index 7338ac77..4f6477d5 100644
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ b/packages/taler-wallet-core/src/operations/pending.ts
@@ -22,6 +22,7 @@ import {
   ProposalStatus,
   ReserveRecordStatus,
   Stores,
+  AbortStatus,
 } from "../types/dbTypes";
 import {
   PendingOperationsResponse,
@@ -381,7 +382,7 @@ async function gatherPurchasePending(
   onlyDue = false,
 ): Promise<void> {
   await tx.iter(Stores.purchases).forEach((pr) => {
-    if (pr.paymentSubmitPending) {
+    if (pr.paymentSubmitPending && pr.abortStatus === AbortStatus.None) {
       resp.nextRetryDelay = updateRetryDelay(
         resp.nextRetryDelay,
         now,
@@ -398,7 +399,7 @@ async function gatherPurchasePending(
         });
       }
     }
-    if (pr.refundStatusRequested) {
+    if (pr.refundQueryRequested) {
       resp.nextRetryDelay = updateRetryDelay(
         resp.nextRetryDelay,
         now,
diff --git a/packages/taler-wallet-core/src/operations/refund.ts 
b/packages/taler-wallet-core/src/operations/refund.ts
index e15a27b3..10a57f90 100644
--- a/packages/taler-wallet-core/src/operations/refund.ts
+++ b/packages/taler-wallet-core/src/operations/refund.ts
@@ -36,6 +36,7 @@ import {
   RefundReason,
   RefundState,
   PurchaseRecord,
+  AbortStatus,
 } from "../types/dbTypes";
 import { NotificationType } from "../types/notifications";
 import { parseRefundUri } from "../util/taleruri";
@@ -46,14 +47,25 @@ import {
   MerchantCoinRefundSuccessStatus,
   MerchantCoinRefundFailureStatus,
   codecForMerchantOrderRefundPickupResponse,
+  AbortRequest,
+  AbortingCoin,
+  codecForMerchantAbortPayRefundStatus,
+  codecForAbortResponse,
 } from "../types/talerTypes";
 import { guardOperationException } from "./errors";
-import { getTimestampNow, Timestamp } from "../util/time";
+import {
+  getTimestampNow,
+  Timestamp,
+  durationAdd,
+  timestampAddDuration,
+} from "../util/time";
 import { Logger } from "../util/logging";
 import { readSuccessResponseJsonOrThrow } from "../util/http";
 import { TransactionHandle } from "../util/query";
 import { URL } from "../util/url";
 import { updateRetryInfoTimeout, initRetryInfo } from "../util/retries";
+import { checkDbInvariant } from "../util/invariants";
+import { TalerErrorCode } from "../TalerErrorCode";
 
 const logger = new Logger("refund.ts");
 
@@ -101,7 +113,7 @@ async function applySuccessfulRefund(
   const refundKey = getRefundKey(r);
   const coin = await tx.get(Stores.coins, r.coin_pub);
   if (!coin) {
-    console.warn("coin not found, can't apply refund");
+    logger.warn("coin not found, can't apply refund");
     return;
   }
   const denom = await tx.get(Stores.denominations, [
@@ -158,7 +170,7 @@ async function storePendingRefund(
 
   const coin = await tx.get(Stores.coins, r.coin_pub);
   if (!coin) {
-    console.warn("coin not found, can't apply refund");
+    logger.warn("coin not found, can't apply refund");
     return;
   }
   const denom = await tx.get(Stores.denominations, [
@@ -202,13 +214,14 @@ async function storePendingRefund(
 async function storeFailedRefund(
   tx: TransactionHandle,
   p: PurchaseRecord,
+  refreshCoinsMap: Record<string, { coinPub: string }>,
   r: MerchantCoinRefundFailureStatus,
 ): Promise<void> {
   const refundKey = getRefundKey(r);
 
   const coin = await tx.get(Stores.coins, r.coin_pub);
   if (!coin) {
-    console.warn("coin not found, can't apply refund");
+    logger.warn("coin not found, can't apply refund");
     return;
   }
   const denom = await tx.get(Stores.denominations, [
@@ -247,6 +260,38 @@ async function storeFailedRefund(
     refundFee: denom.feeRefund,
     totalRefreshCostBound,
   };
+
+  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.REFUND_DEPOSIT_NOT_FOUND) {
+      const coin = await tx.get(Stores.coins, r.coin_pub);
+      if (!coin) {
+        logger.warn("coin not found, can't apply refund");
+        return;
+      }
+      const denom = await tx.get(Stores.denominations, [
+        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.feeRefund).amount;
+      }
+      refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
+      await tx.put(Stores.coins, coin);
+    }
+  }
 }
 
 async function acceptRefunds(
@@ -268,7 +313,7 @@ async function acceptRefunds(
     async (tx) => {
       const p = await tx.get(Stores.purchases, proposalId);
       if (!p) {
-        console.error("purchase not found, not adding refunds");
+        logger.error("purchase not found, not adding refunds");
         return;
       }
 
@@ -280,7 +325,7 @@ async function acceptRefunds(
 
         const isPermanentFailure =
           refundStatus.type === "failure" &&
-          refundStatus.exchange_status === 410;
+          refundStatus.exchange_status >= 400 && refundStatus.exchange_status 
< 500 ;
 
         // Already failed.
         if (existingRefundInfo?.type === RefundState.Failed) {
@@ -306,7 +351,7 @@ async function acceptRefunds(
         if (refundStatus.type === "success") {
           await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
         } else if (isPermanentFailure) {
-          await storeFailedRefund(tx, p, refundStatus);
+          await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
         } else {
           await storePendingRefund(tx, p, refundStatus);
         }
@@ -326,7 +371,11 @@ async function acceptRefunds(
       // after a retry delay?
       let queryDone = true;
 
-      if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
+      if (
+        p.timestampFirstSuccessfulPay &&
+        p.autoRefundDeadline &&
+        p.autoRefundDeadline.t_ms > now.t_ms
+      ) {
         queryDone = false;
       }
 
@@ -347,7 +396,10 @@ async function acceptRefunds(
         p.timestampLastRefundStatus = now;
         p.lastRefundStatusError = undefined;
         p.refundStatusRetryInfo = initRetryInfo(false);
-        p.refundStatusRequested = false;
+        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!
@@ -415,7 +467,7 @@ export async function applyRefund(
         logger.error("no purchase found for refund URL");
         return false;
       }
-      p.refundStatusRequested = true;
+      p.refundQueryRequested = true;
       p.lastRefundStatusError = undefined;
       p.refundStatusRetryInfo = initRetryInfo();
       await tx.put(Stores.purchases, p);
@@ -516,32 +568,121 @@ async function processPurchaseQueryRefundImpl(
     return;
   }
 
-  if (!purchase.refundStatusRequested) {
+  if (!purchase.refundQueryRequested) {
     return;
   }
 
-  const requestUrl = new URL(
-    `orders/${purchase.contractData.orderId}/refund`,
-    purchase.contractData.merchantBaseUrl,
-  );
+  if (purchase.timestampFirstSuccessfulPay) {
+    const requestUrl = new URL(
+      `orders/${purchase.contractData.orderId}/refund`,
+      purchase.contractData.merchantBaseUrl,
+    );
 
-  logger.trace(`making refund request to ${requestUrl.href}`);
+    logger.trace(`making refund request to ${requestUrl.href}`);
 
-  const request = await ws.http.postJson(requestUrl.href, {
-    h_contract: purchase.contractData.contractTermsHash,
-  });
+    const request = await ws.http.postJson(requestUrl.href, {
+      h_contract: purchase.contractData.contractTermsHash,
+    });
+
+    logger.trace(
+      "got json",
+      JSON.stringify(await request.json(), undefined, 2),
+    );
 
-  logger.trace("got json", JSON.stringify(await request.json(), undefined, 2));
+    const refundResponse = await readSuccessResponseJsonOrThrow(
+      request,
+      codecForMerchantOrderRefundPickupResponse(),
+    );
 
-  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.contractData.orderId}/abort`,
+      purchase.contractData.merchantBaseUrl,
+    );
 
-  await acceptRefunds(
-    ws,
-    proposalId,
-    refundResponse.refunds,
-    RefundReason.NormalRefund,
-  );
+    const abortingCoins: AbortingCoin[] = [];
+    for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) {
+      const coinPub = purchase.payCoinSelection.coinPubs[i];
+      const coin = await ws.db.get(Stores.coins, 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.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: timestampAddDuration(purchase.contractData.timestamp, {
+          d_ms: 1000,
+        }),
+      });
+    }
+    await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
+  }
+}
+
+export async function abortFailedPayWithRefund(
+  ws: InternalWalletState,
+  proposalId: string,
+): Promise<void> {
+  await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
+    const purchase = await tx.get(Stores.purchases, 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;
+    purchase.lastPayError = undefined;
+    purchase.payRetryInfo = initRetryInfo(false);
+    await tx.put(Stores.purchases, purchase);
+  });
+  processPurchaseQueryRefund(ws, proposalId, true).catch((e) => {
+    logger.trace(`error during refund processing after abort pay: ${e}`);
+  });
 }
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts 
b/packages/taler-wallet-core/src/operations/transactions.ts
index 5bc4ebac..87236d5a 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -23,6 +23,7 @@ import {
   WalletRefundItem,
   RefundState,
   ReserveRecordStatus,
+  AbortStatus,
 } from "../types/dbTypes";
 import { Amounts, AmountJson } from "../util/amounts";
 import { timestampCmp } from "../util/time";
@@ -242,7 +243,9 @@ export async function getTransactions(
           status: pr.timestampFirstSuccessfulPay
             ? PaymentStatus.Paid
             : PaymentStatus.Accepted,
-          pending: !pr.timestampFirstSuccessfulPay,
+          pending:
+            !pr.timestampFirstSuccessfulPay &&
+            pr.abortStatus === AbortStatus.None,
           timestamp: pr.timestampAccept,
           transactionId: paymentTransactionId,
           info: info,
@@ -324,7 +327,10 @@ export async function getTransactions(
           amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
           pending: !tipRecord.pickedUpTimestamp,
           timestamp: tipRecord.acceptedTimestamp,
-          transactionId: makeEventId(TransactionType.Tip, 
tipRecord.walletTipId),
+          transactionId: makeEventId(
+            TransactionType.Tip,
+            tipRecord.walletTipId,
+          ),
           error: tipRecord.lastError,
         });
       });
@@ -337,5 +343,5 @@ export async function getTransactions(
   txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
   txNotPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
 
-  return { transactions: [...txPending, ...txNotPending] };
+  return { transactions: [...txNotPending, ...txPending] };
 }
diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts 
b/packages/taler-wallet-core/src/types/dbTypes.ts
index ff790e21..d10be80c 100644
--- a/packages/taler-wallet-core/src/types/dbTypes.ts
+++ b/packages/taler-wallet-core/src/types/dbTypes.ts
@@ -1285,6 +1285,12 @@ export interface PayCoinSelection {
   customerDepositFees: AmountJson;
 }
 
+export enum AbortStatus {
+  None = "none",
+  AbortRefund = "abort-refund",
+  AbortFinished = "abort-finished",
+}
+
 /**
  * Record that stores status information about one purchase, starting from when
  * the customer accepts a proposal.  Includes refund status if applicable.
@@ -1352,17 +1358,9 @@ export interface PurchaseRecord {
    * Do we need to query the merchant for the refund status
    * of the payment?
    */
-  refundStatusRequested: boolean;
+  refundQueryRequested: boolean;
 
-  /**
-   * An abort (with refund) was requested for this (incomplete!) purchase.
-   */
-  abortRequested: boolean;
-
-  /**
-   * The abort (with refund) was completed for this (incomplete!) purchase.
-   */
-  abortDone: boolean;
+  abortStatus: AbortStatus;
 
   payRetryInfo: RetryInfo;
 
diff --git a/packages/taler-wallet-core/src/types/talerTypes.ts 
b/packages/taler-wallet-core/src/types/talerTypes.ts
index 16d00e2e..ce83080c 100644
--- a/packages/taler-wallet-core/src/types/talerTypes.ts
+++ b/packages/taler-wallet-core/src/types/talerTypes.ts
@@ -1059,7 +1059,6 @@ export const codecForAuditorHandle = (): 
Codec<AuditorHandle> =>
     .property("url", codecForString())
     .build("AuditorHandle");
 
-
 export const codecForLocation = (): Codec<Location> =>
   buildCodecForObject<Location>()
     .property("country", codecOptional(codecForString()))
@@ -1071,7 +1070,7 @@ export const codecForLocation = (): Codec<Location> =>
     .property("post_code", codecOptional(codecForString()))
     .property("town", codecOptional(codecForString()))
     .property("town_location", codecOptional(codecForString()))
-    .property("address_lines", codecOptional(codecForList(codecForString()))) 
+    .property("address_lines", codecOptional(codecForList(codecForString())))
     .build("Location");
 
 export const codecForMerchantInfo = (): Codec<MerchantInfo> =>
@@ -1351,3 +1350,108 @@ export const codecForMerchantOrderStatusUnpaid = (): 
Codec<
     .property("taler_pay_uri", codecForString())
     .property("already_paid_order_id", codecOptional(codecForString()))
     .build("MerchantOrderStatusUnpaid");
+
+export interface AbortRequest {
+  // hash of the order's contract terms (this is used to authenticate the
+  // wallet/customer in case $ORDER_ID is guessable).
+  h_contract: string;
+
+  // List of coins the wallet would like to see refunds for.
+  // (Should be limited to the coins for which the original
+  // payment succeeded, as far as the wallet knows.)
+  coins: AbortingCoin[];
+}
+
+export interface AbortingCoin {
+  // Public key of a coin for which the wallet is requesting an abort-related 
refund.
+  coin_pub: EddsaPublicKeyString;
+
+  // The amount to be refunded (matches the original contribution)
+  contribution: AmountString;
+
+  // URL of the exchange this coin was withdrawn from.
+  exchange_url: string;
+}
+
+export interface AbortResponse {
+  // List of refund responses about the coins that the wallet
+  // requested an abort for.  In the same order as the 'coins'
+  // from the original request.
+  // The rtransaction_id is implied to be 0.
+  refunds: MerchantAbortPayRefundStatus[];
+}
+
+export const codecForAbortResponse = (): Codec<AbortResponse> =>
+  buildCodecForObject<AbortResponse>()
+    .property("refunds", codecForList(codecForMerchantAbortPayRefundStatus()))
+    .build("AbortResponse");
+
+export type MerchantAbortPayRefundStatus =
+  | MerchantAbortPayRefundSuccessStatus
+  | MerchantAbortPayRefundFailureStatus;
+
+// Details about why a refund failed.
+export interface MerchantAbortPayRefundFailureStatus {
+  // Used as tag for the sum type RefundStatus sum type.
+  type: "failure";
+
+  // HTTP status of the exchange request, must NOT be 200.
+  exchange_status: number;
+
+  // Taler error code from the exchange reply, if available.
+  exchange_code?: number;
+
+  // If available, HTTP reply from the exchange.
+  exchange_reply?: unknown;
+}
+
+// Additional details needed to verify the refund confirmation signature
+// (h_contract_terms and merchant_pub) are already known
+// to the wallet and thus not included.
+export interface MerchantAbortPayRefundSuccessStatus {
+  // Used as tag for the sum type MerchantCoinRefundStatus sum type.
+  type: "success";
+
+  // HTTP status of the exchange request, 200 (integer) required for refund 
confirmations.
+  exchange_status: 200;
+
+  // the EdDSA :ref:signature (binary-only) with purpose
+  // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the
+  // exchange affirming the successful refund
+  exchange_sig: string;
+
+  // public EdDSA key of the exchange that was used to generate the signature.
+  // Should match one of the exchange's signing keys from /keys.  It is given
+  // explicitly as the client might otherwise be confused by clock skew as to
+  // which signing key was used.
+  exchange_pub: string;
+}
+
+export const codecForMerchantAbortPayRefundSuccessStatus = (): Codec<
+  MerchantAbortPayRefundSuccessStatus
+> =>
+  buildCodecForObject<MerchantAbortPayRefundSuccessStatus>()
+    .property("exchange_pub", codecForString())
+    .property("exchange_sig", codecForString())
+    .property("exchange_status", codecForConstNumber(200))
+    .property("type", codecForConstString("success"))
+    .build("MerchantAbortPayRefundSuccessStatus");
+
+export const codecForMerchantAbortPayRefundFailureStatus = (): Codec<
+  MerchantAbortPayRefundFailureStatus
+> =>
+  buildCodecForObject<MerchantAbortPayRefundFailureStatus>()
+    .property("exchange_code", codecForNumber())
+    .property("exchange_reply", codecForAny())
+    .property("exchange_status", codecForNumber())
+    .property("type", codecForConstString("failure"))
+    .build("MerchantAbortPayRefundFailureStatus");
+
+export const codecForMerchantAbortPayRefundStatus = (): Codec<
+  MerchantAbortPayRefundStatus
+> =>
+  buildCodecForUnion<MerchantAbortPayRefundStatus>()
+    .discriminateOn("type")
+    .alternative("success", codecForMerchantAbortPayRefundSuccessStatus())
+    .alternative("failure", codecForMerchantAbortPayRefundFailureStatus())
+    .build("MerchantAbortPayRefundStatus");
diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts 
b/packages/taler-wallet-core/src/types/walletTypes.ts
index 5507822f..b8d8be66 100644
--- a/packages/taler-wallet-core/src/types/walletTypes.ts
+++ b/packages/taler-wallet-core/src/types/walletTypes.ts
@@ -932,3 +932,11 @@ export const codecForAcceptTipRequest = (): 
Codec<AcceptTipRequest> =>
     .property("walletTipId", codecForString())
     .build("AcceptTipRequest");
 
+export interface AbortPayWithRefundRequest {
+  proposalId: string;
+}
+
+export const codecForAbortPayWithRefundRequest = (): 
Codec<AbortPayWithRefundRequest> =>
+  buildCodecForObject<AbortPayWithRefundRequest>()
+    .property("proposalId", codecForString())
+    .build("AbortPayWithRefundRequest");
diff --git a/packages/taler-wallet-core/src/wallet.ts 
b/packages/taler-wallet-core/src/wallet.ts
index e91d74ef..768d5eb0 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -94,6 +94,7 @@ import {
   PrepareTipResult,
   codecForPrepareTipRequest,
   codecForAcceptTipRequest,
+  codecForAbortPayWithRefundRequest,
 } from "./types/walletTypes";
 import { Logger } from "./util/logging";
 
@@ -132,7 +133,7 @@ import {
   PendingOperationType,
 } from "./types/pending";
 import { WalletNotification, NotificationType } from "./types/notifications";
-import { processPurchaseQueryRefund, applyRefund } from "./operations/refund";
+import { processPurchaseQueryRefund, applyRefund, abortFailedPayWithRefund } 
from "./operations/refund";
 import { durationMin, Duration } from "./util/time";
 import { processRecoupGroup } from "./operations/recoup";
 import {
@@ -744,8 +745,8 @@ export class Wallet {
     return prepareTip(this.ws, talerTipUri);
   }
 
-  async abortFailedPayment(contractTermsHash: string): Promise<void> {
-    throw Error("not implemented");
+  async abortFailedPayWithRefund(proposalId: string): Promise<void> {
+    return abortFailedPayWithRefund(this.ws, proposalId);
   }
 
   /**
@@ -1022,11 +1023,6 @@ export class Wallet {
         const req = codecForGetExchangeTosRequest().decode(payload);
         return this.getExchangeTos(req.exchangeBaseUrl);
       }
-      case "abortProposal": {
-        const req = codecForAbortProposalRequest().decode(payload);
-        await this.refuseProposal(req.proposalId);
-        return {};
-      }
       case "retryPendingNow": {
         await this.runPending(true);
         return {};
@@ -1039,6 +1035,11 @@ export class Wallet {
         const req = codecForConfirmPayRequest().decode(payload);
         return await this.confirmPay(req.proposalId, req.sessionId);
       }
+      case "abortFailedPayWithRefund": {
+        const req = codecForAbortPayWithRefundRequest().decode(payload);
+        await this.abortFailedPayWithRefund(req.proposalId);
+        return {};
+      }
       case "dumpCoins": {
         return await this.dumpCoins();
       }

-- 
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]