commit db7b7beea74e50463a0e5ff67db3b304047f2649
parent def199019fab39b492b668c668a80877f672196d
Author: Florian Dold <dold@taler.net>
Date: Thu, 23 Jul 2026 02:55:40 +0200
wallet: use typed merchant client in pay-merchant
Diffstat:
3 files changed, 209 insertions(+), 217 deletions(-)
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -66,7 +66,7 @@ import {
codecForOutOfStockResponse,
codecForPaidRefundStatusResponse,
codecForPaymentDeniedLegallyResponse,
- codecForPaymentResponse,
+ codecForMerchantPayResponse,
codecForPostOrderResponse,
codecForPotAddedResponse,
codecForPotDetailResponse,
@@ -465,7 +465,7 @@ export class TalerMerchantInstanceHttpClient {
this.cacheEvictor.notifySuccess(
TalerMerchantInstanceCacheEviction.UPDATE_ORDER,
);
- return opSuccessFromHttp(resp, codecForPaymentResponse());
+ return opSuccessFromHttp(resp, codecForMerchantPayResponse());
}
case HttpStatusCode.BadRequest:
return opKnownHttpFailure(resp.status, resp);
@@ -542,14 +542,25 @@ export class TalerMerchantInstanceHttpClient {
timeout: this.timeout,
});
+ // The order status is discriminated by the HTTP status code: 200 => paid,
+ // 202 => go to reorder URL, 402 => unpaid. We surface each as a distinct
+ // result case so callers can narrow the body type.
switch (resp.status) {
case HttpStatusCode.Ok:
return opSuccessFromHttp(resp, codecForStatusPaid());
case HttpStatusCode.Accepted:
- return opSuccessFromHttp(resp, codecForStatusGoto());
+ return opKnownAlternativeHttpFailure(
+ resp,
+ HttpStatusCode.Accepted,
+ codecForStatusGoto(),
+ );
// case HttpStatusCode.Found: not possible since content is not HTML
case HttpStatusCode.PaymentRequired:
- return opSuccessFromHttp(resp, codecForStatusStatusUnpaid());
+ return opKnownAlternativeHttpFailure(
+ resp,
+ HttpStatusCode.PaymentRequired,
+ codecForStatusStatusUnpaid(),
+ );
case HttpStatusCode.Forbidden:
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.NotFound:
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -1693,7 +1693,7 @@ export interface CoinPaySig {
}
export interface StatusPaid {
- type: "paid";
+ type?: "paid";
// Was the payment refunded (even partially, via refund or abort)?
refunded: boolean;
@@ -1708,7 +1708,7 @@ export interface StatusPaid {
refund_taken: AmountString;
}
export interface StatusGotoResponse {
- type: "goto";
+ type?: "goto";
// The client should go to the reorder URL, there a fresh
// order might be created as this one is taken by another
// customer or wallet (or repurchase detection logic may
@@ -1716,7 +1716,7 @@ export interface StatusGotoResponse {
public_reorder_url: string;
}
export interface StatusUnpaidResponse {
- type: "unpaid";
+ type?: "unpaid";
// URI that the wallet must process to complete the payment.
taler_pay_uri: TalerUriString;
@@ -4629,18 +4629,20 @@ export const codecForStatusPaid = (): Codec<StatusPaid> =>
.property("refund_pending", codecForBoolean())
.property("refund_taken", codecForAmountString())
.property("refunded", codecForBoolean())
- .property("type", codecForConstString("paid"))
+ // The order-status response is discriminated by HTTP status, not by a
+ // body field; some merchants don't send `type` at all.
+ .property("type", codecOptional(codecForConstString("paid")))
.build("TalerMerchantApi.StatusPaid");
export const codecForStatusGoto = (): Codec<StatusGotoResponse> =>
buildCodecForObject<StatusGotoResponse>()
.property("public_reorder_url", codecForURLString())
- .property("type", codecForConstString("goto"))
+ .property("type", codecOptional(codecForConstString("goto")))
.build("TalerMerchantApi.StatusGotoResponse");
export const codecForStatusStatusUnpaid = (): Codec<StatusUnpaidResponse> =>
buildCodecForObject<StatusUnpaidResponse>()
- .property("type", codecForConstString("unpaid"))
+ .property("type", codecOptional(codecForConstString("unpaid")))
.property("already_paid_order_id", codecOptional(codecForString()))
.property("fulfillment_url", codecOptional(codecForString()))
.property("taler_pay_uri", codecForTalerUriString())
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -37,12 +37,7 @@ import {
checkLogicInvariant,
ChoiceSelectionDetail,
ChoiceSelectionDetailType,
- codecForAbortResponse,
codecForMerchantContractTerms,
- codecForMerchantOrderStatusPaid,
- codecForMerchantOrderStatusUnpaid,
- codecForMerchantPayResponse,
- codecForWalletRefundResponse,
CoinDepositPermission,
CoinRefreshRequest,
ConfirmPayResult,
@@ -111,10 +106,6 @@ import {
} from "@gnu-taler/taler-util";
import {
getHttpResponseErrorDetails,
- HttpResponse,
- readResponseJsonOrThrow,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
@@ -123,8 +114,6 @@ import {
selectPayCoinsInTx,
} from "./coinSelection.js";
import {
- cancelableFetch,
- cancelableLongPoll,
constructTaskIdentifier,
genericWaitForState,
genericWaitForStateVal,
@@ -886,6 +875,11 @@ async function failProposalClaimPermanently(
});
}
+/**
+ * Long-poll timeout (in milliseconds) for merchant order-status requests.
+ */
+const MERCHANT_ORDER_STATUS_LONGPOLL_MS = 30_000;
+
function getPayRequestTimeout(purchase: WalletPurchase): Duration {
return Duration.multiply(
{ d_ms: 15000 },
@@ -3308,11 +3302,6 @@ async function processPurchasePay(
}
if (!purchase.merchantPaySig) {
- const payUrl = new URL(
- `orders/${download.contractTerms.order_id}/pay`,
- download.contractTerms.merchant_base_url,
- );
-
let slates: WalletSlate[] | undefined = undefined;
let donauPlanchets: WalletDonationPlanchet[] | undefined = undefined;
let wallet_data: PayWalletData | undefined = undefined;
@@ -3406,92 +3395,90 @@ async function processPurchasePay(
logger.trace(`making pay request ... ${j2s(reqBody)}`);
}
+ const merchantClient = walletMerchantClient(
+ download.contractTerms.merchant_base_url,
+ wex,
+ getPayRequestTimeout(purchase),
+ );
const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- cancelableFetch(wex, payUrl, {
- method: "POST",
- body: reqBody,
- timeout: getPayRequestTimeout(purchase),
- }),
+ merchantClient.makePayment(download.contractTerms.order_id, reqBody),
);
logger.trace(`got resp ${JSON.stringify(resp)}`);
- if (resp.status === HttpStatusCode.Conflict) {
- const err = await readTalerErrorResponse(resp);
- if (
- err.code ===
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
- ) {
- return handleInsufficientFunds(wex, proposalId, err);
+ switch (resp.case) {
+ case "ok":
+ break;
+ case HttpStatusCode.Conflict: {
+ const err = resp.detail!;
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
+ ) {
+ return handleInsufficientFunds(wex, proposalId, err);
+ }
+ return throwUnexpectedRequestError(resp.response, err);
}
- }
-
- if (resp.status === HttpStatusCode.BadRequest) {
- const err = await readTalerErrorResponse(resp);
- if (
- err.code ===
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND
- ) {
- return handleInsufficientFunds(wex, proposalId, err);
+ case HttpStatusCode.BadRequest: {
+ const err = resp.detail!;
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND
+ ) {
+ return handleInsufficientFunds(wex, proposalId, err);
+ }
+ return throwUnexpectedRequestError(resp.response, err);
}
- }
-
- if (resp.status === HttpStatusCode.BadGateway) {
- const err = await readTalerErrorResponse(resp);
- switch (err.code) {
- case TalerErrorCode.MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS: {
- const exchangeEc = (err.exchange_reply as any)?.code;
- switch (exchangeEc) {
- 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, {
- code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
- message: "Denomination used in payment became invalid.",
- errorDetails: err,
- });
- return TaskRunResult.progress();
+ case HttpStatusCode.BadGateway: {
+ const err = resp.detail!;
+ switch (err.code) {
+ case TalerErrorCode.MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS: {
+ const exchangeEc = (err.exchange_reply as any)?.code;
+ switch (exchangeEc) {
+ 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, {
+ code: TalerErrorCode.WALLET_TRANSACTION_PROTOCOL_VIOLATION,
+ message: "Denomination used in payment became invalid.",
+ errorDetails: err,
+ });
+ return TaskRunResult.progress();
+ }
+ break;
}
- break;
}
+ // We don't know the specific error, so it's safer to retry.
+ return throwUnexpectedRequestError(resp.response, err);
}
- // We don't know the specific error, so it's safer to retry.
- return throwUnexpectedRequestError(resp, err);
- }
-
- if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
- logger.warn(`pay transaction failed, merchant has KYC problems`);
- await ctx.userAbortTransaction(
- makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING, {
- exchangeResponse: await resp.json(),
- }),
- );
- return TaskRunResult.progress();
- }
-
- if (resp.status === HttpStatusCode.Gone) {
- logger.warn(`pay transaction aborted, order expired`);
- await ctx.userAbortTransaction(
- makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_ORDER_GONE, {}),
- );
- return TaskRunResult.progress();
- }
-
- if (resp.status != 200) {
- logger.info(
- `got error response (http status ${resp.status}) from merchant`,
- );
- const err = await readTalerErrorResponse(resp);
- if (logger.shouldLogTrace()) {
- logger.trace(`error body: ${j2s(err)}`);
- }
- return throwUnexpectedRequestError(resp, 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,
+ }),
+ );
+ return TaskRunResult.progress();
+ case HttpStatusCode.Gone:
+ logger.warn(`pay transaction aborted, order expired`);
+ await ctx.userAbortTransaction(
+ makeTalerErrorDetail(
+ TalerErrorCode.WALLET_PAY_MERCHANT_ORDER_GONE,
+ {},
+ ),
+ );
+ return TaskRunResult.progress();
+ default:
+ logger.info(
+ `got error response (http status ${resp.response.status}) from merchant`,
+ );
+ if (logger.shouldLogTrace()) {
+ logger.trace(`error body: ${j2s(resp.detail)}`);
+ }
+ return throwUnexpectedRequestError(resp.response, resp.detail!);
}
- const merchantResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantPayResponse(),
- );
+ const merchantResp = resp.body;
logger.trace("got success from pay URL", merchantResp);
@@ -3589,30 +3576,24 @@ async function processPurchasePay(
await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp);
} else {
- const payAgainUrl = new URL(
- `orders/${download.contractTerms.order_id}/paid`,
- download.contractTerms.merchant_base_url,
- );
const reqBody = {
sig: purchase.merchantPaySig,
h_contract: download.contractTermsHash,
session_id: sessionId ?? "",
};
logger.trace(`/paid request body: ${j2s(reqBody)}`);
+ const merchantClient = walletMerchantClient(
+ download.contractTerms.merchant_base_url,
+ wex,
+ );
const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- cancelableFetch(wex, payAgainUrl, {
- method: "POST",
- body: reqBody,
- }),
+ merchantClient.demostratePayment(download.contractTerms.order_id, reqBody),
);
- logger.trace(`/paid response status: ${resp.status}`);
- if (
- resp.status !== HttpStatusCode.NoContent &&
- resp.status != HttpStatusCode.Ok
- ) {
+ logger.trace(`/paid response status: ${resp.response.status}`);
+ if (resp.case !== "ok") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- getHttpResponseErrorDetails(resp),
+ getHttpResponseErrorDetails(resp.response),
"/paid failed",
);
}
@@ -4137,31 +4118,28 @@ async function checkIfOrderIsAlreadyPaid(
contract: DownloadedContractData,
doLongPolling: boolean,
) {
- const requestUrl = new URL(
- `orders/${contract.contractTerms.order_id}`,
+ const merchantClient = walletMerchantClient(
contract.contractTerms.merchant_base_url,
+ wex,
+ );
+ const resp = await merchantClient.getPaymentStatus(
+ contract.contractTerms.order_id,
+ {
+ contractTermHash: contract.contractTermsHash,
+ timeout: doLongPolling ? MERCHANT_ORDER_STATUS_LONGPOLL_MS : undefined,
+ },
);
- requestUrl.searchParams.set("h_contract", contract.contractTermsHash);
-
- let resp: HttpResponse;
-
- if (doLongPolling) {
- resp = await cancelableLongPoll(wex, requestUrl);
- } else {
- resp = await cancelableFetch(wex, requestUrl);
- }
- if (
- resp.status === HttpStatusCode.Ok ||
- resp.status === HttpStatusCode.Accepted ||
- resp.status === HttpStatusCode.Found
- ) {
- return true;
- } else if (resp.status === HttpStatusCode.PaymentRequired) {
- return false;
+ switch (resp.case) {
+ case "ok": // 200 paid
+ case HttpStatusCode.Accepted: // 202 goto
+ return true;
+ case HttpStatusCode.PaymentRequired: // 402 unpaid
+ return false;
+ default:
+ // forbidden, not found, not acceptable
+ throw Error(`this order cant be paid: ${resp.response.status}`);
}
- // forbidden, not found, not acceptable
- throw Error(`this order cant be paid: ${resp.status}`);
}
async function processPurchaseDialogProposed(
@@ -4239,31 +4217,23 @@ async function processPurchaseDialogShared(
let paidByOther = false;
- const requestUrl = new URL(
- `orders/${contractTerms.order_id}`,
+ const merchantClient = walletMerchantClient(
contractTerms.merchant_base_url,
+ wex,
);
- requestUrl.searchParams.set("h_contract", download.contractTermsHash);
- if (rec.lastSessionId != null) {
- requestUrl.searchParams.set("session_id", rec.lastSessionId);
- }
-
- let httpResp: HttpResponse;
-
- httpResp = await cancelableLongPoll(wex, requestUrl);
+ const resp = await merchantClient.getPaymentStatus(contractTerms.order_id, {
+ contractTermHash: download.contractTermsHash,
+ sessionId: rec.lastSessionId ?? undefined,
+ timeout: MERCHANT_ORDER_STATUS_LONGPOLL_MS,
+ });
- switch (httpResp.status) {
- case HttpStatusCode.Ok:
- case HttpStatusCode.Accepted:
- case HttpStatusCode.Found:
+ switch (resp.case) {
+ case "ok": // 200 paid
+ case HttpStatusCode.Accepted: // 202 goto
paidByOther = true;
break;
- case HttpStatusCode.PaymentRequired:
- const resp = await readResponseJsonOrThrow(
- httpResp,
- codecForMerchantOrderStatusUnpaid(),
- );
- if (resp.already_paid_order_id != null) {
+ case HttpStatusCode.PaymentRequired: // 402 unpaid
+ if (resp.body.already_paid_order_id != null) {
paidByOther = true;
}
break;
@@ -4349,22 +4319,24 @@ async function processPurchaseAutoRefund(
return TaskRunResult.progress();
}
- const requestUrl = new URL(
- `orders/${download.contractTerms.order_id}`,
+ const merchantClient = walletMerchantClient(
download.contractTerms.merchant_base_url,
+ wex,
+ );
+ const resp = await merchantClient.getPaymentStatus(
+ download.contractTerms.order_id,
+ {
+ contractTermHash: download.contractTermsHash,
+ refund: Amounts.stringify(totalKnownRefund),
+ timeout: MERCHANT_ORDER_STATUS_LONGPOLL_MS,
+ },
);
- requestUrl.searchParams.set("h_contract", download.contractTermsHash);
-
- requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund));
-
- const resp = await cancelableLongPoll(wex, requestUrl);
// FIXME: Check other status codes!
-
- const orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
- );
+ if (resp.case !== "ok") {
+ throw Error(`expected paid order status, got HTTP ${resp.response.status}`);
+ }
+ const orderStatus = resp.body;
if (!orderStatus.refund_pending) {
return TaskRunResult.longpollReturnedPending();
@@ -4393,10 +4365,6 @@ async function processPurchaseAbortingRefund(
const ctx = new PayMerchantTransactionContext(wex, proposalId);
const download = await expectProposalDownload(wex, purchase);
logger.trace(`processing aborting-refund for proposal ${proposalId}`);
- const requestUrl = new URL(
- `orders/${download.contractTerms.order_id}/abort`,
- download.contractTerms.merchant_base_url,
- );
const abortingCoins: AbortingCoin[] = [];
@@ -4459,37 +4427,43 @@ async function processPurchaseAbortingRefund(
coins: abortingCoins,
};
- logger.trace(`making order abort request to ${requestUrl.href}`);
+ logger.trace(`making order abort request for ${download.contractTerms.order_id}`);
- const abortHttpResp = await cancelableFetch(wex, requestUrl, {
- method: "POST",
- body: abortReq,
- });
+ const merchantClient = walletMerchantClient(
+ download.contractTerms.merchant_base_url,
+ wex,
+ );
+ const abortHttpResp = await merchantClient.abortIncompletePayment(
+ download.contractTerms.order_id,
+ abortReq,
+ );
- logger.trace(`abort response status: ${j2s(abortHttpResp.status)}`);
+ logger.trace(`abort response status: ${j2s(abortHttpResp.response.status)}`);
- if (abortHttpResp.status === HttpStatusCode.NotFound) {
- const err = await readTalerErrorResponse(abortHttpResp);
- if (
- err.code ===
+ if (
+ abortHttpResp.case === HttpStatusCode.NotFound &&
+ abortHttpResp.detail?.code ===
TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
- ) {
- await wex.runWalletDbTx(async (tx) => {
- const [rec, h] = await ctx.getRecordHandle(tx);
- if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) {
- return;
- }
- rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
- await h.update(rec);
- });
- return TaskRunResult.progress();
- }
+ ) {
+ await wex.runWalletDbTx(async (tx) => {
+ const [rec, h] = await ctx.getRecordHandle(tx);
+ if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) {
+ return;
+ }
+ rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
+ await h.update(rec);
+ });
+ return TaskRunResult.progress();
}
- const abortResp = await readSuccessResponseJsonOrThrow(
- abortHttpResp,
- codecForAbortResponse(),
- );
+ if (abortHttpResp.case !== "ok") {
+ return throwUnexpectedRequestError(
+ abortHttpResp.response,
+ abortHttpResp.detail!,
+ );
+ }
+
+ const abortResp = abortHttpResp.body;
if (logger.shouldLogTrace()) {
logger.trace(`abort response: ${j2s(abortResp)}`);
@@ -4532,17 +4506,20 @@ async function processPurchaseQueryRefund(
const download = await expectProposalDownload(wex, purchase);
- const requestUrl = new URL(
- `orders/${download.contractTerms.order_id}`,
+ const merchantClient = walletMerchantClient(
download.contractTerms.merchant_base_url,
+ wex,
);
- requestUrl.searchParams.set("h_contract", download.contractTermsHash);
-
- const resp = await cancelableFetch(wex, requestUrl);
- const orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
+ const resp = await merchantClient.getPaymentStatus(
+ download.contractTerms.order_id,
+ {
+ contractTermHash: download.contractTermsHash,
+ },
);
+ 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);
@@ -4581,24 +4558,26 @@ async function processPurchaseAcceptRefund(
): Promise<TaskRunResult> {
const download = await expectProposalDownload(wex, purchase);
- const requestUrl = new URL(
- `orders/${download.contractTerms.order_id}/refund`,
+ logger.trace(`making refund request for ${download.contractTerms.order_id}`);
+
+ const merchantClient = walletMerchantClient(
download.contractTerms.merchant_base_url,
+ wex,
);
-
- logger.trace(`making refund request to ${requestUrl.href}`);
-
- const request = await cancelableFetch(wex, requestUrl, {
- method: "POST",
- body: {
+ const request = await merchantClient.obtainRefund(
+ download.contractTerms.order_id,
+ {
h_contract: download.contractTermsHash,
},
- });
-
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForWalletRefundResponse(),
);
+ if (request.case !== "ok") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ getHttpResponseErrorDetails(request.response),
+ "refund request failed",
+ );
+ }
+ const refundResponse = request.body;
return await storeRefunds(
wex,
purchase,