taler-typescript-core

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

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:
Mpackages/taler-harness/src/integrationtests/test-payment-claim.ts | 44++++++++++++++++++++++++++++++--------------
Mpackages/taler-util/src/http-client/merchant.ts | 12++++++++++++
Mpackages/taler-util/src/taler-error-codes.ts | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 66++++++++++++++++++++++++++++++++++++++----------------------------
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 {