From 857c0ab4cd2253a0e1d53e3372a1ff1565cb4150 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 15 Dec 2019 19:04:14 +0100 Subject: introduce refund groups, react correctly to 410 Gone for /refund --- src/operations/pay.ts | 467 +++++--------------------------------------------- 1 file changed, 41 insertions(+), 426 deletions(-) (limited to 'src/operations/pay.ts') diff --git a/src/operations/pay.ts b/src/operations/pay.ts index ccb55305d..388db94ba 100644 --- a/src/operations/pay.ts +++ b/src/operations/pay.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2019 GNUnet e.V. + (C) 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 @@ -14,6 +14,16 @@ GNU Taler; see the file COPYING. If not, see */ +/** + * Implementation of the payment operation, including downloading and + * claiming of proposals. + * + * @author Florian Dold + */ + +/** + * Imports. + */ import { AmountJson } from "../util/amounts"; import { Auditor, @@ -36,9 +46,6 @@ import { OperationError, RefreshReason, } from "../types/walletTypes"; -import { - Database -} from "../util/query"; import { Stores, CoinStatus, @@ -49,6 +56,7 @@ import { ProposalStatus, initRetryInfo, updateRetryInfoTimeout, + RefundReason, } from "../types/dbTypes"; import * as Amounts from "../util/amounts"; import { @@ -56,7 +64,6 @@ import { strcmp, canonicalJson, extractTalerStampOrThrow, - extractTalerDurationOrThrow, extractTalerDuration, } from "../util/helpers"; import { Logger } from "../util/logging"; @@ -69,8 +76,8 @@ import { import { getTotalRefreshCost, createRefreshGroup } from "./refresh"; import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; import { guardOperationException } from "./errors"; -import { assertUnreachable } from "../util/assertUnreachable"; import { NotificationType } from "../types/notifications"; +import { acceptRefundResponse } from "./refund"; export interface SpeculativePayData { payCoinInfo: PayCoinInfo; @@ -237,15 +244,13 @@ async function getCoinsForPayment( continue; } - const coins = await ws.db.iterIndex( - Stores.coins.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); + const coins = await ws.db + .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl) + .toArray(); - const denoms = await ws.db.iterIndex( - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); + const denoms = await ws.db + .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl) + .toArray(); if (!coins || coins.length === 0) { continue; @@ -353,8 +358,6 @@ async function recordConfirmPay( lastSessionId: sessionId, merchantSig: d.merchantSig, payReq, - refundsDone: {}, - refundsPending: {}, acceptTimestamp: getTimestampNow(), lastRefundStatusTimestamp: undefined, proposalId: proposal.proposalId, @@ -368,6 +371,12 @@ async function recordConfirmPay( firstSuccessfulPayTimestamp: undefined, autoRefundDeadline: undefined, paymentSubmitPending: true, + refundState: { + refundGroups: [], + refundsDone: {}, + refundsFailed: {}, + refundsPending: {}, + }, }; await ws.db.runWithWriteTransaction( @@ -447,7 +456,12 @@ export async function abortFailedPayment( } const refundResponse = MerchantRefundResponse.checked(await resp.json()); - await acceptRefundResponse(ws, purchase.proposalId, refundResponse); + await acceptRefundResponse( + ws, + purchase.proposalId, + refundResponse, + RefundReason.AbortRefund, + ); await ws.db.runWithWriteTransaction([Stores.purchases], async tx => { const p = await tx.get(Stores.purchases, proposalId); @@ -502,50 +516,6 @@ async function incrementPurchasePayRetry( ws.notify({ type: NotificationType.PayOperationError }); } -async function incrementPurchaseQueryRefundRetry( - ws: InternalWalletState, - proposalId: string, - err: OperationError | undefined, -): Promise { - console.log("incrementing purchase refund query 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.refundStatusRetryInfo) { - return; - } - pr.refundStatusRetryInfo.retryCounter++; - updateRetryInfoTimeout(pr.refundStatusRetryInfo); - pr.lastRefundStatusError = err; - await tx.put(Stores.purchases, pr); - }); - ws.notify({ type: NotificationType.RefundStatusOperationError }); -} - -async function incrementPurchaseApplyRefundRetry( - ws: InternalWalletState, - proposalId: string, - err: OperationError | undefined, -): Promise { - 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.refundStatusRetryInfo); - pr.lastRefundApplyError = err; - await tx.put(Stores.purchases, pr); - }); - ws.notify({ type: NotificationType.RefundApplyOperationError }); -} - export async function processDownloadProposal( ws: InternalWalletState, proposalId: string, @@ -695,11 +665,11 @@ async function startDownloadProposal( downloadSessionId: sessionId, }; - await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => { - const existingRecord = await tx.getIndexed(Stores.proposals.urlAndOrderIdIndex, [ - merchantBaseUrl, - orderId, - ]); + await ws.db.runWithWriteTransaction([Stores.proposals], async tx => { + const existingRecord = await tx.getIndexed( + Stores.proposals.urlAndOrderIdIndex, + [merchantBaseUrl, orderId], + ); if (existingRecord) { // Created concurrently return; @@ -793,7 +763,11 @@ export async function submitPay( for (let c of modifiedCoins) { await tx.put(Stores.coins, c); } - await createRefreshGroup(tx, modifiedCoins.map((x) => ({ coinPub: x.coinPub })), RefreshReason.Pay); + await createRefreshGroup( + tx, + modifiedCoins.map(x => ({ coinPub: x.coinPub })), + RefreshReason.Pay, + ); await tx.put(Stores.purchases, purchase); }, ); @@ -1069,192 +1043,6 @@ export async function confirmPay( return submitPay(ws, proposalId); } -export async function getFullRefundFees( - ws: InternalWalletState, - refundPermissions: MerchantRefundPermission[], -): Promise { - if (refundPermissions.length === 0) { - throw Error("no refunds given"); - } - const coin0 = await ws.db.get( - Stores.coins, - refundPermissions[0].coin_pub, - ); - if (!coin0) { - throw Error("coin not found"); - } - let feeAcc = Amounts.getZero( - Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, - ); - - const denoms = await ws.db.iterIndex( - Stores.denominations.exchangeBaseUrlIndex, - coin0.exchangeBaseUrl, - ).toArray(); - - for (const rp of refundPermissions) { - const coin = await ws.db.get(Stores.coins, rp.coin_pub); - if (!coin) { - throw Error("coin not found"); - } - const denom = await ws.db.get(Stores.denominations, [ - coin0.exchangeBaseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error(`denom not found (${coin.denomPub})`); - } - // FIXME: this assumes that the refund already happened. - // When it hasn't, the refresh cost is inaccurate. To fix this, - // we need introduce a flag to tell if a coin was refunded or - // refreshed normally (and what about incremental refunds?) - const refundAmount = Amounts.parseOrThrow(rp.refund_amount); - const refundFee = Amounts.parseOrThrow(rp.refund_fee); - const refreshCost = getTotalRefreshCost( - denoms, - denom, - Amounts.sub(refundAmount, refundFee).amount, - ); - feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount; - } - return feeAcc; -} - -async function acceptRefundResponse( - ws: InternalWalletState, - proposalId: string, - refundResponse: MerchantRefundResponse, -): Promise { - const refundPermissions = refundResponse.refund_permissions; - - let numNewRefunds = 0; - - 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; - } - - if (!p.refundStatusRequested) { - return; - } - - for (const perm of refundPermissions) { - if ( - !p.refundsPending[perm.merchant_sig] && - !p.refundsDone[perm.merchant_sig] - ) { - p.refundsPending[perm.merchant_sig] = perm; - numNewRefunds++; - } - } - - // 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 - ) { - queryDone = false; - } - } - - if (queryDone) { - p.lastRefundStatusTimestamp = 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.lastRefundStatusTimestamp = getTimestampNow(); - p.refundStatusRetryInfo.retryCounter++; - updateRetryInfoTimeout(p.refundStatusRetryInfo); - p.lastRefundStatusError = undefined; - console.log("refund query not done"); - } - - if (numNewRefunds) { - p.lastRefundApplyError = undefined; - p.refundApplyRetryInfo = initRetryInfo(); - } - - await tx.put(Stores.purchases, p); - }); - ws.notify({ - type: NotificationType.RefundQueried, - }); - if (numNewRefunds > 0) { - await processPurchaseApplyRefund(ws, proposalId); - } -} - -async function startRefundQuery( - ws: InternalWalletState, - proposalId: string, -): Promise { - const success = await ws.db.runWithWriteTransaction( - [Stores.purchases], - async tx => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - console.log("no purchase found for refund URL"); - return false; - } - p.refundStatusRequested = true; - p.lastRefundStatusError = undefined; - p.refundStatusRetryInfo = initRetryInfo(); - await tx.put(Stores.purchases, p); - return true; - }, - ); - - if (!success) { - return; - } - - ws.notify({ - type: NotificationType.RefundStarted, - }); - - await processPurchaseQueryRefund(ws, proposalId); -} - -/** - * 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 { - const parseResult = parseRefundUri(talerRefundUri); - - console.log("applying refund"); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const purchase = await ws.db.getIndexed( - Stores.purchases.orderIdIndex, - [parseResult.merchantBaseUrl, parseResult.orderId], - ); - - if (!purchase) { - throw Error("no purchase for the taler://refund/ URI was found"); - } - - console.log("processing purchase for refund"); - await startRefundQuery(ws, purchase.proposalId); - - return purchase.contractTermsHash; -} - export async function processPurchasePay( ws: InternalWalletState, proposalId: string, @@ -1298,176 +1086,3 @@ async function processPurchasePayImpl( logger.trace(`processing purchase pay ${proposalId}`); await submitPay(ws, proposalId); } - -export async function processPurchaseQueryRefund( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean = false, -): Promise { - const onOpErr = (e: OperationError) => - incrementPurchaseQueryRefundRetry(ws, proposalId, e); - await guardOperationException( - () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow), - onOpErr, - ); -} - -async function resetPurchaseQueryRefundRetry( - ws: InternalWalletState, - proposalId: string, -) { - await ws.db.mutate(Stores.purchases, proposalId, x => { - if (x.refundStatusRetryInfo.active) { - x.refundStatusRetryInfo = initRetryInfo(); - } - return x; - }); -} - -async function processPurchaseQueryRefundImpl( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean, -): Promise { - if (forceNow) { - await resetPurchaseQueryRefundRetry(ws, proposalId); - } - const purchase = await ws.db.get(Stores.purchases, proposalId); - if (!purchase) { - return; - } - if (!purchase.refundStatusRequested) { - return; - } - - const refundUrlObj = new URL( - "refund", - purchase.contractTerms.merchant_base_url, - ); - refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id); - const refundUrl = refundUrlObj.href; - let resp; - try { - resp = await ws.http.get(refundUrl); - } catch (e) { - console.error("error downloading refund permission", e); - throw e; - } - if (resp.status !== 200) { - throw Error(`unexpected status code (${resp.status}) for /refund`); - } - - const refundResponse = MerchantRefundResponse.checked(await resp.json()); - await acceptRefundResponse(ws, proposalId, refundResponse); -} - -export async function processPurchaseApplyRefund( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean = false, -): Promise { - const onOpErr = (e: OperationError) => - incrementPurchaseApplyRefundRetry(ws, proposalId, e); - await guardOperationException( - () => processPurchaseApplyRefundImpl(ws, proposalId, forceNow), - onOpErr, - ); -} - -async function resetPurchaseApplyRefundRetry( - ws: InternalWalletState, - proposalId: string, -) { - 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 { - 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.refundsPending); - if (pendingKeys.length === 0) { - console.log("no pending refunds"); - return; - } - for (const pk of pendingKeys) { - const perm = purchase.refundsPending[pk]; - const req: RefundRequest = { - coin_pub: perm.coin_pub, - h_contract_terms: purchase.contractTermsHash, - merchant_pub: purchase.contractTerms.merchant_pub, - 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("refund", exchangeUrl); - const resp = await ws.http.postJson(reqUrl.href, req); - console.log("sent refund permission"); - if (resp.status !== 200) { - console.error("refund failed", resp); - continue; - } - - let allRefundsProcessed = false; - - await ws.db.runWithWriteTransaction( - [Stores.purchases, Stores.coins, Stores.refreshGroups], - async tx => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - return; - } - if (p.refundsPending[pk]) { - p.refundsDone[pk] = p.refundsPending[pk]; - delete p.refundsPending[pk]; - } - if (Object.keys(p.refundsPending).length === 0) { - p.refundStatusRetryInfo = initRetryInfo(); - p.lastRefundStatusError = undefined; - allRefundsProcessed = true; - } - await tx.put(Stores.purchases, p); - const c = await tx.get(Stores.coins, perm.coin_pub); - if (!c) { - console.warn("coin not found, can't apply refund"); - return; - } - 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; - await tx.put(Stores.coins, c); - await createRefreshGroup(tx, [{ coinPub: perm.coin_pub }], RefreshReason.Refund); - }, - ); - if (allRefundsProcessed) { - ws.notify({ - type: NotificationType.RefundFinished, - }); - } - } - - ws.notify({ - type: NotificationType.RefundsSubmitted, - proposalId, - }); -} -- cgit v1.2.3