diff options
Diffstat (limited to 'src/operations')
-rw-r--r-- | src/operations/pay.ts | 56 | ||||
-rw-r--r-- | src/operations/pending.ts | 20 | ||||
-rw-r--r-- | src/operations/refund.ts | 502 |
3 files changed, 536 insertions, 42 deletions
diff --git a/src/operations/pay.ts b/src/operations/pay.ts index 388db94ba..5ed293505 100644 --- a/src/operations/pay.ts +++ b/src/operations/pay.ts @@ -24,60 +24,54 @@ /** * Imports. */ -import { AmountJson } from "../util/amounts"; +import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; +import { + CoinRecord, + CoinStatus, + DenominationRecord, + initRetryInfo, + ProposalRecord, + ProposalStatus, + PurchaseRecord, + RefundReason, + Stores, + updateRetryInfoTimeout, +} from "../types/dbTypes"; +import { NotificationType } from "../types/notifications"; import { Auditor, + ContractTerms, ExchangeHandle, MerchantRefundResponse, PayReq, Proposal, - ContractTerms, - MerchantRefundPermission, - RefundRequest, } from "../types/talerTypes"; import { - Timestamp, CoinSelectionResult, CoinWithDenom, - PayCoinInfo, - getTimestampNow, - PreparePayResult, ConfirmPayResult, + getTimestampNow, OperationError, + PayCoinInfo, + PreparePayResult, RefreshReason, + Timestamp, } from "../types/walletTypes"; -import { - Stores, - CoinStatus, - DenominationRecord, - ProposalRecord, - PurchaseRecord, - CoinRecord, - ProposalStatus, - initRetryInfo, - updateRetryInfoTimeout, - RefundReason, -} from "../types/dbTypes"; import * as Amounts from "../util/amounts"; +import { AmountJson } from "../util/amounts"; import { amountToPretty, - strcmp, canonicalJson, - extractTalerStampOrThrow, extractTalerDuration, + extractTalerStampOrThrow, + strcmp, } from "../util/helpers"; import { Logger } from "../util/logging"; -import { InternalWalletState } from "./state"; -import { - parsePayUri, - parseRefundUri, - getOrderDownloadUrl, -} from "../util/taleruri"; -import { getTotalRefreshCost, createRefreshGroup } from "./refresh"; -import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; +import { getOrderDownloadUrl, parsePayUri } from "../util/taleruri"; import { guardOperationException } from "./errors"; -import { NotificationType } from "../types/notifications"; +import { createRefreshGroup, getTotalRefreshCost } from "./refresh"; import { acceptRefundResponse } from "./refund"; +import { InternalWalletState } from "./state"; export interface SpeculativePayData { payCoinInfo: PayCoinInfo; diff --git a/src/operations/pending.ts b/src/operations/pending.ts index f0b29792d..b9b2c664e 100644 --- a/src/operations/pending.ts +++ b/src/operations/pending.ts @@ -18,20 +18,18 @@ * Imports. */ import { - getTimestampNow, - Timestamp, - Duration, -} from "../types/walletTypes"; -import { Database, TransactionHandle } from "../util/query"; -import { InternalWalletState } from "./state"; -import { - Stores, ExchangeUpdateStatus, - ReserveRecordStatus, - CoinStatus, ProposalStatus, + ReserveRecordStatus, + Stores, } from "../types/dbTypes"; -import { PendingOperationsResponse, PendingOperationType } from "../types/pending"; +import { + PendingOperationsResponse, + PendingOperationType, +} from "../types/pending"; +import { Duration, getTimestampNow, Timestamp } from "../types/walletTypes"; +import { TransactionHandle } from "../util/query"; +import { InternalWalletState } from "./state"; function updateRetryDelay( oldDelay: Duration, diff --git a/src/operations/refund.ts b/src/operations/refund.ts new file mode 100644 index 000000000..a2b4dbe24 --- /dev/null +++ b/src/operations/refund.ts @@ -0,0 +1,502 @@ +/* + This file is part of GNU Taler + (C) 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 { InternalWalletState } from "./state"; +import { + OperationError, + getTimestampNow, + RefreshReason, +} from "../types/walletTypes"; +import { + Stores, + updateRetryInfoTimeout, + initRetryInfo, + CoinStatus, + RefundReason, + RefundEventRecord, +} from "../types/dbTypes"; +import { NotificationType } from "../types/notifications"; +import { parseRefundUri } from "../util/taleruri"; +import { createRefreshGroup, getTotalRefreshCost } from "./refresh"; +import * as Amounts from "../util/amounts"; +import { + MerchantRefundPermission, + MerchantRefundResponse, + RefundRequest, +} 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"; + +async function incrementPurchaseQueryRefundRetry( + ws: InternalWalletState, + proposalId: string, + err: OperationError | undefined, +): Promise<void> { + 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<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.refundStatusRetryInfo); + pr.lastRefundApplyError = err; + await tx.put(Stores.purchases, pr); + }); + ws.notify({ type: NotificationType.RefundApplyOperationError }); +} + +export async function getFullRefundFees( + ws: InternalWalletState, + refundPermissions: MerchantRefundPermission[], +): Promise<AmountJson> { + 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; +} + +export async function acceptRefundResponse( + ws: InternalWalletState, + proposalId: string, + refundResponse: MerchantRefundResponse, + reason: RefundReason, +): Promise<void> { + const refundPermissions = refundResponse.refund_permissions; + + let numNewRefunds = 0; + + 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; + } + + if (!p.refundStatusRequested) { + return; + } + + 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, + refundGroupId, + }; + 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 > 0) { + const now = getTimestampNow(); + p.lastRefundApplyError = undefined; + p.refundApplyRetryInfo = initRetryInfo(); + p.refundState.refundGroups.push({ + timestampQueried: now, + reason, + }); + + const refundEvent: RefundEventRecord = { + proposalId, + refundGroupId, + timestamp: now, + }; + await tx.put(Stores.refundEvents, refundEvent); + } + + 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<void> { + 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<string> { + 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 processPurchaseQueryRefund( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean = false, +): Promise<void> { + 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<void> { + 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, + RefundReason.NormalRefund, + ); +} + +export async function processPurchaseApplyRefund( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean = false, +): Promise<void> { + 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<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; + } + 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.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"); + let refundGone = false; + switch (resp.status) { + case HttpResponseStatus.Ok: + break; + case HttpResponseStatus.Gone: + // We're too late, refund is expired. + refundGone = true; + break; + default: + let body: string | null = null; + try { + body = await resp.json(); + } catch {} + const m = "refund request (at exchange) failed"; + throw new OperationFailedError(m, { + message: m, + type: "network", + details: { + body, + }, + }); + } + + 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.refundState.refundsPending[pk]) { + if (refundGone) { + p.refundState.refundsFailed[pk] = p.refundState.refundsPending[pk]; + } else { + p.refundState.refundsDone[pk] = p.refundState.refundsPending[pk]; + } + delete p.refundState.refundsPending[pk]; + } + if (Object.keys(p.refundState.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, + }); +} |