aboutsummaryrefslogtreecommitdiff
path: root/src/operations
diff options
context:
space:
mode:
Diffstat (limited to 'src/operations')
-rw-r--r--src/operations/pay.ts56
-rw-r--r--src/operations/pending.ts20
-rw-r--r--src/operations/refund.ts502
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,
+ });
+}