diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations/refund.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/refund.ts | 201 |
1 files changed, 171 insertions, 30 deletions
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}`); + }); } |