commit 1f0d4fbd6048bdf88408a1b9c21d5ce72b800a9d
parent 1e9b27ff653abc6397cb31e83983a6ca7785dbc9
Author: Sebastian <sebasjm@taler-systems.com>
Date: Wed, 28 Oct 2026 08:12:19 -0300
fix #11894: move compute expected into a testable unit
Diffstat:
5 files changed, 375 insertions(+), 27 deletions(-)
diff --git a/src/backend/meson.build b/src/backend/meson.build
@@ -66,6 +66,7 @@ executable(
taler_merchant_httpd_SOURCES = [
'taler-merchant-httpd.c',
+ 'taler-merchant-httpd_amounts.c',
'taler-merchant-httpd_auth.c',
'taler-merchant-httpd_contract.c',
'taler-merchant-httpd_dispatcher.c',
@@ -390,6 +391,28 @@ test(
suite: ['backend'],
)
+test_merchant_high_fee_order = executable(
+ 'test_merchant_high_fee_order',
+ [
+ 'test_merchant_high_fee_order.c',
+ 'taler-merchant-httpd_amounts.c',
+ ],
+ dependencies: [
+ talerjson_dep,
+ talerutil_dep,
+ gnunetutil_dep,
+ pq_dep,
+ json_dep,
+ ],
+ include_directories: [incdir, configuration_inc],
+ install: false,
+)
+test(
+ 'test_merchant_high_fee_order',
+ test_merchant_high_fee_order,
+ suite: ['backend'],
+)
+
test_merchant_kyccheck = executable(
'test_merchant_kyccheck',
['test_merchant_kyccheck.c'],
diff --git a/src/backend/taler-merchant-httpd_amounts.c b/src/backend/taler-merchant-httpd_amounts.c
@@ -0,0 +1,74 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/backend/taler-merchant-httpd_amounts.c
+ * @brief amount/fee arithmetic helpers for the merchant HTTP daemon
+ */
+#include "platform.h"
+#include <gnunet/gnunet_util_lib.h>
+#include <taler/taler_json_lib.h>
+#include "taler-merchant-httpd_helper.h"
+
+
+enum GNUNET_GenericReturnValue
+TMH_compute_expected_transfer (const struct TALER_Amount *contract_amount,
+ const struct TALER_Amount *refund_amount,
+ const struct TALER_Amount *deposit_fees_total,
+ const struct TALER_Amount *deposit_fees_refunded_total,
+ const struct TALER_Amount *max_fee,
+ bool has_max_fee,
+ struct TALER_Amount *expected_total)
+{
+ struct TALER_Amount net_fee;
+ struct TALER_Amount fee_merchant_pays;
+
+ if (0 >
+ TALER_amount_subtract (expected_total,
+ contract_amount,
+ refund_amount))
+ {
+ /* Refunds exceed the contract value. */
+ return GNUNET_SYSERR;
+ }
+ if (0 >
+ TALER_amount_subtract (&net_fee,
+ deposit_fees_total,
+ deposit_fees_refunded_total))
+ {
+ /* Refunded deposit fees exceed total deposit fees. */
+ return GNUNET_SYSERR;
+ }
+
+ /* The merchant only ever pays up to max_fee; the customer covers any
+ excess. If the contract did not specify a max_fee, fall back to the
+ previous strict behavior. */
+ if (has_max_fee &&
+ (1 == TALER_amount_cmp (&net_fee,
+ max_fee)))
+ fee_merchant_pays = *max_fee;
+ else
+ fee_merchant_pays = net_fee;
+
+ if (0 >
+ TALER_amount_subtract (expected_total,
+ expected_total,
+ &fee_merchant_pays))
+ {
+ /* Deposit fees exceed the amount minus refunds. */
+ return GNUNET_NO;
+ }
+ return GNUNET_OK;
+}
diff --git a/src/backend/taler-merchant-httpd_get-private-orders-ORDER_ID.c b/src/backend/taler-merchant-httpd_get-private-orders-ORDER_ID.c
@@ -322,6 +322,17 @@ struct GetOrderRequestContext
struct TALER_Amount contract_amount;
/**
+ * Maximum fee the merchant is willing to pay for this order/choice.
+ * Only valid if @e has_max_fee is true.
+ */
+ struct TALER_Amount max_fee;
+
+ /**
+ * True if @e max_fee was provided by the contract.
+ */
+ bool has_max_fee;
+
+ /**
* Hash over the @e contract_terms.
*/
struct TALER_PrivateContractHashP h_contract_terms;
@@ -891,6 +902,14 @@ phase_parse_contract (struct GetOrderRequestContext *gorc)
= (NULL != gorc->pc)
? gorc->pc->details.v0.brutto
: gorc->order->details.v0.brutto;
+ gorc->max_fee
+ = (NULL != gorc->pc)
+ ? gorc->pc->details.v0.max_fee
+ : gorc->order->details.v0.max_fee;
+ gorc->has_max_fee
+ = (NULL != gorc->pc)
+ ? true
+ : ! gorc->order->details.v0.no_max_fee;
break;
case TALER_MERCHANT_CONTRACT_VERSION_1:
if (gorc->choice_index >= 0)
@@ -909,10 +928,18 @@ phase_parse_contract (struct GetOrderRequestContext *gorc)
NULL));
return;
}
- gorc->contract_amount =
- (NULL != gorc->pc)
+ gorc->contract_amount
+ = (NULL != gorc->pc)
? gorc->pc->details.v1.choices[gorc->choice_index].amount
: gorc->order->details.v1.choices[gorc->choice_index].amount;
+ gorc->max_fee
+ = (NULL != gorc->pc)
+ ? gorc->pc->details.v1.choices[gorc->choice_index].max_fee
+ : gorc->order->details.v1.choices[gorc->choice_index].max_fee;
+ gorc->has_max_fee
+ = (NULL != gorc->pc)
+ ? true
+ : ! gorc->order->details.v1.choices[gorc->choice_index].no_max_fee;
}
else
{
@@ -1742,32 +1769,30 @@ phase_check_local_transfers (struct GetOrderRequestContext *gorc)
/* we believe(d) the wire transfer did not happen yet, check if maybe
in light of new evidence it did */
struct TALER_Amount expect_total;
-
- if (0 >
- TALER_amount_subtract (&expect_total,
- &gorc->contract_amount,
- &gorc->refund_amount))
+ enum GNUNET_GenericReturnValue fee_ret;
+
+ fee_ret = TMH_compute_expected_transfer (
+ &gorc->contract_amount,
+ &gorc->refund_amount,
+ &gorc->deposit_fees_total,
+ &gorc->deposit_fees_refunded_total,
+ gorc->has_max_fee ? &gorc->max_fee : NULL,
+ gorc->has_max_fee,
+ &expect_total);
+ switch (fee_ret)
{
+ case GNUNET_OK:
+ break;
+ case GNUNET_SYSERR:
GNUNET_break (0);
phase_end (gorc,
TALER_MHD_reply_with_error (
gorc->sc.con,
MHD_HTTP_INTERNAL_SERVER_ERROR,
TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID,
- "refund exceeds contract value"));
+ "refund or refunded deposit fees exceed allowed total"));
return;
- }
- GNUNET_assert (
- 0 <=
- TALER_amount_add (&expect_total,
- &expect_total,
- &gorc->deposit_fees_refunded_total));
-
- if (0 >
- TALER_amount_subtract (&expect_total,
- &expect_total,
- &gorc->deposit_fees_total))
- {
+ case GNUNET_NO:
GNUNET_break (0);
phase_end (gorc,
TALER_MHD_reply_with_error (
@@ -1776,6 +1801,15 @@ phase_check_local_transfers (struct GetOrderRequestContext *gorc)
TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID,
"deposit fees exceed total minus refunds"));
return;
+ default:
+ GNUNET_break (0);
+ phase_end (gorc,
+ TALER_MHD_reply_with_error (
+ gorc->sc.con,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID,
+ "unexpected fee computation result"));
+ return;
}
if (0 >=
TALER_amount_cmp (&expect_total,
diff --git a/src/backend/taler-merchant-httpd_helper.h b/src/backend/taler-merchant-httpd_helper.h
@@ -119,6 +119,37 @@ TMH_compute_order_total (const json_t *contract_terms,
/**
+ * Compute the total transfer amount the merchant expects to have received
+ * for an order, taking into account refunds and the merchant's maximum
+ * deposit fee.
+ *
+ * The merchant only ever pays up to @a max_fee when one was specified;
+ * the customer covers any excess deposit fees.
+ *
+ * @param contract_amount order amount
+ * @param refund_amount total refunds (must not exceed @a contract_amount)
+ * @param deposit_fees_total sum of all deposit fees
+ * @param deposit_fees_refunded_total sum of refunded deposit fees
+ * @param max_fee maximum deposit fee the merchant pays (may be NULL)
+ * @param has_max_fee true if @a max_fee is valid
+ * @param[out] expected_total set to the expected transfer amount
+ * @return #GNUNET_OK on success,
+ * #GNUNET_SYSERR if refunds exceed the contract value or if refunded
+ * deposit fees exceed total deposit fees,
+ * #GNUNET_NO if the merchant's share of deposit fees exceeds the
+ * amount minus refunds.
+ */
+enum GNUNET_GenericReturnValue
+TMH_compute_expected_transfer (const struct TALER_Amount *contract_amount,
+ const struct TALER_Amount *refund_amount,
+ const struct TALER_Amount *deposit_fees_total,
+ const struct TALER_Amount *deposit_fees_refunded_total,
+ const struct TALER_Amount *max_fee,
+ bool has_max_fee,
+ struct TALER_Amount *expected_total);
+
+
+/**
* Set of category IDs.
*/
struct TMH_CategorySet
@@ -374,13 +405,13 @@ TMH_make_order_status_url (struct MHD_Connection *con,
* @param hr a `TALER_EXCHANGE_HttpResponse`
*/
#define TMH_pack_exchange_reply(hr) \
- GNUNET_JSON_pack_uint64 ("exchange_code", (hr)->ec), \
- GNUNET_JSON_pack_uint64 ("exchange_http_status", (hr)->http_status), \
- GNUNET_JSON_pack_uint64 ("exchange_ec", (hr)->ec), /* LEGACY */ \
- GNUNET_JSON_pack_uint64 ("exchange_hc", (hr)->http_status), /* LEGACY */ \
- GNUNET_JSON_pack_allow_null ( \
- GNUNET_JSON_pack_object_incref ("exchange_reply", (json_t *) (hr)-> \
- reply))
+ GNUNET_JSON_pack_uint64 ("exchange_code", (hr)->ec), \
+ GNUNET_JSON_pack_uint64 ("exchange_http_status", (hr)->http_status), \
+ GNUNET_JSON_pack_uint64 ("exchange_ec", (hr)->ec), /* LEGACY */ \
+ GNUNET_JSON_pack_uint64 ("exchange_hc", (hr)->http_status), /* LEGACY */ \
+ GNUNET_JSON_pack_allow_null ( \
+ GNUNET_JSON_pack_object_incref ("exchange_reply", (json_t *) (hr)-> \
+ reply))
/**
diff --git a/src/backend/test_merchant_high_fee_order.c b/src/backend/test_merchant_high_fee_order.c
@@ -0,0 +1,186 @@
+/*
+ This file is part of TALER
+ (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/backend/test_merchant_high_fee_order.c
+ * @brief unit test for the deposit-fee arithmetic in GET /private/orders/ID
+ */
+#include "platform.h"
+#include <gnunet/gnunet_util_lib.h>
+#include <taler/taler_json_lib.h>
+#include "taler-merchant-httpd_helper.h"
+
+
+/**
+ * Wrapper around #TMH_compute_expected_transfer that also checks the
+ * resulting expected total against an expected value.
+ *
+ * @param label label for the test case
+ * @param amount order amount string
+ * @param refund refund amount string
+ * @param deposit_fee deposit fee total string
+ * @param deposit_fee_refunded refunded deposit fee string
+ * @param max_fee maximum merchant fee string, or NULL
+ * @param want expected total string (only checked on success)
+ * @param want_ret expected return value
+ */
+static void
+test_case (const char *label,
+ const char *amount,
+ const char *refund,
+ const char *deposit_fee,
+ const char *deposit_fee_refunded,
+ const char *max_fee,
+ const char *want,
+ enum GNUNET_GenericReturnValue want_ret)
+{
+ struct TALER_Amount contract_amount;
+ struct TALER_Amount refund_amount;
+ struct TALER_Amount deposit_fees_total;
+ struct TALER_Amount deposit_fees_refunded_total;
+ struct TALER_Amount max_fee_amount;
+ struct TALER_Amount expected_total;
+ enum GNUNET_GenericReturnValue ret;
+
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount (amount,
+ &contract_amount));
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount (refund,
+ &refund_amount));
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount (deposit_fee,
+ &deposit_fees_total));
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount (deposit_fee_refunded,
+ &deposit_fees_refunded_total));
+ if (NULL != max_fee)
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount (max_fee,
+ &max_fee_amount));
+
+ ret = TMH_compute_expected_transfer (
+ &contract_amount,
+ &refund_amount,
+ &deposit_fees_total,
+ &deposit_fees_refunded_total,
+ (NULL != max_fee) ? &max_fee_amount : NULL,
+ NULL != max_fee,
+ &expected_total);
+
+ if (ret != want_ret)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Test '%s': expected return %d, got %d\n",
+ label,
+ want_ret,
+ ret);
+ GNUNET_assert (0);
+ }
+ if (GNUNET_OK == ret)
+ {
+ struct TALER_Amount want_total;
+
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount (want,
+ &want_total));
+ if (0 != TALER_amount_cmp (&expected_total,
+ &want_total))
+ {
+ char *got = TALER_amount2s (&expected_total);
+
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Test '%s': expected total %s, got %s\n",
+ label,
+ want,
+ got);
+ GNUNET_free (got);
+ GNUNET_assert (0);
+ }
+ }
+}
+
+
+int
+main (int argc,
+ char *const argv[])
+{
+ (void) argc;
+ (void) argv;
+
+ test_case ("fee below amount",
+ "TESTKUDOS:10",
+ "TESTKUDOS:0",
+ "TESTKUDOS:1",
+ "TESTKUDOS:0",
+ "TESTKUDOS:2",
+ "TESTKUDOS:9",
+ GNUNET_OK);
+
+ /* the per-coin deposit fee exceeds
+ the order amount, but the merchant max_fee caps what the merchant
+ pays. Without the max_fee cap this would fail with
+ deposit fees exceed total. */
+ test_case ("fee exceeds amount but capped by max_fee",
+ "TESTKUDOS:1",
+ "TESTKUDOS:0",
+ "TESTKUDOS:2",
+ "TESTKUDOS:0",
+ "TESTKUDOS:0.2",
+ "TESTKUDOS:0.8",
+ GNUNET_OK);
+
+ test_case ("fee exceeds amount with refunded fee",
+ "TESTKUDOS:1",
+ "TESTKUDOS:0",
+ "TESTKUDOS:2",
+ "TESTKUDOS:0.5",
+ "TESTKUDOS:0.2",
+ "TESTKUDOS:0.8",
+ GNUNET_OK);
+
+ /* Without a max_fee the strict check must still fail when fees exceed
+ the amount. */
+ test_case ("fee exceeds amount without max_fee",
+ "TESTKUDOS:1",
+ "TESTKUDOS:0",
+ "TESTKUDOS:2",
+ "TESTKUDOS:0",
+ NULL,
+ NULL,
+ GNUNET_NO);
+
+ /* Refunded deposit fees must not exceed the total deposit fees. */
+ test_case ("refunded fees exceed total",
+ "TESTKUDOS:10",
+ "TESTKUDOS:0",
+ "TESTKUDOS:1",
+ "TESTKUDOS:2",
+ "TESTKUDOS:2",
+ NULL,
+ GNUNET_SYSERR);
+
+ /* Refunds must not exceed the contract value. */
+ test_case ("refund exceeds contract",
+ "TESTKUDOS:1",
+ "TESTKUDOS:2",
+ "TESTKUDOS:0.1",
+ "TESTKUDOS:0",
+ "TESTKUDOS:0.1",
+ NULL,
+ GNUNET_SYSERR);
+
+ return 0;
+}