commit 34cff64515ddabe6e069d7dd2efdb130712baba2
parent ee49c83596184ee2243d00a2163971ee5b37180b
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:50 +0200
wallet-core: classify merchant payment failures
Diffstat:
5 files changed, 567 insertions(+), 36 deletions(-)
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -495,8 +495,12 @@ export class TalerMerchantInstanceHttpClient {
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.PreconditionFailed:
return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.PayloadTooLarge:
+ return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.BadGateway:
return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.GatewayTimeout:
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.UnavailableForLegalReasons:
diff --git a/packages/taler-util/src/types-taler-merchant.test.ts b/packages/taler-util/src/types-taler-merchant.test.ts
@@ -18,10 +18,24 @@ import assert from "node:assert";
import { test } from "node:test";
import {
codecForLoginTokenSuccessResponse,
+ codecForPaymentDeniedLegallyResponse,
codecForQueryInstancesResponse,
codecForTokenFamilyDetails,
} from "./types-taler-merchant.js";
+test("legal payment refusal retains its protocol error code", () => {
+ assert.deepStrictEqual(
+ codecForPaymentDeniedLegallyResponse().decode({
+ code: 2186,
+ exchange_base_urls: ["https://exchange.example/"],
+ }),
+ {
+ code: 2186,
+ exchange_base_urls: ["https://exchange.example/"],
+ },
+ );
+});
+
function instancesResponse(method: string): any {
return {
name: "Default",
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -1381,6 +1381,10 @@ export interface PaymentResponse {
pos_confirmation?: string;
}
export interface PaymentDeniedLegallyResponse {
+ // Numeric error code identifying whether the exchange refused the payment
+ // outright or because of a transaction limit.
+ code: number;
+
// Base URL of the exchanges that denied the payment.
// The wallet should refresh the coins from these
// exchanges, but may try to pay with coins from
@@ -4663,6 +4667,7 @@ export const codecForPaymentResponse = (): Codec<PaymentResponse> =>
export const codecForPaymentDeniedLegallyResponse =
(): Codec<PaymentDeniedLegallyResponse> =>
buildCodecForObject<PaymentDeniedLegallyResponse>()
+ .property("code", codecForNumber())
.property(
"exchange_base_urls",
codecOptionalDefault(codecForList(codecForString()), []),
diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts
@@ -16,11 +16,13 @@
import {
Amounts,
AmountString,
+ HttpStatusCode,
SelectedCoin,
SignedTokenEnvelope,
TransactionAction,
TransactionIdStr,
TalerPreciseTimestamp,
+ TalerErrorCode,
} from "@gnu-taler/taler-util";
import assert from "node:assert";
import { test } from "node:test";
@@ -28,6 +30,7 @@ import {
PurchaseStatus,
RefundGroupStatus,
WalletPurchase,
+ WalletCoinSelection,
WalletRefundGroup,
WalletSlate,
WalletToken,
@@ -37,11 +40,15 @@ import {
applyFirstPaySuccessState,
computePayMerchantTransactionActions,
getCoinsToSpendForMerchantRepair,
+ getAlreadyPaidRefundRequests,
getPayMerchantAbortTransition,
+ getPayRepairAcceptedExchanges,
getPayMerchantResumeTransition,
getPayMerchantSuspendTransition,
getRefundTotals,
isPaymentSessionComplete,
+ isStableMerchantPayFailure,
+ recoveredPayFailureStatus,
releasePaymentTokensInTx,
setRefundGroupEffectiveAmount,
splitPaymentOutputTokenSignatures,
@@ -80,6 +87,122 @@ test("merchant repair spends only newly selected coins", () => {
assert.deepStrictEqual(toSpend, [added]);
});
+test("merchant repair can use every other contract-accepted exchange", () => {
+ assert.deepStrictEqual(
+ getPayRepairAcceptedExchanges(
+ {
+ exchanges: [
+ { url: "https://a.example/", master_pub: "master-a" },
+ { url: "https://b.example/", master_pub: "master-b" },
+ { url: "https://c.example/", master_pub: "master-c" },
+ ],
+ } as any,
+ new Set(["https://a.example/"]),
+ ),
+ [
+ {
+ exchangeBaseUrl: "https://b.example/",
+ exchangePub: "master-b",
+ },
+ {
+ exchangeBaseUrl: "https://c.example/",
+ exchangePub: "master-c",
+ },
+ ],
+ );
+});
+
+test("merchant pay failures distinguish stable outcomes from retryable outages", () => {
+ for (const status of [
+ HttpStatusCode.BadRequest,
+ HttpStatusCode.PaymentRequired,
+ HttpStatusCode.Forbidden,
+ HttpStatusCode.NotFound,
+ HttpStatusCode.Conflict,
+ HttpStatusCode.Gone,
+ HttpStatusCode.PreconditionFailed,
+ HttpStatusCode.PayloadTooLarge,
+ HttpStatusCode.UnavailableForLegalReasons,
+ HttpStatusCode.NotImplemented,
+ ]) {
+ assert.strictEqual(isStableMerchantPayFailure(status), true, `${status}`);
+ }
+ for (const status of [
+ HttpStatusCode.RequestTimeout,
+ HttpStatusCode.InternalServerError,
+ HttpStatusCode.BadGateway,
+ HttpStatusCode.GatewayTimeout,
+ ]) {
+ assert.strictEqual(isStableMerchantPayFailure(status), false, `${status}`);
+ }
+});
+
+test("already-paid recovery retains the paid-by-other terminal state", () => {
+ assert.strictEqual(
+ recoveredPayFailureStatus({
+ code: TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_ALREADY_PAID,
+ }),
+ PurchaseStatus.FailedPaidByOther,
+ );
+ assert.strictEqual(
+ recoveredPayFailureStatus({
+ code: TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_TOKEN_INVALID,
+ }),
+ PurchaseStatus.Failed,
+ );
+});
+
+test("already-paid refund permissions are bound to selected contributions", () => {
+ const selected = {
+ coinPubs: ["coin-a", "coin-b"],
+ coinContributions: ["TESTKUDOS:1", "TESTKUDOS:2"],
+ } as WalletCoinSelection;
+ const requests = getAlreadyPaidRefundRequests(
+ {
+ code: TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_ALREADY_PAID,
+ refunds: [
+ {
+ coin_pub: "coin-a",
+ merchant_sig: "sig-a",
+ amount: "TESTKUDOS:1",
+ rtransaction_id: 0,
+ },
+ {
+ coin_pub: "coin-b",
+ merchant_sig: "wrong-amount",
+ amount: "TESTKUDOS:1",
+ rtransaction_id: 0,
+ },
+ {
+ coin_pub: "unselected",
+ merchant_sig: "unrelated",
+ amount: "TESTKUDOS:1",
+ rtransaction_id: 0,
+ },
+ ],
+ },
+ selected,
+ "contract-hash",
+ "merchant-pub",
+ );
+
+ assert.deepStrictEqual(
+ [...requests.entries()],
+ [
+ [
+ "coin-a",
+ {
+ refund_amount: "TESTKUDOS:1",
+ h_contract_terms: "contract-hash",
+ rtransaction_id: 0,
+ merchant_pub: "merchant-pub",
+ merchant_sig: "sig-a",
+ },
+ ],
+ ],
+ );
+});
+
test("payment token release is ownership-checked and idempotent", async () => {
const currentTransaction = "payment:current" as TransactionIdStr;
const tokens = new Map<string, WalletToken>([
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -28,6 +28,7 @@ import {
AbortingCoin,
AbortRequest,
AbsoluteTime,
+ AllowedExchangeInfo,
AmountJson,
Amounts,
AmountString,
@@ -49,6 +50,7 @@ import {
Duration,
encodeCrock,
ErrorInfoSummary,
+ ExchangeRefundRequest,
ForcedCoinSel,
GetChoicesForPaymentResult,
getRandomBytes,
@@ -148,6 +150,7 @@ import {
WalletDenomination,
WalletDonationPlanchet,
WalletPurchase,
+ WalletPurchasePayInfo,
WalletRefundGroup,
WalletRefundItem,
WalletSlate,
@@ -516,6 +519,32 @@ export class PayMerchantTransactionContext implements TransactionContext {
});
}
+ /**
+ * Turn a permanent /pay failure into a custody-safe terminal failure.
+ *
+ * Allocated coins must first pass through the normal abort/refund recovery
+ * state. Keeping failReason set distinguishes this automatic failure from
+ * a user-requested abort when recovery finishes.
+ */
+ async recoverFromPayFailure(reason: TalerErrorDetail): Promise<boolean> {
+ const { wex } = this;
+ const changed = await wex.runWalletDbTx(async (tx) => {
+ const [purchase, h] = await this.getRecordHandle(tx);
+ if (purchase?.purchaseStatus !== PurchaseStatus.PendingPaying) {
+ return false;
+ }
+ purchase.abortReason = reason;
+ purchase.failReason = reason;
+ purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+ await h.update(purchase, "pay-failure-recovery");
+ return true;
+ });
+ if (changed) {
+ await wex.taskScheduler.resetTask(this.taskId);
+ }
+ return changed;
+ }
+
async userAbortTransaction(reason?: TalerErrorDetail): Promise<void> {
const { wex } = this;
await wex.runWalletDbTx(async (tx) => {
@@ -998,6 +1027,153 @@ function isOrderUnknown(resp: {
);
}
+export function recoveredPayFailureStatus(
+ reason: TalerErrorDetail | undefined,
+): PurchaseStatus.Failed | PurchaseStatus.FailedPaidByOther {
+ return reason?.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_ALREADY_PAID
+ ? PurchaseStatus.FailedPaidByOther
+ : PurchaseStatus.Failed;
+}
+
+/**
+ * Parse the merchant refund permissions attached to an already-paid reply.
+ *
+ * The merchant backend only returns permissions for newly deposited coins.
+ * Any malformed, duplicate or unrelated entry is ignored; refresh then still
+ * gets a chance to prove that the corresponding deposit never happened.
+ */
+export function getAlreadyPaidRefundRequests(
+ detail: TalerErrorDetail,
+ payCoinSelection: WalletPurchasePayInfo["payCoinSelection"],
+ contractTermsHash: string,
+ merchantPub: string,
+): Map<string, ExchangeRefundRequest> {
+ const result = new Map<string, ExchangeRefundRequest>();
+ if (!payCoinSelection || !Array.isArray(detail.refunds)) {
+ return result;
+ }
+ const contributions = new Map<string, AmountString>();
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ contributions.set(
+ payCoinSelection.coinPubs[i],
+ payCoinSelection.coinContributions[i],
+ );
+ }
+ for (const raw of detail.refunds) {
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
+ continue;
+ }
+ const refund = raw as Record<string, unknown>;
+ const coinPub = refund.coin_pub;
+ const amount = refund.amount;
+ const merchantSig = refund.merchant_sig;
+ const rtransactionId = refund.rtransaction_id;
+ const expectedContribution =
+ typeof coinPub === "string" ? contributions.get(coinPub) : undefined;
+ if (
+ typeof coinPub !== "string" ||
+ typeof amount !== "string" ||
+ typeof merchantSig !== "string" ||
+ typeof rtransactionId !== "number" ||
+ !Number.isSafeInteger(rtransactionId) ||
+ rtransactionId < 0 ||
+ expectedContribution === undefined ||
+ result.has(coinPub)
+ ) {
+ continue;
+ }
+ try {
+ if (Amounts.cmp(amount, expectedContribution) !== 0) {
+ continue;
+ }
+ } catch {
+ continue;
+ }
+ result.set(coinPub, {
+ refund_amount: amount as AmountString,
+ h_contract_terms: contractTermsHash,
+ rtransaction_id: rtransactionId,
+ merchant_pub: merchantPub,
+ merchant_sig: merchantSig,
+ });
+ }
+ return result;
+}
+
+/**
+ * Recover inputs after the merchant proves that the order is already
+ * complete. Calling /abort is forbidden for a complete order, so use the
+ * attached refund permissions (when present) as refresh fallbacks instead.
+ */
+async function recoverPayFailureForCompleteOrder(
+ wex: WalletExecutionContext,
+ ctx: PayMerchantTransactionContext,
+ reason: TalerErrorDetail,
+): Promise<TaskRunResult> {
+ const changed = await wex.runWalletDbTx(async (tx) => {
+ const [purchase, h] = await ctx.getRecordHandle(tx);
+ if (purchase?.purchaseStatus !== PurchaseStatus.PendingPaying) {
+ return false;
+ }
+ const download = await expectProposalDownloadByIdInTx(
+ wex,
+ tx,
+ purchase.proposalId,
+ );
+ const payCoinSelection = purchase.payInfo?.payCoinSelection;
+ purchase.abortReason = reason;
+ purchase.failReason = reason;
+ if (!payCoinSelection || payCoinSelection.coinPubs.length === 0) {
+ await releasePaymentTokensInTx(
+ tx,
+ ctx.transactionId,
+ purchase.payInfo?.payTokenSelection?.tokenPubs ?? [],
+ );
+ purchase.purchaseStatus = recoveredPayFailureStatus(reason);
+ await h.update(purchase, "pay-complete-failure-no-coins");
+ return true;
+ }
+
+ const refundRequests = getAlreadyPaidRefundRequests(
+ reason,
+ payCoinSelection,
+ download.contractTermsHash,
+ download.contractTerms.merchant_pub,
+ );
+ const refreshCoins = payCoinSelection.coinPubs.map((coinPub, i) => ({
+ coinPub,
+ amount: payCoinSelection.coinContributions[i],
+ refundRequest: refundRequests.get(coinPub),
+ }));
+ const refresh = await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(purchase.payInfo!.totalPayCost),
+ refreshCoins,
+ RefreshReason.AbortPay,
+ ctx.transactionId,
+ );
+ purchase.abortRefreshGroupId = refresh.refreshGroupId;
+ purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+ await h.update(purchase, "pay-complete-failure-recovery");
+ return true;
+ });
+ if (!changed) {
+ return TaskRunResult.finished();
+ }
+ await wex.taskScheduler.resetTask(ctx.taskId);
+ return TaskRunResult.progress();
+}
+
+async function recoverPayAfterStableFailure(
+ ctx: PayMerchantTransactionContext,
+ reason: TalerErrorDetail,
+): Promise<TaskRunResult> {
+ await ctx.recoverFromPayFailure(reason);
+ return TaskRunResult.progress();
+}
+
/**
* Give up on paying an order that the merchant does not have anymore.
*
@@ -1954,17 +2130,32 @@ export function getCoinsToSpendForMerchantRepair(
return coinsAddedByRepair(previous, selected);
}
+export function getPayRepairAcceptedExchanges(
+ contractTerms: MerchantContractTerms,
+ excludedExchangeBaseUrls: Set<string> | undefined,
+): AllowedExchangeInfo[] {
+ return contractTerms.exchanges
+ .filter((ex) => !excludedExchangeBaseUrls?.has(ex.url))
+ .map((ex) => ({
+ exchangeBaseUrl: ex.url,
+ exchangePub: ex.master_pub,
+ }));
+}
+
async function reselectCoinsTx(
tx: WalletDbTransaction,
ctx: PayMerchantTransactionContext,
- excludeCoinPub?: string,
-): Promise<void> {
+ opts: {
+ excludeCoinPub?: string;
+ excludeExchangeBaseUrls?: Set<string>;
+ } = {},
+): Promise<"success" | "insufficient" | "gone"> {
const p = await tx.getPurchase(ctx.proposalId);
if (!p) {
- return;
+ return "gone";
}
if (!p.payInfo) {
- return;
+ return "gone";
}
const contractData = await expectProposalDownloadByIdInTx(
@@ -1984,33 +2175,57 @@ async function reselectCoinsTx(
const prevPayCoins: PreviousPayCoins = [];
const prevTokensPubs: string[] = [];
let payCoinsToSpend: SelectedCoin[] = [];
+ const removedExchangeCoins: CoinRefreshRequest[] = [];
const payCoinSelection = p.payInfo.payCoinSelection;
const payTokenSelection = p.payInfo.payTokenSelection;
if (payCoinSelection) {
+ const selectedCoins = await tx.getCoinsByPubs(payCoinSelection.coinPubs);
+ const selectedCoinsByPub = new Map(
+ selectedCoins.map((coin) => [coin.coinPub, coin]),
+ );
for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
const coinPub = payCoinSelection.coinPubs[i];
- if (excludeCoinPub != null && coinPub === excludeCoinPub) {
+ if (opts.excludeCoinPub != null && coinPub === opts.excludeCoinPub) {
// Exclude the coin that the exchange reported as broken
// (e.g. double-spent) so re-selection doesn't just pick the
// same failing coin again.
continue;
}
const contrib = payCoinSelection.coinContributions[i];
+ const coin = selectedCoinsByPub.get(coinPub);
+ checkDbInvariant(!!coin, `selected payment coin ${coinPub} is missing`);
+ if (opts.excludeExchangeBaseUrls?.has(coin.exchangeBaseUrl)) {
+ // A legal refusal is definitive for this exchange and this payment.
+ // The exchange did not accept the deposit, so recover the allocated
+ // contribution while replacing it with a coin from another exchange
+ // accepted by the signed contract.
+ removedExchangeCoins.push({ coinPub, amount: contrib });
+ continue;
+ }
prevPayCoins.push({
coinPub,
contribution: Amounts.parseOrThrow(contrib),
});
}
+ const acceptedExchanges = getPayRepairAcceptedExchanges(
+ contractData.contractTerms,
+ opts.excludeExchangeBaseUrls,
+ );
+
+ if (
+ opts.excludeExchangeBaseUrls &&
+ (removedExchangeCoins.length === 0 || acceptedExchanges.length === 0)
+ ) {
+ return "insufficient";
+ }
+
const res = await selectPayCoinsInTx(ctx.wex, tx, {
restrictExchanges: {
auditors: [],
- exchanges: contractData.contractTerms.exchanges.map((ex) => ({
- exchangeBaseUrl: ex.url,
- exchangePub: ex.master_pub,
- })),
+ exchanges: acceptedExchanges,
},
restrictWireMethod: contractData.contractTerms.wire_method,
contractTermsAmount: Amounts.parseOrThrow(amountRaw),
@@ -2022,7 +2237,7 @@ async function reselectCoinsTx(
switch (res.type) {
case "failure":
logger.trace("insufficient funds for coin re-selection");
- return;
+ return "insufficient";
case "success":
break;
default:
@@ -2056,8 +2271,7 @@ async function reselectCoinsTx(
switch (res.type) {
case "failure":
logger.trace("insufficient tokens for token re-selection");
- return;
- break;
+ return "insufficient";
case "success":
break;
default:
@@ -2078,6 +2292,17 @@ async function reselectCoinsTx(
await tx.upsertPurchase(p);
await ctx.updateTransactionMeta(tx);
+ if (removedExchangeCoins.length > 0) {
+ await createRefreshGroup(
+ ctx.wex,
+ tx,
+ Amounts.currencyOf(p.payInfo.totalPayCost),
+ removedExchangeCoins,
+ RefreshReason.AbortPay,
+ ctx.transactionId,
+ );
+ }
+
if (p.payInfo.payCoinSelection) {
await spendCoins(ctx.wex, tx, {
transactionId: ctx.transactionId,
@@ -2095,6 +2320,7 @@ async function reselectCoinsTx(
tokenPubs: p.payInfo.payTokenSelection.tokenPubs,
});
}
+ return "success";
}
/**
@@ -2130,12 +2356,11 @@ async function handleInsufficientFunds(
) {
const exchangeReply = (err as any).exchange_reply;
if (exchangeReply == null) {
- await ctx.failTransaction(proposal.purchaseStatus, {
+ return recoverPayAfterStableFailure(ctx, {
code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
message:
"merchant claimed insufficient funds without an exchange reply",
});
- return TaskRunResult.progress();
}
if (
exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
@@ -2144,36 +2369,40 @@ async function handleInsufficientFunds(
logger.trace("got exchange error reply (see below)");
logger.trace(j2s(exchangeReply));
}
- await ctx.failTransaction(proposal.purchaseStatus, {
+ return recoverPayAfterStableFailure(ctx, {
code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
message: `unable to handle /pay exchange error response (${exchangeReply.code})`,
exchangeReply,
});
- return TaskRunResult.progress();
}
brokenCoinPub = (exchangeReply as any).coin_pub;
logger.trace(`excluding broken coin pub=${brokenCoinPub}`);
if (!brokenCoinPub) {
- await ctx.failTransaction(proposal.purchaseStatus, {
+ return recoverPayAfterStableFailure(ctx, {
code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
message: "Exchange claimed bad coin, but coin was not used.",
brokenCoinPub,
});
- return TaskRunResult.progress();
+ }
+ if (!proposal.payInfo?.payCoinSelection?.coinPubs.includes(brokenCoinPub)) {
+ return recoverPayAfterStableFailure(ctx, {
+ code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ message: "Exchange claimed a bad coin that was not used in payment.",
+ brokenCoinPub,
+ });
}
} else if (
err.code ===
TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND
) {
// We might support this in the future.
- await ctx.failTransaction(proposal.purchaseStatus, {
+ return recoverPayAfterStableFailure(ctx, {
code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
message: "Denomination used in payment became invalid.",
errorDetails: err,
});
- return TaskRunResult.progress();
} else {
// Caller should have checked.
throw Error(`unsupported error code: ${err.code}`);
@@ -2186,10 +2415,17 @@ async function handleInsufficientFunds(
// FIXME: Above code should go into the transaction.
// TODO: also do token re-selection.
- await wex.runWalletDbTx(async (tx) => {
- await reselectCoinsTx(tx, ctx, brokenCoinPub);
+ const repair = await wex.runWalletDbTx(async (tx) => {
+ return reselectCoinsTx(tx, ctx, { excludeCoinPub: brokenCoinPub });
});
+ if (repair === "insufficient") {
+ return recoverPayAfterStableFailure(ctx, err);
+ }
+ if (repair === "gone") {
+ return TaskRunResult.finished();
+ }
+
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: constructTransactionIdentifier({
@@ -2201,6 +2437,65 @@ async function handleInsufficientFunds(
return TaskRunResult.progress();
}
+export function isStableMerchantPayFailure(httpStatus: number): boolean {
+ switch (httpStatus) {
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.PaymentRequired:
+ case HttpStatusCode.Forbidden:
+ case HttpStatusCode.NotFound:
+ case HttpStatusCode.Conflict:
+ case HttpStatusCode.Gone:
+ case HttpStatusCode.PreconditionFailed:
+ case HttpStatusCode.PayloadTooLarge:
+ case HttpStatusCode.UnavailableForLegalReasons:
+ case HttpStatusCode.NotImplemented:
+ return true;
+ default:
+ return false;
+ }
+}
+
+async function repairPayAfterExchangeFailure(
+ wex: WalletExecutionContext,
+ ctx: PayMerchantTransactionContext,
+ refusedExchangeBaseUrls: string[],
+ reason: TalerErrorDetail,
+): Promise<TaskRunResult> {
+ const refused = new Set(refusedExchangeBaseUrls);
+ if (refused.size === 0) {
+ return recoverPayAfterStableFailure(ctx, reason);
+ }
+ const repair = await wex.runWalletDbTx(async (tx) => {
+ return reselectCoinsTx(tx, ctx, {
+ excludeExchangeBaseUrls: refused,
+ });
+ });
+ if (repair === "gone") {
+ return TaskRunResult.finished();
+ }
+ if (repair === "insufficient") {
+ return recoverPayAfterStableFailure(ctx, reason);
+ }
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+ return TaskRunResult.progress();
+}
+
+function exactAcceptedExchangeHint(
+ purchase: WalletPurchase,
+ contractTerms: MerchantContractTerms,
+ detail: TalerErrorDetail,
+): string[] {
+ if (typeof detail.hint !== "string") {
+ return [];
+ }
+ const accepted = contractTerms.exchanges.some((x) => x.url === detail.hint);
+ const selected = purchase.exchanges?.includes(detail.hint) ?? false;
+ return accepted && selected ? [detail.hint] : [];
+}
+
export async function preparePayForUriV2(
wex: WalletExecutionContext,
talerPayUri: string,
@@ -3449,7 +3744,15 @@ async function processPurchasePay(
) {
return handleInsufficientFunds(wex, proposalId, err);
}
- return throwUnexpectedRequestError(resp.response, err);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_ALREADY_PAID ||
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_MISMATCH
+ ) {
+ return recoverPayFailureForCompleteOrder(wex, ctx, err);
+ }
+ return recoverPayAfterStableFailure(ctx, err);
}
case HttpStatusCode.BadRequest: {
const err = resp.detail!;
@@ -3459,7 +3762,15 @@ async function processPurchasePay(
) {
return handleInsufficientFunds(wex, proposalId, err);
}
- return throwUnexpectedRequestError(resp.response, err);
+ if (err.code === TalerErrorCode.MERCHANT_GENERIC_EXCHANGE_UNTRUSTED) {
+ return repairPayAfterExchangeFailure(
+ wex,
+ ctx,
+ exactAcceptedExchangeHint(purchase, download.contractTerms, err),
+ err,
+ );
+ }
+ return recoverPayAfterStableFailure(ctx, err);
}
case HttpStatusCode.BadGateway: {
const err = resp.detail!;
@@ -3470,12 +3781,11 @@ async function processPurchasePay(
case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN:
// We might want to handle this in the future by re-denomination,
// for now we just abort.
- await ctx.failTransaction(purchase.purchaseStatus, {
+ return recoverPayAfterStableFailure(ctx, {
code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
message: "Denomination used in payment became invalid.",
errorDetails: err,
});
- return TaskRunResult.progress();
}
break;
}
@@ -3483,14 +3793,28 @@ async function processPurchasePay(
// We don't know the specific error, so it's safer to retry.
return throwUnexpectedRequestError(resp.response, err);
}
- case HttpStatusCode.UnavailableForLegalReasons:
- logger.warn(`pay transaction failed, merchant has KYC problems`);
- await ctx.userAbortTransaction(
- makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING, {
- exchangeResponse: resp.body,
- }),
+ case HttpStatusCode.UnavailableForLegalReasons: {
+ logger.warn(`exchange refused merchant payment for legal reasons`);
+ const legalReason: TalerErrorDetail =
+ resp.body.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LEGALLY_REFUSED ||
+ resp.body.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_TRANSACTION_LIMIT_VIOLATION
+ ? {
+ code: resp.body.code,
+ exchangeBaseUrls: resp.body.exchange_base_urls,
+ }
+ : {
+ code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ message: `merchant returned invalid legal-refusal error code ${resp.body.code}`,
+ };
+ return repairPayAfterExchangeFailure(
+ wex,
+ ctx,
+ resp.body.exchange_base_urls,
+ legalReason,
);
- return TaskRunResult.progress();
+ }
case HttpStatusCode.Gone:
logger.warn(`pay transaction aborted, order expired`);
await ctx.userAbortTransaction(
@@ -3502,11 +3826,23 @@ async function processPurchasePay(
return TaskRunResult.progress();
case HttpStatusCode.NotFound: {
if (!isOrderUnknown(resp)) {
- return throwUnexpectedRequestError(resp.response, resp.detail!);
+ return recoverPayAfterStableFailure(ctx, resp.detail!);
}
logger.warn(`pay transaction aborted, merchant deleted the order`);
return abortPayForDeletedOrder(wex, ctx);
}
+ case HttpStatusCode.PreconditionFailed: {
+ const err = resp.detail!;
+ const refusedExchanges = exactAcceptedExchangeHint(
+ purchase,
+ download.contractTerms,
+ err,
+ );
+ if (refusedExchanges.length > 0) {
+ return repairPayAfterExchangeFailure(wex, ctx, refusedExchanges, err);
+ }
+ return recoverPayAfterStableFailure(ctx, err);
+ }
default:
logger.info(
`got error response (http status ${resp.response.status}) from merchant`,
@@ -3514,6 +3850,20 @@ async function processPurchasePay(
if (logger.shouldLogTrace()) {
logger.trace(`error body: ${j2s(resp.detail)}`);
}
+ if (isStableMerchantPayFailure(resp.response.status)) {
+ return recoverPayAfterStableFailure(
+ ctx,
+ resp.detail ??
+ makeTalerErrorDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: resp.response.requestUrl,
+ requestMethod: resp.response.requestMethod,
+ httpStatusCode: resp.response.status,
+ },
+ ),
+ );
+ }
return throwUnexpectedRequestError(resp.response, resp.detail!);
}
@@ -4530,6 +4880,22 @@ async function processPurchaseAbortingRefund(
const download = await expectProposalDownload(wex, purchase);
logger.trace(`processing aborting-refund for proposal ${proposalId}`);
+ const merchantAlreadyCompletedOrder =
+ purchase.failReason?.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_ALREADY_PAID ||
+ purchase.failReason?.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_MISMATCH;
+ if (merchantAlreadyCompletedOrder && purchase.abortRefreshGroupId) {
+ // A complete order cannot be aborted. Its /pay response either carried
+ // the refund permissions needed by refresh (already-paid), or rejected
+ // the choice before accepting these inputs (choice mismatch).
+ return waitForRefreshOnAbortedPayment(
+ wex,
+ purchase,
+ PurchaseStatus.AbortedRefunded,
+ );
+ }
+
const abortingCoins: AbortingCoin[] = [];
const payCoinSelection = purchase.payInfo?.payCoinSelection;
@@ -4547,7 +4913,9 @@ async function processPurchaseAbortingRefund(
ctx.transactionId,
rec.payInfo?.payTokenSelection?.tokenPubs ?? [],
);
- rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
+ rec.purchaseStatus = rec.failReason
+ ? recoveredPayFailureStatus(rec.failReason)
+ : PurchaseStatus.AbortedOrderDeleted;
await h.update(rec, "abort-unpaid");
});
return TaskRunResult.finished();
@@ -4629,6 +4997,21 @@ async function processPurchaseAbortingRefund(
}
if (
+ abortHttpResp.case === HttpStatusCode.PreconditionFailed &&
+ abortHttpResp.detail?.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_REFUND_REFUSED_PAYMENT_COMPLETE
+ ) {
+ // A payment can become complete between /pay failing and /abort arriving.
+ // The precautionary refresh already in progress is then the only sound
+ // evidence of whether these particular coins were accepted.
+ return waitForRefreshOnAbortedPayment(
+ wex,
+ purchase,
+ PurchaseStatus.AbortedRefunded,
+ );
+ }
+
+ if (
abortHttpResp.case !== "ok" &&
abortHttpResp.case !== HttpStatusCode.BadGateway
) {
@@ -4744,7 +5127,9 @@ async function waitForRefreshOnAbortedPayment(
rec.payInfo?.payTokenSelection?.tokenPubs ?? [],
);
if (recovery === "recovered") {
- rec.purchaseStatus = finalStatus;
+ rec.purchaseStatus = rec.failReason
+ ? recoveredPayFailureStatus(rec.failReason)
+ : finalStatus;
await h.update(rec, "abort-refresh-done");
} else {
rec.purchaseStatus = PurchaseStatus.FailedAbort;