merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

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:
Msrc/backend/meson.build | 23+++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_amounts.c | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_get-private-orders-ORDER_ID.c | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/backend/taler-merchant-httpd_helper.h | 45++++++++++++++++++++++++++++++++++++++-------
Asrc/backend/test_merchant_high_fee_order.c | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; +}