diff options
author | Florian Dold <florian@dold.me> | 2024-01-15 19:38:34 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-01-15 19:38:41 +0100 |
commit | cc07d767abb0c1ba37be92014b06a94d3a3206d9 (patch) | |
tree | dbd037f08b4a438a3cc786778876b83762fc175e /packages | |
parent | 728bab6584ee5632def40f22103dc7578ec3d64c (diff) | |
download | wallet-core-cc07d767abb0c1ba37be92014b06a94d3a3206d9.tar.xz |
wallet-core: fix pay state machine when order is deleted
Diffstat (limited to 'packages')
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( |