commit 929a9559d4cab717227598e1c2f87b11f0a6c025
parent d850113896c6dd5046ed2a1476c15257b6fb2123
Author: Florian Dold <florian@dold.me>
Date: Tue, 2 Dec 2025 17:08:20 +0100
wallet-core: better handling of errors during claiming
Diffstat:
4 files changed, 162 insertions(+), 52 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-payment-claim.ts b/packages/taler-harness/src/integrationtests/test-payment-claim.ts
@@ -18,6 +18,7 @@
* Imports.
*/
import {
+ j2s,
PreparePayResultType,
succeedOrThrow,
TalerErrorCode,
@@ -37,14 +38,19 @@ import { GlobalTestState } from "../harness/harness.js";
export async function runPaymentClaimTest(t: GlobalTestState) {
// Set up test environment
- const { walletClient, bankClient, exchange, merchant, merchantAdminAccessToken } =
- await createSimpleTestkudosEnvironmentV3(t);
+ const {
+ walletClient,
+ bankClient,
+ exchange,
+ merchant,
+ merchantAdminAccessToken,
+ } = await createSimpleTestkudosEnvironmentV3(t);
const merchantClient = new TalerMerchantInstanceHttpClient(
merchant.makeInstanceBaseUrl(),
);
- const w2 = await createWalletDaemonWithClient(t, { name: "w2" });
+ const w2 = await createWalletDaemonWithClient(t, { name: "w2", persistent: true });
// Withdraw digital cash into the wallet.
@@ -70,7 +76,10 @@ export async function runPaymentClaimTest(t: GlobalTestState) {
);
let orderStatus = succeedOrThrow(
- await merchantClient.getOrderDetails(merchantAdminAccessToken, orderResp.order_id),
+ await merchantClient.getOrderDetails(
+ merchantAdminAccessToken,
+ orderResp.order_id,
+ ),
);
t.assertTrue(orderStatus.order_status === "unpaid");
@@ -90,11 +99,11 @@ export async function runPaymentClaimTest(t: GlobalTestState) {
preparePayResult.status === PreparePayResultType.PaymentPossible,
);
- const errOne = t.assertThrowsTalerErrorAsyncLegacy(
- w2.walletClient.call(WalletApiOperation.PreparePayForUri, {
+ const errOne = await t.assertThrowsTalerErrorAsync(async () => {
+ await w2.walletClient.call(WalletApiOperation.PreparePayForUri, {
talerPayUri,
- })
- );
+ });
+ });
console.log(errOne);
@@ -105,22 +114,29 @@ export async function runPaymentClaimTest(t: GlobalTestState) {
// Check if payment was successful.
orderStatus = succeedOrThrow(
- await merchantClient.getOrderDetails(merchantAdminAccessToken, orderResp.order_id),
+ await merchantClient.getOrderDetails(
+ merchantAdminAccessToken,
+ orderResp.order_id,
+ ),
);
t.assertTrue(orderStatus.order_status === "paid");
await w2.walletClient.call(WalletApiOperation.ClearDb, {});
- const err = await t.assertThrowsTalerErrorAsyncLegacy(
- w2.walletClient.call(WalletApiOperation.PreparePayForUri, {
+ const err = await t.assertThrowsTalerErrorAsync(async () => {
+ await w2.walletClient.call(WalletApiOperation.PreparePayForUri, {
talerPayUri,
- })
- );
+ });
+ });
t.assertTrue(err.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED));
- await t.shutdown();
+ const txn = await w2.walletClient.call(WalletApiOperation.GetTransactionsV2, {
+ includeAll: true,
+ });
+
+ console.log(j2s(txn));
}
runPaymentClaimTest.suites = ["wallet"];
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -66,6 +66,7 @@ import {
codecForStatusGoto,
codecForStatusPaid,
codecForStatusStatusUnpaid,
+ codecForTalerErrorDetail,
codecForTalerMerchantConfigResponse,
codecForTansferList,
codecForTemplateDetails,
@@ -339,6 +340,7 @@ export class TalerMerchantInstanceHttpClient {
}): Promise<
| OperationOk<TalerMerchantApi.ClaimResponse>
| OperationFail<HttpStatusCode.Conflict>
+ | OperationFail<TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND>
> {
const { orderId, body } = args;
const url = new URL(`orders/${orderId}/claim`, this.baseUrl);
@@ -358,6 +360,16 @@ export class TalerMerchantInstanceHttpClient {
}
case HttpStatusCode.Conflict:
return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound: {
+ const body = await resp.json();
+ const details = codecForTalerErrorDetail().decode(body);
+ switch (details.code) {
+ case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownHttpFailure(resp, details);
+ }
+ }
default:
return opUnknownHttpFailure(resp);
}
diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts
@@ -745,6 +745,46 @@ export enum TalerErrorCode {
/**
+ * The process to generate a PDF from a template failed. A likely cause is a syntactic error in the template. This needs to be investigated by the exchange operator.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_TYPST_TEMPLATE_FAILURE = 1044,
+
+
+ /**
+ * A process to combine multiple PDFs into one larger document failed. A likely cause is a resource exhaustion problem on the server. This needs to be investigated by the exchange operator.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_PDFTK_FAILURE = 1045,
+
+
+ /**
+ * The process to generate a PDF from a template crashed. A likely cause is a bug in the Typst software. This needs to be investigated by the exchange operator.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_TYPST_CRASH = 1046,
+
+
+ /**
+ * The process to combine multiple PDFs into a larger document crashed. A likely cause is a bug in the pdftk software. This needs to be investigated by the exchange operator.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_PDFTK_CRASH = 1047,
+
+
+ /**
+ * One of the binaries needed to generate the PDF is not installed. If this feature is required, the system administrator should make sure Typst and pdftk are both installed.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_IMPLEMENTED (501).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_NO_TYPST_OR_PDFTK = 1048,
+
+
+ /**
* The exchange did not find information about the specified transaction in the database.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
@@ -929,8 +969,8 @@ export enum TalerErrorCode {
/**
- * The batch withdraw included a planchet that was already withdrawn. This is not allowed.
- * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * The withdraw operation included the same planchet more than once. This is not allowed.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
EXCHANGE_WITHDRAW_IDEMPOTENT_PLANCHET = 1175,
@@ -2361,6 +2401,14 @@ export enum TalerErrorCode {
/**
+ * The unit referenced in the request is not known to the backend.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_UNIT_UNKNOWN = 2004,
+
+
+ /**
* The proposal is not known to the backend.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
@@ -2577,6 +2625,22 @@ export enum TalerErrorCode {
/**
+ * A donation authority (Donau) provided an invalid response. This should be analyzed by the administrator. Trying again later may help.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_DONAU_INVALID_RESPONSE = 2032,
+
+
+ /**
+ * The unit referenced in the request is builtin and cannot be modified or deleted.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_UNIT_BUILTIN = 2033,
+
+
+ /**
* The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response.
* Returned with an HTTP status code of #MHD_HTTP_OK (200).
* (A value of 0 indicates that the error is generated client-side).
@@ -4377,14 +4441,6 @@ export enum TalerErrorCode {
/**
- * The exchange does not know about the reserve (yet), and thus withdrawal can't progress.
- * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
- * (A value of 0 indicates that the error is generated client-side).
- */
- WALLET_EXCHANGE_WITHDRAW_RESERVE_UNKNOWN_AT_EXCHANGE = 7010,
-
-
- /**
* The wallet core service is not available.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
@@ -4689,6 +4745,14 @@ export enum TalerErrorCode {
/**
+ * The order could not be found. Maybe the merchant deleted it.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_MERCHANT_ORDER_NOT_FOUND = 7049,
+
+
+ /**
* We encountered a timeout with our payment backend.
* Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504).
* (A value of 0 indicates that the error is generated client-side).
@@ -5305,6 +5369,14 @@ export enum TalerErrorCode {
/**
+ * A charity with the same public key is already registered.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ DONAU_CHARITY_PUB_EXISTS = 8618,
+
+
+ /**
* A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -823,7 +823,7 @@ export async function getTotalPaymentCostInTx(
return Amounts.sum([zero, ...costs]).amount;
}
-async function failProposalPermanently(
+async function failProposalClaimPermanently(
wex: WalletExecutionContext,
proposalId: string,
err: TalerErrorDetail,
@@ -832,25 +832,13 @@ async function failProposalPermanently(
await wex.db.runReadWriteTx(
{ storeNames: ["purchases", "transactionsMeta"] },
async (tx) => {
- const p = await tx.purchases.get(proposalId);
+ const [p, h] = await ctx.getRecordHandle(tx);
if (!p) {
return;
}
- // FIXME: We don't store the error detail here?!
- const oldTxState = computePayMerchantTransactionState(p);
- const oldStId = p.purchaseStatus;
p.purchaseStatus = PurchaseStatus.FailedClaim;
- const newTxState = computePayMerchantTransactionState(p);
- const newStId = p.purchaseStatus;
- await tx.purchases.put(p);
- await ctx.updateTransactionMeta(tx);
- applyNotifyTransition(tx.notify, ctx.transactionId, {
- oldTxState,
- newTxState,
- balanceEffect: BalanceEffect.Any,
- newStId,
- oldStId,
- });
+ p.failReason = err;
+ await h.update(p);
},
);
}
@@ -970,14 +958,31 @@ async function processDownloadProposal(
case "ok":
break;
case HttpStatusCode.Conflict:
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
- {
- orderId: proposal.orderId,
- claimUrl: orderClaimUrl.href,
- },
- "order already claimed (likely by other wallet)",
+ await failProposalClaimPermanently(
+ wex,
+ proposalId,
+ makeTalerErrorDetail(
+ TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
+ {
+ orderId: proposal.orderId,
+ claimUrl: orderClaimUrl.href,
+ },
+ "order already claimed (likely by other wallet)",
+ ),
);
+ return TaskRunResult.finished();
+ case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND: {
+ await failProposalClaimPermanently(
+ wex,
+ proposalId,
+ makeTalerErrorDetail(
+ TalerErrorCode.WALLET_MERCHANT_ORDER_NOT_FOUND,
+ {},
+ "order not found (expired?)",
+ ),
+ );
+ return TaskRunResult.finished();
+ }
default:
assertUnreachable(claimResp);
}
@@ -1006,7 +1011,7 @@ async function processDownloadProposal(
{},
"validation for well-formedness failed",
);
- await failProposalPermanently(wex, proposalId, err);
+ await failProposalClaimPermanently(wex, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
@@ -1030,7 +1035,7 @@ async function processDownloadProposal(
{},
`schema validation failed: ${e}`,
);
- await failProposalPermanently(wex, proposalId, err);
+ await failProposalClaimPermanently(wex, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
@@ -1049,7 +1054,7 @@ async function processDownloadProposal(
{},
"validation for well-formedness failed",
);
- await failProposalPermanently(wex, proposalId, err);
+ await failProposalClaimPermanently(wex, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
@@ -1072,7 +1077,7 @@ async function processDownloadProposal(
},
"merchant's signature on contract terms is invalid",
);
- await failProposalPermanently(wex, proposalId, err);
+ await failProposalClaimPermanently(wex, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
@@ -1094,7 +1099,7 @@ async function processDownloadProposal(
},
"merchant base URL mismatch",
);
- await failProposalPermanently(wex, proposalId, err);
+ await failProposalClaimPermanently(wex, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
@@ -2173,7 +2178,12 @@ async function waitProposalDownloaded(
if (purchase.download) {
return true;
}
+ const exc = purchase.failReason ?? purchase.abortReason;
+ if (exc) {
+ throw TalerError.fromUncheckedDetail(exc);
+ }
if (retryInfo) {
+ // FIXME: This should really be a return status of `preparePay`.
if (retryInfo.lastError) {
throw TalerError.fromUncheckedDetail(retryInfo.lastError);
} else {