From 67df550b4f6d67f8de346985df26133dc8da5c05 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 9 Sep 2020 02:18:03 +0530 Subject: implement payment aborts with integration test --- .../taler-integrationtests/src/faultInjection.ts | 2 +- packages/taler-integrationtests/src/harness.ts | 10 + packages/taler-integrationtests/src/helpers.ts | 5 +- .../taler-integrationtests/src/test-tipping.ts | 1 - packages/taler-wallet-core/src/operations/pay.ts | 138 +++++++------- .../taler-wallet-core/src/operations/pending.ts | 5 +- .../taler-wallet-core/src/operations/refund.ts | 201 ++++++++++++++++++--- .../src/operations/transactions.ts | 12 +- packages/taler-wallet-core/src/types/dbTypes.ts | 18 +- packages/taler-wallet-core/src/types/talerTypes.ts | 108 ++++++++++- .../taler-wallet-core/src/types/walletTypes.ts | 8 + packages/taler-wallet-core/src/wallet.ts | 17 +- 12 files changed, 405 insertions(+), 120 deletions(-) (limited to 'packages') diff --git a/packages/taler-integrationtests/src/faultInjection.ts b/packages/taler-integrationtests/src/faultInjection.ts index a85b1dd7d..a2d4836d9 100644 --- a/packages/taler-integrationtests/src/faultInjection.ts +++ b/packages/taler-integrationtests/src/faultInjection.ts @@ -80,7 +80,7 @@ export class FaultProxy { start() { const server = http.createServer((req, res) => { const requestChunks: Buffer[] = []; - const requestUrl = `http://locahost:${this.faultProxyConfig.inboundPort}${req.url}`; + const requestUrl = `http://localhost:${this.faultProxyConfig.inboundPort}${req.url}`; console.log("request for", new URL(requestUrl)); req.on("data", (chunk) => { requestChunks.push(chunk); diff --git a/packages/taler-integrationtests/src/harness.ts b/packages/taler-integrationtests/src/harness.ts index b71fe4104..a25ee90b1 100644 --- a/packages/taler-integrationtests/src/harness.ts +++ b/packages/taler-integrationtests/src/harness.ts @@ -76,6 +76,7 @@ import { PrepareTipRequest, codecForPrepareTipResult, AcceptTipRequest, + AbortPayWithRefundRequest, } from "taler-wallet-core"; import { URL } from "url"; import axios, { AxiosError } from "axios"; @@ -1538,6 +1539,15 @@ export class WalletCli { throw new OperationFailedError(resp.error); } + + async abortFailedPayWithRefund(req: AbortPayWithRefundRequest): Promise { + const resp = await this.apiRequest("abortFailedPayWithRefund", req); + if (resp.type === "response") { + return; + } + throw new OperationFailedError(resp.error); + } + async confirmPay(req: ConfirmPayRequest): Promise { const resp = await this.apiRequest("confirmPay", req); if (resp.type === "response") { diff --git a/packages/taler-integrationtests/src/helpers.ts b/packages/taler-integrationtests/src/helpers.ts index bdccdba8a..f633ea82d 100644 --- a/packages/taler-integrationtests/src/helpers.ts +++ b/packages/taler-integrationtests/src/helpers.ts @@ -36,6 +36,7 @@ import { BankApi, BankAccessApi, MerchantPrivateApi, + ExchangeServiceInterface, } from "./harness"; import { AmountString, @@ -233,7 +234,7 @@ export async function startWithdrawViaBank( p: { wallet: WalletCli; bank: BankService; - exchange: ExchangeService; + exchange: ExchangeServiceInterface; amount: AmountString; }, ): Promise { @@ -272,7 +273,7 @@ export async function withdrawViaBank( p: { wallet: WalletCli; bank: BankService; - exchange: ExchangeService; + exchange: ExchangeServiceInterface; amount: AmountString; }, ): Promise { diff --git a/packages/taler-integrationtests/src/test-tipping.ts b/packages/taler-integrationtests/src/test-tipping.ts index 4c080293e..6703ab4b2 100644 --- a/packages/taler-integrationtests/src/test-tipping.ts +++ b/packages/taler-integrationtests/src/test-tipping.ts @@ -21,7 +21,6 @@ import { runTest, GlobalTestState, MerchantPrivateApi, - BankAccessApi, BankApi, } from "./harness"; import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers"; diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index 3dc5e1600..8dbc2af41 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -35,6 +35,7 @@ import { CoinRecord, DenominationRecord, PayCoinSelection, + AbortStatus, } from "../types/dbTypes"; import { NotificationType } from "../types/notifications"; import { @@ -77,7 +78,11 @@ import { } from "../util/http"; import { TalerErrorCode } from "../TalerErrorCode"; import { URL } from "../util/url"; -import { initRetryInfo, updateRetryInfoTimeout, getRetryDuration } from "../util/retries"; +import { + initRetryInfo, + updateRetryInfoTimeout, + getRetryDuration, +} from "../util/retries"; /** * Logger. @@ -111,7 +116,6 @@ export interface AvailableCoinInfo { feeDeposit: AmountJson; } - /** * Compute the total cost of a payment to the customer. * @@ -429,8 +433,7 @@ async function recordConfirmPay( logger.trace(`recording payment with session ID ${sessionId}`); const payCostInfo = await getTotalPaymentCost(ws, coinSelection); const t: PurchaseRecord = { - abortDone: false, - abortRequested: false, + abortStatus: AbortStatus.None, contractTermsRaw: d.contractTermsRaw, contractData: d.contractData, lastSessionId: sessionId, @@ -444,7 +447,7 @@ async function recordConfirmPay( lastRefundStatusError: undefined, payRetryInfo: initRetryInfo(), refundStatusRetryInfo: initRetryInfo(), - refundStatusRequested: false, + refundQueryRequested: false, timestampFirstSuccessfulPay: undefined, autoRefundDeadline: undefined, paymentSubmitPending: true, @@ -522,6 +525,10 @@ 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, @@ -579,7 +586,10 @@ function getProposalRequestTimeout(proposal: ProposalRecord): Duration { } function getPayRequestTimeout(purchase: PurchaseRecord): Duration { - return durationMul({ d_ms: 5000 }, 1 + purchase.payCoinSelection.coinPubs.length / 20); + return durationMul( + { d_ms: 5000 }, + 1 + purchase.payCoinSelection.coinPubs.length / 20, + ); } async function processDownloadProposalImpl( @@ -794,40 +804,37 @@ async function storeFirstPaySuccess( paySig: string, ): Promise { const now = getTimestampNow(); - await ws.db.runWithWriteTransaction( - [Stores.purchases], - async (tx) => { - const purchase = await tx.get(Stores.purchases, proposalId); + await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { + const purchase = await tx.get(Stores.purchases, proposalId); - if (!purchase) { - logger.warn("purchase does not exist anymore"); - return; - } - const isFirst = purchase.timestampFirstSuccessfulPay === undefined; - if (!isFirst) { - logger.warn("payment success already stored"); - return; - } - purchase.timestampFirstSuccessfulPay = now; - purchase.paymentSubmitPending = false; - purchase.lastPayError = undefined; - purchase.lastSessionId = sessionId; - purchase.payRetryInfo = initRetryInfo(false); - purchase.merchantPaySig = paySig; - if (isFirst) { - const ar = purchase.contractData.autoRefund; - if (ar) { - logger.info("auto_refund present"); - purchase.refundStatusRequested = true; - purchase.refundStatusRetryInfo = initRetryInfo(); - purchase.lastRefundStatusError = undefined; - purchase.autoRefundDeadline = timestampAddDuration(now, ar); - } + if (!purchase) { + logger.warn("purchase does not exist anymore"); + return; + } + const isFirst = purchase.timestampFirstSuccessfulPay === undefined; + if (!isFirst) { + logger.warn("payment success already stored"); + return; + } + purchase.timestampFirstSuccessfulPay = now; + purchase.paymentSubmitPending = false; + purchase.lastPayError = undefined; + purchase.lastSessionId = sessionId; + purchase.payRetryInfo = initRetryInfo(false); + purchase.merchantPaySig = paySig; + if (isFirst) { + const ar = purchase.contractData.autoRefund; + if (ar) { + logger.info("auto_refund present"); + purchase.refundQueryRequested = true; + purchase.refundStatusRetryInfo = initRetryInfo(); + purchase.lastRefundStatusError = undefined; + purchase.autoRefundDeadline = timestampAddDuration(now, ar); } + } - await tx.put(Stores.purchases, purchase); - }, - ); + await tx.put(Stores.purchases, purchase); + }); } async function storePayReplaySuccess( @@ -835,26 +842,23 @@ async function storePayReplaySuccess( proposalId: string, sessionId: string | undefined, ): Promise { - await ws.db.runWithWriteTransaction( - [Stores.purchases], - async (tx) => { - const purchase = await tx.get(Stores.purchases, proposalId); + await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { + const purchase = await tx.get(Stores.purchases, proposalId); - if (!purchase) { - logger.warn("purchase does not exist anymore"); - return; - } - const isFirst = purchase.timestampFirstSuccessfulPay === undefined; - if (isFirst) { - throw Error("invalid payment state"); - } - purchase.paymentSubmitPending = false; - purchase.lastPayError = undefined; - purchase.payRetryInfo = initRetryInfo(false); - purchase.lastSessionId = sessionId; - await tx.put(Stores.purchases, purchase); - }, - ); + if (!purchase) { + logger.warn("purchase does not exist anymore"); + return; + } + const isFirst = purchase.timestampFirstSuccessfulPay === undefined; + if (isFirst) { + throw Error("invalid payment state"); + } + purchase.paymentSubmitPending = false; + purchase.lastPayError = undefined; + purchase.payRetryInfo = initRetryInfo(false); + purchase.lastSessionId = sessionId; + await tx.put(Stores.purchases, purchase); + }); } /** @@ -863,7 +867,7 @@ async function storePayReplaySuccess( * If the wallet has previously paid, it just transmits the merchant's * own signature certifying that the wallet has previously paid. */ -export async function submitPay( +async function submitPay( ws: InternalWalletState, proposalId: string, ): Promise { @@ -871,7 +875,7 @@ export async function submitPay( if (!purchase) { throw Error("Purchase not found: " + proposalId); } - if (purchase.abortRequested) { + if (purchase.abortStatus !== AbortStatus.None) { throw Error("not submitting payment for aborted purchase"); } const sessionId = purchase.lastSessionId; @@ -1047,7 +1051,11 @@ export async function preparePayForUri( p.lastSessionId = uriResult.sessionId; await tx.put(Stores.purchases, p); }); - const r = await submitPay(ws, proposalId); + const r = await guardOperationException( + () => submitPay(ws, proposalId), + (e: TalerErrorDetails): Promise => + incrementPurchasePayRetry(ws, proposalId, e), + ); if (r.type !== ConfirmPayResultType.Done) { throw Error("submitting pay failed"); } @@ -1125,7 +1133,11 @@ export async function confirmPay( }); } logger.trace("confirmPay: submitting payment for existing purchase"); - return submitPay(ws, proposalId); + return await guardOperationException( + () => submitPay(ws, proposalId), + (e: TalerErrorDetails): Promise => + incrementPurchasePayRetry(ws, proposalId, e), + ); } logger.trace("confirmPay: purchase record does not exist yet"); @@ -1179,7 +1191,11 @@ export async function confirmPay( sessionIdOverride, ); - return submitPay(ws, proposalId); + return await guardOperationException( + () => submitPay(ws, proposalId), + (e: TalerErrorDetails): Promise => + incrementPurchasePayRetry(ws, proposalId, e), + ); } export async function processPurchasePay( diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts index 7338ac77d..4f6477d50 100644 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ b/packages/taler-wallet-core/src/operations/pending.ts @@ -22,6 +22,7 @@ import { ProposalStatus, ReserveRecordStatus, Stores, + AbortStatus, } from "../types/dbTypes"; import { PendingOperationsResponse, @@ -381,7 +382,7 @@ async function gatherPurchasePending( onlyDue = false, ): Promise { await tx.iter(Stores.purchases).forEach((pr) => { - if (pr.paymentSubmitPending) { + if (pr.paymentSubmitPending && pr.abortStatus === AbortStatus.None) { resp.nextRetryDelay = updateRetryDelay( resp.nextRetryDelay, now, @@ -398,7 +399,7 @@ async function gatherPurchasePending( }); } } - if (pr.refundStatusRequested) { + if (pr.refundQueryRequested) { resp.nextRetryDelay = updateRetryDelay( resp.nextRetryDelay, now, diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts index e15a27b3a..10a57f909 100644 --- a/packages/taler-wallet-core/src/operations/refund.ts +++ b/packages/taler-wallet-core/src/operations/refund.ts @@ -36,6 +36,7 @@ import { RefundReason, RefundState, PurchaseRecord, + AbortStatus, } from "../types/dbTypes"; import { NotificationType } from "../types/notifications"; import { parseRefundUri } from "../util/taleruri"; @@ -46,14 +47,25 @@ import { MerchantCoinRefundSuccessStatus, MerchantCoinRefundFailureStatus, codecForMerchantOrderRefundPickupResponse, + AbortRequest, + AbortingCoin, + codecForMerchantAbortPayRefundStatus, + codecForAbortResponse, } from "../types/talerTypes"; import { guardOperationException } from "./errors"; -import { getTimestampNow, Timestamp } from "../util/time"; +import { + getTimestampNow, + Timestamp, + durationAdd, + timestampAddDuration, +} from "../util/time"; import { Logger } from "../util/logging"; import { readSuccessResponseJsonOrThrow } from "../util/http"; import { TransactionHandle } from "../util/query"; import { URL } from "../util/url"; import { updateRetryInfoTimeout, initRetryInfo } from "../util/retries"; +import { checkDbInvariant } from "../util/invariants"; +import { TalerErrorCode } from "../TalerErrorCode"; const logger = new Logger("refund.ts"); @@ -101,7 +113,7 @@ async function applySuccessfulRefund( const refundKey = getRefundKey(r); const coin = await tx.get(Stores.coins, r.coin_pub); if (!coin) { - console.warn("coin not found, can't apply refund"); + logger.warn("coin not found, can't apply refund"); return; } const denom = await tx.get(Stores.denominations, [ @@ -158,7 +170,7 @@ async function storePendingRefund( const coin = await tx.get(Stores.coins, r.coin_pub); if (!coin) { - console.warn("coin not found, can't apply refund"); + logger.warn("coin not found, can't apply refund"); return; } const denom = await tx.get(Stores.denominations, [ @@ -202,13 +214,14 @@ async function storePendingRefund( async function storeFailedRefund( tx: TransactionHandle, p: PurchaseRecord, + refreshCoinsMap: Record, r: MerchantCoinRefundFailureStatus, ): Promise { const refundKey = getRefundKey(r); const coin = await tx.get(Stores.coins, r.coin_pub); if (!coin) { - console.warn("coin not found, can't apply refund"); + logger.warn("coin not found, can't apply refund"); return; } const denom = await tx.get(Stores.denominations, [ @@ -247,6 +260,38 @@ async function storeFailedRefund( refundFee: denom.feeRefund, totalRefreshCostBound, }; + + if (p.abortStatus === AbortStatus.AbortRefund) { + // Refund failed because the merchant didn't even try to deposit + // the coin yet, so we try to refresh. + if (r.exchange_code === TalerErrorCode.REFUND_DEPOSIT_NOT_FOUND) { + const coin = await tx.get(Stores.coins, r.coin_pub); + if (!coin) { + logger.warn("coin not found, can't apply refund"); + return; + } + const denom = await tx.get(Stores.denominations, [ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + logger.warn("denomination for coin missing"); + return; + } + let contrib: AmountJson | undefined; + for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) { + if (p.payCoinSelection.coinPubs[i] === r.coin_pub) { + contrib = p.payCoinSelection.coinContributions[i]; + } + } + if (contrib) { + coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount; + coin.currentAmount = Amounts.sub(coin.currentAmount, denom.feeRefund).amount; + } + refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; + await tx.put(Stores.coins, coin); + } + } } async function acceptRefunds( @@ -268,7 +313,7 @@ async function acceptRefunds( async (tx) => { const p = await tx.get(Stores.purchases, proposalId); if (!p) { - console.error("purchase not found, not adding refunds"); + logger.error("purchase not found, not adding refunds"); return; } @@ -280,7 +325,7 @@ async function acceptRefunds( const isPermanentFailure = refundStatus.type === "failure" && - refundStatus.exchange_status === 410; + refundStatus.exchange_status >= 400 && refundStatus.exchange_status < 500 ; // Already failed. if (existingRefundInfo?.type === RefundState.Failed) { @@ -306,7 +351,7 @@ async function acceptRefunds( if (refundStatus.type === "success") { await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus); } else if (isPermanentFailure) { - await storeFailedRefund(tx, p, refundStatus); + await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus); } else { await storePendingRefund(tx, p, refundStatus); } @@ -326,7 +371,11 @@ async function acceptRefunds( // after a retry delay? let queryDone = true; - if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) { + if ( + p.timestampFirstSuccessfulPay && + p.autoRefundDeadline && + p.autoRefundDeadline.t_ms > now.t_ms + ) { queryDone = false; } @@ -347,7 +396,10 @@ async function acceptRefunds( p.timestampLastRefundStatus = now; p.lastRefundStatusError = undefined; p.refundStatusRetryInfo = initRetryInfo(false); - p.refundStatusRequested = false; + p.refundQueryRequested = false; + if (p.abortStatus === AbortStatus.AbortRefund) { + p.abortStatus = AbortStatus.AbortFinished; + } logger.trace("refund query done"); } else { // No error, but we need to try again! @@ -415,7 +467,7 @@ export async function applyRefund( logger.error("no purchase found for refund URL"); return false; } - p.refundStatusRequested = true; + p.refundQueryRequested = true; p.lastRefundStatusError = undefined; p.refundStatusRetryInfo = initRetryInfo(); await tx.put(Stores.purchases, p); @@ -516,32 +568,121 @@ async function processPurchaseQueryRefundImpl( return; } - if (!purchase.refundStatusRequested) { + if (!purchase.refundQueryRequested) { return; } - const requestUrl = new URL( - `orders/${purchase.contractData.orderId}/refund`, - purchase.contractData.merchantBaseUrl, - ); + if (purchase.timestampFirstSuccessfulPay) { + const requestUrl = new URL( + `orders/${purchase.contractData.orderId}/refund`, + purchase.contractData.merchantBaseUrl, + ); - logger.trace(`making refund request to ${requestUrl.href}`); + logger.trace(`making refund request to ${requestUrl.href}`); - const request = await ws.http.postJson(requestUrl.href, { - h_contract: purchase.contractData.contractTermsHash, - }); + const request = await ws.http.postJson(requestUrl.href, { + h_contract: purchase.contractData.contractTermsHash, + }); + + logger.trace( + "got json", + JSON.stringify(await request.json(), undefined, 2), + ); - logger.trace("got json", JSON.stringify(await request.json(), undefined, 2)); + const refundResponse = await readSuccessResponseJsonOrThrow( + request, + codecForMerchantOrderRefundPickupResponse(), + ); - const refundResponse = await readSuccessResponseJsonOrThrow( - request, - codecForMerchantOrderRefundPickupResponse(), - ); + await acceptRefunds( + ws, + proposalId, + refundResponse.refunds, + RefundReason.NormalRefund, + ); + } else if (purchase.abortStatus === AbortStatus.AbortRefund) { + const requestUrl = new URL( + `orders/${purchase.contractData.orderId}/abort`, + purchase.contractData.merchantBaseUrl, + ); - await acceptRefunds( - ws, - proposalId, - refundResponse.refunds, - RefundReason.NormalRefund, - ); + const abortingCoins: AbortingCoin[] = []; + for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) { + const coinPub = purchase.payCoinSelection.coinPubs[i]; + const coin = await ws.db.get(Stores.coins, coinPub); + checkDbInvariant(!!coin, "expected coin to be present"); + abortingCoins.push({ + coin_pub: coinPub, + contribution: Amounts.stringify( + purchase.payCoinSelection.coinContributions[i], + ), + exchange_url: coin.exchangeBaseUrl, + }); + } + + const abortReq: AbortRequest = { + h_contract: purchase.contractData.contractTermsHash, + coins: abortingCoins, + }; + + logger.trace(`making order abort request to ${requestUrl.href}`); + + const request = await ws.http.postJson(requestUrl.href, abortReq); + const abortResp = await readSuccessResponseJsonOrThrow( + request, + codecForAbortResponse(), + ); + + const refunds: MerchantCoinRefundStatus[] = []; + + if (abortResp.refunds.length != abortingCoins.length) { + // FIXME: define error code! + throw Error("invalid order abort response"); + } + + for (let i = 0; i < abortResp.refunds.length; i++) { + const r = abortResp.refunds[i]; + refunds.push({ + ...r, + coin_pub: purchase.payCoinSelection.coinPubs[i], + refund_amount: Amounts.stringify( + purchase.payCoinSelection.coinContributions[i], + ), + rtransaction_id: 0, + execution_time: timestampAddDuration(purchase.contractData.timestamp, { + d_ms: 1000, + }), + }); + } + await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund); + } +} + +export async function abortFailedPayWithRefund( + ws: InternalWalletState, + proposalId: string, +): Promise { + await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { + const purchase = await tx.get(Stores.purchases, proposalId); + if (!purchase) { + throw Error("purchase not found"); + } + if (purchase.timestampFirstSuccessfulPay) { + // No point in aborting it. We don't even report an error. + logger.warn(`tried to abort successful payment`); + return; + } + if (purchase.abortStatus !== AbortStatus.None) { + return; + } + purchase.refundQueryRequested = true; + purchase.paymentSubmitPending = false; + purchase.abortStatus = AbortStatus.AbortRefund; + purchase.lastPayError = undefined; + purchase.payRetryInfo = initRetryInfo(false); + await tx.put(Stores.purchases, purchase); + }); + processPurchaseQueryRefund(ws, proposalId, true).catch((e) => { + logger.trace(`error during refund processing after abort pay: ${e}`); + }); } diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 5bc4ebace..87236d5a0 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -23,6 +23,7 @@ import { WalletRefundItem, RefundState, ReserveRecordStatus, + AbortStatus, } from "../types/dbTypes"; import { Amounts, AmountJson } from "../util/amounts"; import { timestampCmp } from "../util/time"; @@ -242,7 +243,9 @@ export async function getTransactions( status: pr.timestampFirstSuccessfulPay ? PaymentStatus.Paid : PaymentStatus.Accepted, - pending: !pr.timestampFirstSuccessfulPay, + pending: + !pr.timestampFirstSuccessfulPay && + pr.abortStatus === AbortStatus.None, timestamp: pr.timestampAccept, transactionId: paymentTransactionId, info: info, @@ -324,7 +327,10 @@ export async function getTransactions( amountRaw: Amounts.stringify(tipRecord.tipAmountRaw), pending: !tipRecord.pickedUpTimestamp, timestamp: tipRecord.acceptedTimestamp, - transactionId: makeEventId(TransactionType.Tip, tipRecord.walletTipId), + transactionId: makeEventId( + TransactionType.Tip, + tipRecord.walletTipId, + ), error: tipRecord.lastError, }); }); @@ -337,5 +343,5 @@ export async function getTransactions( txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp)); txNotPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp)); - return { transactions: [...txPending, ...txNotPending] }; + return { transactions: [...txNotPending, ...txPending] }; } diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts index ff790e216..d10be80ce 100644 --- a/packages/taler-wallet-core/src/types/dbTypes.ts +++ b/packages/taler-wallet-core/src/types/dbTypes.ts @@ -1285,6 +1285,12 @@ export interface PayCoinSelection { customerDepositFees: AmountJson; } +export enum AbortStatus { + None = "none", + AbortRefund = "abort-refund", + AbortFinished = "abort-finished", +} + /** * Record that stores status information about one purchase, starting from when * the customer accepts a proposal. Includes refund status if applicable. @@ -1352,17 +1358,9 @@ export interface PurchaseRecord { * Do we need to query the merchant for the refund status * of the payment? */ - refundStatusRequested: boolean; + refundQueryRequested: boolean; - /** - * An abort (with refund) was requested for this (incomplete!) purchase. - */ - abortRequested: boolean; - - /** - * The abort (with refund) was completed for this (incomplete!) purchase. - */ - abortDone: boolean; + abortStatus: AbortStatus; payRetryInfo: RetryInfo; diff --git a/packages/taler-wallet-core/src/types/talerTypes.ts b/packages/taler-wallet-core/src/types/talerTypes.ts index 16d00e2ea..ce83080cc 100644 --- a/packages/taler-wallet-core/src/types/talerTypes.ts +++ b/packages/taler-wallet-core/src/types/talerTypes.ts @@ -1059,7 +1059,6 @@ export const codecForAuditorHandle = (): Codec => .property("url", codecForString()) .build("AuditorHandle"); - export const codecForLocation = (): Codec => buildCodecForObject() .property("country", codecOptional(codecForString())) @@ -1071,7 +1070,7 @@ export const codecForLocation = (): Codec => .property("post_code", codecOptional(codecForString())) .property("town", codecOptional(codecForString())) .property("town_location", codecOptional(codecForString())) - .property("address_lines", codecOptional(codecForList(codecForString()))) + .property("address_lines", codecOptional(codecForList(codecForString()))) .build("Location"); export const codecForMerchantInfo = (): Codec => @@ -1351,3 +1350,108 @@ export const codecForMerchantOrderStatusUnpaid = (): Codec< .property("taler_pay_uri", codecForString()) .property("already_paid_order_id", codecOptional(codecForString())) .build("MerchantOrderStatusUnpaid"); + +export interface AbortRequest { + // hash of the order's contract terms (this is used to authenticate the + // wallet/customer in case $ORDER_ID is guessable). + h_contract: string; + + // List of coins the wallet would like to see refunds for. + // (Should be limited to the coins for which the original + // payment succeeded, as far as the wallet knows.) + coins: AbortingCoin[]; +} + +export interface AbortingCoin { + // Public key of a coin for which the wallet is requesting an abort-related refund. + coin_pub: EddsaPublicKeyString; + + // The amount to be refunded (matches the original contribution) + contribution: AmountString; + + // URL of the exchange this coin was withdrawn from. + exchange_url: string; +} + +export interface AbortResponse { + // List of refund responses about the coins that the wallet + // requested an abort for. In the same order as the 'coins' + // from the original request. + // The rtransaction_id is implied to be 0. + refunds: MerchantAbortPayRefundStatus[]; +} + +export const codecForAbortResponse = (): Codec => + buildCodecForObject() + .property("refunds", codecForList(codecForMerchantAbortPayRefundStatus())) + .build("AbortResponse"); + +export type MerchantAbortPayRefundStatus = + | MerchantAbortPayRefundSuccessStatus + | MerchantAbortPayRefundFailureStatus; + +// Details about why a refund failed. +export interface MerchantAbortPayRefundFailureStatus { + // Used as tag for the sum type RefundStatus sum type. + type: "failure"; + + // HTTP status of the exchange request, must NOT be 200. + exchange_status: number; + + // Taler error code from the exchange reply, if available. + exchange_code?: number; + + // If available, HTTP reply from the exchange. + exchange_reply?: unknown; +} + +// Additional details needed to verify the refund confirmation signature +// (h_contract_terms and merchant_pub) are already known +// to the wallet and thus not included. +export interface MerchantAbortPayRefundSuccessStatus { + // Used as tag for the sum type MerchantCoinRefundStatus sum type. + type: "success"; + + // HTTP status of the exchange request, 200 (integer) required for refund confirmations. + exchange_status: 200; + + // the EdDSA :ref:signature (binary-only) with purpose + // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the + // exchange affirming the successful refund + exchange_sig: string; + + // public EdDSA key of the exchange that was used to generate the signature. + // Should match one of the exchange's signing keys from /keys. It is given + // explicitly as the client might otherwise be confused by clock skew as to + // which signing key was used. + exchange_pub: string; +} + +export const codecForMerchantAbortPayRefundSuccessStatus = (): Codec< + MerchantAbortPayRefundSuccessStatus +> => + buildCodecForObject() + .property("exchange_pub", codecForString()) + .property("exchange_sig", codecForString()) + .property("exchange_status", codecForConstNumber(200)) + .property("type", codecForConstString("success")) + .build("MerchantAbortPayRefundSuccessStatus"); + +export const codecForMerchantAbortPayRefundFailureStatus = (): Codec< + MerchantAbortPayRefundFailureStatus +> => + buildCodecForObject() + .property("exchange_code", codecForNumber()) + .property("exchange_reply", codecForAny()) + .property("exchange_status", codecForNumber()) + .property("type", codecForConstString("failure")) + .build("MerchantAbortPayRefundFailureStatus"); + +export const codecForMerchantAbortPayRefundStatus = (): Codec< + MerchantAbortPayRefundStatus +> => + buildCodecForUnion() + .discriminateOn("type") + .alternative("success", codecForMerchantAbortPayRefundSuccessStatus()) + .alternative("failure", codecForMerchantAbortPayRefundFailureStatus()) + .build("MerchantAbortPayRefundStatus"); diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts index 5507822fa..b8d8be668 100644 --- a/packages/taler-wallet-core/src/types/walletTypes.ts +++ b/packages/taler-wallet-core/src/types/walletTypes.ts @@ -932,3 +932,11 @@ export const codecForAcceptTipRequest = (): Codec => .property("walletTipId", codecForString()) .build("AcceptTipRequest"); +export interface AbortPayWithRefundRequest { + proposalId: string; +} + +export const codecForAbortPayWithRefundRequest = (): Codec => + buildCodecForObject() + .property("proposalId", codecForString()) + .build("AbortPayWithRefundRequest"); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index e91d74efb..768d5eb0f 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -94,6 +94,7 @@ import { PrepareTipResult, codecForPrepareTipRequest, codecForAcceptTipRequest, + codecForAbortPayWithRefundRequest, } from "./types/walletTypes"; import { Logger } from "./util/logging"; @@ -132,7 +133,7 @@ import { PendingOperationType, } from "./types/pending"; import { WalletNotification, NotificationType } from "./types/notifications"; -import { processPurchaseQueryRefund, applyRefund } from "./operations/refund"; +import { processPurchaseQueryRefund, applyRefund, abortFailedPayWithRefund } from "./operations/refund"; import { durationMin, Duration } from "./util/time"; import { processRecoupGroup } from "./operations/recoup"; import { @@ -744,8 +745,8 @@ export class Wallet { return prepareTip(this.ws, talerTipUri); } - async abortFailedPayment(contractTermsHash: string): Promise { - throw Error("not implemented"); + async abortFailedPayWithRefund(proposalId: string): Promise { + return abortFailedPayWithRefund(this.ws, proposalId); } /** @@ -1022,11 +1023,6 @@ export class Wallet { const req = codecForGetExchangeTosRequest().decode(payload); return this.getExchangeTos(req.exchangeBaseUrl); } - case "abortProposal": { - const req = codecForAbortProposalRequest().decode(payload); - await this.refuseProposal(req.proposalId); - return {}; - } case "retryPendingNow": { await this.runPending(true); return {}; @@ -1039,6 +1035,11 @@ export class Wallet { const req = codecForConfirmPayRequest().decode(payload); return await this.confirmPay(req.proposalId, req.sessionId); } + case "abortFailedPayWithRefund": { + const req = codecForAbortPayWithRefundRequest().decode(payload); + await this.abortFailedPayWithRefund(req.proposalId); + return {}; + } case "dumpCoins": { return await this.dumpCoins(); } -- cgit v1.2.3