summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/refund.ts
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2020-09-09 02:18:03 +0530
committerFlorian Dold <florian.dold@gmail.com>2020-09-09 02:18:03 +0530
commit67df550b4f6d67f8de346985df26133dc8da5c05 (patch)
tree575b514c1f6a9723fd32678da42f21c3c7ab523b /packages/taler-wallet-core/src/operations/refund.ts
parent68ca4600e0e3460423a6c33530bd4bb8096afa65 (diff)
downloadwallet-core-67df550b4f6d67f8de346985df26133dc8da5c05.tar.gz
wallet-core-67df550b4f6d67f8de346985df26133dc8da5c05.tar.bz2
wallet-core-67df550b4f6d67f8de346985df26133dc8da5c05.zip
implement payment aborts with integration test
Diffstat (limited to 'packages/taler-wallet-core/src/operations/refund.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/refund.ts201
1 files changed, 171 insertions, 30 deletions
diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts
index e15a27b3a..10a57f909 100644
--- a/packages/taler-wallet-core/src/operations/refund.ts
+++ b/packages/taler-wallet-core/src/operations/refund.ts
@@ -36,6 +36,7 @@ import {
RefundReason,
RefundState,
PurchaseRecord,
+ AbortStatus,
} from "../types/dbTypes";
import { NotificationType } from "../types/notifications";
import { parseRefundUri } from "../util/taleruri";
@@ -46,14 +47,25 @@ import {
MerchantCoinRefundSuccessStatus,
MerchantCoinRefundFailureStatus,
codecForMerchantOrderRefundPickupResponse,
+ AbortRequest,
+ AbortingCoin,
+ codecForMerchantAbortPayRefundStatus,
+ codecForAbortResponse,
} from "../types/talerTypes";
import { guardOperationException } from "./errors";
-import { getTimestampNow, Timestamp } from "../util/time";
+import {
+ getTimestampNow,
+ Timestamp,
+ durationAdd,
+ timestampAddDuration,
+} from "../util/time";
import { Logger } from "../util/logging";
import { readSuccessResponseJsonOrThrow } from "../util/http";
import { TransactionHandle } from "../util/query";
import { URL } from "../util/url";
import { updateRetryInfoTimeout, initRetryInfo } from "../util/retries";
+import { checkDbInvariant } from "../util/invariants";
+import { TalerErrorCode } from "../TalerErrorCode";
const logger = new Logger("refund.ts");
@@ -101,7 +113,7 @@ async function applySuccessfulRefund(
const refundKey = getRefundKey(r);
const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) {
- console.warn("coin not found, can't apply refund");
+ logger.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.get(Stores.denominations, [
@@ -158,7 +170,7 @@ async function storePendingRefund(
const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) {
- console.warn("coin not found, can't apply refund");
+ logger.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.get(Stores.denominations, [
@@ -202,13 +214,14 @@ async function storePendingRefund(
async function storeFailedRefund(
tx: TransactionHandle,
p: PurchaseRecord,
+ refreshCoinsMap: Record<string, { coinPub: string }>,
r: MerchantCoinRefundFailureStatus,
): Promise<void> {
const refundKey = getRefundKey(r);
const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) {
- console.warn("coin not found, can't apply refund");
+ logger.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.get(Stores.denominations, [
@@ -247,6 +260,38 @@ async function storeFailedRefund(
refundFee: denom.feeRefund,
totalRefreshCostBound,
};
+
+ if (p.abortStatus === AbortStatus.AbortRefund) {
+ // Refund failed because the merchant didn't even try to deposit
+ // the coin yet, so we try to refresh.
+ if (r.exchange_code === TalerErrorCode.REFUND_DEPOSIT_NOT_FOUND) {
+ const coin = await tx.get(Stores.coins, r.coin_pub);
+ if (!coin) {
+ logger.warn("coin not found, can't apply refund");
+ return;
+ }
+ const denom = await tx.get(Stores.denominations, [
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ logger.warn("denomination for coin missing");
+ return;
+ }
+ let contrib: AmountJson | undefined;
+ for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) {
+ if (p.payCoinSelection.coinPubs[i] === r.coin_pub) {
+ contrib = p.payCoinSelection.coinContributions[i];
+ }
+ }
+ if (contrib) {
+ coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
+ coin.currentAmount = Amounts.sub(coin.currentAmount, denom.feeRefund).amount;
+ }
+ refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
+ await tx.put(Stores.coins, coin);
+ }
+ }
}
async function acceptRefunds(
@@ -268,7 +313,7 @@ async function acceptRefunds(
async (tx) => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
- console.error("purchase not found, not adding refunds");
+ logger.error("purchase not found, not adding refunds");
return;
}
@@ -280,7 +325,7 @@ async function acceptRefunds(
const isPermanentFailure =
refundStatus.type === "failure" &&
- refundStatus.exchange_status === 410;
+ refundStatus.exchange_status >= 400 && refundStatus.exchange_status < 500 ;
// Already failed.
if (existingRefundInfo?.type === RefundState.Failed) {
@@ -306,7 +351,7 @@ async function acceptRefunds(
if (refundStatus.type === "success") {
await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
} else if (isPermanentFailure) {
- await storeFailedRefund(tx, p, refundStatus);
+ await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
} else {
await storePendingRefund(tx, p, refundStatus);
}
@@ -326,7 +371,11 @@ async function acceptRefunds(
// after a retry delay?
let queryDone = true;
- if (p.autoRefundDeadline && p.autoRefundDeadline.t_ms > now.t_ms) {
+ if (
+ p.timestampFirstSuccessfulPay &&
+ p.autoRefundDeadline &&
+ p.autoRefundDeadline.t_ms > now.t_ms
+ ) {
queryDone = false;
}
@@ -347,7 +396,10 @@ async function acceptRefunds(
p.timestampLastRefundStatus = now;
p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo(false);
- p.refundStatusRequested = false;
+ p.refundQueryRequested = false;
+ if (p.abortStatus === AbortStatus.AbortRefund) {
+ p.abortStatus = AbortStatus.AbortFinished;
+ }
logger.trace("refund query done");
} else {
// No error, but we need to try again!
@@ -415,7 +467,7 @@ export async function applyRefund(
logger.error("no purchase found for refund URL");
return false;
}
- p.refundStatusRequested = true;
+ p.refundQueryRequested = true;
p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo();
await tx.put(Stores.purchases, p);
@@ -516,32 +568,121 @@ async function processPurchaseQueryRefundImpl(
return;
}
- if (!purchase.refundStatusRequested) {
+ if (!purchase.refundQueryRequested) {
return;
}
- const requestUrl = new URL(
- `orders/${purchase.contractData.orderId}/refund`,
- purchase.contractData.merchantBaseUrl,
- );
+ if (purchase.timestampFirstSuccessfulPay) {
+ const requestUrl = new URL(
+ `orders/${purchase.contractData.orderId}/refund`,
+ purchase.contractData.merchantBaseUrl,
+ );
- logger.trace(`making refund request to ${requestUrl.href}`);
+ logger.trace(`making refund request to ${requestUrl.href}`);
- const request = await ws.http.postJson(requestUrl.href, {
- h_contract: purchase.contractData.contractTermsHash,
- });
+ const request = await ws.http.postJson(requestUrl.href, {
+ h_contract: purchase.contractData.contractTermsHash,
+ });
+
+ logger.trace(
+ "got json",
+ JSON.stringify(await request.json(), undefined, 2),
+ );
- logger.trace("got json", JSON.stringify(await request.json(), undefined, 2));
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForMerchantOrderRefundPickupResponse(),
+ );
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForMerchantOrderRefundPickupResponse(),
- );
+ await acceptRefunds(
+ ws,
+ proposalId,
+ refundResponse.refunds,
+ RefundReason.NormalRefund,
+ );
+ } else if (purchase.abortStatus === AbortStatus.AbortRefund) {
+ const requestUrl = new URL(
+ `orders/${purchase.contractData.orderId}/abort`,
+ purchase.contractData.merchantBaseUrl,
+ );
- await acceptRefunds(
- ws,
- proposalId,
- refundResponse.refunds,
- RefundReason.NormalRefund,
- );
+ const abortingCoins: AbortingCoin[] = [];
+ for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) {
+ const coinPub = purchase.payCoinSelection.coinPubs[i];
+ const coin = await ws.db.get(Stores.coins, coinPub);
+ checkDbInvariant(!!coin, "expected coin to be present");
+ abortingCoins.push({
+ coin_pub: coinPub,
+ contribution: Amounts.stringify(
+ purchase.payCoinSelection.coinContributions[i],
+ ),
+ exchange_url: coin.exchangeBaseUrl,
+ });
+ }
+
+ const abortReq: AbortRequest = {
+ h_contract: purchase.contractData.contractTermsHash,
+ coins: abortingCoins,
+ };
+
+ logger.trace(`making order abort request to ${requestUrl.href}`);
+
+ const request = await ws.http.postJson(requestUrl.href, abortReq);
+ const abortResp = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForAbortResponse(),
+ );
+
+ const refunds: MerchantCoinRefundStatus[] = [];
+
+ if (abortResp.refunds.length != abortingCoins.length) {
+ // FIXME: define error code!
+ throw Error("invalid order abort response");
+ }
+
+ for (let i = 0; i < abortResp.refunds.length; i++) {
+ const r = abortResp.refunds[i];
+ refunds.push({
+ ...r,
+ coin_pub: purchase.payCoinSelection.coinPubs[i],
+ refund_amount: Amounts.stringify(
+ purchase.payCoinSelection.coinContributions[i],
+ ),
+ rtransaction_id: 0,
+ execution_time: timestampAddDuration(purchase.contractData.timestamp, {
+ d_ms: 1000,
+ }),
+ });
+ }
+ await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
+ }
+}
+
+export async function abortFailedPayWithRefund(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
+ const purchase = await tx.get(Stores.purchases, proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ if (purchase.timestampFirstSuccessfulPay) {
+ // No point in aborting it. We don't even report an error.
+ logger.warn(`tried to abort successful payment`);
+ return;
+ }
+ if (purchase.abortStatus !== AbortStatus.None) {
+ return;
+ }
+ purchase.refundQueryRequested = true;
+ purchase.paymentSubmitPending = false;
+ purchase.abortStatus = AbortStatus.AbortRefund;
+ purchase.lastPayError = undefined;
+ purchase.payRetryInfo = initRetryInfo(false);
+ await tx.put(Stores.purchases, purchase);
+ });
+ processPurchaseQueryRefund(ws, proposalId, true).catch((e) => {
+ logger.trace(`error during refund processing after abort pay: ${e}`);
+ });
}