gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] branch master updated (68ca4600 -> 75e73c11)


From: gnunet
Subject: [taler-wallet-core] branch master updated (68ca4600 -> 75e73c11)
Date: Tue, 08 Sep 2020 22:58:07 +0200

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

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

    from 68ca4600 missing file
     new 67df550b implement payment aborts with integration test
     new 7d9c98a9 include proposal ID, to allow aborting
     new 75e73c11 integration test file

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../taler-integrationtests/src/faultInjection.ts   |   2 +-
 packages/taler-integrationtests/src/harness.ts     |  10 +
 packages/taler-integrationtests/src/helpers.ts     |   5 +-
 .../{test-payment-fault.ts => test-pay-abort.ts}   | 128 +++++++------
 .../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                 |  13 +-
 packages/taler-wallet-core/src/types/dbTypes.ts    |  18 +-
 packages/taler-wallet-core/src/types/talerTypes.ts | 108 ++++++++++-
 .../taler-wallet-core/src/types/transactions.ts    |   5 +
 .../taler-wallet-core/src/types/walletTypes.ts     |   8 +
 packages/taler-wallet-core/src/wallet.ts           |  17 +-
 14 files changed, 473 insertions(+), 186 deletions(-)
 copy packages/taler-integrationtests/src/{test-payment-fault.ts => 
test-pay-abort.ts} (61%)

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-payment-fault.ts 
b/packages/taler-integrationtests/src/test-pay-abort.ts
similarity index 61%
copy from packages/taler-integrationtests/src/test-payment-fault.ts
copy to packages/taler-integrationtests/src/test-pay-abort.ts
index 8a8678a2..1d01c6f7 100644
--- a/packages/taler-integrationtests/src/test-payment-fault.ts
+++ b/packages/taler-integrationtests/src/test-pay-abort.ts
@@ -15,7 +15,8 @@
  */
 
 /**
- * Sample fault injection test.
+ * Fault injection test to check aborting partial payment
+ * via refunds.
  */
 
 /**
@@ -38,8 +39,13 @@ import {
   FaultInjectionRequestContext,
   FaultInjectionResponseContext,
 } from "./faultInjection";
-import { CoreApiResponse } from "taler-wallet-core";
+import {
+  PreparePayResultType,
+  URL,
+  TalerErrorCode,
+} from "taler-wallet-core";
 import { defaultCoinConfig } from "./denomStructures";
+import { withdrawViaBank, makeTestPayment } from "./helpers";
 
 /**
  * Run test for basic, bank-integrated withdrawal.
@@ -82,16 +88,6 @@ runTest(async (t: GlobalTestState) => {
 
   const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
 
-  // Print all requests to the exchange
-  faultyExchange.faultProxy.addFault({
-    modifyRequest(ctx: FaultInjectionRequestContext) {
-      console.log("got request", ctx);
-    },
-    modifyResponse(ctx: FaultInjectionResponseContext) {
-      console.log("got response", ctx);
-    },
-  });
-
   const merchant = await MerchantService.create(t, {
     name: "testmerchant-1",
     currency: "TESTKUDOS",
@@ -116,46 +112,23 @@ runTest(async (t: GlobalTestState) => {
 
   // Create withdrawal operation
 
-  const user = await BankApi.createRandomBankUser(bank);
-  const wop = await BankAccessApi.createWithdrawalOperation(
+  await withdrawViaBank(t, {
+    wallet,
+    exchange: faultyExchange,
+    amount: "TESTKUDOS:20",
     bank,
-    user,
-    "TESTKUDOS:20",
-  );
-
-  // Hand it to the wallet
-
-  const r1 = await wallet.apiRequest("getWithdrawalDetailsForUri", {
-    talerWithdrawUri: wop.taler_withdraw_uri,
   });
-  t.assertTrue(r1.type === "response");
-
-  await wallet.runPending();
 
-  // Confirm it
-
-  await BankApi.confirmWithdrawalOperation(bank, user, wop);
-
-  // Withdraw
-
-  const r2 = await wallet.apiRequest("acceptBankIntegratedWithdrawal", {
-    exchangeBaseUrl: faultyExchange.baseUrl,
-    talerWithdrawUri: wop.taler_withdraw_uri,
-  });
-  t.assertTrue(r2.type === "response");
-  await wallet.runUntilDone();
-
-  // Check balance
-
-  const balApiResp = await wallet.apiRequest("getBalances", {});
-  t.assertTrue(balApiResp.type === "response");
-
-  // Set up order.
+  // faultyExchange.faultProxy.addFault({
+  //   modifyRequest(ctx: FaultInjectionRequestContext) {
+  //     console.log("proxy request to", ctx.requestUrl);
+  //   }
+  // });
 
   const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
     order: {
       summary: "Buy me!",
-      amount: "TESTKUDOS:5",
+      amount: "TESTKUDOS:15",
       fulfillment_url: "taler://fulfillment-success/thx",
     },
   });
@@ -168,43 +141,66 @@ runTest(async (t: GlobalTestState) => {
 
   // Make wallet pay for the order
 
-  let apiResp: CoreApiResponse;
-
-  apiResp = await wallet.apiRequest("preparePay", {
+  const preparePayResult = await wallet.preparePay({
     talerPayUri: orderStatus.taler_pay_uri,
   });
-  t.assertTrue(apiResp.type === "response");
 
-  const proposalId = (apiResp.result as any).proposalId;
+  t.assertTrue(
+    preparePayResult.status === PreparePayResultType.PaymentPossible,
+  );
 
-  await wallet.runPending();
+  // We let only the first deposit through!
+  let firstDepositUrl: string | undefined;
 
-  // Drop 10 responses from the exchange.
-  let faultCount = 0;
   faultyExchange.faultProxy.addFault({
+    modifyRequest(ctx: FaultInjectionRequestContext) {
+      const url = new URL(ctx.requestUrl);
+      if (url.pathname.endsWith("/deposit")) {
+        if (!firstDepositUrl) {
+          firstDepositUrl = url.href;
+          return;
+        }
+        if (url.href != firstDepositUrl) {
+          url.pathname = "/doesntexist";
+          ctx.requestUrl = url.href;
+        }
+      }
+    },
     modifyResponse(ctx: FaultInjectionResponseContext) {
-      if (faultCount < 10) {
-        faultCount++;
-        ctx.dropResponse = true;
+      const url = new URL(ctx.request.requestUrl);
+      if (url.pathname.endsWith("/deposit") && url.href != firstDepositUrl) {
+        ctx.responseBody = Buffer.from("{}");
+        ctx.statusCode = 500;
       }
     },
   });
 
-  // confirmPay won't work, as the exchange is unreachable
+  await t.assertThrowsOperationErrorAsync(async () => {
+    await wallet.confirmPay({
+      proposalId: preparePayResult.proposalId,
+    });
+  });
+
+  let txr = await wallet.getTransactions();
+  console.log(JSON.stringify(txr, undefined, 2));
 
-  apiResp = await wallet.apiRequest("confirmPay", {
-    // FIXME: should be validated, don't cast!
-    proposalId: proposalId,
+  t.assertDeepEqual(txr.transactions[1].type, "payment");
+  t.assertDeepEqual(txr.transactions[1].pending, true);
+  t.assertDeepEqual(
+    txr.transactions[1].error?.code,
+    TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+  );
+
+  await wallet.abortFailedPayWithRefund({
+    proposalId: preparePayResult.proposalId,
   });
-  t.assertTrue(apiResp.type === "error");
 
   await wallet.runUntilDone();
 
-  // Check if payment was successful.
+  txr = await wallet.getTransactions();
+  console.log(JSON.stringify(txr, undefined, 2));
 
-  orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
-    orderId: orderResp.order_id,
-  });
+  const txTypes = txr.transactions.map((x) => x.type);
 
-  t.assertTrue(orderStatus.order_status === "paid");
+  t.assertDeepEqual(txTypes, ["withdrawal", "payment", "refund"]);
 });
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..4f318fab 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,9 +243,12 @@ 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,
+          proposalId: pr.proposalId,
           info: info,
           ...(err ? { error: err } : {}),
         });
@@ -324,7 +328,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 +344,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/transactions.ts 
b/packages/taler-wallet-core/src/types/transactions.ts
index 21d7ee18..b9d18944 100644
--- a/packages/taler-wallet-core/src/types/transactions.ts
+++ b/packages/taler-wallet-core/src/types/transactions.ts
@@ -195,6 +195,11 @@ export interface TransactionPayment extends 
TransactionCommon {
    */
   info: OrderShortInfo;
 
+  /**
+   * Wallet-internal end-to-end identifier for the payment.
+   */
+  proposalId: string;
+
   /**
    * How far did the wallet get with processing the payment?
    */
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]