diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations/refund.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/refund.ts | 777 |
1 files changed, 0 insertions, 777 deletions
diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts deleted file mode 100644 index a5846f259..000000000 --- a/packages/taler-wallet-core/src/operations/refund.ts +++ /dev/null @@ -1,777 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019-2019 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 <http://www.gnu.org/licenses/> - */ - -/** - * Implementation of the refund operation. - * - * @author Florian Dold - */ - -/** - * Imports. - */ -import { - AbortingCoin, - AbortRequest, - AmountJson, - Amounts, - ApplyRefundResponse, - codecForAbortResponse, - codecForMerchantOrderRefundPickupResponse, - CoinPublicKey, - getTimestampNow, - Logger, - MerchantCoinRefundFailureStatus, - MerchantCoinRefundStatus, - MerchantCoinRefundSuccessStatus, - NotificationType, - parseRefundUri, - RefreshReason, - TalerErrorCode, - TalerErrorDetails, - URL, - timestampAddDuration, - codecForMerchantOrderStatusPaid, - isTimestampExpired, -} from "@gnu-taler/taler-util"; -import { - AbortStatus, - CoinStatus, - PurchaseRecord, - RefundReason, - RefundState, - WalletStoresV1, -} from "../db.js"; -import { readSuccessResponseJsonOrThrow } from "../util/http.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadWriteAccess } from "../util/query.js"; -import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; -import { guardOperationException } from "../errors.js"; -import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; -import { InternalWalletState } from "../common.js"; - -const logger = new Logger("refund.ts"); - -/** - * Retry querying and applying refunds for an order later. - */ -async function incrementPurchaseQueryRefundRetry( - ws: InternalWalletState, - proposalId: string, - err: TalerErrorDetails | undefined, -): Promise<void> { - await ws.db - .mktx((x) => ({ - purchases: x.purchases, - })) - .runReadWrite(async (tx) => { - const pr = await tx.purchases.get(proposalId); - if (!pr) { - return; - } - if (!pr.refundStatusRetryInfo) { - return; - } - pr.refundStatusRetryInfo.retryCounter++; - updateRetryInfoTimeout(pr.refundStatusRetryInfo); - pr.lastRefundStatusError = err; - await tx.purchases.put(pr); - }); - if (err) { - ws.notify({ - type: NotificationType.RefundStatusOperationError, - error: err, - }); - } -} - -function getRefundKey(d: MerchantCoinRefundStatus): string { - return `${d.coin_pub}-${d.rtransaction_id}`; -} - -async function applySuccessfulRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record<string, { coinPub: string }>, - r: MerchantCoinRefundSuccessStatus, -): Promise<void> { - // FIXME: check signature before storing it as valid! - - const refundKey = getRefundKey(r); - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - throw Error("inconsistent database"); - } - refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; - const refundAmount = Amounts.parseOrThrow(r.refund_amount); - const refundFee = denom.feeRefund; - coin.status = CoinStatus.Dormant; - coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount; - coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount; - logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`); - await tx.coins.put(coin); - - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - const amountLeft = Amounts.sub( - Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) - .amount, - denom.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - denom, - amountLeft, - ); - - p.refunds[refundKey] = { - type: RefundState.Applied, - obtainedTime: getTimestampNow(), - executionTime: r.execution_time, - refundAmount: Amounts.parseOrThrow(r.refund_amount), - refundFee: denom.feeRefund, - totalRefreshCostBound, - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; -} - -async function storePendingRefund( - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - }>, - p: PurchaseRecord, - r: MerchantCoinRefundFailureStatus, -): Promise<void> { - const refundKey = getRefundKey(r); - - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - - if (!denom) { - throw Error("inconsistent database"); - } - - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - const amountLeft = Amounts.sub( - Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) - .amount, - denom.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - denom, - amountLeft, - ); - - p.refunds[refundKey] = { - type: RefundState.Pending, - obtainedTime: getTimestampNow(), - executionTime: r.execution_time, - refundAmount: Amounts.parseOrThrow(r.refund_amount), - refundFee: denom.feeRefund, - totalRefreshCostBound, - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; -} - -async function storeFailedRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record<string, { coinPub: string }>, - r: MerchantCoinRefundFailureStatus, -): Promise<void> { - const refundKey = getRefundKey(r); - - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - - if (!denom) { - throw Error("inconsistent database"); - } - - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - const amountLeft = Amounts.sub( - Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) - .amount, - denom.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - denom, - amountLeft, - ); - - p.refunds[refundKey] = { - type: RefundState.Failed, - obtainedTime: getTimestampNow(), - executionTime: r.execution_time, - refundAmount: Amounts.parseOrThrow(r.refund_amount), - refundFee: denom.feeRefund, - totalRefreshCostBound, - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; - - 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.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) { - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - 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.coins.put(coin); - } - } -} - -async function acceptRefunds( - ws: InternalWalletState, - proposalId: string, - refunds: MerchantCoinRefundStatus[], - reason: RefundReason, -): Promise<void> { - logger.trace("handling refunds", refunds); - const now = getTimestampNow(); - - await ws.db - .mktx((x) => ({ - purchases: x.purchases, - coins: x.coins, - denominations: x.denominations, - refreshGroups: x.refreshGroups, - })) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - logger.error("purchase not found, not adding refunds"); - return; - } - - const refreshCoinsMap: Record<string, CoinPublicKey> = {}; - - for (const refundStatus of refunds) { - const refundKey = getRefundKey(refundStatus); - const existingRefundInfo = p.refunds[refundKey]; - - const isPermanentFailure = - refundStatus.type === "failure" && - refundStatus.exchange_status >= 400 && - refundStatus.exchange_status < 500; - - // Already failed. - if (existingRefundInfo?.type === RefundState.Failed) { - continue; - } - - // Already applied. - if (existingRefundInfo?.type === RefundState.Applied) { - continue; - } - - // Still pending. - if ( - refundStatus.type === "failure" && - !isPermanentFailure && - existingRefundInfo?.type === RefundState.Pending - ) { - continue; - } - - // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending) - - if (refundStatus.type === "success") { - await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus); - } else if (isPermanentFailure) { - await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus); - } else { - await storePendingRefund(tx, p, refundStatus); - } - } - - const refreshCoinsPubs = Object.values(refreshCoinsMap); - if (refreshCoinsPubs.length > 0) { - await createRefreshGroup( - ws, - tx, - refreshCoinsPubs, - RefreshReason.Refund, - ); - } - - // Are we done with querying yet, or do we need to do another round - // after a retry delay? - let queryDone = true; - - if ( - p.timestampFirstSuccessfulPay && - p.autoRefundDeadline && - p.autoRefundDeadline.t_ms > now.t_ms - ) { - queryDone = false; - } - - let numPendingRefunds = 0; - for (const ri of Object.values(p.refunds)) { - switch (ri.type) { - case RefundState.Pending: - numPendingRefunds++; - break; - } - } - - if (numPendingRefunds > 0) { - queryDone = false; - } - - if (queryDone) { - p.timestampLastRefundStatus = now; - p.lastRefundStatusError = undefined; - p.refundStatusRetryInfo = initRetryInfo(); - 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! - p.timestampLastRefundStatus = now; - p.refundStatusRetryInfo.retryCounter++; - updateRetryInfoTimeout(p.refundStatusRetryInfo); - p.lastRefundStatusError = undefined; - logger.trace("refund query not done"); - } - - await tx.purchases.put(p); - }); - - ws.notify({ - type: NotificationType.RefundQueried, - }); -} - -/** - * Summary of the refund status of a purchase. - */ -export interface RefundSummary { - pendingAtExchange: boolean; - amountEffectivePaid: AmountJson; - amountRefundGranted: AmountJson; - amountRefundGone: AmountJson; -} - -/** - * Accept a refund, return the contract hash for the contract - * that was involved in the refund. - */ -export async function applyRefund( - ws: InternalWalletState, - talerRefundUri: string, -): Promise<ApplyRefundResponse> { - const parseResult = parseRefundUri(talerRefundUri); - - logger.trace("applying refund", parseResult); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - let purchase = await ws.db - .mktx((x) => ({ - purchases: x.purchases, - })) - .runReadOnly(async (tx) => { - return tx.purchases.indexes.byMerchantUrlAndOrderId.get([ - parseResult.merchantBaseUrl, - parseResult.orderId, - ]); - }); - - if (!purchase) { - throw Error( - `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, - ); - } - - const proposalId = purchase.proposalId; - - logger.info("processing purchase for refund"); - const success = await ws.db - .mktx((x) => ({ - purchases: x.purchases, - })) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - logger.error("no purchase found for refund URL"); - return false; - } - p.refundQueryRequested = true; - p.lastRefundStatusError = undefined; - p.refundStatusRetryInfo = initRetryInfo(); - await tx.purchases.put(p); - return true; - }); - - if (success) { - ws.notify({ - type: NotificationType.RefundStarted, - }); - await processPurchaseQueryRefundImpl(ws, proposalId, true, false); - } - - purchase = await ws.db - .mktx((x) => ({ - purchases: x.purchases, - })) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - - if (!purchase) { - throw Error("purchase no longer exists"); - } - - const p = purchase; - - let amountRefundGranted = Amounts.getZero( - purchase.download.contractData.amount.currency, - ); - let amountRefundGone = Amounts.getZero( - purchase.download.contractData.amount.currency, - ); - - let pendingAtExchange = false; - - Object.keys(purchase.refunds).forEach((rk) => { - const refund = p.refunds[rk]; - if (refund.type === RefundState.Pending) { - pendingAtExchange = true; - } - if ( - refund.type === RefundState.Applied || - refund.type === RefundState.Pending - ) { - amountRefundGranted = Amounts.add( - amountRefundGranted, - Amounts.sub( - refund.refundAmount, - refund.refundFee, - refund.totalRefreshCostBound, - ).amount, - ).amount; - } else { - amountRefundGone = Amounts.add(amountRefundGone, refund.refundAmount) - .amount; - } - }); - - return { - contractTermsHash: purchase.download.contractData.contractTermsHash, - proposalId: purchase.proposalId, - amountEffectivePaid: Amounts.stringify(purchase.totalPayCost), - amountRefundGone: Amounts.stringify(amountRefundGone), - amountRefundGranted: Amounts.stringify(amountRefundGranted), - pendingAtExchange, - info: { - contractTermsHash: purchase.download.contractData.contractTermsHash, - merchant: purchase.download.contractData.merchant, - orderId: purchase.download.contractData.orderId, - products: purchase.download.contractData.products, - summary: purchase.download.contractData.summary, - fulfillmentMessage: purchase.download.contractData.fulfillmentMessage, - summary_i18n: purchase.download.contractData.summaryI18n, - fulfillmentMessage_i18n: - purchase.download.contractData.fulfillmentMessageI18n, - }, - }; -} - -export async function processPurchaseQueryRefund( - ws: InternalWalletState, - proposalId: string, - forceNow = false, -): Promise<void> { - const onOpErr = (e: TalerErrorDetails): Promise<void> => - incrementPurchaseQueryRefundRetry(ws, proposalId, e); - await guardOperationException( - () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow, true), - onOpErr, - ); -} - -async function resetPurchaseQueryRefundRetry( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - await ws.db - .mktx((x) => ({ - purchases: x.purchases, - })) - .runReadWrite(async (tx) => { - const x = await tx.purchases.get(proposalId); - if (x) { - x.refundStatusRetryInfo = initRetryInfo(); - await tx.purchases.put(x); - } - }); -} - -async function processPurchaseQueryRefundImpl( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean, - waitForAutoRefund: boolean, -): Promise<void> { - if (forceNow) { - await resetPurchaseQueryRefundRetry(ws, proposalId); - } - const purchase = await ws.db - .mktx((x) => ({ - purchases: x.purchases, - })) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - if (!purchase) { - return; - } - - if (!purchase.refundQueryRequested) { - return; - } - - if (purchase.timestampFirstSuccessfulPay) { - if ( - waitForAutoRefund && - purchase.autoRefundDeadline && - !isTimestampExpired(purchase.autoRefundDeadline) - ) { - const requestUrl = new URL( - `orders/${purchase.download.contractData.orderId}`, - purchase.download.contractData.merchantBaseUrl, - ); - requestUrl.searchParams.set( - "h_contract", - purchase.download.contractData.contractTermsHash, - ); - // Long-poll for one second - requestUrl.searchParams.set("timeout_ms", "1000"); - requestUrl.searchParams.set("await_refund_obtained", "yes"); - logger.trace("making long-polling request for auto-refund"); - const resp = await ws.http.get(requestUrl.href); - const orderStatus = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantOrderStatusPaid(), - ); - if (!orderStatus.refunded) { - incrementPurchaseQueryRefundRetry(ws, proposalId, undefined); - return; - } - } - - const requestUrl = new URL( - `orders/${purchase.download.contractData.orderId}/refund`, - purchase.download.contractData.merchantBaseUrl, - ); - - logger.trace(`making refund request to ${requestUrl.href}`); - - const request = await ws.http.postJson(requestUrl.href, { - h_contract: purchase.download.contractData.contractTermsHash, - }); - - logger.trace( - "got json", - JSON.stringify(await request.json(), undefined, 2), - ); - - 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.download.contractData.orderId}/abort`, - purchase.download.contractData.merchantBaseUrl, - ); - - const abortingCoins: AbortingCoin[] = []; - - await ws.db - .mktx((x) => ({ - coins: x.coins, - })) - .runReadOnly(async (tx) => { - for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) { - const coinPub = purchase.payCoinSelection.coinPubs[i]; - const coin = await tx.coins.get(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.download.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.download.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 - .mktx((x) => ({ - purchases: x.purchases, - })) - .runReadWrite(async (tx) => { - const purchase = await tx.purchases.get(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(); - await tx.purchases.put(purchase); - }); - processPurchaseQueryRefund(ws, proposalId, true).catch((e) => { - logger.trace(`error during refund processing after abort pay: ${e}`); - }); -} |