diff options
Diffstat (limited to 'src/operations')
-rw-r--r-- | src/operations/history.ts | 8 | ||||
-rw-r--r-- | src/operations/pay.ts | 76 | ||||
-rw-r--r-- | src/operations/pending.ts | 20 | ||||
-rw-r--r-- | src/operations/refund.ts | 448 |
4 files changed, 175 insertions, 377 deletions
diff --git a/src/operations/history.ts b/src/operations/history.ts index efbfbf377..f32dbbe2d 100644 --- a/src/operations/history.ts +++ b/src/operations/history.ts @@ -453,8 +453,8 @@ export async function getHistory( let amountRefundedRaw = Amounts.getZero(purchaseAmount.currency); let amountRefundedInvalid = Amounts.getZero(purchaseAmount.currency); let amountRefundedEffective = Amounts.getZero(purchaseAmount.currency); - Object.keys(purchase.refundState.refundsDone).forEach((x, i) => { - const r = purchase.refundState.refundsDone[x]; + Object.keys(purchase.refundsDone).forEach((x, i) => { + const r = purchase.refundsDone[x]; if (r.refundGroupId !== re.refundGroupId) { return; } @@ -471,8 +471,8 @@ export async function getHistory( refundFee, ).amount; }); - Object.keys(purchase.refundState.refundsFailed).forEach((x, i) => { - const r = purchase.refundState.refundsFailed[x]; + Object.keys(purchase.refundsFailed).forEach((x, i) => { + const r = purchase.refundsFailed[x]; if (r.refundGroupId !== re.refundGroupId) { return; } diff --git a/src/operations/pay.ts b/src/operations/pay.ts index 337068b55..a75284393 100644 --- a/src/operations/pay.ts +++ b/src/operations/pay.ts @@ -31,7 +31,6 @@ import { ProposalRecord, ProposalStatus, PurchaseRecord, - RefundReason, Stores, updateRetryInfoTimeout, PayEventRecord, @@ -40,7 +39,6 @@ import { import { NotificationType } from "../types/notifications"; import { PayReq, - codecForMerchantRefundResponse, codecForProposal, codecForContractTerms, CoinDepositPermission, @@ -57,7 +55,6 @@ import { Logger } from "../util/logging"; import { getOrderDownloadUrl, parsePayUri } from "../util/taleruri"; import { guardOperationException } from "./errors"; import { createRefreshGroup, getTotalRefreshCost } from "./refresh"; -import { acceptRefundResponse } from "./refund"; import { InternalWalletState } from "./state"; import { getTimestampNow, timestampAddDuration } from "../util/time"; import { strcmp, canonicalJson } from "../util/helpers"; @@ -446,17 +443,13 @@ async function recordConfirmPay( payRetryInfo: initRetryInfo(), refundStatusRetryInfo: initRetryInfo(), refundStatusRequested: false, - lastRefundApplyError: undefined, - refundApplyRetryInfo: initRetryInfo(), timestampFirstSuccessfulPay: undefined, autoRefundDeadline: undefined, paymentSubmitPending: true, - refundState: { - refundGroups: [], - refundsDone: {}, - refundsFailed: {}, - refundsPending: {}, - }, + refundGroups: [], + refundsDone: {}, + refundsFailed: {}, + refundsPending: {}, }; await ws.db.runWithWriteTransaction( @@ -511,67 +504,6 @@ function getNextUrl(contractData: WalletContractData): string { } } -export async function abortFailedPayment( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const purchase = await ws.db.get(Stores.purchases, proposalId); - if (!purchase) { - throw Error("Purchase not found, unable to abort with refund"); - } - if (purchase.timestampFirstSuccessfulPay) { - throw Error("Purchase already finished, not aborting"); - } - if (purchase.abortDone) { - console.warn("abort requested on already aborted purchase"); - return; - } - - purchase.abortRequested = true; - - // From now on, we can't retry payment anymore, - // so mark this in the DB in case the /pay abort - // does not complete on the first try. - await ws.db.put(Stores.purchases, purchase); - - let resp; - - const abortReq = { ...purchase.payReq, mode: "abort-refund" }; - - const payUrl = new URL("pay", purchase.contractData.merchantBaseUrl).href; - - try { - resp = await ws.http.postJson(payUrl, abortReq); - } catch (e) { - // Gives the user the option to retry / abort and refresh - console.log("aborting payment failed", e); - throw e; - } - - if (resp.status !== 200) { - throw Error(`unexpected status for /pay (${resp.status})`); - } - - const refundResponse = codecForMerchantRefundResponse().decode( - await resp.json(), - ); - await acceptRefundResponse( - ws, - purchase.proposalId, - refundResponse, - RefundReason.AbortRefund, - ); - - await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - return; - } - p.abortDone = true; - await tx.put(Stores.purchases, p); - }); -} - async function incrementProposalRetry( ws: InternalWalletState, proposalId: string, diff --git a/src/operations/pending.ts b/src/operations/pending.ts index 3e548a27f..a797763bf 100644 --- a/src/operations/pending.ts +++ b/src/operations/pending.ts @@ -396,26 +396,6 @@ async function gatherPurchasePending( }); } } - const numRefundsPending = Object.keys(pr.refundState.refundsPending).length; - if (numRefundsPending > 0) { - const numRefundsDone = Object.keys(pr.refundState.refundsDone).length; - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - pr.refundApplyRetryInfo.nextRetry, - ); - if (!onlyDue || pr.refundApplyRetryInfo.nextRetry.t_ms <= now.t_ms) { - resp.pendingOperations.push({ - type: PendingOperationType.RefundApply, - numRefundsDone, - numRefundsPending, - givesLifeness: true, - proposalId: pr.proposalId, - retryInfo: pr.refundApplyRetryInfo, - lastError: pr.lastRefundApplyError, - }); - } - } }); } diff --git a/src/operations/refund.ts b/src/operations/refund.ts index 8feb2baea..9b18cafd4 100644 --- a/src/operations/refund.ts +++ b/src/operations/refund.ts @@ -43,16 +43,14 @@ import { parseRefundUri } from "../util/taleruri"; import { createRefreshGroup, getTotalRefreshCost } from "./refresh"; import { Amounts } from "../util/amounts"; import { - MerchantRefundPermission, + MerchantRefundDetails, MerchantRefundResponse, - RefundRequest, codecForMerchantRefundResponse, } from "../types/talerTypes"; import { AmountJson } from "../util/amounts"; import { guardOperationException, OperationFailedError } from "./errors"; import { randomBytes } from "../crypto/primitives/nacl-fast"; import { encodeCrock } from "../crypto/talerCrypto"; -import { HttpResponseStatus } from "../util/http"; import { getTimestampNow } from "../util/time"; import { Logger } from "../util/logging"; @@ -80,31 +78,9 @@ async function incrementPurchaseQueryRefundRetry( ws.notify({ type: NotificationType.RefundStatusOperationError }); } -async function incrementPurchaseApplyRefundRetry( - ws: InternalWalletState, - proposalId: string, - err: OperationError | undefined, -): Promise<void> { - console.log("incrementing purchase refund apply retry with error", err); - await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { - const pr = await tx.get(Stores.purchases, proposalId); - if (!pr) { - return; - } - if (!pr.refundApplyRetryInfo) { - return; - } - pr.refundApplyRetryInfo.retryCounter++; - updateRetryInfoTimeout(pr.refundApplyRetryInfo); - pr.lastRefundApplyError = err; - await tx.put(Stores.purchases, pr); - }); - ws.notify({ type: NotificationType.RefundApplyOperationError }); -} - export async function getFullRefundFees( ws: InternalWalletState, - refundPermissions: MerchantRefundPermission[], + refundPermissions: MerchantRefundDetails[], ): Promise<AmountJson> { if (refundPermissions.length === 0) { throw Error("no refunds given"); @@ -149,88 +125,196 @@ export async function getFullRefundFees( return feeAcc; } -export async function acceptRefundResponse( +function getRefundKey(d: MerchantRefundDetails): string { + return `{d.coin_pub}-{d.rtransaction_id}`; +} + +async function acceptRefundResponse( ws: InternalWalletState, proposalId: string, refundResponse: MerchantRefundResponse, reason: RefundReason, ): Promise<void> { - const refundPermissions = refundResponse.refund_permissions; - - let numNewRefunds = 0; + const refunds = refundResponse.refunds; const refundGroupId = encodeCrock(randomBytes(32)); - await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - console.error("purchase not found, not adding refunds"); - return; - } + let numNewRefunds = 0; - if (!p.refundStatusRequested) { - return; + const finishedRefunds: MerchantRefundDetails[] = []; + const unfinishedRefunds: MerchantRefundDetails[] = []; + const failedRefunds: MerchantRefundDetails[] = []; + + for (const rd of refunds) { + if (rd.exchange_http_status === 200) { + // FIXME: also verify signature if necessary. + finishedRefunds.push(rd); + } else if ( + rd.exchange_http_status >= 400 && + rd.exchange_http_status < 400 + ) { + failedRefunds.push(rd); + } else { + unfinishedRefunds.push(rd); } + } - for (const perm of refundPermissions) { - const isDone = p.refundState.refundsDone[perm.merchant_sig]; - const isPending = p.refundState.refundsPending[perm.merchant_sig]; - if (!isDone && !isPending) { - p.refundState.refundsPending[perm.merchant_sig] = { - perm, + await ws.db.runWithWriteTransaction( + [Stores.purchases, Stores.coins, Stores.refreshGroups, Stores.refundEvents], + async (tx) => { + const p = await tx.get(Stores.purchases, proposalId); + if (!p) { + console.error("purchase not found, not adding refunds"); + return; + } + + // Groups that newly failed/succeeded + const changedGroups: { [refundGroupId: string]: boolean } = {}; + + for (const rd of failedRefunds) { + const refundKey = getRefundKey(rd); + if (p.refundsFailed[refundKey]) { + continue; + } + if (!p.refundsFailed[refundKey]) { + p.refundsFailed[refundKey] = { + perm: rd, + refundGroupId, + }; + numNewRefunds++; + changedGroups[refundGroupId] = true; + } + const oldPending = p.refundsPending[refundKey]; + if (oldPending) { + delete p.refundsPending[refundKey]; + changedGroups[oldPending.refundGroupId] = true; + } + } + + for (const rd of unfinishedRefunds) { + const refundKey = getRefundKey(rd); + if (!p.refundsPending[refundKey]) { + p.refundsPending[refundKey] = { + perm: rd, + refundGroupId, + }; + numNewRefunds++; + } + } + + // Avoid duplicates + const refreshCoinsMap: { [coinPub: string]: CoinPublicKey } = {}; + + for (const rd of finishedRefunds) { + const refundKey = getRefundKey(rd); + if (p.refundsDone[refundKey]) { + continue; + } + p.refundsDone[refundKey] = { + perm: rd, refundGroupId, }; - numNewRefunds++; + const oldPending = p.refundsPending[refundKey]; + if (oldPending) { + delete p.refundsPending[refundKey]; + changedGroups[oldPending.refundGroupId] = true; + } else { + numNewRefunds++; + } + + const c = await tx.get(Stores.coins, rd.coin_pub); + + if (!c) { + console.warn("coin not found, can't apply refund"); + return; + } + refreshCoinsMap[c.coinPub] = { coinPub: c.coinPub }; + logger.trace(`commiting refund ${refundKey} to coin ${c.coinPub}`); + logger.trace( + `coin amount before is ${Amounts.stringify(c.currentAmount)}`, + ); + logger.trace(`refund amount (via merchant) is ${refundKey}`); + logger.trace(`refund fee (via merchant) is ${refundKey}`); + const refundAmount = Amounts.parseOrThrow(rd.refund_amount); + const refundFee = Amounts.parseOrThrow(rd.refund_fee); + c.status = CoinStatus.Dormant; + c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; + c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; + logger.trace( + `coin amount after is ${Amounts.stringify(c.currentAmount)}`, + ); + await tx.put(Stores.coins, c); } - } - // Are we done with querying yet, or do we need to do another round - // after a retry delay? - let queryDone = true; + // Are we done with querying yet, or do we need to do another round + // after a retry delay? + let queryDone = true; - if (numNewRefunds === 0) { - if ( - p.autoRefundDeadline && - p.autoRefundDeadline.t_ms > getTimestampNow().t_ms - ) { + if (numNewRefunds === 0) { + if ( + p.autoRefundDeadline && + p.autoRefundDeadline.t_ms > getTimestampNow().t_ms + ) { + queryDone = false; + } + } + + if (Object.keys(unfinishedRefunds).length != 0) { queryDone = false; } - } - if (queryDone) { - p.timestampLastRefundStatus = getTimestampNow(); - p.lastRefundStatusError = undefined; - p.refundStatusRetryInfo = initRetryInfo(); - p.refundStatusRequested = false; - console.log("refund query done"); - } else { - // No error, but we need to try again! - p.timestampLastRefundStatus = getTimestampNow(); - p.refundStatusRetryInfo.retryCounter++; - updateRetryInfoTimeout(p.refundStatusRetryInfo); - p.lastRefundStatusError = undefined; - console.log("refund query not done"); - } + if (queryDone) { + p.timestampLastRefundStatus = getTimestampNow(); + p.lastRefundStatusError = undefined; + p.refundStatusRetryInfo = initRetryInfo(false); + p.refundStatusRequested = false; + console.log("refund query done"); + } else { + // No error, but we need to try again! + p.timestampLastRefundStatus = getTimestampNow(); + p.refundStatusRetryInfo.retryCounter++; + updateRetryInfoTimeout(p.refundStatusRetryInfo); + p.lastRefundStatusError = undefined; + console.log("refund query not done"); + } - if (numNewRefunds > 0) { - const now = getTimestampNow(); - p.lastRefundApplyError = undefined; - p.refundApplyRetryInfo = initRetryInfo(); - p.refundState.refundGroups.push({ - timestampQueried: now, - reason, - }); - } + await tx.put(Stores.purchases, p); - await tx.put(Stores.purchases, p); - }); + const coinsPubsToBeRefreshed = Object.values(refreshCoinsMap); + if (coinsPubsToBeRefreshed.length > 0) { + await createRefreshGroup( + tx, + coinsPubsToBeRefreshed, + RefreshReason.Refund, + ); + } + + // Check if any of the refund groups are done, and we + // can emit an corresponding event. + const now = getTimestampNow(); + for (const g of Object.keys(changedGroups)) { + let groupDone = true; + for (const pk of Object.keys(p.refundsPending)) { + const r = p.refundsPending[pk]; + if (r.refundGroupId == g) { + groupDone = false; + } + } + if (groupDone) { + const refundEvent: RefundEventRecord = { + proposalId, + refundGroupId: g, + timestamp: now, + }; + await tx.put(Stores.refundEvents, refundEvent); + } + } + }, + ); ws.notify({ type: NotificationType.RefundQueried, }); - if (numNewRefunds > 0) { - await processPurchaseApplyRefund(ws, proposalId); - } } async function startRefundQuery( @@ -362,201 +446,3 @@ async function processPurchaseQueryRefundImpl( RefundReason.NormalRefund, ); } - -export async function processPurchaseApplyRefund( - ws: InternalWalletState, - proposalId: string, - forceNow = false, -): Promise<void> { - const onOpErr = (e: OperationError): Promise<void> => - incrementPurchaseApplyRefundRetry(ws, proposalId, e); - await guardOperationException( - () => processPurchaseApplyRefundImpl(ws, proposalId, forceNow), - onOpErr, - ); -} - -async function resetPurchaseApplyRefundRetry( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - await ws.db.mutate(Stores.purchases, proposalId, (x) => { - if (x.refundApplyRetryInfo.active) { - x.refundApplyRetryInfo = initRetryInfo(); - } - return x; - }); -} - -async function processPurchaseApplyRefundImpl( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean, -): Promise<void> { - if (forceNow) { - await resetPurchaseApplyRefundRetry(ws, proposalId); - } - const purchase = await ws.db.get(Stores.purchases, proposalId); - if (!purchase) { - console.error("not submitting refunds, payment not found:"); - return; - } - const pendingKeys = Object.keys(purchase.refundState.refundsPending); - if (pendingKeys.length === 0) { - console.log("no pending refunds"); - return; - } - - const newRefundsDone: { [sig: string]: RefundInfo } = {}; - const newRefundsFailed: { [sig: string]: RefundInfo } = {}; - for (const pk of pendingKeys) { - const info = purchase.refundState.refundsPending[pk]; - const perm = info.perm; - const req: RefundRequest = { - coin_pub: perm.coin_pub, - h_contract_terms: purchase.contractData.contractTermsHash, - merchant_pub: purchase.contractData.merchantPub, - merchant_sig: perm.merchant_sig, - refund_amount: perm.refund_amount, - refund_fee: perm.refund_fee, - rtransaction_id: perm.rtransaction_id, - }; - console.log("sending refund permission", perm); - // FIXME: not correct once we support multiple exchanges per payment - const exchangeUrl = purchase.payReq.coins[0].exchange_url; - const reqUrl = new URL(`coins/${perm.coin_pub}/refund`, exchangeUrl); - const resp = await ws.http.postJson(reqUrl.href, req); - console.log("sent refund permission"); - switch (resp.status) { - case HttpResponseStatus.Ok: - newRefundsDone[pk] = info; - break; - case HttpResponseStatus.Gone: - // We're too late, refund is expired. - newRefundsFailed[pk] = info; - break; - default: { - let body: string | null = null; - // FIXME: error handling! - body = await resp.json(); - const m = "refund request (at exchange) failed"; - throw new OperationFailedError({ - message: m, - type: "network", - details: { - body, - }, - }); - } - } - } - let allRefundsProcessed = false; - await ws.db.runWithWriteTransaction( - [Stores.purchases, Stores.coins, Stores.refreshGroups, Stores.refundEvents], - async (tx) => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - return; - } - - // Groups that failed/succeeded - const groups: { [refundGroupId: string]: boolean } = {}; - - // Avoid duplicates - const refreshCoinsMap: { [coinPub: string]: CoinPublicKey } = {}; - - const modCoin = async (perm: MerchantRefundPermission): Promise<void> => { - const c = await tx.get(Stores.coins, perm.coin_pub); - if (!c) { - console.warn("coin not found, can't apply refund"); - return; - } - refreshCoinsMap[c.coinPub] = { coinPub: c.coinPub }; - logger.trace( - `commiting refund ${perm.merchant_sig} to coin ${c.coinPub}`, - ); - logger.trace( - `coin amount before is ${Amounts.stringify(c.currentAmount)}`, - ); - logger.trace(`refund amount (via merchant) is ${perm.refund_amount}`); - logger.trace(`refund fee (via merchant) is ${perm.refund_fee}`); - const refundAmount = Amounts.parseOrThrow(perm.refund_amount); - const refundFee = Amounts.parseOrThrow(perm.refund_fee); - c.status = CoinStatus.Dormant; - c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; - c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; - logger.trace( - `coin amount after is ${Amounts.stringify(c.currentAmount)}`, - ); - await tx.put(Stores.coins, c); - }; - - for (const pk of Object.keys(newRefundsFailed)) { - if (p.refundState.refundsDone[pk]) { - // We already processed this one. - break; - } - const r = newRefundsFailed[pk]; - groups[r.refundGroupId] = true; - delete p.refundState.refundsPending[pk]; - p.refundState.refundsFailed[pk] = r; - } - - for (const pk of Object.keys(newRefundsDone)) { - if (p.refundState.refundsDone[pk]) { - // We already processed this one. - break; - } - const r = newRefundsDone[pk]; - groups[r.refundGroupId] = true; - delete p.refundState.refundsPending[pk]; - p.refundState.refundsDone[pk] = r; - await modCoin(r.perm); - } - - const now = getTimestampNow(); - for (const g of Object.keys(groups)) { - let groupDone = true; - for (const pk of Object.keys(p.refundState.refundsPending)) { - const r = p.refundState.refundsPending[pk]; - if (r.refundGroupId == g) { - groupDone = false; - } - } - if (groupDone) { - const refundEvent: RefundEventRecord = { - proposalId, - refundGroupId: g, - timestamp: now, - }; - await tx.put(Stores.refundEvents, refundEvent); - } - } - - if (Object.keys(p.refundState.refundsPending).length === 0) { - p.refundStatusRetryInfo = initRetryInfo(); - p.lastRefundStatusError = undefined; - allRefundsProcessed = true; - } - await tx.put(Stores.purchases, p); - const coinsPubsToBeRefreshed = Object.values(refreshCoinsMap); - if (coinsPubsToBeRefreshed.length > 0) { - await createRefreshGroup( - tx, - coinsPubsToBeRefreshed, - RefreshReason.Refund, - ); - } - }, - ); - if (allRefundsProcessed) { - ws.notify({ - type: NotificationType.RefundFinished, - }); - } - - ws.notify({ - type: NotificationType.RefundsSubmitted, - proposalId, - }); -} |