aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2024-01-15 19:38:34 +0100
committerFlorian Dold <florian@dold.me>2024-01-15 19:38:41 +0100
commitcc07d767abb0c1ba37be92014b06a94d3a3206d9 (patch)
treedbd037f08b4a438a3cc786778876b83762fc175e
parent728bab6584ee5632def40f22103dc7578ec3d64c (diff)
downloadwallet-core-cc07d767abb0c1ba37be92014b06a94d3a3206d9.tar.gz
wallet-core-cc07d767abb0c1ba37be92014b06a94d3a3206d9.tar.bz2
wallet-core-cc07d767abb0c1ba37be92014b06a94d3a3206d9.zip
wallet-core: fix pay state machine when order is deleted
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-deleted.ts106
-rw-r--r--packages/taler-harness/src/integrationtests/testrunner.ts2
-rw-r--r--packages/taler-util/src/MerchantApiClient.ts15
-rw-r--r--packages/taler-wallet-core/src/db.ts6
-rw-r--r--packages/taler-wallet-core/src/operations/pay-merchant.ts175
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(