aboutsummaryrefslogtreecommitdiff
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
parent68ca4600e0e3460423a6c33530bd4bb8096afa65 (diff)
implement payment aborts with integration test
-rw-r--r--packages/taler-integrationtests/src/faultInjection.ts2
-rw-r--r--packages/taler-integrationtests/src/harness.ts10
-rw-r--r--packages/taler-integrationtests/src/helpers.ts5
-rw-r--r--packages/taler-integrationtests/src/test-tipping.ts1
-rw-r--r--packages/taler-wallet-core/src/operations/pay.ts138
-rw-r--r--packages/taler-wallet-core/src/operations/pending.ts5
-rw-r--r--packages/taler-wallet-core/src/operations/refund.ts201
-rw-r--r--packages/taler-wallet-core/src/operations/transactions.ts12
-rw-r--r--packages/taler-wallet-core/src/types/dbTypes.ts18
-rw-r--r--packages/taler-wallet-core/src/types/talerTypes.ts108
-rw-r--r--packages/taler-wallet-core/src/types/walletTypes.ts8
-rw-r--r--packages/taler-wallet-core/src/wallet.ts17
12 files changed, 405 insertions, 120 deletions
diff --git a/packages/taler-integrationtests/src/faultInjection.ts b/packages/taler-integrationtests/src/faultInjection.ts
index a85b1dd7d..a2d4836d9 100644
--- a/packages/taler-integrationtests/src/faultInjection.ts
+++ b/packages/taler-integrationtests/src/faultInjection.ts
@@ -80,7 +80,7 @@ export class FaultProxy {
start() {
const server = http.createServer((req, res) => {
const requestChunks: Buffer[] = [];
- const requestUrl = `http://locahost:${this.faultProxyConfig.inboundPort}${req.url}`;
+ const requestUrl = `http://localhost:${this.faultProxyConfig.inboundPort}${req.url}`;
console.log("request for", new URL(requestUrl));
req.on("data", (chunk) => {
requestChunks.push(chunk);
diff --git a/packages/taler-integrationtests/src/harness.ts b/packages/taler-integrationtests/src/harness.ts
index b71fe4104..a25ee90b1 100644
--- a/packages/taler-integrationtests/src/harness.ts
+++ b/packages/taler-integrationtests/src/harness.ts
@@ -76,6 +76,7 @@ import {
PrepareTipRequest,
codecForPrepareTipResult,
AcceptTipRequest,
+ AbortPayWithRefundRequest,
} from "taler-wallet-core";
import { URL } from "url";
import axios, { AxiosError } from "axios";
@@ -1538,6 +1539,15 @@ export class WalletCli {
throw new OperationFailedError(resp.error);
}
+
+ async abortFailedPayWithRefund(req: AbortPayWithRefundRequest): Promise<void> {
+ const resp = await this.apiRequest("abortFailedPayWithRefund", req);
+ if (resp.type === "response") {
+ return;
+ }
+ throw new OperationFailedError(resp.error);
+ }
+
async confirmPay(req: ConfirmPayRequest): Promise<ConfirmPayResult> {
const resp = await this.apiRequest("confirmPay", req);
if (resp.type === "response") {
diff --git a/packages/taler-integrationtests/src/helpers.ts b/packages/taler-integrationtests/src/helpers.ts
index bdccdba8a..f633ea82d 100644
--- a/packages/taler-integrationtests/src/helpers.ts
+++ b/packages/taler-integrationtests/src/helpers.ts
@@ -36,6 +36,7 @@ import {
BankApi,
BankAccessApi,
MerchantPrivateApi,
+ ExchangeServiceInterface,
} from "./harness";
import {
AmountString,
@@ -233,7 +234,7 @@ export async function startWithdrawViaBank(
p: {
wallet: WalletCli;
bank: BankService;
- exchange: ExchangeService;
+ exchange: ExchangeServiceInterface;
amount: AmountString;
},
): Promise<void> {
@@ -272,7 +273,7 @@ export async function withdrawViaBank(
p: {
wallet: WalletCli;
bank: BankService;
- exchange: ExchangeService;
+ exchange: ExchangeServiceInterface;
amount: AmountString;
},
): Promise<void> {
diff --git a/packages/taler-integrationtests/src/test-tipping.ts b/packages/taler-integrationtests/src/test-tipping.ts
index 4c080293e..6703ab4b2 100644
--- a/packages/taler-integrationtests/src/test-tipping.ts
+++ b/packages/taler-integrationtests/src/test-tipping.ts
@@ -21,7 +21,6 @@ import {
runTest,
GlobalTestState,
MerchantPrivateApi,
- BankAccessApi,
BankApi,
} from "./harness";
import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts
index 3dc5e1600..8dbc2af41 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay.ts
@@ -35,6 +35,7 @@ import {
CoinRecord,
DenominationRecord,
PayCoinSelection,
+ AbortStatus,
} from "../types/dbTypes";
import { NotificationType } from "../types/notifications";
import {
@@ -77,7 +78,11 @@ import {
} from "../util/http";
import { TalerErrorCode } from "../TalerErrorCode";
import { URL } from "../util/url";
-import { initRetryInfo, updateRetryInfoTimeout, getRetryDuration } from "../util/retries";
+import {
+ initRetryInfo,
+ updateRetryInfoTimeout,
+ getRetryDuration,
+} from "../util/retries";
/**
* Logger.
@@ -111,7 +116,6 @@ export interface AvailableCoinInfo {
feeDeposit: AmountJson;
}
-
/**
* Compute the total cost of a payment to the customer.
*
@@ -429,8 +433,7 @@ async function recordConfirmPay(
logger.trace(`recording payment with session ID ${sessionId}`);
const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
const t: PurchaseRecord = {
- abortDone: false,
- abortRequested: false,
+ abortStatus: AbortStatus.None,
contractTermsRaw: d.contractTermsRaw,
contractData: d.contractData,
lastSessionId: sessionId,
@@ -444,7 +447,7 @@ async function recordConfirmPay(
lastRefundStatusError: undefined,
payRetryInfo: initRetryInfo(),
refundStatusRetryInfo: initRetryInfo(),
- refundStatusRequested: false,
+ refundQueryRequested: false,
timestampFirstSuccessfulPay: undefined,
autoRefundDeadline: undefined,
paymentSubmitPending: true,
@@ -522,6 +525,10 @@ async function incrementProposalRetry(
}
}
+/**
+ * FIXME: currently pay operations aren't ever automatically retried.
+ * But we still keep a payRetryInfo around in the database.
+ */
async function incrementPurchasePayRetry(
ws: InternalWalletState,
proposalId: string,
@@ -579,7 +586,10 @@ function getProposalRequestTimeout(proposal: ProposalRecord): Duration {
}
function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
- return durationMul({ d_ms: 5000 }, 1 + purchase.payCoinSelection.coinPubs.length / 20);
+ return durationMul(
+ { d_ms: 5000 },
+ 1 + purchase.payCoinSelection.coinPubs.length / 20,
+ );
}
async function processDownloadProposalImpl(
@@ -794,40 +804,37 @@ async function storeFirstPaySuccess(
paySig: string,
): Promise<void> {
const now = getTimestampNow();
- await ws.db.runWithWriteTransaction(
- [Stores.purchases],
- async (tx) => {
- const purchase = await tx.get(Stores.purchases, proposalId);
+ await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
+ const purchase = await tx.get(Stores.purchases, proposalId);
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (!isFirst) {
- logger.warn("payment success already stored");
- return;
- }
- purchase.timestampFirstSuccessfulPay = now;
- purchase.paymentSubmitPending = false;
- purchase.lastPayError = undefined;
- purchase.lastSessionId = sessionId;
- purchase.payRetryInfo = initRetryInfo(false);
- purchase.merchantPaySig = paySig;
- if (isFirst) {
- const ar = purchase.contractData.autoRefund;
- if (ar) {
- logger.info("auto_refund present");
- purchase.refundStatusRequested = true;
- purchase.refundStatusRetryInfo = initRetryInfo();
- purchase.lastRefundStatusError = undefined;
- purchase.autoRefundDeadline = timestampAddDuration(now, ar);
- }
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (!isFirst) {
+ logger.warn("payment success already stored");
+ return;
+ }
+ purchase.timestampFirstSuccessfulPay = now;
+ purchase.paymentSubmitPending = false;
+ purchase.lastPayError = undefined;
+ purchase.lastSessionId = sessionId;
+ purchase.payRetryInfo = initRetryInfo(false);
+ purchase.merchantPaySig = paySig;
+ if (isFirst) {
+ const ar = purchase.contractData.autoRefund;
+ if (ar) {
+ logger.info("auto_refund present");
+ purchase.refundQueryRequested = true;
+ purchase.refundStatusRetryInfo = initRetryInfo();
+ purchase.lastRefundStatusError = undefined;
+ purchase.autoRefundDeadline = timestampAddDuration(now, ar);
}
+ }
- await tx.put(Stores.purchases, purchase);
- },
- );
+ await tx.put(Stores.purchases, purchase);
+ });
}
async function storePayReplaySuccess(
@@ -835,26 +842,23 @@ async function storePayReplaySuccess(
proposalId: string,
sessionId: string | undefined,
): Promise<void> {
- await ws.db.runWithWriteTransaction(
- [Stores.purchases],
- async (tx) => {
- const purchase = await tx.get(Stores.purchases, proposalId);
+ await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
+ const purchase = await tx.get(Stores.purchases, proposalId);
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (isFirst) {
- throw Error("invalid payment state");
- }
- purchase.paymentSubmitPending = false;
- purchase.lastPayError = undefined;
- purchase.payRetryInfo = initRetryInfo(false);
- purchase.lastSessionId = sessionId;
- await tx.put(Stores.purchases, purchase);
- },
- );
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (isFirst) {
+ throw Error("invalid payment state");
+ }
+ purchase.paymentSubmitPending = false;
+ purchase.lastPayError = undefined;
+ purchase.payRetryInfo = initRetryInfo(false);
+ purchase.lastSessionId = sessionId;
+ await tx.put(Stores.purchases, purchase);
+ });
}
/**
@@ -863,7 +867,7 @@ async function storePayReplaySuccess(
* If the wallet has previously paid, it just transmits the merchant's
* own signature certifying that the wallet has previously paid.
*/
-export async function submitPay(
+async function submitPay(
ws: InternalWalletState,
proposalId: string,
): Promise<ConfirmPayResult> {
@@ -871,7 +875,7 @@ export async function submitPay(
if (!purchase) {
throw Error("Purchase not found: " + proposalId);
}
- if (purchase.abortRequested) {
+ if (purchase.abortStatus !== AbortStatus.None) {
throw Error("not submitting payment for aborted purchase");
}
const sessionId = purchase.lastSessionId;
@@ -1047,7 +1051,11 @@ export async function preparePayForUri(
p.lastSessionId = uriResult.sessionId;
await tx.put(Stores.purchases, p);
});
- const r = await submitPay(ws, proposalId);
+ const r = await guardOperationException(
+ () => submitPay(ws, proposalId),
+ (e: TalerErrorDetails): Promise<void> =>
+ incrementPurchasePayRetry(ws, proposalId, e),
+ );
if (r.type !== ConfirmPayResultType.Done) {
throw Error("submitting pay failed");
}
@@ -1125,7 +1133,11 @@ export async function confirmPay(
});
}
logger.trace("confirmPay: submitting payment for existing purchase");
- return submitPay(ws, proposalId);
+ return await guardOperationException(
+ () => submitPay(ws, proposalId),
+ (e: TalerErrorDetails): Promise<void> =>
+ incrementPurchasePayRetry(ws, proposalId, e),
+ );
}
logger.trace("confirmPay: purchase record does not exist yet");
@@ -1179,7 +1191,11 @@ export async function confirmPay(
sessionIdOverride,
);
- return submitPay(ws, proposalId);
+ return await guardOperationException(
+ () => submitPay(ws, proposalId),
+ (e: TalerErrorDetails): Promise<void> =>
+ incrementPurchasePayRetry(ws, proposalId, e),
+ );
}
export async function processPurchasePay(
diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts
index 7338ac77d..4f6477d50 100644
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ b/packages/taler-wallet-core/src/operations/pending.ts
@@ -22,6 +22,7 @@ import {
ProposalStatus,
ReserveRecordStatus,
Stores,
+ AbortStatus,
} from "../types/dbTypes";
import {
PendingOperationsResponse,
@@ -381,7 +382,7 @@ async function gatherPurchasePending(
onlyDue = false,
): Promise<void> {
await tx.iter(Stores.purchases).forEach((pr) => {
- if (pr.paymentSubmitPending) {
+ if (pr.paymentSubmitPending && pr.abortStatus === AbortStatus.None) {
resp.nextRetryDelay = updateRetryDelay(
resp.nextRetryDelay,
now,
@@ -398,7 +399,7 @@ async function gatherPurchasePending(
});
}
}
- if (pr.refundStatusRequested) {
+ if (pr.refundQueryRequested) {
resp.nextRetryDelay = updateRetryDelay(
resp.nextRetryDelay,
now,
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}`);
+ });
}
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts
index 5bc4ebace..87236d5a0 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -23,6 +23,7 @@ import {
WalletRefundItem,
RefundState,
ReserveRecordStatus,
+ AbortStatus,
} from "../types/dbTypes";
import { Amounts, AmountJson } from "../util/amounts";
import { timestampCmp } from "../util/time";
@@ -242,7 +243,9 @@ export async function getTransactions(
status: pr.timestampFirstSuccessfulPay
? PaymentStatus.Paid
: PaymentStatus.Accepted,
- pending: !pr.timestampFirstSuccessfulPay,
+ pending:
+ !pr.timestampFirstSuccessfulPay &&
+ pr.abortStatus === AbortStatus.None,
timestamp: pr.timestampAccept,
transactionId: paymentTransactionId,
info: info,
@@ -324,7 +327,10 @@ export async function getTransactions(
amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
pending: !tipRecord.pickedUpTimestamp,
timestamp: tipRecord.acceptedTimestamp,
- transactionId: makeEventId(TransactionType.Tip, tipRecord.walletTipId),
+ transactionId: makeEventId(
+ TransactionType.Tip,
+ tipRecord.walletTipId,
+ ),
error: tipRecord.lastError,
});
});
@@ -337,5 +343,5 @@ export async function getTransactions(
txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
txNotPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
- return { transactions: [...txPending, ...txNotPending] };
+ return { transactions: [...txNotPending, ...txPending] };
}
diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts
index ff790e216..d10be80ce 100644
--- a/packages/taler-wallet-core/src/types/dbTypes.ts
+++ b/packages/taler-wallet-core/src/types/dbTypes.ts
@@ -1285,6 +1285,12 @@ export interface PayCoinSelection {
customerDepositFees: AmountJson;
}
+export enum AbortStatus {
+ None = "none",
+ AbortRefund = "abort-refund",
+ AbortFinished = "abort-finished",
+}
+
/**
* Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable.
@@ -1352,17 +1358,9 @@ export interface PurchaseRecord {
* Do we need to query the merchant for the refund status
* of the payment?
*/
- refundStatusRequested: boolean;
+ refundQueryRequested: boolean;
- /**
- * An abort (with refund) was requested for this (incomplete!) purchase.
- */
- abortRequested: boolean;
-
- /**
- * The abort (with refund) was completed for this (incomplete!) purchase.
- */
- abortDone: boolean;
+ abortStatus: AbortStatus;
payRetryInfo: RetryInfo;
diff --git a/packages/taler-wallet-core/src/types/talerTypes.ts b/packages/taler-wallet-core/src/types/talerTypes.ts
index 16d00e2ea..ce83080cc 100644
--- a/packages/taler-wallet-core/src/types/talerTypes.ts
+++ b/packages/taler-wallet-core/src/types/talerTypes.ts
@@ -1059,7 +1059,6 @@ export const codecForAuditorHandle = (): Codec<AuditorHandle> =>
.property("url", codecForString())
.build("AuditorHandle");
-
export const codecForLocation = (): Codec<Location> =>
buildCodecForObject<Location>()
.property("country", codecOptional(codecForString()))
@@ -1071,7 +1070,7 @@ export const codecForLocation = (): Codec<Location> =>
.property("post_code", codecOptional(codecForString()))
.property("town", codecOptional(codecForString()))
.property("town_location", codecOptional(codecForString()))
- .property("address_lines", codecOptional(codecForList(codecForString())))
+ .property("address_lines", codecOptional(codecForList(codecForString())))
.build("Location");
export const codecForMerchantInfo = (): Codec<MerchantInfo> =>
@@ -1351,3 +1350,108 @@ export const codecForMerchantOrderStatusUnpaid = (): Codec<
.property("taler_pay_uri", codecForString())
.property("already_paid_order_id", codecOptional(codecForString()))
.build("MerchantOrderStatusUnpaid");
+
+export interface AbortRequest {
+ // hash of the order's contract terms (this is used to authenticate the
+ // wallet/customer in case $ORDER_ID is guessable).
+ h_contract: string;
+
+ // List of coins the wallet would like to see refunds for.
+ // (Should be limited to the coins for which the original
+ // payment succeeded, as far as the wallet knows.)
+ coins: AbortingCoin[];
+}
+
+export interface AbortingCoin {
+ // Public key of a coin for which the wallet is requesting an abort-related refund.
+ coin_pub: EddsaPublicKeyString;
+
+ // The amount to be refunded (matches the original contribution)
+ contribution: AmountString;
+
+ // URL of the exchange this coin was withdrawn from.
+ exchange_url: string;
+}
+
+export interface AbortResponse {
+ // List of refund responses about the coins that the wallet
+ // requested an abort for. In the same order as the 'coins'
+ // from the original request.
+ // The rtransaction_id is implied to be 0.
+ refunds: MerchantAbortPayRefundStatus[];
+}
+
+export const codecForAbortResponse = (): Codec<AbortResponse> =>
+ buildCodecForObject<AbortResponse>()
+ .property("refunds", codecForList(codecForMerchantAbortPayRefundStatus()))
+ .build("AbortResponse");
+
+export type MerchantAbortPayRefundStatus =
+ | MerchantAbortPayRefundSuccessStatus
+ | MerchantAbortPayRefundFailureStatus;
+
+// Details about why a refund failed.
+export interface MerchantAbortPayRefundFailureStatus {
+ // Used as tag for the sum type RefundStatus sum type.
+ type: "failure";
+
+ // HTTP status of the exchange request, must NOT be 200.
+ exchange_status: number;
+
+ // Taler error code from the exchange reply, if available.
+ exchange_code?: number;
+
+ // If available, HTTP reply from the exchange.
+ exchange_reply?: unknown;
+}
+
+// Additional details needed to verify the refund confirmation signature
+// (h_contract_terms and merchant_pub) are already known
+// to the wallet and thus not included.
+export interface MerchantAbortPayRefundSuccessStatus {
+ // Used as tag for the sum type MerchantCoinRefundStatus sum type.
+ type: "success";
+
+ // HTTP status of the exchange request, 200 (integer) required for refund confirmations.
+ exchange_status: 200;
+
+ // the EdDSA :ref:signature (binary-only) with purpose
+ // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the
+ // exchange affirming the successful refund
+ exchange_sig: string;
+
+ // public EdDSA key of the exchange that was used to generate the signature.
+ // Should match one of the exchange's signing keys from /keys. It is given
+ // explicitly as the client might otherwise be confused by clock skew as to
+ // which signing key was used.
+ exchange_pub: string;
+}
+
+export const codecForMerchantAbortPayRefundSuccessStatus = (): Codec<
+ MerchantAbortPayRefundSuccessStatus
+> =>
+ buildCodecForObject<MerchantAbortPayRefundSuccessStatus>()
+ .property("exchange_pub", codecForString())
+ .property("exchange_sig", codecForString())
+ .property("exchange_status", codecForConstNumber(200))
+ .property("type", codecForConstString("success"))
+ .build("MerchantAbortPayRefundSuccessStatus");
+
+export const codecForMerchantAbortPayRefundFailureStatus = (): Codec<
+ MerchantAbortPayRefundFailureStatus
+> =>
+ buildCodecForObject<MerchantAbortPayRefundFailureStatus>()
+ .property("exchange_code", codecForNumber())
+ .property("exchange_reply", codecForAny())
+ .property("exchange_status", codecForNumber())
+ .property("type", codecForConstString("failure"))
+ .build("MerchantAbortPayRefundFailureStatus");
+
+export const codecForMerchantAbortPayRefundStatus = (): Codec<
+ MerchantAbortPayRefundStatus
+> =>
+ buildCodecForUnion<MerchantAbortPayRefundStatus>()
+ .discriminateOn("type")
+ .alternative("success", codecForMerchantAbortPayRefundSuccessStatus())
+ .alternative("failure", codecForMerchantAbortPayRefundFailureStatus())
+ .build("MerchantAbortPayRefundStatus");
diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts
index 5507822fa..b8d8be668 100644
--- a/packages/taler-wallet-core/src/types/walletTypes.ts
+++ b/packages/taler-wallet-core/src/types/walletTypes.ts
@@ -932,3 +932,11 @@ export const codecForAcceptTipRequest = (): Codec<AcceptTipRequest> =>
.property("walletTipId", codecForString())
.build("AcceptTipRequest");
+export interface AbortPayWithRefundRequest {
+ proposalId: string;
+}
+
+export const codecForAbortPayWithRefundRequest = (): Codec<AbortPayWithRefundRequest> =>
+ buildCodecForObject<AbortPayWithRefundRequest>()
+ .property("proposalId", codecForString())
+ .build("AbortPayWithRefundRequest");
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index e91d74efb..768d5eb0f 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -94,6 +94,7 @@ import {
PrepareTipResult,
codecForPrepareTipRequest,
codecForAcceptTipRequest,
+ codecForAbortPayWithRefundRequest,
} from "./types/walletTypes";
import { Logger } from "./util/logging";
@@ -132,7 +133,7 @@ import {
PendingOperationType,
} from "./types/pending";
import { WalletNotification, NotificationType } from "./types/notifications";
-import { processPurchaseQueryRefund, applyRefund } from "./operations/refund";
+import { processPurchaseQueryRefund, applyRefund, abortFailedPayWithRefund } from "./operations/refund";
import { durationMin, Duration } from "./util/time";
import { processRecoupGroup } from "./operations/recoup";
import {
@@ -744,8 +745,8 @@ export class Wallet {
return prepareTip(this.ws, talerTipUri);
}
- async abortFailedPayment(contractTermsHash: string): Promise<void> {
- throw Error("not implemented");
+ async abortFailedPayWithRefund(proposalId: string): Promise<void> {
+ return abortFailedPayWithRefund(this.ws, proposalId);
}
/**
@@ -1022,11 +1023,6 @@ export class Wallet {
const req = codecForGetExchangeTosRequest().decode(payload);
return this.getExchangeTos(req.exchangeBaseUrl);
}
- case "abortProposal": {
- const req = codecForAbortProposalRequest().decode(payload);
- await this.refuseProposal(req.proposalId);
- return {};
- }
case "retryPendingNow": {
await this.runPending(true);
return {};
@@ -1039,6 +1035,11 @@ export class Wallet {
const req = codecForConfirmPayRequest().decode(payload);
return await this.confirmPay(req.proposalId, req.sessionId);
}
+ case "abortFailedPayWithRefund": {
+ const req = codecForAbortPayWithRefundRequest().decode(payload);
+ await this.abortFailedPayWithRefund(req.proposalId);
+ return {};
+ }
case "dumpCoins": {
return await this.dumpCoins();
}