diff --git a/packages/taler-integrationtests/src/test-payment-transient.ts b/packages/taler-integrationtests/src/test-payment-transient.ts new file mode 100644 index 000000000..aa0bda2c5 --- /dev/null +++ b/packages/taler-integrationtests/src/test-payment-transient.ts @@ -0,0 +1,169 @@ +/* + 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 + */ + +/** + * Imports. + */ +import { runTest, GlobalTestState, MerchantPrivateApi } from "./harness"; +import { + withdrawViaBank, + createFaultInjectedMerchantTestkudosEnvironment, +} from "./helpers"; +import { + PreparePayResultType, + codecForMerchantOrderStatusUnpaid, + ConfirmPayResultType, + URL, + codecForExchangeKeysJson, + TalerErrorDetails, + TalerErrorCode, +} from "taler-wallet-core"; +import axios from "axios"; +import { FaultInjectionRequestContext, FaultInjectionResponseContext } from "./faultInjection"; + +/** + * Run test for a payment where the merchant has a transient + * failure in /pay + */ +runTest(async (t: GlobalTestState) => { + // Set up test environment + + const { + wallet, + bank, + exchange, + faultyMerchant, + } = await createFaultInjectedMerchantTestkudosEnvironment(t); + + // Withdraw digital cash into the wallet. + + await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" }); + + const merchant = faultyMerchant; + + let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", { + order: { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "https://example.com/article42", + }, + }); + + let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, { + orderId: orderResp.order_id, + sessionId: "mysession-one", + }); + + t.assertTrue(orderStatus.order_status === "unpaid"); + + t.assertTrue(orderStatus.already_paid_order_id === undefined); + let publicOrderStatusUrl = orderStatus.order_status_url; + + let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, { + validateStatus: () => true, + }); + + if (publicOrderStatusResp.status != 402) { + throw Error( + `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`, + ); + } + + let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( + publicOrderStatusResp.data, + ); + + console.log(pubUnpaidStatus); + + let preparePayResp = await wallet.preparePay({ + talerPayUri: pubUnpaidStatus.taler_pay_uri, + }); + + t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible); + + const proposalId = preparePayResp.proposalId; + + publicOrderStatusResp = await axios.get(publicOrderStatusUrl, { + validateStatus: () => true, + }); + + if (publicOrderStatusResp.status != 402) { + throw Error( + `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`, + ); + } + + pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode( + publicOrderStatusResp.data, + ); + + let faultInjected = false; + + faultyMerchant.faultProxy.addFault({ + modifyResponse(ctx: FaultInjectionResponseContext) { + console.log("in modifyResponse"); + const url = new URL(ctx.request.requestUrl); + console.log("pathname is", url.pathname); + if (!url.pathname.endsWith("/pay")) { + return; + } + if (faultInjected) { + console.log("not injecting pay fault"); + return; + } + faultInjected = true; + console.log("injecting pay fault"); + const err: TalerErrorDetails = { + code: TalerErrorCode.PAY_DB_FETCH_TRANSACTION_ERROR, + details: {}, + hint: "huh", + message: "something went wrong", + }; + ctx.responseBody = Buffer.from(JSON.stringify(err)); + ctx.statusCode = 500; + } + }); + + const confirmPayResp = await wallet.confirmPay({ + proposalId, + }); + + console.log(confirmPayResp); + + t.assertTrue(confirmPayResp.type === ConfirmPayResultType.Pending); + t.assertTrue(faultInjected); + + const confirmPayRespTwo = await wallet.confirmPay({ + proposalId, + }); + + t.assertTrue(confirmPayRespTwo.type === ConfirmPayResultType.Done); + + // Now ask the merchant if paid + + publicOrderStatusResp = await axios.get(publicOrderStatusUrl, { + validateStatus: () => true, + }); + + console.log(publicOrderStatusResp.data); + + if (publicOrderStatusResp.status != 202) { + console.log(publicOrderStatusResp.data); + throw Error( + `expected status 202 (after paying), but got ${publicOrderStatusResp.status}`, + ); + } +}); diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index 6079ea08f..442aeca71 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -435,7 +435,7 @@ async function recordConfirmPay( } else { sessionId = proposal.downloadSessionId; } - logger.trace(`recording payment with session ID ${sessionId}`); + logger.trace(`recording payment on ${proposal.orderId} with session ID ${sessionId}`); const payCostInfo = await getTotalPaymentCost(ws, coinSelection); const t: PurchaseRecord = { abortStatus: AbortStatus.None, @@ -530,10 +530,6 @@ 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, @@ -947,7 +943,7 @@ async function submitPay( session_id: purchase.lastSessionId, }; - logger.trace("making pay request", JSON.stringify(reqBody, undefined, 2)); + logger.trace("making pay request ... ", JSON.stringify(reqBody, undefined, 2)); const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => ws.http.postJson(payUrl, reqBody, { @@ -955,6 +951,27 @@ async function submitPay( }), ); + logger.trace(`got resp ${JSON.stringify(resp)}`); + + // Hide transient errors. + if ( + purchase.payRetryInfo.retryCounter <= 5 && + resp.status >= 500 && + resp.status <= 599 + ) { + logger.trace("treating /pay error as transient"); + const err = makeErrorDetails( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + "/pay failed", + getHttpResponseErrorDetails(resp), + ); + incrementPurchasePayRetry(ws, proposalId, undefined); + return { + type: ConfirmPayResultType.Pending, + lastError: err, + }; + } + const merchantResp = await readSuccessResponseJsonOrThrow( resp, codecForMerchantPayResponse(), @@ -989,6 +1006,23 @@ async function submitPay( const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => ws.http.postJson(payAgainUrl, reqBody), ); + // Hide transient errors. + if ( + purchase.payRetryInfo.retryCounter <= 5 && + resp.status >= 500 && + resp.status <= 599 + ) { + const err = makeErrorDetails( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + "/paid failed", + getHttpResponseErrorDetails(resp), + ); + incrementPurchasePayRetry(ws, proposalId, undefined); + return { + type: ConfirmPayResultType.Pending, + lastError: err, + }; + } if (resp.status !== 204) { throw OperationFailedError.fromCode( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, @@ -999,6 +1033,11 @@ async function submitPay( await storePayReplaySuccess(ws, proposalId, sessionId); } + ws.notify({ + type: NotificationType.PayOperationSuccess, + proposalId: purchase.proposalId, + }); + return { type: ConfirmPayResultType.Done, contractTerms: JSON.parse(purchase.contractTermsRaw), @@ -1171,7 +1210,7 @@ export async function confirmPay( let purchase = await ws.db.get( Stores.purchases, - d.contractData.contractTermsHash, + proposalId, ); if (purchase) { diff --git a/packages/taler-wallet-core/src/types/notifications.ts b/packages/taler-wallet-core/src/types/notifications.ts index d86c5ae59..7faf730ef 100644 --- a/packages/taler-wallet-core/src/types/notifications.ts +++ b/packages/taler-wallet-core/src/types/notifications.ts @@ -53,6 +53,7 @@ export enum NotificationType { ProposalOperationError = "proposal-error", TipOperationError = "tip-error", PayOperationError = "pay-error", + PayOperationSuccess = "pay-operation-success", WithdrawOperationError = "withdraw-error", ReserveNotYetFound = "reserve-not-yet-found", ReserveOperationError = "reserve-error", @@ -220,6 +221,18 @@ export interface ReserveRegisteredWithBankNotification { type: NotificationType.ReserveRegisteredWithBank; } +/** + * Notification sent when a pay (or pay replay) operation succeeded. + * + * We send this notification because the confirmPay request can return + * a "confirmed" response that indicates that the payment has been confirmed + * by the user, but we're still waiting for the payment to succeed or fail. + */ +export interface PayOperationSuccessNotification { + type: NotificationType.PayOperationSuccess; + proposalId: string; +} + export type WalletNotification = | WithdrawOperationErrorNotification | ReserveOperationErrorNotification @@ -254,4 +267,5 @@ export type WalletNotification = | PendingOperationProcessedNotification | ProposalRefusedNotification | ReserveRegisteredWithBankNotification - | ReserveNotYetFoundNotification; + | ReserveNotYetFoundNotification + | PayOperationSuccessNotification;