taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-util/src/http-client/merchant.ts | 4++++
Mpackages/taler-util/src/types-taler-merchant.test.ts | 14++++++++++++++
Mpackages/taler-util/src/types-taler-merchant.ts | 5+++++
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 457++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
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;