diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations')
-rw-r--r-- | packages/taler-wallet-core/src/operations/pay.ts | 138 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/pending.ts | 5 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/refund.ts | 201 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/transactions.ts | 12 |
4 files changed, 260 insertions, 96 deletions
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<void> { 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<void> { - 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<ConfirmPayResult> { @@ -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<void> => + 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<void> => + 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<void> => + 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<void> { 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<string, { coinPub: string }>, r: MerchantCoinRefundFailureStatus, ): Promise<void> { 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<void> { + 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] }; } |