implement payment aborts with integration test

This commit is contained in:
Florian Dold 2020-09-09 02:18:03 +05:30
parent 68ca4600e0
commit 67df550b4f
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
12 changed files with 405 additions and 120 deletions

View File

@ -80,7 +80,7 @@ export class FaultProxy {
start() { start() {
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const requestChunks: Buffer[] = []; 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)); console.log("request for", new URL(requestUrl));
req.on("data", (chunk) => { req.on("data", (chunk) => {
requestChunks.push(chunk); requestChunks.push(chunk);

View File

@ -76,6 +76,7 @@ import {
PrepareTipRequest, PrepareTipRequest,
codecForPrepareTipResult, codecForPrepareTipResult,
AcceptTipRequest, AcceptTipRequest,
AbortPayWithRefundRequest,
} from "taler-wallet-core"; } from "taler-wallet-core";
import { URL } from "url"; import { URL } from "url";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
@ -1538,6 +1539,15 @@ export class WalletCli {
throw new OperationFailedError(resp.error); 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> { async confirmPay(req: ConfirmPayRequest): Promise<ConfirmPayResult> {
const resp = await this.apiRequest("confirmPay", req); const resp = await this.apiRequest("confirmPay", req);
if (resp.type === "response") { if (resp.type === "response") {

View File

@ -36,6 +36,7 @@ import {
BankApi, BankApi,
BankAccessApi, BankAccessApi,
MerchantPrivateApi, MerchantPrivateApi,
ExchangeServiceInterface,
} from "./harness"; } from "./harness";
import { import {
AmountString, AmountString,
@ -233,7 +234,7 @@ export async function startWithdrawViaBank(
p: { p: {
wallet: WalletCli; wallet: WalletCli;
bank: BankService; bank: BankService;
exchange: ExchangeService; exchange: ExchangeServiceInterface;
amount: AmountString; amount: AmountString;
}, },
): Promise<void> { ): Promise<void> {
@ -272,7 +273,7 @@ export async function withdrawViaBank(
p: { p: {
wallet: WalletCli; wallet: WalletCli;
bank: BankService; bank: BankService;
exchange: ExchangeService; exchange: ExchangeServiceInterface;
amount: AmountString; amount: AmountString;
}, },
): Promise<void> { ): Promise<void> {

View File

@ -21,7 +21,6 @@ import {
runTest, runTest,
GlobalTestState, GlobalTestState,
MerchantPrivateApi, MerchantPrivateApi,
BankAccessApi,
BankApi, BankApi,
} from "./harness"; } from "./harness";
import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers"; import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";

View File

@ -35,6 +35,7 @@ import {
CoinRecord, CoinRecord,
DenominationRecord, DenominationRecord,
PayCoinSelection, PayCoinSelection,
AbortStatus,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { import {
@ -77,7 +78,11 @@ import {
} from "../util/http"; } from "../util/http";
import { TalerErrorCode } from "../TalerErrorCode"; import { TalerErrorCode } from "../TalerErrorCode";
import { URL } from "../util/url"; import { URL } from "../util/url";
import { initRetryInfo, updateRetryInfoTimeout, getRetryDuration } from "../util/retries"; import {
initRetryInfo,
updateRetryInfoTimeout,
getRetryDuration,
} from "../util/retries";
/** /**
* Logger. * Logger.
@ -111,7 +116,6 @@ export interface AvailableCoinInfo {
feeDeposit: AmountJson; feeDeposit: AmountJson;
} }
/** /**
* Compute the total cost of a payment to the customer. * 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}`); logger.trace(`recording payment with session ID ${sessionId}`);
const payCostInfo = await getTotalPaymentCost(ws, coinSelection); const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
const t: PurchaseRecord = { const t: PurchaseRecord = {
abortDone: false, abortStatus: AbortStatus.None,
abortRequested: false,
contractTermsRaw: d.contractTermsRaw, contractTermsRaw: d.contractTermsRaw,
contractData: d.contractData, contractData: d.contractData,
lastSessionId: sessionId, lastSessionId: sessionId,
@ -444,7 +447,7 @@ async function recordConfirmPay(
lastRefundStatusError: undefined, lastRefundStatusError: undefined,
payRetryInfo: initRetryInfo(), payRetryInfo: initRetryInfo(),
refundStatusRetryInfo: initRetryInfo(), refundStatusRetryInfo: initRetryInfo(),
refundStatusRequested: false, refundQueryRequested: false,
timestampFirstSuccessfulPay: undefined, timestampFirstSuccessfulPay: undefined,
autoRefundDeadline: undefined, autoRefundDeadline: undefined,
paymentSubmitPending: true, 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( async function incrementPurchasePayRetry(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -579,7 +586,10 @@ function getProposalRequestTimeout(proposal: ProposalRecord): Duration {
} }
function getPayRequestTimeout(purchase: PurchaseRecord): 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( async function processDownloadProposalImpl(
@ -794,40 +804,37 @@ async function storeFirstPaySuccess(
paySig: string, paySig: string,
): Promise<void> { ): Promise<void> {
const now = getTimestampNow(); const now = getTimestampNow();
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
[Stores.purchases], const purchase = await tx.get(Stores.purchases, proposalId);
async (tx) => {
const purchase = await tx.get(Stores.purchases, proposalId);
if (!purchase) { if (!purchase) {
logger.warn("purchase does not exist anymore"); logger.warn("purchase does not exist anymore");
return; return;
} }
const isFirst = purchase.timestampFirstSuccessfulPay === undefined; const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
if (!isFirst) { if (!isFirst) {
logger.warn("payment success already stored"); logger.warn("payment success already stored");
return; return;
} }
purchase.timestampFirstSuccessfulPay = now; purchase.timestampFirstSuccessfulPay = now;
purchase.paymentSubmitPending = false; purchase.paymentSubmitPending = false;
purchase.lastPayError = undefined; purchase.lastPayError = undefined;
purchase.lastSessionId = sessionId; purchase.lastSessionId = sessionId;
purchase.payRetryInfo = initRetryInfo(false); purchase.payRetryInfo = initRetryInfo(false);
purchase.merchantPaySig = paySig; purchase.merchantPaySig = paySig;
if (isFirst) { if (isFirst) {
const ar = purchase.contractData.autoRefund; const ar = purchase.contractData.autoRefund;
if (ar) { if (ar) {
logger.info("auto_refund present"); logger.info("auto_refund present");
purchase.refundStatusRequested = true; purchase.refundQueryRequested = true;
purchase.refundStatusRetryInfo = initRetryInfo(); purchase.refundStatusRetryInfo = initRetryInfo();
purchase.lastRefundStatusError = undefined; purchase.lastRefundStatusError = undefined;
purchase.autoRefundDeadline = timestampAddDuration(now, ar); purchase.autoRefundDeadline = timestampAddDuration(now, ar);
}
} }
}
await tx.put(Stores.purchases, purchase); await tx.put(Stores.purchases, purchase);
}, });
);
} }
async function storePayReplaySuccess( async function storePayReplaySuccess(
@ -835,26 +842,23 @@ async function storePayReplaySuccess(
proposalId: string, proposalId: string,
sessionId: string | undefined, sessionId: string | undefined,
): Promise<void> { ): Promise<void> {
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
[Stores.purchases], const purchase = await tx.get(Stores.purchases, proposalId);
async (tx) => {
const purchase = await tx.get(Stores.purchases, proposalId);
if (!purchase) { if (!purchase) {
logger.warn("purchase does not exist anymore"); logger.warn("purchase does not exist anymore");
return; return;
} }
const isFirst = purchase.timestampFirstSuccessfulPay === undefined; const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
if (isFirst) { if (isFirst) {
throw Error("invalid payment state"); throw Error("invalid payment state");
} }
purchase.paymentSubmitPending = false; purchase.paymentSubmitPending = false;
purchase.lastPayError = undefined; purchase.lastPayError = undefined;
purchase.payRetryInfo = initRetryInfo(false); purchase.payRetryInfo = initRetryInfo(false);
purchase.lastSessionId = sessionId; purchase.lastSessionId = sessionId;
await tx.put(Stores.purchases, purchase); 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 * If the wallet has previously paid, it just transmits the merchant's
* own signature certifying that the wallet has previously paid. * own signature certifying that the wallet has previously paid.
*/ */
export async function submitPay( async function submitPay(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
): Promise<ConfirmPayResult> { ): Promise<ConfirmPayResult> {
@ -871,7 +875,7 @@ export async function submitPay(
if (!purchase) { if (!purchase) {
throw Error("Purchase not found: " + proposalId); throw Error("Purchase not found: " + proposalId);
} }
if (purchase.abortRequested) { if (purchase.abortStatus !== AbortStatus.None) {
throw Error("not submitting payment for aborted purchase"); throw Error("not submitting payment for aborted purchase");
} }
const sessionId = purchase.lastSessionId; const sessionId = purchase.lastSessionId;
@ -1047,7 +1051,11 @@ export async function preparePayForUri(
p.lastSessionId = uriResult.sessionId; p.lastSessionId = uriResult.sessionId;
await tx.put(Stores.purchases, p); 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) { if (r.type !== ConfirmPayResultType.Done) {
throw Error("submitting pay failed"); throw Error("submitting pay failed");
} }
@ -1125,7 +1133,11 @@ export async function confirmPay(
}); });
} }
logger.trace("confirmPay: submitting payment for existing purchase"); 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"); logger.trace("confirmPay: purchase record does not exist yet");
@ -1179,7 +1191,11 @@ export async function confirmPay(
sessionIdOverride, sessionIdOverride,
); );
return submitPay(ws, proposalId); return await guardOperationException(
() => submitPay(ws, proposalId),
(e: TalerErrorDetails): Promise<void> =>
incrementPurchasePayRetry(ws, proposalId, e),
);
} }
export async function processPurchasePay( export async function processPurchasePay(

View File

@ -22,6 +22,7 @@ import {
ProposalStatus, ProposalStatus,
ReserveRecordStatus, ReserveRecordStatus,
Stores, Stores,
AbortStatus,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { import {
PendingOperationsResponse, PendingOperationsResponse,
@ -381,7 +382,7 @@ async function gatherPurchasePending(
onlyDue = false, onlyDue = false,
): Promise<void> { ): Promise<void> {
await tx.iter(Stores.purchases).forEach((pr) => { await tx.iter(Stores.purchases).forEach((pr) => {
if (pr.paymentSubmitPending) { if (pr.paymentSubmitPending && pr.abortStatus === AbortStatus.None) {
resp.nextRetryDelay = updateRetryDelay( resp.nextRetryDelay = updateRetryDelay(
resp.nextRetryDelay, resp.nextRetryDelay,
now, now,
@ -398,7 +399,7 @@ async function gatherPurchasePending(
}); });
} }
} }
if (pr.refundStatusRequested) { if (pr.refundQueryRequested) {
resp.nextRetryDelay = updateRetryDelay( resp.nextRetryDelay = updateRetryDelay(
resp.nextRetryDelay, resp.nextRetryDelay,
now, now,

View File

@ -36,6 +36,7 @@ import {
RefundReason, RefundReason,
RefundState, RefundState,
PurchaseRecord, PurchaseRecord,
AbortStatus,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { parseRefundUri } from "../util/taleruri"; import { parseRefundUri } from "../util/taleruri";
@ -46,14 +47,25 @@ import {
MerchantCoinRefundSuccessStatus, MerchantCoinRefundSuccessStatus,
MerchantCoinRefundFailureStatus, MerchantCoinRefundFailureStatus,
codecForMerchantOrderRefundPickupResponse, codecForMerchantOrderRefundPickupResponse,
AbortRequest,
AbortingCoin,
codecForMerchantAbortPayRefundStatus,
codecForAbortResponse,
} from "../types/talerTypes"; } from "../types/talerTypes";
import { guardOperationException } from "./errors"; import { guardOperationException } from "./errors";
import { getTimestampNow, Timestamp } from "../util/time"; import {
getTimestampNow,
Timestamp,
durationAdd,
timestampAddDuration,
} from "../util/time";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
import { readSuccessResponseJsonOrThrow } from "../util/http"; import { readSuccessResponseJsonOrThrow } from "../util/http";
import { TransactionHandle } from "../util/query"; import { TransactionHandle } from "../util/query";
import { URL } from "../util/url"; import { URL } from "../util/url";
import { updateRetryInfoTimeout, initRetryInfo } from "../util/retries"; import { updateRetryInfoTimeout, initRetryInfo } from "../util/retries";
import { checkDbInvariant } from "../util/invariants";
import { TalerErrorCode } from "../TalerErrorCode";
const logger = new Logger("refund.ts"); const logger = new Logger("refund.ts");
@ -101,7 +113,7 @@ async function applySuccessfulRefund(
const refundKey = getRefundKey(r); const refundKey = getRefundKey(r);
const coin = await tx.get(Stores.coins, r.coin_pub); const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) { if (!coin) {
console.warn("coin not found, can't apply refund"); logger.warn("coin not found, can't apply refund");
return; return;
} }
const denom = await tx.get(Stores.denominations, [ const denom = await tx.get(Stores.denominations, [
@ -158,7 +170,7 @@ async function storePendingRefund(
const coin = await tx.get(Stores.coins, r.coin_pub); const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) { if (!coin) {
console.warn("coin not found, can't apply refund"); logger.warn("coin not found, can't apply refund");
return; return;
} }
const denom = await tx.get(Stores.denominations, [ const denom = await tx.get(Stores.denominations, [
@ -202,13 +214,14 @@ async function storePendingRefund(
async function storeFailedRefund( async function storeFailedRefund(
tx: TransactionHandle, tx: TransactionHandle,
p: PurchaseRecord, p: PurchaseRecord,
refreshCoinsMap: Record<string, { coinPub: string }>,
r: MerchantCoinRefundFailureStatus, r: MerchantCoinRefundFailureStatus,
): Promise<void> { ): Promise<void> {
const refundKey = getRefundKey(r); const refundKey = getRefundKey(r);
const coin = await tx.get(Stores.coins, r.coin_pub); const coin = await tx.get(Stores.coins, r.coin_pub);
if (!coin) { if (!coin) {
console.warn("coin not found, can't apply refund"); logger.warn("coin not found, can't apply refund");
return; return;
} }
const denom = await tx.get(Stores.denominations, [ const denom = await tx.get(Stores.denominations, [
@ -247,6 +260,38 @@ async function storeFailedRefund(
refundFee: denom.feeRefund, refundFee: denom.feeRefund,
totalRefreshCostBound, 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( async function acceptRefunds(
@ -268,7 +313,7 @@ async function acceptRefunds(
async (tx) => { async (tx) => {
const p = await tx.get(Stores.purchases, proposalId); const p = await tx.get(Stores.purchases, proposalId);
if (!p) { if (!p) {
console.error("purchase not found, not adding refunds"); logger.error("purchase not found, not adding refunds");
return; return;
} }
@ -280,7 +325,7 @@ async function acceptRefunds(
const isPermanentFailure = const isPermanentFailure =
refundStatus.type === "failure" && refundStatus.type === "failure" &&
refundStatus.exchange_status === 410; refundStatus.exchange_status >= 400 && refundStatus.exchange_status < 500 ;
// Already failed. // Already failed.
if (existingRefundInfo?.type === RefundState.Failed) { if (existingRefundInfo?.type === RefundState.Failed) {
@ -306,7 +351,7 @@ async function acceptRefunds(
if (refundStatus.type === "success") { if (refundStatus.type === "success") {
await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus); await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
} else if (isPermanentFailure) { } else if (isPermanentFailure) {
await storeFailedRefund(tx, p, refundStatus); await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
} else { } else {
await storePendingRefund(tx, p, refundStatus); await storePendingRefund(tx, p, refundStatus);
} }
@ -326,7 +371,11 @@ async function acceptRefunds(
// after a retry delay? // after a retry delay?
let queryDone = true; 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; queryDone = false;
} }
@ -347,7 +396,10 @@ async function acceptRefunds(
p.timestampLastRefundStatus = now; p.timestampLastRefundStatus = now;
p.lastRefundStatusError = undefined; p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo(false); p.refundStatusRetryInfo = initRetryInfo(false);
p.refundStatusRequested = false; p.refundQueryRequested = false;
if (p.abortStatus === AbortStatus.AbortRefund) {
p.abortStatus = AbortStatus.AbortFinished;
}
logger.trace("refund query done"); logger.trace("refund query done");
} else { } else {
// No error, but we need to try again! // No error, but we need to try again!
@ -415,7 +467,7 @@ export async function applyRefund(
logger.error("no purchase found for refund URL"); logger.error("no purchase found for refund URL");
return false; return false;
} }
p.refundStatusRequested = true; p.refundQueryRequested = true;
p.lastRefundStatusError = undefined; p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo(); p.refundStatusRetryInfo = initRetryInfo();
await tx.put(Stores.purchases, p); await tx.put(Stores.purchases, p);
@ -516,32 +568,121 @@ async function processPurchaseQueryRefundImpl(
return; return;
} }
if (!purchase.refundStatusRequested) { if (!purchase.refundQueryRequested) {
return; return;
} }
const requestUrl = new URL( if (purchase.timestampFirstSuccessfulPay) {
`orders/${purchase.contractData.orderId}/refund`, const requestUrl = new URL(
purchase.contractData.merchantBaseUrl, `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, { const request = await ws.http.postJson(requestUrl.href, {
h_contract: purchase.contractData.contractTermsHash, 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( const refundResponse = await readSuccessResponseJsonOrThrow(
request, request,
codecForMerchantOrderRefundPickupResponse(), codecForMerchantOrderRefundPickupResponse(),
); );
await acceptRefunds( await acceptRefunds(
ws, ws,
proposalId, proposalId,
refundResponse.refunds, refundResponse.refunds,
RefundReason.NormalRefund, RefundReason.NormalRefund,
); );
} else if (purchase.abortStatus === AbortStatus.AbortRefund) {
const requestUrl = new URL(
`orders/${purchase.contractData.orderId}/abort`,
purchase.contractData.merchantBaseUrl,
);
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}`);
});
} }

View File

@ -23,6 +23,7 @@ import {
WalletRefundItem, WalletRefundItem,
RefundState, RefundState,
ReserveRecordStatus, ReserveRecordStatus,
AbortStatus,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { Amounts, AmountJson } from "../util/amounts"; import { Amounts, AmountJson } from "../util/amounts";
import { timestampCmp } from "../util/time"; import { timestampCmp } from "../util/time";
@ -242,7 +243,9 @@ export async function getTransactions(
status: pr.timestampFirstSuccessfulPay status: pr.timestampFirstSuccessfulPay
? PaymentStatus.Paid ? PaymentStatus.Paid
: PaymentStatus.Accepted, : PaymentStatus.Accepted,
pending: !pr.timestampFirstSuccessfulPay, pending:
!pr.timestampFirstSuccessfulPay &&
pr.abortStatus === AbortStatus.None,
timestamp: pr.timestampAccept, timestamp: pr.timestampAccept,
transactionId: paymentTransactionId, transactionId: paymentTransactionId,
info: info, info: info,
@ -324,7 +327,10 @@ export async function getTransactions(
amountRaw: Amounts.stringify(tipRecord.tipAmountRaw), amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
pending: !tipRecord.pickedUpTimestamp, pending: !tipRecord.pickedUpTimestamp,
timestamp: tipRecord.acceptedTimestamp, timestamp: tipRecord.acceptedTimestamp,
transactionId: makeEventId(TransactionType.Tip, tipRecord.walletTipId), transactionId: makeEventId(
TransactionType.Tip,
tipRecord.walletTipId,
),
error: tipRecord.lastError, error: tipRecord.lastError,
}); });
}); });
@ -337,5 +343,5 @@ export async function getTransactions(
txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp)); txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
txNotPending.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] };
} }

View File

@ -1285,6 +1285,12 @@ export interface PayCoinSelection {
customerDepositFees: AmountJson; customerDepositFees: AmountJson;
} }
export enum AbortStatus {
None = "none",
AbortRefund = "abort-refund",
AbortFinished = "abort-finished",
}
/** /**
* Record that stores status information about one purchase, starting from when * Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable. * 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 * Do we need to query the merchant for the refund status
* of the payment? * of the payment?
*/ */
refundStatusRequested: boolean; refundQueryRequested: boolean;
/** abortStatus: AbortStatus;
* An abort (with refund) was requested for this (incomplete!) purchase.
*/
abortRequested: boolean;
/**
* The abort (with refund) was completed for this (incomplete!) purchase.
*/
abortDone: boolean;
payRetryInfo: RetryInfo; payRetryInfo: RetryInfo;

View File

@ -1059,7 +1059,6 @@ export const codecForAuditorHandle = (): Codec<AuditorHandle> =>
.property("url", codecForString()) .property("url", codecForString())
.build("AuditorHandle"); .build("AuditorHandle");
export const codecForLocation = (): Codec<Location> => export const codecForLocation = (): Codec<Location> =>
buildCodecForObject<Location>() buildCodecForObject<Location>()
.property("country", codecOptional(codecForString())) .property("country", codecOptional(codecForString()))
@ -1351,3 +1350,108 @@ export const codecForMerchantOrderStatusUnpaid = (): Codec<
.property("taler_pay_uri", codecForString()) .property("taler_pay_uri", codecForString())
.property("already_paid_order_id", codecOptional(codecForString())) .property("already_paid_order_id", codecOptional(codecForString()))
.build("MerchantOrderStatusUnpaid"); .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");

View File

@ -932,3 +932,11 @@ export const codecForAcceptTipRequest = (): Codec<AcceptTipRequest> =>
.property("walletTipId", codecForString()) .property("walletTipId", codecForString())
.build("AcceptTipRequest"); .build("AcceptTipRequest");
export interface AbortPayWithRefundRequest {
proposalId: string;
}
export const codecForAbortPayWithRefundRequest = (): Codec<AbortPayWithRefundRequest> =>
buildCodecForObject<AbortPayWithRefundRequest>()
.property("proposalId", codecForString())
.build("AbortPayWithRefundRequest");

View File

@ -94,6 +94,7 @@ import {
PrepareTipResult, PrepareTipResult,
codecForPrepareTipRequest, codecForPrepareTipRequest,
codecForAcceptTipRequest, codecForAcceptTipRequest,
codecForAbortPayWithRefundRequest,
} from "./types/walletTypes"; } from "./types/walletTypes";
import { Logger } from "./util/logging"; import { Logger } from "./util/logging";
@ -132,7 +133,7 @@ import {
PendingOperationType, PendingOperationType,
} from "./types/pending"; } from "./types/pending";
import { WalletNotification, NotificationType } from "./types/notifications"; 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 { durationMin, Duration } from "./util/time";
import { processRecoupGroup } from "./operations/recoup"; import { processRecoupGroup } from "./operations/recoup";
import { import {
@ -744,8 +745,8 @@ export class Wallet {
return prepareTip(this.ws, talerTipUri); return prepareTip(this.ws, talerTipUri);
} }
async abortFailedPayment(contractTermsHash: string): Promise<void> { async abortFailedPayWithRefund(proposalId: string): Promise<void> {
throw Error("not implemented"); return abortFailedPayWithRefund(this.ws, proposalId);
} }
/** /**
@ -1022,11 +1023,6 @@ export class Wallet {
const req = codecForGetExchangeTosRequest().decode(payload); const req = codecForGetExchangeTosRequest().decode(payload);
return this.getExchangeTos(req.exchangeBaseUrl); return this.getExchangeTos(req.exchangeBaseUrl);
} }
case "abortProposal": {
const req = codecForAbortProposalRequest().decode(payload);
await this.refuseProposal(req.proposalId);
return {};
}
case "retryPendingNow": { case "retryPendingNow": {
await this.runPending(true); await this.runPending(true);
return {}; return {};
@ -1039,6 +1035,11 @@ export class Wallet {
const req = codecForConfirmPayRequest().decode(payload); const req = codecForConfirmPayRequest().decode(payload);
return await this.confirmPay(req.proposalId, req.sessionId); return await this.confirmPay(req.proposalId, req.sessionId);
} }
case "abortFailedPayWithRefund": {
const req = codecForAbortPayWithRefundRequest().decode(payload);
await this.abortFailedPayWithRefund(req.proposalId);
return {};
}
case "dumpCoins": { case "dumpCoins": {
return await this.dumpCoins(); return await this.dumpCoins();
} }