From 7f0edb6a783d9a50f94f65c815c1280baecaac89 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Fri, 5 May 2023 19:03:44 +0200 Subject: wallet-core: refund DD37 refactoring --- .../src/operations/pay-merchant.ts | 1492 +++++++++----------- 1 file changed, 656 insertions(+), 836 deletions(-) (limited to 'packages/taler-wallet-core/src/operations/pay-merchant.ts') diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index 6aad1d742..99b9a18d2 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -58,19 +58,23 @@ import { MerchantCoinRefundSuccessStatus, MerchantContractTerms, MerchantPayResponse, + MerchantRefundResponse, NotificationType, parsePayUri, parseRefundUri, + parseTalerUri, PayCoinSelection, PreparePayResult, PreparePayResultType, PrepareRefundResult, + randomBytes, RefreshReason, TalerError, TalerErrorCode, TalerErrorDetail, TalerProtocolTimestamp, TalerProtocolViolationError, + TalerUriAction, TransactionMajorState, TransactionMinorState, TransactionState, @@ -93,11 +97,16 @@ import { PurchaseRecord, PurchaseStatus, RefundReason, - RefundState, WalletContractData, WalletStoresV1, } from "../db.js"; -import { GetReadWriteAccess, PendingTaskType } from "../index.js"; +import { + PendingTaskType, + RefundGroupRecord, + RefundGroupStatus, + RefundItemRecord, + RefundItemStatus, +} from "../index.js"; import { EXCHANGE_COINS_LOCK, InternalWalletState, @@ -116,10 +125,19 @@ import { } from "../util/retries.js"; import { makeTransactionId, + runLongpollAsync, runOperationWithErrorReporting, spendCoins, } from "./common.js"; -import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; +import { + calculateRefreshOutput, + createRefreshGroup, + getTotalRefreshCost, +} from "./refresh.js"; +import { + constructTransactionIdentifier, + notifyTransition, +} from "./transactions.js"; /** * Logger. @@ -193,7 +211,7 @@ async function failProposalPermanently( if (!p) { return; } - p.purchaseStatus = PurchaseStatus.ProposalDownloadFailed; + p.purchaseStatus = PurchaseStatus.FailedClaim; await tx.purchases.put(p); }); } @@ -601,7 +619,6 @@ async function startDownloadProposal( merchantPaySig: undefined, payInfo: undefined, refundAmountAwaiting: undefined, - refunds: {}, timestampAccept: undefined, timestampFirstSuccessfulPay: undefined, timestampLastRefundStatus: undefined, @@ -649,7 +666,7 @@ async function storeFirstPaySuccess( return; } if (purchase.purchaseStatus === PurchaseStatus.Paying) { - purchase.purchaseStatus = PurchaseStatus.Paid; + purchase.purchaseStatus = PurchaseStatus.Done; } purchase.timestampFirstSuccessfulPay = now; purchase.lastSessionId = sessionId; @@ -701,7 +718,7 @@ async function storePayReplaySuccess( purchase.purchaseStatus === PurchaseStatus.Paying || purchase.purchaseStatus === PurchaseStatus.PayingReplay ) { - purchase.purchaseStatus = PurchaseStatus.Paid; + purchase.purchaseStatus = PurchaseStatus.Done; } purchase.lastSessionId = sessionId; await tx.purchases.put(purchase); @@ -899,6 +916,11 @@ export async function checkPaymentByProposalId( proposalId = proposal.proposalId; + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const talerUri = constructPayUri( proposal.merchantBaseUrl, proposal.orderId, @@ -937,6 +959,7 @@ export async function checkPaymentByProposalId( status: PreparePayResultType.InsufficientBalance, contractTerms: d.contractTermsRaw, proposalId: proposal.proposalId, + transactionId, noncePriv: proposal.noncePriv, amountRaw: Amounts.stringify(d.contractData.amount), talerUri, @@ -951,6 +974,7 @@ export async function checkPaymentByProposalId( return { status: PreparePayResultType.PaymentPossible, contractTerms: d.contractTermsRaw, + transactionId, proposalId: proposal.proposalId, noncePriv: proposal.noncePriv, amountEffective: Amounts.stringify(totalCost), @@ -961,7 +985,7 @@ export async function checkPaymentByProposalId( } if ( - purchase.purchaseStatus === PurchaseStatus.Paid && + purchase.purchaseStatus === PurchaseStatus.Done && purchase.lastSessionId !== sessionId ) { logger.trace( @@ -992,6 +1016,7 @@ export async function checkPaymentByProposalId( paid: true, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + transactionId, proposalId, talerUri, }; @@ -1004,12 +1029,13 @@ export async function checkPaymentByProposalId( paid: false, amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + transactionId, proposalId, talerUri, }; } else { const paid = - purchase.purchaseStatus === PurchaseStatus.Paid || + purchase.purchaseStatus === PurchaseStatus.Done || purchase.purchaseStatus === PurchaseStatus.QueryingRefund || purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund; const download = await expectProposalDownload(ws, purchase); @@ -1021,6 +1047,7 @@ export async function checkPaymentByProposalId( amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), ...(paid ? { nextUrl: download.contractData.orderId } : {}), + transactionId, proposalId, talerUri, }; @@ -1244,7 +1271,7 @@ export async function confirmPay( ) { logger.trace(`changing session ID to ${sessionIdOverride}`); purchase.lastSessionId = sessionIdOverride; - if (purchase.purchaseStatus === PurchaseStatus.Paid) { + if (purchase.purchaseStatus === PurchaseStatus.Done) { purchase.purchaseStatus = PurchaseStatus.PayingReplay; } await tx.purchases.put(purchase); @@ -1331,7 +1358,7 @@ export async function confirmPay( refreshReason: RefreshReason.PayMerchant, }); break; - case PurchaseStatus.Paid: + case PurchaseStatus.Done: case PurchaseStatus.Paying: default: break; @@ -1371,20 +1398,24 @@ export async function processPurchase( switch (purchase.purchaseStatus) { case PurchaseStatus.DownloadingProposal: - return processDownloadProposal(ws, proposalId, options); + return processDownloadProposal(ws, proposalId); case PurchaseStatus.Paying: case PurchaseStatus.PayingReplay: - return processPurchasePay(ws, proposalId, options); + return processPurchasePay(ws, proposalId); case PurchaseStatus.QueryingRefund: + return processPurchaseQueryRefund(ws, purchase); case PurchaseStatus.QueryingAutoRefund: + return processPurchaseAutoRefund(ws, purchase); case PurchaseStatus.AbortingWithRefund: - return processPurchaseQueryRefund(ws, proposalId, options); - case PurchaseStatus.ProposalDownloadFailed: - case PurchaseStatus.Paid: + return processPurchaseAbortingRefund(ws, purchase); + case PurchaseStatus.PendingAcceptRefund: + return processPurchaseAcceptRefund(ws, purchase); + case PurchaseStatus.FailedClaim: + case PurchaseStatus.Done: case PurchaseStatus.RepurchaseDetected: case PurchaseStatus.Proposed: - case PurchaseStatus.ProposalRefused: - case PurchaseStatus.PaymentAbortFinished: + case PurchaseStatus.AbortedProposalRefused: + case PurchaseStatus.AbortedIncompletePayment: return { type: OperationAttemptResultType.Finished, result: undefined, @@ -1588,7 +1619,7 @@ export async function refuseProposal( if (proposal.purchaseStatus !== PurchaseStatus.Proposed) { return false; } - proposal.purchaseStatus = PurchaseStatus.ProposalRefused; + proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused; await tx.purchases.put(proposal); return true; }); @@ -1599,942 +1630,731 @@ export async function refuseProposal( } } -export async function prepareRefund( +export async function abortPayMerchant( ws: InternalWalletState, - talerRefundUri: string, -): Promise { - const parseResult = parseRefundUri(talerRefundUri); - - logger.trace("preparing refund offer", parseResult); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.indexes.byUrlAndOrderId.get([ - parseResult.merchantBaseUrl, - parseResult.orderId, - ]); + proposalId: string, +): Promise { + const opId = constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId, + }); + await ws.db + .mktx((x) => [ + x.purchases, + x.refreshGroups, + x.denominations, + x.coinAvailability, + x.coins, + x.operationRetries, + ]) + .runReadWrite(async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) { + throw Error("purchase not found"); + } + const oldStatus = purchase.purchaseStatus; + if (purchase.timestampFirstSuccessfulPay) { + // No point in aborting it. We don't even report an error. + logger.warn(`tried to abort successful payment`); + return; + } + if (oldStatus === PurchaseStatus.Paying) { + purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; + } + await tx.purchases.put(purchase); + if (oldStatus === PurchaseStatus.Paying) { + if (purchase.payInfo) { + const coinSel = purchase.payInfo.payCoinSelection; + const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost); + const refreshCoins: CoinRefreshRequest[] = []; + for (let i = 0; i < coinSel.coinPubs.length; i++) { + refreshCoins.push({ + amount: coinSel.coinContributions[i], + coinPub: coinSel.coinPubs[i], + }); + } + await createRefreshGroup( + ws, + tx, + currency, + refreshCoins, + RefreshReason.AbortPay, + ); + } + } + await tx.operationRetries.delete(opId); }); - if (!purchase) { - throw Error( - `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, - ); + ws.workAvailable.trigger(); +} + +export function computePayMerchantTransactionState( + purchaseRecord: PurchaseRecord, +): TransactionState { + switch (purchaseRecord.purchaseStatus) { + case PurchaseStatus.DownloadingProposal: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.ClaimProposal, + }; + case PurchaseStatus.Done: + return { + major: TransactionMajorState.Done, + }; + case PurchaseStatus.AbortedIncompletePayment: + return { + major: TransactionMajorState.Aborted, + }; + case PurchaseStatus.Proposed: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.MerchantOrderProposed, + }; + case PurchaseStatus.FailedClaim: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.ClaimProposal, + }; + case PurchaseStatus.RepurchaseDetected: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.Repurchase, + }; + case PurchaseStatus.AbortingWithRefund: + return { + major: TransactionMajorState.Aborting, + }; + case PurchaseStatus.Paying: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Pay, + }; + case PurchaseStatus.PayingReplay: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.RebindSession, + }; + case PurchaseStatus.AbortedProposalRefused: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.Refused, + }; + case PurchaseStatus.QueryingAutoRefund: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.AutoRefund, + }; + case PurchaseStatus.QueryingRefund: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.CheckRefunds, + }; + case PurchaseStatus.PendingAcceptRefund: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.AcceptRefund, + }; } +} - const awaiting = await queryAndSaveAwaitingRefund(ws, purchase); - const summary = await calculateRefundSummary(ws, purchase); +async function processPurchaseAutoRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise { const proposalId = purchase.proposalId; + logger.trace(`processing auto-refund for proposal ${proposalId}`); - const { contractData: c } = await expectProposalDownload(ws, purchase); + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId, + }); - return { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, proposalId, - effectivePaid: Amounts.stringify(summary.amountEffectivePaid), - gone: Amounts.stringify(summary.amountRefundGone), - granted: Amounts.stringify(summary.amountRefundGranted), - pending: summary.pendingAtExchange, - awaiting: Amounts.stringify(awaiting), - info: { - contractTermsHash: c.contractTermsHash, - merchant: c.merchant, - orderId: c.orderId, - products: c.products, - summary: c.summary, - fulfillmentMessage: c.fulfillmentMessage, - summary_i18n: c.summaryI18n, - fulfillmentMessage_i18n: c.fulfillmentMessageI18n, - }, - }; -} + }); -function getRefundKey(d: MerchantCoinRefundStatus): string { - return `${d.coin_pub}-${d.rtransaction_id}`; -} + // FIXME: Put this logic into runLongpollAsync? + if (ws.activeLongpoll[taskId]) { + return OperationAttemptResult.longpoll(); + } -async function applySuccessfulRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record, - r: MerchantCoinRefundSuccessStatus, - denomselAllowLate: boolean, -): Promise { - // FIXME: check signature before storing it as valid! + const download = await expectProposalDownload(ws, purchase); - 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 refundAmount = Amounts.parseOrThrow(r.refund_amount); - const refundFee = denom.fees.feeRefund; - const amountLeft = Amounts.sub(refundAmount, refundFee).amount; - coin.status = CoinStatus.Dormant; - await tx.coins.put(coin); - - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - denomselAllowLate, - ); + runLongpollAsync(ws, taskId, async (ct) => { + if ( + !purchase.autoRefundDeadline || + AbsoluteTime.isExpired( + AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), + ) + ) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) { + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.Done; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + return { + ready: true, + }; + } - refreshCoinsMap[coin.coinPub] = { - coinPub: coin.coinPub, - amount: Amounts.stringify(amountLeft), - }; + const requestUrl = new URL( + `orders/${download.contractData.orderId}`, + download.contractData.merchantBaseUrl, + ); + requestUrl.searchParams.set( + "h_contract", + download.contractData.contractTermsHash, + ); - p.refunds[refundKey] = { - type: RefundState.Applied, - obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - executionTime: r.execution_time, - refundAmount: Amounts.stringify(r.refund_amount), - refundFee: Amounts.stringify(denom.fees.feeRefund), - totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound), - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; -} + requestUrl.searchParams.set("timeout_ms", "1000"); + requestUrl.searchParams.set("await_refund_obtained", "yes"); -async function storePendingRefund( - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - }>, - p: PurchaseRecord, - r: MerchantCoinRefundFailureStatus, - denomselAllowLate: boolean, -): Promise { - const refundKey = getRefundKey(r); + const resp = await ws.http.fetch(requestUrl.href); - 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, - ]); + // FIXME: Check other status codes! - if (!denom) { - throw Error("inconsistent database"); - } + const orderStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantOrderStatusPaid(), + ); - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - // Refunded amount after fees. - const amountLeft = Amounts.sub( - Amounts.parseOrThrow(r.refund_amount), - denom.fees.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - denomselAllowLate, - ); + if (orderStatus.refund_pending) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + if (p.purchaseStatus !== PurchaseStatus.QueryingAutoRefund) { + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + return { + ready: true, + }; + } else { + return { + ready: false, + }; + } + }); - p.refunds[refundKey] = { - type: RefundState.Pending, - obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - executionTime: r.execution_time, - refundAmount: Amounts.stringify(r.refund_amount), - refundFee: Amounts.stringify(denom.fees.feeRefund), - totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound), - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; + return OperationAttemptResult.longpoll(); } -async function storeFailedRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record, - r: MerchantCoinRefundFailureStatus, - denomselAllowLate: boolean, -): Promise { - const refundKey = getRefundKey(r); +async function processPurchaseAbortingRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise { + const proposalId = purchase.proposalId; + const download = await expectProposalDownload(ws, purchase); + logger.trace(`processing aborting-refund for proposal ${proposalId}`); - 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, - ]); + const requestUrl = new URL( + `orders/${download.contractData.orderId}/abort`, + download.contractData.merchantBaseUrl, + ); - if (!denom) { - throw Error("inconsistent database"); + const abortingCoins: AbortingCoin[] = []; + + const payCoinSelection = purchase.payInfo?.payCoinSelection; + if (!payCoinSelection) { + throw Error("can't abort, no coins selected"); } - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); + await ws.db + .mktx((x) => [x.coins]) + .runReadOnly(async (tx) => { + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = 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( + payCoinSelection.coinContributions[i], + ), + exchange_url: coin.exchangeBaseUrl, + }); + } + }); + + const abortReq: AbortRequest = { + h_contract: download.contractData.contractTermsHash, + coins: abortingCoins, + }; - const amountLeft = Amounts.sub( - Amounts.parseOrThrow(r.refund_amount), - denom.fees.feeRefund, - ).amount; + logger.trace(`making order abort request to ${requestUrl.href}`); - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - denomselAllowLate, + const request = await ws.http.postJson(requestUrl.href, abortReq); + const abortResp = await readSuccessResponseJsonOrThrow( + request, + codecForAbortResponse(), ); - p.refunds[refundKey] = { - type: RefundState.Failed, - obtainedTime: TalerProtocolTimestamp.now(), - executionTime: r.execution_time, - refundAmount: Amounts.stringify(r.refund_amount), - refundFee: Amounts.stringify(denom.fees.feeRefund), - totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound), - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; + const refunds: MerchantCoinRefundStatus[] = []; - if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) { - // Refund failed because the merchant didn't even try to deposit - // the coin yet, so we try to refresh. - // FIXME: Is this case tested?! - 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; - } - const payCoinSelection = p.payInfo?.payCoinSelection; - if (!payCoinSelection) { - logger.warn("no pay coin selection, can't apply refund"); - return; - } - let contrib: AmountJson | undefined; - for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { - if (payCoinSelection.coinPubs[i] === r.coin_pub) { - contrib = Amounts.parseOrThrow(payCoinSelection.coinContributions[i]); - } - } - // FIXME: Is this case tested?! - refreshCoinsMap[coin.coinPub] = { - coinPub: coin.coinPub, - amount: Amounts.stringify(amountLeft), - }; - await tx.coins.put(coin); - } + 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: payCoinSelection.coinPubs[i], + refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]), + rtransaction_id: 0, + execution_time: AbsoluteTime.toTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.fromTimestamp(download.contractData.timestamp), + Duration.fromSpec({ seconds: 1 }), + ), + ), + }); } + return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund); } -async function acceptRefunds( +async function processPurchaseQueryRefund( ws: InternalWalletState, - proposalId: string, - refunds: MerchantCoinRefundStatus[], - reason: RefundReason, -): Promise { - logger.trace("handling refunds", refunds); - const now = TalerProtocolTimestamp.now(); + purchase: PurchaseRecord, +): Promise { + const proposalId = purchase.proposalId; + logger.trace(`processing query-refund for proposal ${proposalId}`); - await ws.db - .mktx((x) => [ - x.purchases, - x.coins, - x.coinAvailability, - x.denominations, - 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 = {}; - 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; - } + const download = await expectProposalDownload(ws, purchase); - // Already applied. - if (existingRefundInfo?.type === RefundState.Applied) { - continue; - } + const requestUrl = new URL( + `orders/${download.contractData.orderId}`, + download.contractData.merchantBaseUrl, + ); + requestUrl.searchParams.set( + "h_contract", + download.contractData.contractTermsHash, + ); - // Still pending. - if ( - refundStatus.type === "failure" && - !isPermanentFailure && - existingRefundInfo?.type === RefundState.Pending - ) { - continue; - } + const resp = await ws.http.fetch(requestUrl.href); + const orderStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantOrderStatusPaid(), + ); - // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending) + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); - if (refundStatus.type === "success") { - await applySuccessfulRefund( - tx, - p, - refreshCoinsMap, - refundStatus, - ws.config.testing.denomselAllowLate, - ); - } else if (isPermanentFailure) { - await storeFailedRefund( - tx, - p, - refreshCoinsMap, - refundStatus, - ws.config.testing.denomselAllowLate, - ); - } else { - await storePendingRefund( - tx, - p, - refundStatus, - ws.config.testing.denomselAllowLate, - ); + if (!orderStatus.refund_pending) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return undefined; } - } - - if (reason !== RefundReason.AbortRefund) { - // For abort-refunds, the refresh group has already been - // created before the refund was started. - // For other refunds, we need to create it after we know - // the amounts. - const refreshCoinsPubs = Object.values(refreshCoinsMap); - logger.info(`refreshCoinMap ${j2s(refreshCoinsMap)}`); - if (refreshCoinsPubs.length > 0) { - await createRefreshGroup( - ws, - tx, - Amounts.currencyOf(refreshCoinsPubs[0].amount), - refreshCoinsPubs, - RefreshReason.Refund, - ); + if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) { + return undefined; } - } - - // Are we done with querying yet, or do we need to do another round - // after a retry delay? - let queryDone = true; + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.Done; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + return OperationAttemptResult.finishedEmpty(); + } else { + const refundAwaiting = Amounts.sub( + Amounts.parseOrThrow(orderStatus.refund_amount), + Amounts.parseOrThrow(orderStatus.refund_taken), + ).amount; - let numPendingRefunds = 0; - for (const ri of Object.values(p.refunds)) { - switch (ri.type) { - case RefundState.Pending: - numPendingRefunds++; - break; + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; } - } - - if (numPendingRefunds > 0) { - queryDone = false; - } - - if (queryDone) { - p.timestampLastRefundStatus = now; - if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) { - p.purchaseStatus = PurchaseStatus.PaymentAbortFinished; - } else if (p.purchaseStatus === PurchaseStatus.QueryingAutoRefund) { - const autoRefundDeadline = p.autoRefundDeadline; - checkDbInvariant(!!autoRefundDeadline); - if ( - AbsoluteTime.isExpired( - AbsoluteTime.fromTimestamp(autoRefundDeadline), - ) - ) { - p.purchaseStatus = PurchaseStatus.Paid; - } - } else if (p.purchaseStatus === PurchaseStatus.QueryingRefund) { - p.purchaseStatus = PurchaseStatus.Paid; - p.refundAmountAwaiting = undefined; + if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) { + return; } - logger.trace("refund query done"); - ws.notify({ - type: NotificationType.RefundFinished, - transactionId: makeTransactionId( - TransactionType.Payment, - p.proposalId, - ), - }); - } else { - // No error, but we need to try again! - p.timestampLastRefundStatus = now; - logger.trace("refund query not done"); - } - - await tx.purchases.put(p); - }); - - ws.notify({ - type: NotificationType.RefundQueried, - transactionId: makeTransactionId(TransactionType.Payment, proposalId), - }); + const oldTxState = computePayMerchantTransactionState(p); + p.refundAmountAwaiting = Amounts.stringify(refundAwaiting); + p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + notifyTransition(ws, transactionId, transitionInfo); + return OperationAttemptResult.finishedEmpty(); + } } -async function calculateRefundSummary( +async function processPurchaseAcceptRefund( ws: InternalWalletState, - p: PurchaseRecord, -): Promise { - const download = await expectProposalDownload(ws, p); - let amountRefundGranted = Amounts.zeroOfAmount(download.contractData.amount); - let amountRefundGone = Amounts.zeroOfAmount(download.contractData.amount); + purchase: PurchaseRecord, +): Promise { + const proposalId = purchase.proposalId; - let pendingAtExchange = false; + const download = await expectProposalDownload(ws, purchase); - const payInfo = p.payInfo; - if (!payInfo) { - throw Error("can't calculate refund summary without payInfo"); - } + const requestUrl = new URL( + `orders/${download.contractData.orderId}/refund`, + download.contractData.merchantBaseUrl, + ); - Object.keys(p.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; - } + logger.trace(`making refund request to ${requestUrl.href}`); + + const request = await ws.http.postJson(requestUrl.href, { + h_contract: download.contractData.contractTermsHash, }); - return { - amountEffectivePaid: Amounts.parseOrThrow(payInfo.totalPayCost), - amountRefundGone, - amountRefundGranted, - pendingAtExchange, - }; -} -/** - * Summary of the refund status of a purchase. - */ -export interface RefundSummary { - pendingAtExchange: boolean; - amountEffectivePaid: AmountJson; - amountRefundGranted: AmountJson; - amountRefundGone: AmountJson; + const refundResponse = await readSuccessResponseJsonOrThrow( + request, + codecForMerchantOrderRefundPickupResponse(), + ); + return await storeRefunds( + ws, + purchase, + refundResponse.refunds, + RefundReason.AbortRefund, + ); } -/** - * Accept a refund, return the contract hash for the contract - * that was involved in the refund. - */ -export async function applyRefund( +export async function startRefundQueryForUri( ws: InternalWalletState, - talerRefundUri: string, -): Promise { - const parseResult = parseRefundUri(talerRefundUri); - - logger.trace("applying refund", parseResult); - - if (!parseResult) { - throw Error("invalid refund URI"); + talerUri: string, +): Promise { + const parsedUri = parseTalerUri(talerUri); + if (!parsedUri) { + throw Error("invalid taler:// URI"); } - - const purchase = await ws.db + if (parsedUri.type !== TalerUriAction.Refund) { + throw Error("expected taler://refund URI"); + } + const purchaseRecord = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { return tx.purchases.indexes.byUrlAndOrderId.get([ - parseResult.merchantBaseUrl, - parseResult.orderId, + parsedUri.merchantBaseUrl, + parsedUri.orderId, ]); }); - - if (!purchase) { - throw Error( - `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, - ); + if (!purchaseRecord) { + throw Error("no purchase found, can't refund"); } - - return applyRefundFromPurchaseId(ws, purchase.proposalId); + return startQueryRefund(ws, purchaseRecord.proposalId); } -export async function applyRefundFromPurchaseId( +export async function startQueryRefund( ws: InternalWalletState, proposalId: string, -): Promise { - logger.trace("applying refund for purchase", proposalId); - - logger.info("processing purchase for refund"); - const success = await ws.db +): Promise { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { const p = await tx.purchases.get(proposalId); if (!p) { - logger.error("no purchase found for refund URL"); - return false; + logger.warn(`purchase ${proposalId} does not exist anymore`); + return; } - if (p.purchaseStatus === PurchaseStatus.Paid) { - p.purchaseStatus = PurchaseStatus.QueryingRefund; + if (p.purchaseStatus !== PurchaseStatus.Done) { + return; } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.QueryingRefund; + const newTxState = computePayMerchantTransactionState(p); await tx.purchases.put(p); - return true; - }); - - if (success) { - ws.notify({ - type: NotificationType.RefundStarted, - }); - await processPurchaseQueryRefund(ws, proposalId, { - forceNow: true, - waitForAutoRefund: false, + return { oldTxState, newTxState }; }); - } - - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - - if (!purchase) { - throw Error("purchase no longer exists"); - } - - const summary = await calculateRefundSummary(ws, purchase); - const download = await expectProposalDownload(ws, purchase); - - const lastExec = Object.values(purchase.refunds).reduce( - (prev, cur) => { - return TalerProtocolTimestamp.max(cur.executionTime, prev); - }, - { t_s: 0 } as TalerProtocolTimestamp, - ); - - const transactionId = - lastExec.t_s === "never" || lastExec.t_s === 0 - ? makeTransactionId(TransactionType.Payment, proposalId) - : makeTransactionId( - TransactionType.Refund, - proposalId, - String(lastExec.t_s), - ); - - return { - contractTermsHash: download.contractData.contractTermsHash, - proposalId: purchase.proposalId, - transactionId, - amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid), - amountRefundGone: Amounts.stringify(summary.amountRefundGone), - amountRefundGranted: Amounts.stringify(summary.amountRefundGranted), - pendingAtExchange: summary.pendingAtExchange, - info: { - contractTermsHash: download.contractData.contractTermsHash, - merchant: download.contractData.merchant, - orderId: download.contractData.orderId, - products: download.contractData.products, - summary: download.contractData.summary, - fulfillmentMessage: download.contractData.fulfillmentMessage, - summary_i18n: download.contractData.summaryI18n, - fulfillmentMessage_i18n: download.contractData.fulfillmentMessageI18n, - }, - }; + notifyTransition(ws, transactionId, transitionInfo); + ws.workAvailable.trigger(); } -async function queryAndSaveAwaitingRefund( +/** + * Store refunds, possibly creating a new refund group. + */ +async function storeRefunds( ws: InternalWalletState, purchase: PurchaseRecord, - waitForAutoRefund?: boolean, -): Promise { - const download = await expectProposalDownload(ws, purchase); - const requestUrl = new URL( - `orders/${download.contractData.orderId}`, - download.contractData.merchantBaseUrl, - ); - requestUrl.searchParams.set( - "h_contract", - download.contractData.contractTermsHash, - ); - // Long-poll for one second - if (waitForAutoRefund) { - 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) { - // Wait for retry ... - return Amounts.zeroOfAmount(download.contractData.amount); - } - - const refundAwaiting = Amounts.sub( - Amounts.parseOrThrow(orderStatus.refund_amount), - Amounts.parseOrThrow(orderStatus.refund_taken), - ).amount; - - if ( - purchase.refundAmountAwaiting === undefined || - Amounts.cmp(refundAwaiting, purchase.refundAmountAwaiting) !== 0 - ) { - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(purchase.proposalId); - if (!p) { - logger.warn("purchase does not exist anymore"); - return; - } - p.refundAmountAwaiting = Amounts.stringify(refundAwaiting); - await tx.purchases.put(p); - }); - } - - return refundAwaiting; -} - -export async function processPurchaseQueryRefund( - ws: InternalWalletState, - proposalId: string, - options: { - forceNow?: boolean; - waitForAutoRefund?: boolean; - } = {}, + refunds: MerchantCoinRefundStatus[], + reason: RefundReason, ): Promise { - logger.trace(`processing refund query for proposal ${proposalId}`); - const waitForAutoRefund = options.waitForAutoRefund ?? false; - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - if (!purchase) { - return OperationAttemptResult.finishedEmpty(); - } + logger.info(`storing refunds: ${j2s(refunds)}`); - if ( - !( - purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund || - purchase.purchaseStatus === PurchaseStatus.QueryingRefund || - purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund - ) - ) { - return OperationAttemptResult.finishedEmpty(); - } - - const download = await expectProposalDownload(ws, purchase); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: purchase.proposalId, + }); - if (purchase.timestampFirstSuccessfulPay) { - if ( - !purchase.autoRefundDeadline || - !AbsoluteTime.isExpired( - AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), - ) - ) { - const awaitingAmount = await queryAndSaveAwaitingRefund( - ws, - purchase, - waitForAutoRefund, - ); - if (Amounts.isZero(awaitingAmount)) { - // Maybe the user wanted to check for refund to find out - // that there is no refund pending from merchant - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - logger.warn("purchase does not exist anymore"); - return; - } - p.purchaseStatus = PurchaseStatus.Paid; - await tx.purchases.put(p); - }); + const newRefundGroupId = encodeCrock(randomBytes(32)); + const now = TalerProtocolTimestamp.now(); - // No new refunds, but we still need to notify - // the wallet client that the query finished. - ws.notify({ - type: NotificationType.RefundQueried, - transactionId: makeTransactionId(TransactionType.Payment, proposalId), - }); + const download = await expectProposalDownload(ws, purchase); + const currency = Amounts.currencyOf(download.contractData.amount); - return OperationAttemptResult.finishedEmpty(); + const getItemStatus = (rf: MerchantCoinRefundStatus) => { + if (rf.type === "success") { + return RefundItemStatus.Done; + } else { + if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { + return RefundItemStatus.Pending; + } else { + return RefundItemStatus.Failed; } } + }; - const requestUrl = new URL( - `orders/${download.contractData.orderId}/refund`, - download.contractData.merchantBaseUrl, - ); - - logger.trace(`making refund request to ${requestUrl.href}`); - - const request = await ws.http.postJson(requestUrl.href, { - h_contract: download.contractData.contractTermsHash, - }); - - const refundResponse = await readSuccessResponseJsonOrThrow( - request, - codecForMerchantOrderRefundPickupResponse(), - ); - - await acceptRefunds( - ws, - proposalId, - refundResponse.refunds, - RefundReason.NormalRefund, - ); - } else if (purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund) { - const requestUrl = new URL( - `orders/${download.contractData.orderId}/abort`, - download.contractData.merchantBaseUrl, - ); - - const abortingCoins: AbortingCoin[] = []; - - const payCoinSelection = purchase.payInfo?.payCoinSelection; - if (!payCoinSelection) { - throw Error("can't abort, no coins selected"); - } - - await ws.db - .mktx((x) => [x.coins]) - .runReadOnly(async (tx) => { - for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { - const coinPub = 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( - payCoinSelection.coinContributions[i], - ), - exchange_url: coin.exchangeBaseUrl, - }); - } - }); - - const abortReq: AbortRequest = { - h_contract: 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: payCoinSelection.coinPubs[i], - refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]), - rtransaction_id: 0, - execution_time: AbsoluteTime.toTimestamp( - AbsoluteTime.addDuration( - AbsoluteTime.fromTimestamp(download.contractData.timestamp), - Duration.fromSpec({ seconds: 1 }), - ), - ), - }); - } - await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund); - } - return OperationAttemptResult.finishedEmpty(); -} - -export async function abortPayMerchant( - ws: InternalWalletState, - proposalId: string, - cancelImmediately?: boolean, -): Promise { - const opId = constructTaskIdentifier({ - tag: PendingTaskType.Purchase, - proposalId, - }); - await ws.db + const result = await ws.db .mktx((x) => [ x.purchases, - x.refreshGroups, + x.refundGroups, + x.refundItems, + x.coins, x.denominations, x.coinAvailability, - x.coins, - x.operationRetries, + x.refreshGroups, ]) .runReadWrite(async (tx) => { - const purchase = await tx.purchases.get(proposalId); - if (!purchase) { - throw Error("purchase not found"); + const computeRefreshRequest = async (items: RefundItemRecord[]) => { + const refreshCoins: CoinRefreshRequest[] = []; + for (const item of items) { + const coin = await tx.coins.get(item.coinPub); + if (!coin) { + throw Error("coin not found"); + } + const denomInfo = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denomInfo) { + throw Error("denom not found"); + } + if (item.status === RefundItemStatus.Done) { + const refundedAmount = Amounts.sub( + item.refundAmount, + denomInfo.feeRefund, + ).amount; + refreshCoins.push({ + amount: Amounts.stringify(refundedAmount), + coinPub: item.coinPub, + }); + } + } + return refreshCoins; + }; + + const myPurchase = await tx.purchases.get(purchase.proposalId); + if (!myPurchase) { + logger.warn("purchase group not found anymore"); + return; } - const oldStatus = purchase.purchaseStatus; - if (purchase.timestampFirstSuccessfulPay) { - // No point in aborting it. We don't even report an error. - logger.warn(`tried to abort successful payment`); + if (myPurchase.purchaseStatus !== PurchaseStatus.PendingAcceptRefund) { return; } - if (oldStatus === PurchaseStatus.Paying) { - purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; + + let newGroup: RefundGroupRecord | undefined = undefined; + // Pending, but not part of an aborted refund group. + let numPendingItemsTotal = 0; + const newGroupRefunds: RefundItemRecord[] = []; + + for (const rf of refunds) { + const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([ + rf.coin_pub, + rf.rtransaction_id, + ]); + if (oldItem) { + logger.info("already have refund in database"); + if (oldItem.status === RefundItemStatus.Done) { + continue; + } + if (rf.type === "success") { + oldItem.status = RefundItemStatus.Done; + } else { + if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { + oldItem.status = RefundItemStatus.Pending; + numPendingItemsTotal += 1; + } else { + oldItem.status = RefundItemStatus.Failed; + } + } + await tx.refundItems.put(oldItem); + } else { + // Put refund item into a new group! + if (!newGroup) { + newGroup = { + proposalId: purchase.proposalId, + refundGroupId: newRefundGroupId, + status: RefundGroupStatus.Pending, + timestampCreated: now, + amountEffective: Amounts.stringify( + Amounts.zeroOfCurrency(currency), + ), + amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)), + }; + } + const status: RefundItemStatus = getItemStatus(rf); + const newItem: RefundItemRecord = { + coinPub: rf.coin_pub, + executionTime: rf.execution_time, + obtainedTime: now, + refundAmount: rf.refund_amount, + refundGroupId: newGroup.refundGroupId, + rtxid: rf.rtransaction_id, + status, + }; + if (status === RefundItemStatus.Pending) { + numPendingItemsTotal += 1; + } + newGroupRefunds.push(newItem); + await tx.refundItems.put(newItem); + } } - if ( - cancelImmediately && - oldStatus === PurchaseStatus.AbortingWithRefund - ) { - purchase.purchaseStatus = PurchaseStatus.PaymentAbortFinished; + + // Now that we know all the refunds for the new refund group, + // we can compute the raw/effective amounts. + if (newGroup) { + const amountsRaw = newGroupRefunds.map((x) => x.refundAmount); + const refreshCoins = await computeRefreshRequest(newGroupRefunds); + const outInfo = await calculateRefreshOutput( + ws, + tx, + currency, + refreshCoins, + ); + newGroup.amountEffective = Amounts.stringify( + Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount, + ); + newGroup.amountRaw = Amounts.stringify( + Amounts.sumOrZero(currency, amountsRaw).amount, + ); + await tx.refundGroups.put(newGroup); } - await tx.purchases.put(purchase); - if (oldStatus === PurchaseStatus.Paying) { - if (purchase.payInfo) { - const coinSel = purchase.payInfo.payCoinSelection; - const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost); - const refreshCoins: CoinRefreshRequest[] = []; - for (let i = 0; i < coinSel.coinPubs.length; i++) { - refreshCoins.push({ - amount: coinSel.coinContributions[i], - coinPub: coinSel.coinPubs[i], - }); + + const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll( + myPurchase.proposalId, + ); + + logger.info( + `refund groups for proposal ${myPurchase.proposalId}: ${j2s( + refundGroups, + )}`, + ); + + for (const refundGroup of refundGroups) { + if (refundGroup.status === RefundGroupStatus.Aborted) { + continue; + } + if (refundGroup.status === RefundGroupStatus.Done) { + continue; + } + const items = await tx.refundItems.indexes.byRefundGroupId.getAll( + refundGroup.refundGroupId, + ); + let numPending = 0; + for (const item of items) { + if (item.status === RefundItemStatus.Pending) { + numPending++; } + } + logger.info(`refund items pending for refund group: ${numPending}`); + if (numPending === 0) { + logger.info("refund group is done!"); + // We're done for this refund group! + refundGroup.status = RefundGroupStatus.Done; + await tx.refundGroups.put(refundGroup); + const refreshCoins = await computeRefreshRequest(items); await createRefreshGroup( ws, tx, - currency, + Amounts.currencyOf(download.contractData.amount), refreshCoins, - RefreshReason.AbortPay, + RefreshReason.Refund, ); } } - await tx.operationRetries.delete(opId); - }); - runOperationWithErrorReporting(ws, opId, async () => { - return await processPurchaseQueryRefund(ws, proposalId, { - forceNow: true, + const oldTxState = computePayMerchantTransactionState(myPurchase); + if (numPendingItemsTotal === 0) { + myPurchase.purchaseStatus = PurchaseStatus.Done; + } + await tx.purchases.put(myPurchase); + const newTxState = computePayMerchantTransactionState(myPurchase); + + return { + numPendingItemsTotal, + transitionInfo: { + oldTxState, + newTxState, + }, + }; }); - }); + + if (!result) { + return OperationAttemptResult.finishedEmpty(); + } + + notifyTransition(ws, transactionId, result.transitionInfo); + + if (result.numPendingItemsTotal > 0) { + return OperationAttemptResult.pendingEmpty(); + } + + return OperationAttemptResult.finishedEmpty(); } -export function computePayMerchantTransactionState( - purchaseRecord: PurchaseRecord, +export function computeRefundTransactionState( + refundGroupRecord: RefundGroupRecord, ): TransactionState { - switch (purchaseRecord.purchaseStatus) { - case PurchaseStatus.DownloadingProposal: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.ClaimProposal, - }; - case PurchaseStatus.Paid: - return { - major: TransactionMajorState.Done, - }; - case PurchaseStatus.PaymentAbortFinished: + switch (refundGroupRecord.status) { + case RefundGroupStatus.Aborted: return { major: TransactionMajorState.Aborted, }; - case PurchaseStatus.Proposed: + case RefundGroupStatus.Done: return { - major: TransactionMajorState.Dialog, - minor: TransactionMinorState.MerchantOrderProposed, - }; - case PurchaseStatus.ProposalDownloadFailed: - return { - major: TransactionMajorState.Failed, - minor: TransactionMinorState.ClaimProposal, - }; - case PurchaseStatus.RepurchaseDetected: - return { - major: TransactionMajorState.Failed, - minor: TransactionMinorState.Repurchase, - }; - case PurchaseStatus.AbortingWithRefund: - return { - major: TransactionMajorState.Aborting, - }; - case PurchaseStatus.Paying: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.Pay, - }; - case PurchaseStatus.PayingReplay: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.RebindSession, + major: TransactionMajorState.Done, }; - case PurchaseStatus.ProposalRefused: + case RefundGroupStatus.Failed: return { major: TransactionMajorState.Failed, - minor: TransactionMinorState.Refused, - }; - case PurchaseStatus.QueryingAutoRefund: - return { - major: TransactionMajorState.Pending, - minor: TransactionMinorState.AutoRefund, }; - case PurchaseStatus.QueryingRefund: + case RefundGroupStatus.Pending: return { major: TransactionMajorState.Pending, - minor: TransactionMinorState.CheckRefunds, - }; + } } } -- cgit v1.2.3