From cc07d767abb0c1ba37be92014b06a94d3a3206d9 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 15 Jan 2024 19:38:34 +0100 Subject: wallet-core: fix pay state machine when order is deleted --- packages/taler-wallet-core/src/db.ts | 6 +- .../src/operations/pay-merchant.ts | 175 ++++++++++++++++----- 2 files changed, 138 insertions(+), 43 deletions(-) (limited to 'packages/taler-wallet-core') 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, + ): Promise { + return this.transitionExtra( + { + extraStores: [], + }, + f, + ); + } + + /** + * Transition a payment transition. + * Extra object stores may be accessed during the transition. + */ + async transitionExtra< + StoreNameArray extends Array> = [], + >( + opts: { extraStores: StoreNameArray }, + f: ( + rec: PurchaseRecord, + tx: DbReadWriteTransactionArr< + typeof WalletStoresV1, + ["purchases", ...StoreNameArray] + >, + ) => Promise, + ): Promise { + 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 { const { ws, proposalId } = this; await ws.db @@ -210,16 +273,16 @@ export class PayMerchantTransactionContext implements TransactionContext { async abortTransaction(): Promise { 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 { 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 { - const proposalId = purchase.proposalId; - const download = await expectProposalDownload(ws, purchase); const requestUrl = new URL( -- cgit v1.2.3