gnunet-svn
[Top][All Lists]
Advanced

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

[taler-wallet-core] branch master updated: wallet-core: fix pay state ma


From: gnunet
Subject: [taler-wallet-core] branch master updated: wallet-core: fix pay state machine when order is deleted
Date: Mon, 15 Jan 2024 19:38:44 +0100

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

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

The following commit(s) were added to refs/heads/master by this push:
     new cc07d767a wallet-core: fix pay state machine when order is deleted
cc07d767a is described below

commit cc07d767abb0c1ba37be92014b06a94d3a3206d9
Author: Florian Dold <florian@dold.me>
AuthorDate: Mon Jan 15 19:38:34 2024 +0100

    wallet-core: fix pay state machine when order is deleted
---
 .../src/integrationtests/test-payment-deleted.ts   | 106 +++++++++++++
 .../src/integrationtests/testrunner.ts             |   2 +
 packages/taler-util/src/MerchantApiClient.ts       |  15 ++
 packages/taler-wallet-core/src/db.ts               |   6 +-
 .../src/operations/pay-merchant.ts                 | 175 ++++++++++++++++-----
 5 files changed, 261 insertions(+), 43 deletions(-)

diff --git 
a/packages/taler-harness/src/integrationtests/test-payment-deleted.ts 
b/packages/taler-harness/src/integrationtests/test-payment-deleted.ts
new file mode 100644
index 000000000..3796c3e2b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-deleted.ts
@@ -0,0 +1,106 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 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/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+  createSimpleTestkudosEnvironmentV2,
+  withdrawViaBankV2,
+  makeTestPaymentV2,
+} from "../harness/helpers.js";
+import {
+  ConfirmPayResultType,
+  MerchantApiClient,
+  PreparePayResultType,
+  TransactionMajorState,
+  j2s,
+} from "@gnu-taler/taler-util";
+
+/**
+ * Test behavior when an order is deleted while the wallet is paying for it.
+ */
+export async function runPaymentDeletedTest(t: GlobalTestState) {
+  // Set up test environment
+
+  const { walletClient, bank, exchange, merchant } =
+    await createSimpleTestkudosEnvironmentV2(t);
+
+  // First, make a "free" payment when we don't even have
+  // any money in the
+
+  // Withdraw digital cash into the wallet.
+  await withdrawViaBankV2(t, {
+    walletClient,
+    bank,
+    exchange,
+    amount: "TESTKUDOS:20",
+  });
+
+  await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+  const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+  const orderResp = await merchantClient.createOrder({
+    order: {
+      summary: "Hello",
+      amount: "TESTKUDOS:2",
+    },
+  });
+
+  let orderStatus = await merchantClient.queryPrivateOrderStatus({
+    orderId: orderResp.order_id,
+  });
+
+  t.assertTrue(orderStatus.order_status === "unpaid");
+
+  // Make wallet pay for the order
+
+  const preparePayResult = await walletClient.call(
+    WalletApiOperation.PreparePayForUri,
+    {
+      talerPayUri: orderStatus.taler_pay_uri,
+    },
+  );
+
+  t.assertTrue(
+    preparePayResult.status === PreparePayResultType.PaymentPossible,
+  );
+
+  await merchantClient.deleteOrder({
+    orderId: orderResp.order_id,
+    force: true,
+  });
+
+  const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+    transactionId: preparePayResult.transactionId,
+  });
+
+  t.assertTrue(r2.type === ConfirmPayResultType.Pending);
+
+  await walletClient.call(WalletApiOperation.AbortTransaction, {
+    transactionId: preparePayResult.transactionId,
+  });
+
+  await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+  const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+  console.log(j2s(bal));
+}
+
+runPaymentDeletedTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts 
b/packages/taler-harness/src/integrationtests/testrunner.ts
index b363e58a9..1d8353acf 100644
--- a/packages/taler-harness/src/integrationtests/testrunner.ts
+++ b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -96,6 +96,7 @@ import { runLibeufinBankTest } from "./test-libeufin-bank.js";
 import { runMultiExchangeTest } from "./test-multiexchange.js";
 import { runAgeRestrictionsDepositTest } from 
"./test-age-restrictions-deposit.js";
 import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js";
+import { runPaymentDeletedTest } from "./test-payment-deleted.js";
 
 /**
  * Test runner.
@@ -181,6 +182,7 @@ const allTests: TestMainFunction[] = [
   runPaymentExpiredTest,
   runWalletGenDbTest,
   runLibeufinBankTest,
+  runPaymentDeletedTest,
 ];
 
 export interface TestRunSpec {
diff --git a/packages/taler-util/src/MerchantApiClient.ts 
b/packages/taler-util/src/MerchantApiClient.ts
index 2e10e394a..fe523cd43 100644
--- a/packages/taler-util/src/MerchantApiClient.ts
+++ b/packages/taler-util/src/MerchantApiClient.ts
@@ -239,6 +239,21 @@ export class MerchantApiClient {
     );
   }
 
+  async deleteOrder(req: { orderId: string; force?: boolean }): Promise<void> {
+    let url = new URL(`private/orders/${req.orderId}`, this.baseUrl);
+    if (req.force) {
+      url.searchParams.set("force", "yes");
+    }
+    const resp = await this.httpClient.fetch(url.href, {
+      method: "DELETE",
+      body: req,
+      headers: this.makeAuthHeader(),
+    });
+    if (resp.status !== 204) {
+      throw Error(`failed to delete order (status ${resp.status})`);
+    }
+  }
+
   async queryPrivateOrderStatus(
     query: PrivateOrderStatusQuery,
   ): Promise<MerchantOrderPrivateStatusResponse> {
diff --git a/packages/taler-wallet-core/src/db.ts 
b/packages/taler-wallet-core/src/db.ts
index 84066aaf0..5a412fb27 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -1179,12 +1179,14 @@ export enum PurchaseStatus {
    */
   AbortedIncompletePayment = 0x0503_0000,
 
+  AbortedRefunded = 0x0503_0001,
+
+  AbortedOrderDeleted = 0x0503_0002,
+
   /**
    * Tried to abort, but aborting failed or was cancelled.
    */
   FailedAbort = 0x0501_0001,
-
-  AbortedRefunded = 0x0503_0000,
 }
 
 /**
diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts 
b/packages/taler-wallet-core/src/operations/pay-merchant.ts
index bc9e94a21..50b73acb7 100644
--- a/packages/taler-wallet-core/src/operations/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts
@@ -119,7 +119,11 @@ import {
 import { assertUnreachable } from "../util/assertUnreachable.js";
 import { PreviousPayCoins, selectPayCoinsNew } from "../util/coinSelection.js";
 import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadOnlyAccess } from "../util/query.js";
+import {
+  DbReadWriteTransactionArr,
+  GetReadOnlyAccess,
+  StoreNames,
+} from "../util/query.js";
 import {
   constructTaskIdentifier,
   DbRetryInfo,
@@ -131,6 +135,7 @@ import {
   TaskRunResultType,
   TombstoneTag,
   TransactionContext,
+  TransitionResult,
 } from "./common.js";
 import {
   calculateRefreshOutput,
@@ -166,6 +171,64 @@ export class PayMerchantTransactionContext implements 
TransactionContext {
     });
   }
 
+  /**
+   * Transition a payment transition.
+   */
+  async transition(
+    f: (rec: PurchaseRecord) => Promise<TransitionResult>,
+  ): Promise<void> {
+    return this.transitionExtra(
+      {
+        extraStores: [],
+      },
+      f,
+    );
+  }
+
+  /**
+   * Transition a payment transition.
+   * Extra object stores may be accessed during the transition.
+   */
+  async transitionExtra<
+    StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+  >(
+    opts: { extraStores: StoreNameArray },
+    f: (
+      rec: PurchaseRecord,
+      tx: DbReadWriteTransactionArr<
+        typeof WalletStoresV1,
+        ["purchases", ...StoreNameArray]
+      >,
+    ) => Promise<TransitionResult>,
+  ): Promise<void> {
+    const ws = this.ws;
+    const extraStores = opts.extraStores ?? [];
+    const transitionInfo = await ws.db.runReadWriteTx(
+      ["purchases", ...extraStores],
+      async (tx) => {
+        const purchaseRec = await tx.purchases.get(this.proposalId);
+        if (!purchaseRec) {
+          throw Error("purchase not found anymore");
+        }
+        const oldTxState = computePayMerchantTransactionState(purchaseRec);
+        const res = await f(purchaseRec, tx);
+        switch (res) {
+          case TransitionResult.Transition: {
+            await tx.purchases.put(purchaseRec);
+            const newTxState = computePayMerchantTransactionState(purchaseRec);
+            return {
+              oldTxState,
+              newTxState,
+            };
+          }
+          default:
+            return undefined;
+        }
+      },
+    );
+    notifyTransition(ws, this.transactionId, transitionInfo);
+  }
+
   async deleteTransaction(): Promise<void> {
     const { ws, proposalId } = this;
     await ws.db
@@ -210,16 +273,16 @@ export class PayMerchantTransactionContext implements 
TransactionContext {
 
   async abortTransaction(): Promise<void> {
     const { ws, proposalId, transactionId } = this;
-    const transitionInfo = await ws.db
-      .mktx((x) => [
-        x.purchases,
-        x.refreshGroups,
-        x.denominations,
-        x.coinAvailability,
-        x.coins,
-        x.operationRetries,
-      ])
-      .runReadWrite(async (tx) => {
+    const transitionInfo = await ws.db.runReadWriteTx(
+      [
+        "purchases",
+        "refreshGroups",
+        "denominations",
+        "coinAvailability",
+        "coins",
+        "operationRetries",
+      ],
+      async (tx) => {
         const purchase = await tx.purchases.get(proposalId);
         if (!purchase) {
           throw Error("purchase not found");
@@ -231,34 +294,44 @@ export class PayMerchantTransactionContext implements 
TransactionContext {
           logger.warn(`tried to abort successful payment`);
           return;
         }
-        if (oldStatus === PurchaseStatus.PendingPaying) {
-          purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
-        }
-        await tx.purchases.put(purchase);
-        if (oldStatus === PurchaseStatus.PendingPaying) {
-          if (purchase.payInfo) {
-            const coinSel = purchase.payInfo.payCoinSelection;
-            const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost);
-            const refreshCoins: CoinRefreshRequest[] = [];
-            for (let i = 0; i < coinSel.coinPubs.length; i++) {
-              refreshCoins.push({
-                amount: coinSel.coinContributions[i],
-                coinPub: coinSel.coinPubs[i],
-              });
+        switch (oldStatus) {
+          case PurchaseStatus.Done:
+            return;
+          case PurchaseStatus.PendingPaying:
+          case PurchaseStatus.SuspendedPaying: {
+            purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+            if (purchase.payInfo) {
+              const coinSel = purchase.payInfo.payCoinSelection;
+              const currency = Amounts.currencyOf(
+                purchase.payInfo.totalPayCost,
+              );
+              const refreshCoins: CoinRefreshRequest[] = [];
+              for (let i = 0; i < coinSel.coinPubs.length; i++) {
+                refreshCoins.push({
+                  amount: coinSel.coinContributions[i],
+                  coinPub: coinSel.coinPubs[i],
+                });
+              }
+              await createRefreshGroup(
+                ws,
+                tx,
+                currency,
+                refreshCoins,
+                RefreshReason.AbortPay,
+              );
             }
-            await createRefreshGroup(
-              ws,
-              tx,
-              currency,
-              refreshCoins,
-              RefreshReason.AbortPay,
-            );
+            break;
           }
+          case PurchaseStatus.DialogProposed:
+            purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+            break;
         }
+        await tx.purchases.put(purchase);
         await tx.operationRetries.delete(this.retryTag);
         const newTxState = computePayMerchantTransactionState(purchase);
         return { oldTxState, newTxState };
-      });
+      },
+    );
     notifyTransition(ws, transactionId, transitionInfo);
     ws.workAvailable.trigger();
   }
@@ -1302,7 +1375,7 @@ export async function checkPaymentByProposalId(
       });
     notifyTransition(ws, transactionId, transitionInfo);
     // FIXME: What about error handling?! This doesn't properly store errors 
in the DB.
-    const r = await processPurchasePay(ws, proposalId, { forceNow: true });
+    const r = await processPurchasePay(ws, proposalId);
     if (r.type !== TaskRunResultType.Finished) {
       // FIXME: This does not surface the original error
       throw Error("submitting pay failed");
@@ -1536,7 +1609,7 @@ async function runPayForConfirmPay(
     proposalId,
   });
   const res = await runTaskWithErrorReporting(ws, taskId, async () => {
-    return await processPurchasePay(ws, proposalId, { forceNow: true });
+    return await processPurchasePay(ws, proposalId);
   });
   logger.trace(`processPurchasePay response type ${res.type}`);
   switch (res.type) {
@@ -1788,6 +1861,7 @@ export async function processPurchase(
     case PurchaseStatus.DialogProposed:
     case PurchaseStatus.AbortedProposalRefused:
     case PurchaseStatus.AbortedIncompletePayment:
+    case PurchaseStatus.AbortedOrderDeleted:
     case PurchaseStatus.AbortedRefunded:
     case PurchaseStatus.SuspendedAbortingWithRefund:
     case PurchaseStatus.SuspendedDownloadingProposal:
@@ -1807,7 +1881,6 @@ export async function processPurchase(
 async function processPurchasePay(
   ws: InternalWalletState,
   proposalId: string,
-  options: unknown = {},
 ): Promise<TaskRunResult> {
   const purchase = await ws.db
     .mktx((x) => [x.purchases])
@@ -2170,6 +2243,7 @@ export function computePayMerchantTransactionState(
         major: TransactionMajorState.Failed,
         minor: TransactionMinorState.Refused,
       };
+    case PurchaseStatus.AbortedOrderDeleted:
     case PurchaseStatus.AbortedRefunded:
       return {
         major: TransactionMajorState.Aborted,
@@ -2250,7 +2324,7 @@ export function computePayMerchantTransactionActions(
       return [];
     // Final States
     case PurchaseStatus.AbortedProposalRefused:
-      return [TransactionAction.Delete];
+    case PurchaseStatus.AbortedOrderDeleted:
     case PurchaseStatus.AbortedRefunded:
       return [TransactionAction.Delete];
     case PurchaseStatus.Done:
@@ -2554,9 +2628,30 @@ async function processPurchaseAbortingRefund(
 
   logger.trace(`making order abort request to ${requestUrl.href}`);
 
-  const request = await ws.http.postJson(requestUrl.href, abortReq);
+  const abortHttpResp = await ws.http.fetch(requestUrl.href, {
+    method: "POST",
+    body: abortReq,
+  });
+
+  if (abortHttpResp.status === HttpStatusCode.NotFound) {
+    const err = await readTalerErrorResponse(abortHttpResp);
+    if (
+      err.code ===
+      TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
+    ) {
+      const ctx = new PayMerchantTransactionContext(ws, proposalId);
+      await ctx.transition(async (rec) => {
+        if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
+          rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
+          return TransitionResult.Transition;
+        }
+        return TransitionResult.Stay;
+      });
+    }
+  }
+
   const abortResp = await readSuccessResponseJsonOrThrow(
-    request,
+    abortHttpResp,
     codecForAbortResponse(),
   );
 
@@ -2668,8 +2763,6 @@ async function processPurchaseAcceptRefund(
   ws: InternalWalletState,
   purchase: PurchaseRecord,
 ): Promise<TaskRunResult> {
-  const proposalId = purchase.proposalId;
-
   const download = await expectProposalDownload(ws, purchase);
 
   const requestUrl = new URL(

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