From 857c0ab4cd2253a0e1d53e3372a1ff1565cb4150 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sun, 15 Dec 2019 19:04:14 +0100 Subject: [PATCH] introduce refund groups, react correctly to 410 Gone for /refund --- src/operations/pay.ts | 467 ++++---------------------------------- src/operations/pending.ts | 4 +- src/types/dbTypes.ts | 83 ++++++- src/util/http.ts | 5 + src/wallet.ts | 21 +- tsconfig.json | 1 + 6 files changed, 131 insertions(+), 450 deletions(-) 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, - }); -} diff --git a/src/operations/pending.ts b/src/operations/pending.ts index 27892df06..f0b29792d 100644 --- a/src/operations/pending.ts +++ b/src/operations/pending.ts @@ -365,9 +365,9 @@ async function gatherPurchasePending( }); } } - const numRefundsPending = Object.keys(pr.refundsPending).length; + const numRefundsPending = Object.keys(pr.refundState.refundsPending).length; if (numRefundsPending > 0) { - const numRefundsDone = Object.keys(pr.refundsDone).length; + const numRefundsDone = Object.keys(pr.refundState.refundsDone).length; resp.nextRetryDelay = updateRetryDelay( resp.nextRetryDelay, now, diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts index c05aa68d7..9d2f6fe5d 100644 --- a/src/types/dbTypes.ts +++ b/src/types/dbTypes.ts @@ -1002,6 +1002,63 @@ export interface WireFee { sig: string; } +/** + * Record to store information about a refund event. + * + * All information about a refund is stored with the purchase, + * this event is just for the history. + * + * The event is only present for completed refunds. + */ +export interface RefundEventRecord { + timestamp: Timestamp; + refundGroupId: string; + proposalId: string; +} + +export interface RefundInfo { + refundGroupId: string; + perm: MerchantRefundPermission; +} + +export const enum RefundReason { + /** + * Normal refund given by the merchant. + */ + NormalRefund = "normal-refund", + /** + * Refund from an aborted payment. + */ + AbortRefund = "abort-refund", +} + +export interface RefundGroupInfo { + timestampQueried: Timestamp; + reason: RefundReason; +} + +export interface PurchaseRefundState { + /** + * Information regarding each group of refunds we receive at once. + */ + refundGroups: RefundGroupInfo[]; + + /** + * Pending refunds for the purchase. + */ + refundsPending: { [refundSig: string]: RefundInfo }; + + /** + * Applied refunds for the purchase. + */ + refundsDone: { [refundSig: string]: RefundInfo }; + + /** + * Submitted refunds for the purchase. + */ + refundsFailed: { [refundSig: string]: RefundInfo }; +} + /** * Record that stores status information about one purchase, starting from when * the customer accepts a proposal. Includes refund status if applicable. @@ -1034,24 +1091,23 @@ export interface PurchaseRecord { */ merchantSig: string; + /** + * Timestamp of the first time that sending a payment to the merchant + * for this purchase was successful. + */ firstSuccessfulPayTimestamp: Timestamp | undefined; - /** - * Pending refunds for the purchase. - */ - refundsPending: { [refundSig: string]: MerchantRefundPermission }; - - /** - * Submitted refunds for the purchase. - */ - refundsDone: { [refundSig: string]: MerchantRefundPermission }; - /** * When was the purchase made? * Refers to the time that the user accepted. */ acceptTimestamp: Timestamp; + /** + * State of refunds for this proposal. + */ + refundState: PurchaseRefundState; + /** * When was the last refund made? * Set to 0 if no refund was made on the purchase. @@ -1370,6 +1426,12 @@ export namespace Stores { } } + class RefundEventsStore extends Store { + constructor() { + super("refundEvents", { keyPath: "refundGroupId" }); + } + } + class BankWithdrawUrisStore extends Store { constructor() { super("bankWithdrawUris", { keyPath: "talerWithdrawUri" }); @@ -1394,6 +1456,7 @@ export namespace Stores { export const senderWires = new SenderWiresStore(); export const withdrawalSession = new WithdrawalSessionsStore(); export const bankWithdrawUris = new BankWithdrawUrisStore(); + export const refundEvents = new RefundEventsStore(); } /* tslint:enable:completed-docs */ diff --git a/src/util/http.ts b/src/util/http.ts index 79039f516..93c748d79 100644 --- a/src/util/http.ts +++ b/src/util/http.ts @@ -33,6 +33,11 @@ export interface HttpRequestOptions { headers?: { [name: string]: string }; } +export enum HttpResponseStatus { + Ok = 200, + Gone = 210, +} + /** * Headers, roughly modeled after the fetch API's headers object. */ diff --git a/src/wallet.ts b/src/wallet.ts index 163f3def9..a4e201074 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -42,11 +42,7 @@ import { preparePay, confirmPay, processDownloadProposal, - applyRefund, - getFullRefundFees, processPurchasePay, - processPurchaseQueryRefund, - processPurchaseApplyRefund, } from "./operations/pay"; import { @@ -107,6 +103,7 @@ import { AsyncOpMemoSingle } from "./util/asyncMemo"; import { PendingOperationInfo, PendingOperationsResponse, PendingOperationType } from "./types/pending"; import { WalletNotification, NotificationType } from "./types/notifications"; import { HistoryQuery, HistoryEvent } from "./types/history"; +import { processPurchaseQueryRefund, processPurchaseApplyRefund, getFullRefundFees, applyRefund } from "./operations/refund"; /** * Wallet protocol version spoken with the exchange @@ -695,21 +692,21 @@ export class Wallet { if (!purchase) { throw Error("unknown purchase"); } - const refundsDoneAmounts = Object.values(purchase.refundsDone).map(x => - Amounts.parseOrThrow(x.refund_amount), + const refundsDoneAmounts = Object.values(purchase.refundState.refundsDone).map(x => + Amounts.parseOrThrow(x.perm.refund_amount), ); const refundsPendingAmounts = Object.values( - purchase.refundsPending, - ).map(x => Amounts.parseOrThrow(x.refund_amount)); + purchase.refundState.refundsPending, + ).map(x => Amounts.parseOrThrow(x.perm.refund_amount)); const totalRefundAmount = Amounts.sum([ ...refundsDoneAmounts, ...refundsPendingAmounts, ]).amount; - const refundsDoneFees = Object.values(purchase.refundsDone).map(x => - Amounts.parseOrThrow(x.refund_amount), + const refundsDoneFees = Object.values(purchase.refundState.refundsDone).map(x => + Amounts.parseOrThrow(x.perm.refund_amount), ); - const refundsPendingFees = Object.values(purchase.refundsPending).map(x => - Amounts.parseOrThrow(x.refund_amount), + const refundsPendingFees = Object.values(purchase.refundState.refundsPending).map(x => + Amounts.parseOrThrow(x.perm.refund_amount), ); const totalRefundFees = Amounts.sum([ ...refundsDoneFees, diff --git a/tsconfig.json b/tsconfig.json index 43ccd7e21..81e529fad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,6 +54,7 @@ "src/operations/payback.ts", "src/operations/pending.ts", "src/operations/refresh.ts", + "src/operations/refund.ts", "src/operations/reserves.ts", "src/operations/return.ts", "src/operations/state.ts",