commit a033009111114db0394e675ddb783ebb70eba557
parent eddb1980419c2b15829b40e30f63de3ada28dda7
Author: Florian Dold <dold@taler.net>
Date: Wed, 29 Jul 2026 12:46:57 +0200
wallet-core: stop asking the merchant about a deleted order
A merchant that deleted an order answers every request about it with the same
404, so the wallet's retries only piled up requests that could never be
satisfied, for as long as the transaction existed.
Issue: https://bugs.taler.net/n/10261
Diffstat:
2 files changed, 201 insertions(+), 14 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-payment-deleted.ts b/packages/taler-harness/src/integrationtests/test-payment-deleted.ts
@@ -113,14 +113,24 @@ export async function runPaymentDeletedTest(t: GlobalTestState) {
t.assertTrue(r2.type === ConfirmPayResultType.Pending);
- await walletClient.call(WalletApiOperation.AbortTransaction, {
+ // The merchant does not have the order anymore, so the payment can never go
+ // through. The wallet must give up on it on its own, without the user
+ // aborting and without asking the merchant about the order forever.
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
transactionId: preparePayResult.transactionId,
+ txState: {
+ major: TransactionMajorState.Aborted,
+ },
});
await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
console.log(j2s(bal));
+
+ // The coins that were allocated for the payment are back, minus the fees
+ // for refreshing them.
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:19.05");
}
runPaymentDeletedTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -962,6 +962,61 @@ async function discardClaim(
*/
const MERCHANT_ORDER_STATUS_LONGPOLL_MS = 30_000;
+/**
+ * Did the merchant say that it does not have the order (anymore)?
+ *
+ * The merchant only answers this once the order is gone from its database, so
+ * asking again can never yield a different result. Requests that are repeated
+ * anyway accumulate at the merchant: every wallet that still holds a
+ * transaction for a long-deleted order would keep asking about it forever.
+ *
+ * A bare 404 is not enough to conclude this, since that is also what
+ * misconfigured infrastructure in front of a merchant responds with.
+ */
+function isOrderUnknown(resp: {
+ case: unknown;
+ detail?: TalerErrorDetail;
+}): boolean {
+ return (
+ resp.case === HttpStatusCode.NotFound &&
+ resp.detail?.code === TalerErrorCode.MERCHANT_GENERIC_ORDER_UNKNOWN
+ );
+}
+
+/**
+ * Give up on paying an order that the merchant does not have anymore.
+ *
+ * This takes the payment through the regular abort path, which recovers the
+ * coins that were already allocated for it. The abort request runs into the
+ * same deleted order and then settles the transaction for good.
+ */
+async function abortPayForDeletedOrder(
+ wex: WalletExecutionContext,
+ ctx: PayMerchantTransactionContext,
+): Promise<TaskRunResult> {
+ const aborted = await wex.runWalletDbTx(async (tx) => {
+ const [p, h] = await ctx.getRecordHandle(tx);
+ if (p?.purchaseStatus !== PurchaseStatus.PendingPaying) {
+ return false;
+ }
+ p.abortReason = makeTalerErrorDetail(
+ TalerErrorCode.WALLET_MERCHANT_ORDER_NOT_FOUND,
+ {},
+ "merchant deleted the order",
+ );
+ p.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+ await h.update(p, "pay-order-gone");
+ return true;
+ });
+ if (!aborted) {
+ // Something else moved the payment on already. Retrying the request would
+ // only ask the merchant about the same deleted order again.
+ return TaskRunResult.finished();
+ }
+ await wex.taskScheduler.resetTask(ctx.taskId);
+ return TaskRunResult.progress();
+}
+
function getPayRequestTimeout(purchase: WalletPurchase): Duration {
return Duration.multiply(
{ d_ms: 15000 },
@@ -1499,11 +1554,11 @@ async function createOrReusePurchase(
}
if (oldProposal.shared || oldProposal.createdFromShared) {
const download = await expectProposalDownload(wex, oldProposal);
- const paid = await checkIfOrderIsAlreadyPaid(wex, download, false);
- logger.info(`old proposal paid: ${paid}`);
+ const orderStatus = await checkIfOrderIsAlreadyPaid(wex, download, false);
+ logger.info(`old proposal order status: ${orderStatus}`);
// if this transaction was shared and the order is paid then it
// means that another wallet already paid the proposal
- if (paid) {
+ if (orderStatus === "paid") {
await wex.runWalletDbTx(async (tx) => {
const [rec, h] = await oldCtx.getRecordHandle(tx);
// The order is only paid by another wallet
@@ -2122,6 +2177,20 @@ async function waitPaymentResult(
};
}
+ if (
+ isUnsuccessfulTransaction(computePayMerchantTransactionState(purchase))
+ ) {
+ // The payment gave up instead of going through. Reporting it as done
+ // would tell the caller that the purchase succeeded, so it is
+ // reported like a payment that has not happened (yet), with whatever
+ // reason the transaction recorded for giving up.
+ return {
+ type: ConfirmPayResultType.Pending,
+ lastError: purchase.failReason ?? purchase.abortReason,
+ transactionId: ctx.transactionId,
+ };
+ }
+
if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) {
return {
type: ConfirmPayResultType.Done,
@@ -2840,9 +2909,13 @@ async function processPurchasePay(
const download = await expectProposalDownload(wex, purchase);
if (purchase.shared) {
- const paid = await checkIfOrderIsAlreadyPaid(wex, download, false);
+ const orderStatus = await checkIfOrderIsAlreadyPaid(wex, download, false);
+
+ if (orderStatus === "gone") {
+ return abortPayForDeletedOrder(wex, ctx);
+ }
- if (paid) {
+ if (orderStatus === "paid") {
await wex.runWalletDbTx(async (tx) => {
const [p, h] = await ctx.getRecordHandle(tx);
if (!p) {
@@ -3127,6 +3200,13 @@ async function processPurchasePay(
),
);
return TaskRunResult.progress();
+ case HttpStatusCode.NotFound: {
+ if (!isOrderUnknown(resp)) {
+ return throwUnexpectedRequestError(resp.response, resp.detail!);
+ }
+ logger.warn(`pay transaction aborted, merchant deleted the order`);
+ return abortPayForDeletedOrder(wex, ctx);
+ }
default:
logger.info(
`got error response (http status ${resp.response.status}) from merchant`,
@@ -3249,6 +3329,25 @@ async function processPurchasePay(
merchantClient.demostratePayment(download.contractTerms.order_id, reqBody),
);
logger.trace(`/paid response status: ${resp.response.status}`);
+ if (isOrderUnknown(resp)) {
+ // The merchant deleted the order, so there is no session left to bind
+ // the payment to. The payment itself stays valid, and repeating the
+ // request could only produce the same answer.
+ logger.warn(`session rebinding failed, merchant deleted the order`);
+ await wex.runWalletDbTx(async (tx) => {
+ const [p, h] = await ctx.getRecordHandle(tx);
+ switch (p?.purchaseStatus) {
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.PendingPayingReplay:
+ break;
+ default:
+ return;
+ }
+ p.purchaseStatus = PurchaseStatus.Done;
+ await h.update(p, "replay-order-gone", BalanceEffect.None);
+ });
+ return TaskRunResult.progress();
+ }
if (resp.case !== "ok") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
@@ -3772,11 +3871,18 @@ export async function sharePayment(
return { privatePayUri };
}
+/**
+ * Status of an order from the point of view of a wallet that is not the one
+ * that (possibly) paid it. "gone" means that the merchant does not have the
+ * order anymore, and thus nobody can pay it.
+ */
+type ForeignOrderStatus = "paid" | "unpaid" | "gone";
+
async function checkIfOrderIsAlreadyPaid(
wex: WalletExecutionContext,
contract: DownloadedContractData,
doLongPolling: boolean,
-) {
+): Promise<ForeignOrderStatus> {
const merchantClient = walletMerchantClient(
contract.contractTerms.merchant_base_url,
wex,
@@ -3789,12 +3895,16 @@ async function checkIfOrderIsAlreadyPaid(
},
);
+ if (isOrderUnknown(resp)) {
+ return "gone";
+ }
+
switch (resp.case) {
case "ok": // 200 paid
case HttpStatusCode.Accepted: // 202 goto
- return true;
+ return "paid";
case HttpStatusCode.PaymentRequired: // 402 unpaid
- return false;
+ return "unpaid";
default:
// forbidden, not found, not acceptable
throw Error(`this order cant be paid: ${resp.response.status}`);
@@ -3886,6 +3996,20 @@ async function processPurchaseDialogShared(
timeout: MERCHANT_ORDER_STATUS_LONGPOLL_MS,
});
+ if (isOrderUnknown(resp)) {
+ // The merchant deleted the order, so no other wallet can pay it anymore
+ // and there is nothing left to watch for.
+ await wex.runWalletDbTx(async (tx) => {
+ const [p, h] = await ctx.getRecordHandle(tx);
+ if (p?.purchaseStatus !== PurchaseStatus.DialogShared) {
+ return;
+ }
+ p.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
+ await h.update(p, "shared-order-gone");
+ });
+ return TaskRunResult.progress();
+ }
+
switch (resp.case) {
case "ok": // 200 paid
case HttpStatusCode.Accepted: // 202 goto
@@ -3991,6 +4115,26 @@ async function processPurchaseAutoRefund(
},
);
+ if (isOrderUnknown(resp)) {
+ // The merchant deleted the order, so no auto-refund can arrive for it
+ // anymore. That leaves the payment where the auto-refund deadline would
+ // have left it.
+ await wex.runWalletDbTx(async (tx) => {
+ const [p, h] = await ctx.getRecordHandle(tx);
+ switch (p?.purchaseStatus) {
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ case PurchaseStatus.FinalizingQueryingAutoRefund:
+ break;
+ default:
+ return;
+ }
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ await h.update(p, "auto-refund-order-gone");
+ });
+ return TaskRunResult.progress();
+ }
+
// FIXME: Check other status codes!
if (resp.case !== "ok") {
throw Error(`expected paid order status, got HTTP ${resp.response.status}`);
@@ -4100,9 +4244,10 @@ async function processPurchaseAbortingRefund(
logger.trace(`abort response status: ${j2s(abortHttpResp.response.status)}`);
if (
- abortHttpResp.case === HttpStatusCode.NotFound &&
- abortHttpResp.detail?.code ===
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
+ (abortHttpResp.case === HttpStatusCode.NotFound &&
+ abortHttpResp.detail?.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND) ||
+ isOrderUnknown(abortHttpResp)
) {
await wex.runWalletDbTx(async (tx) => {
const [rec, h] = await ctx.getRecordHandle(tx);
@@ -4175,13 +4320,30 @@ async function processPurchaseQueryRefund(
contractTermHash: download.contractTermsHash,
},
);
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ if (isOrderUnknown(resp)) {
+ // The merchant deleted the order, so it can never report a refund for it.
+ // The payment itself remains valid, so the transaction goes back to being
+ // a plain successful payment.
+ await wex.runWalletDbTx(async (tx) => {
+ const [p, h] = await ctx.getRecordHandle(tx);
+ if (p?.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return;
+ }
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ await h.update(p, "query-refund-order-gone");
+ });
+ return TaskRunResult.progress();
+ }
+
if (resp.case !== "ok") {
throw Error(`expected paid order status, got HTTP ${resp.response.status}`);
}
const orderStatus = resp.body;
- const ctx = new PayMerchantTransactionContext(wex, proposalId);
-
if (!orderStatus.refund_pending) {
await wex.runWalletDbTx(async (tx) => {
const [p, h] = await ctx.getRecordHandle(tx);
@@ -4229,6 +4391,21 @@ async function processPurchaseAcceptRefund(
h_contract: download.contractTermsHash,
},
);
+ if (isOrderUnknown(request)) {
+ // The merchant deleted the order, so the refund it announced can never be
+ // collected anymore. The payment itself stays valid.
+ const ctx = new PayMerchantTransactionContext(wex, purchase.proposalId);
+ await wex.runWalletDbTx(async (tx) => {
+ const [p, h] = await ctx.getRecordHandle(tx);
+ if (p?.purchaseStatus !== PurchaseStatus.PendingAcceptRefund) {
+ return;
+ }
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ await h.update(p, "accept-refund-order-gone");
+ });
+ return TaskRunResult.progress();
+ }
if (request.case !== "ok") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,