commit c4ed28a2e3c4539d284999201bbdb1b9ef34b5a4
parent 03b6d9a18feec688d7cfd4146eb821c04aaddac6
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Mon, 20 Jul 2026 09:28:34 +0200
[mixed payments] initial version
Diffstat:
57 files changed, 4969 insertions(+), 52 deletions(-)
diff --git a/meson.build b/meson.build
@@ -335,11 +335,11 @@ if not get_option('only-doc')
libltversions = [
- ['libtalermerchant', '11:0:0'],
- ['libtalermerchantutil', '1:1:1'],
+ ['libtalermerchant', '12:0:1'],
+ ['libtalermerchantutil', '2:0:2'],
['libtalermerchantbank', '0:1:0'],
- ['libtalermerchantdb', '8:0:0'],
- ['libtalermerchanttesting', '4:1:1'],
+ ['libtalermerchantdb', '9:0:1'],
+ ['libtalermerchanttesting', '5:0:2'],
]
solibversions = {}
diff --git a/src/backend/meson.build b/src/backend/meson.build
@@ -136,7 +136,9 @@ taler_merchant_httpd_SOURCES = [
'taler-merchant-httpd_post-management-instances.c',
'taler-merchant-httpd_post-management-instances-INSTANCE-auth.c',
'taler-merchant-httpd_post-private-token.c',
+ 'taler-merchant-httpd_post-private-orders-ORDER_ID-collect.c',
'taler-merchant-httpd_post-private-orders-ORDER_ID-refund.c',
+ 'taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c',
'taler-merchant-httpd_post-private-orders.c',
'taler-merchant-httpd_post-private-products.c',
'taler-merchant-httpd_post-private-otp-devices.c',
diff --git a/src/backend/taler-merchant-httpd_delete-private-orders-ORDER_ID.c b/src/backend/taler-merchant-httpd_delete-private-orders-ORDER_ID.c
@@ -24,11 +24,28 @@
#include <taler/taler_json_lib.h>
#include "merchant-database/delete_contract_terms.h"
#include "merchant-database/delete_order.h"
+#include "merchant-database/get_contract_terms.h"
#include "merchant-database/get_contract_terms_status.h"
#include "merchant-database/get_order.h"
/**
+ * Check if the given @a contract_terms contain external payment
+ * entries ("amount_external"). Such orders may have been settled
+ * outside of Taler and must not be deleted without force.
+ *
+ * @param contract_terms contract terms to check
+ * @return true if external payment entries are present
+ */
+static bool
+has_external_payments (const json_t *contract_terms)
+{
+ return 0 != json_array_size (json_object_get (contract_terms,
+ "amount_external"));
+}
+
+
+/**
* Handle a DELETE "/orders/$ID" request.
*
* @param rh context of the handler
@@ -51,6 +68,48 @@ TMH_private_delete_orders_ID (const struct TMH_RequestHandler *rh,
"force",
false,
&force);
+ if (! force)
+ {
+ /* Orders with externally settled payments require an explicit
+ force operation to be removed. */
+ json_t *contract_terms = NULL;
+ uint64_t order_serial;
+
+ qs = TALER_MERCHANTDB_get_contract_terms (TMH_db,
+ mi->settings.id,
+ hc->infix,
+ &contract_terms,
+ &order_serial,
+ NULL);
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ {
+ struct TALER_MerchantPostDataHashP unused;
+
+ qs = TALER_MERCHANTDB_get_order (TMH_db,
+ mi->settings.id,
+ hc->infix,
+ NULL,
+ &unused,
+ &contract_terms);
+ }
+ if (0 > qs)
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "lookup order for deletion");
+ if (NULL != contract_terms)
+ {
+ bool blocked = has_external_payments (contract_terms);
+
+ json_decref (contract_terms);
+ if (blocked)
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_DELETE_ORDERS_EXTERNALLY_PAID,
+ hc->infix);
+ }
+ }
qs = TALER_MERCHANTDB_delete_order (TMH_db,
mi->settings.id,
hc->infix,
diff --git a/src/backend/taler-merchant-httpd_dispatcher.c b/src/backend/taler-merchant-httpd_dispatcher.c
@@ -87,7 +87,9 @@
#include "taler-merchant-httpd_post-private-token.h"
#include "taler-merchant-httpd_post-private-otp-devices.h"
#include "taler-merchant-httpd_post-private-orders.h"
+#include "taler-merchant-httpd_post-private-orders-ORDER_ID-collect.h"
#include "taler-merchant-httpd_post-private-orders-ORDER_ID-refund.h"
+#include "taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.h"
#include "taler-merchant-httpd_post-private-products.h"
#include "taler-merchant-httpd_post-private-products-PRODUCT_ID-lock.h"
#include "taler-merchant-httpd_post-private-templates.h"
@@ -556,6 +558,30 @@ determine_handler_group (const char **urlp,
to set a conservative bound for sane wallets */
.max_upload = 1024 * 1024
},
+ /* POST /orders/$ID/refund-external: */
+ {
+ .url_prefix = "/orders/",
+ .url_suffix = "refund-external",
+ .method = MHD_HTTP_METHOD_POST,
+ .have_id_segment = true,
+ .permission = "orders-refund",
+ .handler = &TMH_private_post_orders_ID_refund_external,
+ /* the body should be pretty small, allow 1 MB of upload
+ to set a conservative bound for sane wallets */
+ .max_upload = 1024 * 1024
+ },
+ /* POST /orders/$ID/collect: */
+ {
+ .url_prefix = "/orders/",
+ .url_suffix = "collect",
+ .method = MHD_HTTP_METHOD_POST,
+ .have_id_segment = true,
+ .permission = "orders-write",
+ .handler = &TMH_private_post_orders_ID_collect,
+ /* the body should be pretty small, allow 1 MB of upload
+ to set a conservative bound for sane wallets */
+ .max_upload = 1024 * 1024
+ },
/* PATCH /orders/$ID/forget: */
{
.url_prefix = "/orders/",
diff --git a/src/backend/taler-merchant-httpd_get-config.c b/src/backend/taler-merchant-httpd_get-config.c
@@ -44,7 +44,7 @@
* #MERCHANT_PROTOCOL_CURRENT and #MERCHANT_PROTOCOL_AGE in
* merchant_api_get_config.c!
*/
-#define MERCHANT_PROTOCOL_VERSION "41:0:29"
+#define MERCHANT_PROTOCOL_VERSION "42:0:30"
/**
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
@@ -37,6 +37,7 @@
#include "merchant-database/get_order.h"
#include "merchant-database/get_order_by_fulfillment.h"
#include "merchant-database/iterate_refunds_detailed.h"
+#include "merchant-database/iterate_external_refunds.h"
#include "merchant-database/iterate_transfer_details_by_order.h"
#include "merchant-database/update_to_contract_terms_wired.h"
#include "merchant-database/preflight.h"
@@ -311,6 +312,11 @@ struct GetOrderRequestContext
json_t *refund_details;
/**
+ * Details about external refunds, empty array if there are none.
+ */
+ json_t *refunds_external;
+
+ /**
* Amount of the order, unset for unpaid v1 orders.
*/
struct TALER_Amount contract_amount;
@@ -584,6 +590,8 @@ gorc_cleanup (void *cls)
json_decref (gorc->wire_details);
if (NULL != gorc->refund_details)
json_decref (gorc->refund_details);
+ if (NULL != gorc->refunds_external)
+ json_decref (gorc->refunds_external);
if (NULL != gorc->tt)
{
GNUNET_SCHEDULER_cancel (gorc->tt);
@@ -1492,6 +1500,50 @@ process_refunds_cb (
/**
+ * Function called with information about an external refund.
+ * Appends the refund entry to the "refunds_external" array.
+ *
+ * @param cls a `struct GetOrderRequestContext`
+ * @param refund_id identifier of the refund within the order
+ * @param refund_timestamp when was the refund recorded
+ * @param method external payment method used for the refund
+ * @param payment_id id of the original external payment entry
+ * @param amount amount returned to the customer
+ * @param reason human-readable refund justification
+ */
+static void
+process_external_refunds_cb (void *cls,
+ const char *refund_id,
+ struct GNUNET_TIME_Timestamp refund_timestamp,
+ const char *method,
+ const char *payment_id,
+ const struct TALER_Amount *amount,
+ const char *reason)
+{
+ struct GetOrderRequestContext *gorc = cls;
+
+ GNUNET_assert (
+ 0 ==
+ json_array_append_new (
+ gorc->refunds_external,
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("id",
+ refund_id),
+ GNUNET_JSON_pack_string ("method",
+ method),
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_string ("payment_id",
+ payment_id)),
+ TALER_JSON_pack_amount ("amount",
+ amount),
+ GNUNET_JSON_pack_string ("reason",
+ reason),
+ GNUNET_JSON_pack_timestamp ("timestamp",
+ refund_timestamp))));
+}
+
+
+/**
* Check refund status for the order.
*
* @param[in,out] gorc order context to update
@@ -1541,6 +1593,24 @@ phase_check_refunds (struct GetOrderRequestContext *gorc)
GNUNET_log (GNUNET_ERROR_TYPE_INFO,
"Total refunds are %s\n",
TALER_amount2s (&gorc->refund_amount));
+ json_array_clear (gorc->refunds_external);
+ qs = TALER_MERCHANTDB_iterate_external_refunds (
+ TMH_db,
+ hc->instance->settings.id,
+ hc->infix,
+ &process_external_refunds_cb,
+ gorc);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ phase_end (gorc,
+ TALER_MHD_reply_with_error (
+ gorc->sc.con,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "external refunds"));
+ return;
+ }
gorc->phase++;
}
@@ -1806,6 +1876,8 @@ phase_reply_result (struct GetOrderRequestContext *gorc)
gorc->wire_details),
GNUNET_JSON_pack_array_incref ("refund_details",
gorc->refund_details),
+ GNUNET_JSON_pack_array_incref ("refunds_external",
+ gorc->refunds_external),
GNUNET_JSON_pack_string ("order_status_url",
order_status_url),
(gorc->choice_index >= 0)
@@ -1858,6 +1930,8 @@ TMH_private_get_orders_ID (
GNUNET_assert (NULL != gorc->wire_details);
gorc->refund_details = json_array ();
GNUNET_assert (NULL != gorc->refund_details);
+ gorc->refunds_external = json_array ();
+ GNUNET_assert (NULL != gorc->refunds_external);
gorc->session_id = MHD_lookup_connection_value (connection,
MHD_GET_ARGUMENT_KIND,
"session_id");
diff --git a/src/backend/taler-merchant-httpd_helper.c b/src/backend/taler-merchant-httpd_helper.c
@@ -1260,3 +1260,105 @@ TMH_make_taler_pay_uri (struct MHD_Connection *con,
return GNUNET_buffer_reap_str (&buf);
}
+
+
+enum GNUNET_GenericReturnValue
+TMH_compute_order_total (const json_t *contract_terms,
+ int16_t choice_index,
+ struct TALER_Amount *total)
+{
+ enum TALER_MERCHANT_ContractVersion version
+ = TALER_MERCHANT_CONTRACT_VERSION_0;
+ const json_t *amount_bearer = contract_terms;
+ struct TALER_Amount amount;
+ const json_t *amount_external;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_mark_optional (
+ TALER_MERCHANT_spec_contract_version ("version",
+ &version),
+ NULL),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (contract_terms,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break (0);
+ return GNUNET_SYSERR;
+ }
+ if (TALER_MERCHANT_CONTRACT_VERSION_1 == version)
+ {
+ if (0 > choice_index)
+ {
+ /* Without a selected choice the total is undefined; the caller
+ must only ask for orders where a choice was picked. */
+ GNUNET_break (0);
+ return GNUNET_SYSERR;
+ }
+ amount_bearer = json_array_get (
+ json_object_get (contract_terms,
+ "choices"),
+ (size_t) choice_index);
+ if (NULL == amount_bearer)
+ {
+ GNUNET_break (0);
+ return GNUNET_SYSERR;
+ }
+ }
+ {
+ struct GNUNET_JSON_Specification aspec[] = {
+ TALER_JSON_spec_amount_any ("amount",
+ &amount),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (amount_bearer,
+ aspec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break (0);
+ return GNUNET_SYSERR;
+ }
+ }
+ *total = amount;
+ amount_external = json_object_get (contract_terms,
+ "amount_external");
+ {
+ size_t idx;
+ json_t *entry;
+
+ json_array_foreach ((json_t *) amount_external, idx, entry)
+ {
+ struct TALER_Amount ext_amount;
+ struct GNUNET_JSON_Specification espec[] = {
+ TALER_JSON_spec_amount_any ("amount",
+ &ext_amount),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (entry,
+ espec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break (0);
+ return GNUNET_SYSERR;
+ }
+ if (0 >
+ TALER_amount_add (total,
+ total,
+ &ext_amount))
+ {
+ GNUNET_break (0);
+ return GNUNET_SYSERR;
+ }
+ }
+ }
+ return GNUNET_OK;
+}
diff --git a/src/backend/taler-merchant-httpd_helper.h b/src/backend/taler-merchant-httpd_helper.h
@@ -99,6 +99,26 @@ TMH_validate_unit_price_array (const struct TALER_Amount *prices,
/**
+ * Compute the full value of an order from its contract terms.
+ *
+ * The total consists of the Taler amount of the selected choice plus all
+ * entries in "amount_external".
+ *
+ * @param contract_terms contract terms of the order
+ * @param choice_index selected choice, ignored for v0 contracts; for v1
+ * contracts it must be non-negative, as the total is undefined
+ * while no choice was selected
+ * @param[out] total set to the full order total
+ * @return #GNUNET_OK on success, #GNUNET_SYSERR if the contract is
+ * malformed or no choice was selected for a v1 contract
+ */
+enum GNUNET_GenericReturnValue
+TMH_compute_order_total (const json_t *contract_terms,
+ int16_t choice_index,
+ struct TALER_Amount *total);
+
+
+/**
* Set of category IDs.
*/
struct TMH_CategorySet
@@ -354,13 +374,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/taler-merchant-httpd_post-orders-ORDER_ID-claim.c b/src/backend/taler-merchant-httpd_post-orders-ORDER_ID-claim.c
@@ -44,27 +44,12 @@
#define MAX_RETRIES 3
-/**
- * Run transaction to claim @a order_id for @a nonce.
- *
- * @param hc handler context with information about instance to claim order at
- * @param order_id order to claim
- * @param nonce nonce to use for the claim
- * @param claim_token the token that should be used to verify the claim
- * @param[out] contract_terms set to the resulting contract terms
- * (for any non-negative result;
- * @return transaction status code
- * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the order was claimed by a different
- * nonce (@a contract_terms set to non-NULL)
- * OR if the order is is unknown (@a contract_terms is NULL)
- * #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the order was successfully claimed
- */
-static enum GNUNET_DB_QueryStatus
-claim_order (struct TMH_HandlerContext *hc,
- const char *order_id,
- const struct GNUNET_CRYPTO_EddsaPublicKey *nonce,
- const struct TALER_ClaimTokenP *claim_token,
- json_t **contract_terms)
+enum GNUNET_DB_QueryStatus
+TMH_claim_order (struct TMH_HandlerContext *hc,
+ const char *order_id,
+ const struct GNUNET_CRYPTO_EddsaPublicKey *nonce,
+ const struct TALER_ClaimTokenP *claim_token,
+ json_t **contract_terms)
{
const char *instance_id = hc->instance->settings.id;
struct TALER_ClaimTokenP order_ct;
@@ -265,11 +250,11 @@ TMH_post_orders_ID_claim (const struct TMH_RequestHandler *rh,
for (unsigned int i = 0; i<MAX_RETRIES; i++)
{
TALER_MERCHANTDB_preflight (TMH_db);
- qs = claim_order (hc,
- order_id,
- &nonce,
- &claim_token,
- &contract_terms);
+ qs = TMH_claim_order (hc,
+ order_id,
+ &nonce,
+ &claim_token,
+ &contract_terms);
if (GNUNET_DB_STATUS_SOFT_ERROR != qs)
break;
}
diff --git a/src/backend/taler-merchant-httpd_post-orders-ORDER_ID-claim.h b/src/backend/taler-merchant-httpd_post-orders-ORDER_ID-claim.h
@@ -39,4 +39,31 @@ TMH_post_orders_ID_claim (const struct TMH_RequestHandler *rh,
struct MHD_Connection *connection,
struct TMH_HandlerContext *hc);
+
+/**
+ * Run the transaction that claims @a order_id for @a nonce, that is
+ * the very same claiming the handler above performs. Exposed so that
+ * other handlers can claim an order without going through HTTP.
+ *
+ * @param hc handler context with information about instance to claim order at
+ * @param order_id order to claim
+ * @param nonce nonce to use for the claim
+ * @param claim_token the token that should be used to verify the claim;
+ * a caller that is the merchant itself can read the order's own
+ * claim token from the database and pass that
+ * @param[out] contract_terms set to the resulting contract terms
+ * (for any non-negative result)
+ * @return transaction status code
+ * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the order was claimed by a
+ * different nonce or with a wrong claim token (@a contract_terms set
+ * to non-NULL) OR if the order is unknown (@a contract_terms is NULL)
+ * #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the order was claimed
+ */
+enum GNUNET_DB_QueryStatus
+TMH_claim_order (struct TMH_HandlerContext *hc,
+ const char *order_id,
+ const struct GNUNET_CRYPTO_EddsaPublicKey *nonce,
+ const struct TALER_ClaimTokenP *claim_token,
+ json_t **contract_terms);
+
#endif
diff --git a/src/backend/taler-merchant-httpd_post-orders-ORDER_ID-pay.c b/src/backend/taler-merchant-httpd_post-orders-ORDER_ID-pay.c
@@ -59,6 +59,7 @@ struct ExchangeGroup;
#include "merchant-database/insert_order_token_blinded_sig.h"
#include "merchant-database/insert_used_token.h"
#include "merchant-database/get_contract_terms_pos.h"
+#include "merchant-database/get_contract_terms_status.h"
#include "merchant-database/iterate_deposits.h"
#include "merchant-database/iterate_deposits_by_order.h"
#include "merchant-database/get_donau_instance_by_url.h"
@@ -4420,6 +4421,62 @@ phase_contract_paid (struct PayContext *pc)
json_t *refunds;
bool unmatched = false;
+ /* Just check if the choice provided with this payment round,
+ matches the previous one. Pretty much to tell the wallet, hey
+ you paid for another choice. */
+ if (TALER_MERCHANT_CONTRACT_VERSION_1 ==
+ pc->check_contract.contract_terms->pc->base->version)
+ {
+ enum GNUNET_DB_QueryStatus qs;
+ uint64_t order_serial;
+ bool paid;
+ bool wired;
+ bool session_matches;
+ int16_t paid_choice_index;
+
+ qs = TALER_MERCHANTDB_get_contract_terms_status (
+ TMH_db,
+ pc->hc->instance->settings.id,
+ pc->order_id,
+ NULL,
+ NULL,
+ &order_serial,
+ &paid,
+ &wired,
+ &session_matches,
+ NULL,
+ &paid_choice_index);
+ if (0 > qs)
+ {
+ GNUNET_break (0);
+ pay_end (pc,
+ TALER_MHD_reply_with_error (
+ pc->connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_contract_terms_status"));
+ return;
+ }
+ if ( (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs) &&
+ (paid_choice_index != pc->parse_wallet_data.choice_index) )
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Order `%s' was paid with choice %d, not %d\n",
+ pc->order_id,
+ (int) paid_choice_index,
+ (int) pc->parse_wallet_data.choice_index);
+ pay_end (pc,
+ TALER_MHD_REPLY_JSON_PACK (
+ pc->connection,
+ MHD_HTTP_CONFLICT,
+ TALER_JSON_pack_ec (
+ TALER_EC_MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_MISMATCH),
+ GNUNET_JSON_pack_int64 ("choice_index",
+ paid_choice_index)));
+ return;
+ }
+ }
+
{
enum GNUNET_DB_QueryStatus qs;
diff --git a/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-collect.c b/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-collect.c
@@ -0,0 +1,432 @@
+/*
+ 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 Affero 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_post-private-orders-ORDER_ID-collect.c
+ * @brief Handle request to collect a zero-Taler order without a wallet
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+#include <jansson.h>
+#include <taler/taler_json_lib.h>
+#include <taler/taler_merchant_util.h>
+#include "taler-merchant-httpd_post-private-orders-ORDER_ID-collect.h"
+#include "taler-merchant-httpd_post-orders-ORDER_ID-claim.h"
+#include "taler-merchant-httpd_post-orders-ORDER_ID-pay.h"
+#include "merchant-database/get_contract_terms.h"
+#include "merchant-database/get_order.h"
+#include "merchant-database/preflight.h"
+
+
+/**
+ * Outcome of checking whether an order may be collected.
+ */
+enum CollectCheck
+{
+
+ /**
+ * The contract terms could not be parsed, or use a contract version
+ * we do not know. This is about the contract we stored ourselves,
+ * so it indicates a problem on our side, not a bad request.
+ */
+ COLLECT_CHECK_INVALID = 0,
+
+ /**
+ * The order can be collected: nothing remains to be paid with
+ * coins, so the payment logic will find an empty balance to settle.
+ */
+ COLLECT_CHECK_OK,
+
+ /**
+ * The order is not free on the Taler side, so completing it needs a
+ * wallet and the merchant must not do it on the customer's behalf.
+ * "Free" here means all of: the amount to be paid over Taler is
+ * zero (the rest being covered by @e amount_external), the choice
+ * consumes no token @e inputs, and it yields no token @e outputs.
+ */
+ COLLECT_CHECK_NOT_FREE,
+
+ /**
+ * The contract is v1 and thus offers several choices, but the
+ * client did not say which one to collect. We do not pick one on
+ * the client's behalf, just as the backend does not pick one on the
+ * wallet's behalf when paying normally.
+ */
+ COLLECT_CHECK_CHOICE_MISSING,
+
+ /**
+ * A choice was given that the contract does not offer: either an
+ * index beyond the end of the v1 @e choices array, or any index at
+ * all for a v0 contract, which has no choices to select from.
+ */
+ COLLECT_CHECK_CHOICE_OUT_OF_BOUNDS
+
+};
+
+
+/**
+ * How often do we retry the database transaction?
+ */
+#define MAX_RETRIES 3
+
+
+/**
+ * Derive the deterministic nonce this backend uses to claim
+ * @a order_id itself. Using a deterministic nonce allows us to
+ * distinguish orders we claimed via collect from orders claimed
+ * by a customer wallet, and makes the collect operation
+ * idempotent.
+ *
+ * @param hc handler context with the instance public key
+ * @param order_id order the nonce is for
+ * @param[out] nonce set to the derived nonce
+ */
+static void
+derive_collect_nonce (const struct TMH_HandlerContext *hc,
+ const char *order_id,
+ struct GNUNET_CRYPTO_EddsaPublicKey *nonce)
+{
+ GNUNET_assert (GNUNET_YES ==
+ GNUNET_CRYPTO_hkdf_gnunet (
+ nonce,
+ sizeof (*nonce),
+ order_id,
+ strlen (order_id),
+ &hc->instance->merchant_pub,
+ sizeof (hc->instance->merchant_pub)));
+}
+
+
+/**
+ * Check that @a contract_terms describe an order the backend may
+ * complete on its own: a genuinely free Taler payment.
+ *
+ * The payment logic we hand the order to assumes it is paid with
+ * coins; handing it an order that actually costs something would make
+ * it fail in a way that says nothing useful, so we filter those out
+ * before claiming anything.
+ *
+ * @param contract_terms contract terms to check
+ * @param choice_index choice selected by the client, -1 if none was given
+ * @return #COLLECT_CHECK_OK if the order can be collected
+ */
+static enum CollectCheck
+check_collectable (const json_t *contract_terms,
+ int16_t choice_index)
+{
+ enum TALER_MERCHANT_ContractVersion version
+ = TALER_MERCHANT_CONTRACT_VERSION_0;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_mark_optional (
+ TALER_MERCHANT_spec_contract_version ("version",
+ &version),
+ NULL),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (contract_terms,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break (0);
+ return COLLECT_CHECK_INVALID;
+ }
+ switch (version)
+ {
+ case TALER_MERCHANT_CONTRACT_VERSION_0:
+ {
+ struct TALER_Amount amount;
+ struct GNUNET_JSON_Specification aspec[] = {
+ TALER_JSON_spec_amount_any ("amount",
+ &amount),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (contract_terms,
+ aspec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break (0);
+ return COLLECT_CHECK_INVALID;
+ }
+ if (0 <= choice_index)
+ {
+ /* v0 contracts have no choices to select from */
+ GNUNET_break_op (0);
+ return COLLECT_CHECK_CHOICE_OUT_OF_BOUNDS;
+ }
+ if (! TALER_amount_is_zero (&amount))
+ return COLLECT_CHECK_NOT_FREE;
+ return COLLECT_CHECK_OK;
+ }
+ case TALER_MERCHANT_CONTRACT_VERSION_1:
+ {
+ const json_t *choice;
+ struct TALER_Amount amount;
+
+ if (0 > choice_index)
+ {
+ /* Which choice to complete is the client's decision, just as
+ it is the wallet's decision when paying normally. */
+ GNUNET_break_op (0);
+ return COLLECT_CHECK_CHOICE_MISSING;
+ }
+ choice = json_array_get (json_object_get (contract_terms,
+ "choices"),
+ (size_t) choice_index);
+ if (NULL == choice)
+ {
+ GNUNET_break_op (0);
+ return COLLECT_CHECK_CHOICE_OUT_OF_BOUNDS;
+ }
+ {
+ struct GNUNET_JSON_Specification cspec[] = {
+ TALER_JSON_spec_amount_any ("amount",
+ &amount),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (choice,
+ cspec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break (0);
+ return COLLECT_CHECK_INVALID;
+ }
+ }
+ if (! TALER_amount_is_zero (&amount))
+ return COLLECT_CHECK_NOT_FREE;
+ if (0 != json_array_size (json_object_get (choice,
+ "inputs")))
+ return COLLECT_CHECK_NOT_FREE;
+ if (0 != json_array_size (json_object_get (choice,
+ "outputs")))
+ return COLLECT_CHECK_NOT_FREE;
+ return COLLECT_CHECK_OK;
+ }
+ }
+ GNUNET_break (0);
+ return COLLECT_CHECK_INVALID;
+}
+
+
+enum MHD_Result
+TMH_private_post_orders_ID_collect (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc)
+{
+ const char *order_id = hc->infix;
+ const char *session_id = NULL;
+ int16_t choice_index = -1;
+ struct GNUNET_CRYPTO_EddsaPublicKey nonce;
+ struct TALER_ClaimTokenP order_ct = { 0 };
+ json_t *contract_terms = NULL;
+ enum GNUNET_DB_QueryStatus qs;
+
+ if (NULL != hc->ctx)
+ {
+ /* We already handed this request over to the payment logic and
+ were resumed; let it continue where it left off. */
+ return TMH_post_orders_ID_pay (rh,
+ connection,
+ hc);
+ }
+ if (NULL != hc->request_body)
+ {
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_string ("session_id",
+ &session_id),
+ NULL),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_int16 ("choice_index",
+ &choice_index),
+ NULL),
+ GNUNET_JSON_spec_end ()
+ };
+ enum GNUNET_GenericReturnValue res;
+
+ res = TALER_MHD_parse_json_data (connection,
+ hc->request_body,
+ spec);
+ if (GNUNET_OK != res)
+ {
+ GNUNET_break_op (0);
+ return (GNUNET_NO == res)
+ ? MHD_YES
+ : MHD_NO;
+ }
+ }
+
+ /* Pre-filter: only genuinely free orders may be completed without a
+ wallet, and the client has to say which choice to complete. */
+ {
+ json_t *order_terms = NULL;
+ uint64_t order_serial;
+
+ TALER_MERCHANTDB_preflight (TMH_db);
+ qs = TALER_MERCHANTDB_get_contract_terms (TMH_db,
+ hc->instance->settings.id,
+ order_id,
+ &order_terms,
+ &order_serial,
+ NULL);
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ {
+ struct TALER_MerchantPostDataHashP unused;
+
+ /* Remember the order's own claim token: as the merchant we may
+ present it ourselves when claiming the order below. */
+ qs = TALER_MERCHANTDB_get_order (TMH_db,
+ hc->instance->settings.id,
+ order_id,
+ &order_ct,
+ &unused,
+ &order_terms);
+ }
+ if (0 > qs)
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get order for collection");
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN,
+ order_id);
+ {
+ enum CollectCheck cc;
+
+ cc = check_collectable (order_terms,
+ choice_index);
+ json_decref (order_terms);
+ switch (cc)
+ {
+ case COLLECT_CHECK_OK:
+ break;
+ case COLLECT_CHECK_NOT_FREE:
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_COLLECT_NOT_FREE,
+ order_id);
+ case COLLECT_CHECK_CHOICE_MISSING:
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_MISSING,
+ order_id);
+ case COLLECT_CHECK_CHOICE_OUT_OF_BOUNDS:
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_OUT_OF_BOUNDS,
+ order_id);
+ case COLLECT_CHECK_INVALID:
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID,
+ order_id);
+ }
+ }
+ }
+
+ /* Claim the order for ourselves */
+ derive_collect_nonce (hc,
+ order_id,
+ &nonce);
+ for (unsigned int i=0; i<MAX_RETRIES; i++)
+ {
+ TALER_MERCHANTDB_preflight (TMH_db);
+ qs = TMH_claim_order (hc,
+ order_id,
+ &nonce,
+ &order_ct,
+ &contract_terms);
+ if (GNUNET_DB_STATUS_SOFT_ERROR != qs)
+ break;
+ }
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_COMMIT_FAILED,
+ NULL);
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_SOFT_FAILURE,
+ NULL);
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ if (NULL == contract_terms)
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN,
+ order_id);
+ /* Claimed by a customer wallet: the wallet owns the order and has
+ to execute the payment itself. */
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_COLLECT_ALREADY_CLAIMED,
+ order_id);
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ GNUNET_assert (NULL != contract_terms);
+ json_decref (contract_terms);
+ break;
+ }
+
+ /* Turn our request into the payment request a wallet would send for
+ a free order and let the regular payment logic handle it, so that
+ collecting an order behaves exactly like paying it. */
+ {
+ json_t *pay_request;
+
+ pay_request = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_array_steal ("coins",
+ json_array ()),
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_string ("session_id",
+ session_id)));
+ GNUNET_assert (NULL != pay_request);
+ if (0 <= choice_index)
+ GNUNET_assert (0 ==
+ json_object_set_new (
+ pay_request,
+ "wallet_data",
+ GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_int64 ("choice_index",
+ choice_index))));
+ if (NULL != hc->request_body)
+ json_decref (hc->request_body);
+ hc->request_body = pay_request;
+ }
+ return TMH_post_orders_ID_pay (rh,
+ connection,
+ hc);
+}
+
+
+/* end of taler-merchant-httpd_post-private-orders-ORDER_ID-collect.c */
diff --git a/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-collect.h b/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-collect.h
@@ -0,0 +1,45 @@
+/*
+ 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 Affero 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_post-private-orders-ORDER_ID-collect.h
+ * @brief Handle request to collect a zero-Taler order without a wallet
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_PRIVATE_POST_ORDERS_ID_COLLECT_H
+#define TALER_MERCHANT_HTTPD_PRIVATE_POST_ORDERS_ID_COLLECT_H
+#include <microhttpd.h>
+#include "taler-merchant-httpd.h"
+
+
+/**
+ * Handle request to collect a zero-Taler order. The backend acts
+ * like a customer wallet collecting a free order: it claims the
+ * order with a backend-generated nonce and then marks the contract
+ * as paid.
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] hc context with further information about the request
+ * @return MHD result code
+ */
+enum MHD_Result
+TMH_private_post_orders_ID_collect (const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc);
+
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c b/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c
@@ -0,0 +1,513 @@
+/*
+ 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 Affero 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_post-private-orders-ORDER_ID-refund-external.c
+ * @brief Handle request to record an external refund for an order
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+#include <jansson.h>
+#include <taler/taler_dbevents.h>
+#include <taler/taler_json_lib.h>
+#include "taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.h"
+#include "taler-merchant-httpd_get-private-orders.h"
+#include "taler-merchant-httpd_helper.h"
+#include "merchant-database/insert_external_refund.h"
+#include "merchant-database/get_contract_terms_status.h"
+#include "merchant-database/get_external_refund.h"
+#include "merchant-database/iterate_refunds.h"
+#include "merchant-database/event_notify.h"
+#include "merchant-database/preflight.h"
+#include "merchant-database/start.h"
+
+
+/**
+ * How often do we retry the database transaction?
+ */
+#define MAX_RETRIES 3
+
+
+/**
+ * Closure for summing up refund amounts.
+ */
+struct RefundSum
+{
+ /**
+ * Total refunded so far. Invalid if no refunds were seen yet.
+ */
+ struct TALER_Amount total;
+
+ /**
+ * Set to true if amounts of different currencies were seen.
+ */
+ bool currency_mismatch;
+};
+
+
+/**
+ * Add @a amount to the given @a sum.
+ *
+ * @param[in,out] sum sum to increment
+ * @param amount amount to add
+ */
+static void
+sum_refund (struct RefundSum *sum,
+ const struct TALER_Amount *amount)
+{
+ if (GNUNET_OK !=
+ TALER_amount_is_valid (&sum->total))
+ {
+ sum->total = *amount;
+ return;
+ }
+ if (0 >
+ TALER_amount_add (&sum->total,
+ &sum->total,
+ amount))
+ sum->currency_mismatch = true;
+}
+
+
+/**
+ * Function called with information about a Taler refund.
+ *
+ * @param cls a `struct RefundSum *`
+ * @param coin_pub public coin from which the refund comes from
+ * @param refund_amount refund amount which is being taken from @a coin_pub
+ */
+static void
+taler_refund_cb (void *cls,
+ const struct TALER_CoinSpendPublicKeyP *coin_pub,
+ const struct TALER_Amount *refund_amount)
+{
+ struct RefundSum *sum = cls;
+
+ (void) coin_pub;
+ sum_refund (sum,
+ refund_amount);
+}
+
+
+enum MHD_Result
+TMH_private_post_orders_ID_refund_external (
+ const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc)
+{
+ const char *method;
+ const char *refund_id;
+ const char *payment_id = NULL;
+ struct TALER_Amount amount;
+ const char *reason;
+ struct TALER_MerchantPostDataHashP h_post_data;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_string ("method",
+ &method),
+ GNUNET_JSON_spec_string ("id",
+ &refund_id),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_string ("payment_id",
+ &payment_id),
+ NULL),
+ TALER_JSON_spec_amount_any ("amount",
+ &amount),
+ GNUNET_JSON_spec_string ("reason",
+ &reason),
+ GNUNET_JSON_spec_end ()
+ };
+
+ (void) rh;
+ {
+ enum GNUNET_GenericReturnValue res;
+
+ res = TALER_MHD_parse_json_data (connection,
+ hc->request_body,
+ spec);
+ if (GNUNET_OK != res)
+ {
+ return (GNUNET_NO == res)
+ ? MHD_YES
+ : MHD_NO;
+ }
+ }
+ if (! TALER_MERCHANT_payment_method_valid (method))
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "method");
+ if ('\0' == refund_id[0])
+ {
+ GNUNET_break_op (0);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_BAD_REQUEST,
+ TALER_EC_GENERIC_PARAMETER_MALFORMED,
+ "id");
+ }
+ /* Compute h_post_data (for idempotency check) */
+ {
+ char *req_body_enc;
+
+ /* Dump normalized JSON to string. */
+ if (NULL == (req_body_enc
+ = json_dumps (hc->request_body,
+ JSON_ENCODE_ANY
+ | JSON_COMPACT
+ | JSON_SORT_KEYS)))
+ {
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_ALLOCATION_FAILURE,
+ "request body normalization for hashing");
+ }
+ GNUNET_CRYPTO_hash (req_body_enc,
+ strlen (req_body_enc),
+ &h_post_data.hash);
+ GNUNET_free (req_body_enc);
+ }
+
+ for (unsigned int i = 0; i<MAX_RETRIES; i++)
+ {
+ json_t *contract_terms = NULL;
+ uint64_t order_serial;
+ int16_t choice_index;
+ bool paid = false;
+ struct TALER_Amount total;
+ struct TALER_Amount remaining;
+ struct RefundSum taler_sum = { 0 };
+ struct TALER_Amount external_total = { 0 };
+ bool external_mismatch = false;
+ enum GNUNET_DB_QueryStatus qs;
+
+ TALER_MERCHANTDB_preflight (TMH_db);
+ if (GNUNET_OK !=
+ TALER_MERCHANTDB_start (TMH_db,
+ "record external refund"))
+ {
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_START_FAILED,
+ NULL);
+ }
+ {
+ bool wired;
+ bool session_matches;
+
+ qs = TALER_MERCHANTDB_get_contract_terms_status (TMH_db,
+ hc->instance->settings.id,
+ hc->infix,
+ NULL,
+ &contract_terms,
+ &order_serial,
+ &paid,
+ &wired,
+ &session_matches,
+ NULL,
+ &choice_index);
+ }
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ TALER_MERCHANTDB_rollback (TMH_db);
+ continue;
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ TALER_MERCHANTDB_rollback (TMH_db);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_contract_terms_status");
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ TALER_MERCHANTDB_rollback (TMH_db);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN,
+ hc->infix);
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ break;
+ }
+ if (! paid)
+ {
+ /* Unpaid orders have no settled payments to reverse; the
+ POS should delete the order and create a new one instead. */
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_ORDER_UNPAID,
+ hc->infix);
+ }
+ /* Check for an existing refund under the same ID before looking at
+ the refund limits: a replay must not be rejected for exceeding
+ the limit by the very entry it is replaying. */
+ {
+ struct TALER_MerchantPostDataHashP orig_post;
+
+ qs = TALER_MERCHANTDB_get_external_refund (TMH_db,
+ hc->instance->settings.id,
+ hc->infix,
+ refund_id,
+ &orig_post);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ continue;
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "get_external_refund");
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ if (0 !=
+ GNUNET_memcmp (&orig_post,
+ &h_post_data))
+ {
+ GNUNET_break_op (0);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_ALREADY_EXISTS,
+ refund_id);
+ }
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "External refund `%s' already recorded, idempotent\n",
+ refund_id);
+ return TALER_MHD_REPLY_JSON_PACK (
+ connection,
+ MHD_HTTP_OK,
+ GNUNET_JSON_pack_string ("refund_id",
+ refund_id));
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ /* No refund under this ID yet, record it below. */
+ break;
+ }
+ }
+ if (GNUNET_OK !=
+ TMH_compute_order_total (contract_terms,
+ choice_index,
+ &total))
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID,
+ "amount");
+ }
+ if (GNUNET_OK !=
+ TALER_amount_cmp_currency (&amount,
+ &total))
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_GENERIC_CURRENCY_MISMATCH,
+ "refund currency does not match order currency");
+ }
+ {
+ struct TALER_PrivateContractHashP h_contract_terms;
+
+ if (GNUNET_OK !=
+ TALER_JSON_contract_hash (contract_terms,
+ &h_contract_terms))
+ {
+ GNUNET_break (0);
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_FAILED_COMPUTE_JSON_HASH,
+ NULL);
+ }
+ qs = TALER_MERCHANTDB_iterate_refunds (TMH_db,
+ hc->instance->settings.id,
+ &h_contract_terms,
+ &taler_refund_cb,
+ &taler_sum);
+ }
+ if (0 > qs)
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
+ continue;
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "lookup refunds");
+ }
+ qs = TALER_MERCHANTDB_get_external_refunds_total (
+ TMH_db,
+ hc->instance->settings.id,
+ hc->infix,
+ &external_total,
+ &external_mismatch);
+ if (0 > qs)
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
+ continue;
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "select external refunds");
+ }
+ json_decref (contract_terms);
+ if (taler_sum.currency_mismatch ||
+ external_mismatch)
+ {
+ GNUNET_break (0);
+ TALER_MERCHANTDB_rollback (TMH_db);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "refund currency in database does not match order currency");
+ }
+ /* The cumulative externally refunded amount must not exceed
+ the full order total minus the amount already refunded
+ through Taler. */
+ remaining = total;
+ if (TALER_amount_is_valid (&taler_sum.total))
+ {
+ if (0 >
+ TALER_amount_subtract (&remaining,
+ &remaining,
+ &taler_sum.total))
+ {
+ GNUNET_break (0);
+ TALER_MERCHANTDB_rollback (TMH_db);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_INCONSISTENT_AMOUNT,
+ "Taler refunds exceed order total");
+ }
+ }
+ if (TALER_amount_is_valid (&external_total))
+ {
+ if (0 >
+ TALER_amount_subtract (&remaining,
+ &remaining,
+ &external_total))
+ {
+ GNUNET_break (0);
+ TALER_MERCHANTDB_rollback (TMH_db);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_INCONSISTENT_AMOUNT,
+ "external refunds exceed remaining order total");
+ }
+ }
+ if (1 ==
+ TALER_amount_cmp (&amount,
+ &remaining))
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Refusing external refund of %s that would exceed remaining refundable amount of %s\n",
+ TALER_amount2s (&amount),
+ TALER_amount2s (&remaining));
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_INCONSISTENT_AMOUNT,
+ "amount above remaining refundable order total");
+ }
+ qs = TALER_MERCHANTDB_insert_external_refund (TMH_db,
+ hc->instance->settings.id,
+ hc->infix,
+ refund_id,
+ &h_post_data,
+ method,
+ payment_id,
+ &amount,
+ reason);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ TALER_MERCHANTDB_rollback (TMH_db);
+ continue;
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ TALER_MERCHANTDB_rollback (TMH_db);
+ continue;
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ TALER_MERCHANTDB_rollback (TMH_db);
+ GNUNET_break (0);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "insert external refund");
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ break;
+ }
+ qs = TALER_MERCHANTDB_commit (TMH_db);
+ if (0 > qs)
+ {
+ if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
+ continue;
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_COMMIT_FAILED,
+ NULL);
+ }
+ {
+ struct TMH_OrderPayEventP pay_eh = {
+ .header.size = htons (sizeof (pay_eh)),
+ .header.type = htons (TALER_DBEVENT_MERCHANT_ORDER_STATUS_CHANGED),
+ .merchant_pub = hc->instance->merchant_pub
+ };
+
+ GNUNET_log (GNUNET_ERROR_TYPE_INFO,
+ "Notifying clients about status change of order %s\n",
+ hc->infix);
+ GNUNET_CRYPTO_hash (hc->infix,
+ strlen (hc->infix),
+ &pay_eh.h_order_id);
+ TALER_MERCHANTDB_event_notify (TMH_db,
+ &pay_eh.header,
+ NULL,
+ 0);
+ }
+ return TALER_MHD_REPLY_JSON_PACK (
+ connection,
+ MHD_HTTP_OK,
+ GNUNET_JSON_pack_string ("refund_id",
+ refund_id));
+ } /* retries loop */
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_SOFT_FAILURE,
+ NULL);
+}
+
+
+/* end of taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c */
diff --git a/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.h b/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.h
@@ -0,0 +1,46 @@
+/*
+ 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 Affero 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_post-private-orders-ORDER_ID-refund-external.h
+ * @brief Handle request to record an external refund for an order
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#ifndef TALER_MERCHANT_HTTPD_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_H
+#define TALER_MERCHANT_HTTPD_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_H
+#include <microhttpd.h>
+#include "taler-merchant-httpd.h"
+
+
+/**
+ * Handle request to record an external refund (a refund settled
+ * outside of Taler) for an order. This is a bookkeeping operation
+ * only; the actual return of funds is performed by the POS or
+ * external payment integration.
+ *
+ * @param rh context of the handler
+ * @param connection the MHD connection to handle
+ * @param[in,out] hc context with further information about the request
+ * @return MHD result code
+ */
+enum MHD_Result
+TMH_private_post_orders_ID_refund_external (
+ const struct TMH_RequestHandler *rh,
+ struct MHD_Connection *connection,
+ struct TMH_HandlerContext *hc);
+
+
+#endif
diff --git a/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund.c b/src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund.c
@@ -30,7 +30,7 @@
#include "taler-merchant-httpd_helper.h"
#include "taler-merchant-httpd_get-exchanges.h"
#include "merchant-database/do_increase_refund.h"
-#include "merchant-database/get_contract_terms.h"
+#include "merchant-database/get_contract_terms_status.h"
#include "merchant-database/get_order_summary.h"
#include "merchant-database/start.h"
#include "merchant-database/preflight.h"
@@ -182,6 +182,8 @@ TMH_private_post_orders_ID_refund (
struct TALER_PrivateContractHashP h_contract;
json_t *contract_terms;
struct GNUNET_TIME_Timestamp timestamp;
+ struct TALER_Amount order_total;
+ int16_t choice_index;
{
enum GNUNET_GenericReturnValue res;
@@ -200,15 +202,23 @@ TMH_private_post_orders_ID_refund (
{
enum GNUNET_DB_QueryStatus qs;
uint64_t order_serial;
+ bool paid;
+ bool wired;
+ bool session_matches;
struct GNUNET_TIME_Timestamp refund_deadline;
struct GNUNET_TIME_Timestamp wire_deadline;
- qs = TALER_MERCHANTDB_get_contract_terms (TMH_db,
- hc->instance->settings.id,
- hc->infix,
- &contract_terms,
- &order_serial,
- NULL);
+ qs = TALER_MERCHANTDB_get_contract_terms_status (TMH_db,
+ hc->instance->settings.id,
+ hc->infix,
+ NULL,
+ &contract_terms,
+ &order_serial,
+ &paid,
+ &wired,
+ &session_matches,
+ NULL,
+ &choice_index);
if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs)
{
if (qs < 0)
@@ -295,6 +305,9 @@ TMH_private_post_orders_ID_refund (
TALER_MERCHANTDB_preflight (TMH_db);
for (unsigned int i = 0; i<MAX_RETRIES; i++)
{
+ struct TALER_Amount external_total = {0};
+ bool external_mismatch = false;
+
if (GNUNET_OK !=
TALER_MERCHANTDB_start (TMH_db,
"increase refund"))
@@ -306,6 +319,89 @@ TMH_private_post_orders_ID_refund (
TALER_EC_GENERIC_DB_START_FAILED,
NULL);
}
+ {
+ enum GNUNET_DB_QueryStatus eqs;
+
+ eqs = TALER_MERCHANTDB_get_external_refunds_total (
+ TMH_db,
+ hc->instance->settings.id,
+ hc->infix,
+ &external_total,
+ &external_mismatch);
+ if (0 > eqs)
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ if (GNUNET_DB_STATUS_SOFT_ERROR == eqs)
+ continue;
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_FETCH_FAILED,
+ "select external refunds");
+ }
+ }
+ if (external_mismatch)
+ {
+ GNUNET_break (0);
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_INVARIANT_FAILURE,
+ "external refund currencies do not match");
+ }
+ if (TALER_amount_is_valid (&external_total))
+ {
+ struct TALER_Amount remaining;
+
+ /* External refunds only exist for paid orders, so at this point a
+ choice was selected and the order total is well-defined. */
+ if (GNUNET_OK !=
+ TMH_compute_order_total (contract_terms,
+ choice_index,
+ &order_total))
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID,
+ "amount");
+ }
+ if (0 >
+ TALER_amount_subtract (&remaining,
+ &order_total,
+ &external_total))
+ {
+ GNUNET_break (0);
+ TALER_MERCHANTDB_rollback (TMH_db);
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_INVARIANT_FAILURE,
+ "external refunds exceed order total");
+ }
+ if (1 ==
+ TALER_amount_cmp (&refund,
+ &remaining))
+ {
+ TALER_MERCHANTDB_rollback (TMH_db);
+ GNUNET_log (
+ GNUNET_ERROR_TYPE_WARNING,
+ "Refusing Taler refund of %s that would exceed remaining refundable amount of %s\n",
+ TALER_amount2s (&refund),
+ TALER_amount2s (&remaining));
+ json_decref (contract_terms);
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_INCONSISTENT_AMOUNT,
+ "Taler and external refunds would exceed the order total");
+ }
+ }
rs = TALER_MERCHANTDB_do_increase_refund (TMH_db,
hc->instance->settings.id,
hc->infix,
@@ -400,7 +496,7 @@ TMH_private_post_orders_ID_refund (
return TALER_MHD_reply_with_error (
connection,
MHD_HTTP_CONFLICT,
- TALER_EC_EXCHANGE_REFUND_INCONSISTENT_AMOUNT,
+ TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_INCONSISTENT_AMOUNT,
"Amount above payment");
case TALER_MERCHANTDB_RS_SOFT_ERROR:
case TALER_MERCHANTDB_RS_HARD_ERROR:
diff --git a/src/backenddb/get_external_refund.c b/src/backenddb/get_external_refund.c
@@ -0,0 +1,64 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU 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/backenddb/get_external_refund.c
+ * @brief Implementation of the get_external_refund function for Postgres
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/get_external_refund.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_external_refund (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *order_id,
+ const char *refund_id,
+ struct TALER_MerchantPostDataHashP *h_post_data)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (order_id),
+ GNUNET_PQ_query_param_string (refund_id),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_auto_from_type ("h_post_data",
+ h_post_data),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ /* no preflight check here, run in transaction by caller! */
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " h_post_data"
+ " FROM merchant_refunds_external"
+ " WHERE refund_id=$2"
+ " AND order_serial="
+ " (SELECT order_serial"
+ " FROM merchant_contract_terms"
+ " WHERE order_id=$1)");
+ return GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs);
+}
diff --git a/src/backenddb/get_external_refunds_total.c b/src/backenddb/get_external_refunds_total.c
@@ -0,0 +1,111 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU 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/backenddb/get_external_refunds_total.c
+ * @brief Implementation of the get_external_refunds_total function for Postgres
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/get_external_refunds_total.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_external_refunds_total (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *order_id,
+ struct TALER_Amount *total,
+ bool *currency_mismatch)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (order_id),
+ GNUNET_PQ_query_param_end
+ };
+ uint64_t value_sum;
+ uint64_t frac_sum;
+ uint64_t currencies;
+ char *currency;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("refund_value_sum",
+ &value_sum),
+ GNUNET_PQ_result_spec_uint64 ("refund_frac_sum",
+ &frac_sum),
+ GNUNET_PQ_result_spec_uint64 ("currencies",
+ ¤cies),
+ GNUNET_PQ_result_spec_string ("currency",
+ ¤cy),
+ GNUNET_PQ_result_spec_end
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ *currency_mismatch = false;
+ /* no preflight check here, run in transaction by caller! */
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " SUM((refund_amount).val)::INT8"
+ " AS refund_value_sum"
+ ",SUM((refund_amount).frac)::INT8"
+ " AS refund_frac_sum"
+ ",COUNT(DISTINCT (refund_amount).curr)::INT8"
+ " AS currencies"
+ ",MIN((refund_amount).curr) AS currency"
+ " FROM merchant_refunds_external"
+ " WHERE order_serial="
+ " (SELECT order_serial"
+ " FROM merchant_contract_terms"
+ " WHERE order_id=$1)"
+ " HAVING COUNT(*) > 0");
+ qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs);
+ if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs)
+ return qs;
+ if (1 != currencies)
+ {
+ /* Refunds in different currencies were recorded; their sum is
+ meaningless, so we only report the mismatch. */
+ *currency_mismatch = true;
+ GNUNET_PQ_cleanup_result (rs);
+ return qs;
+ }
+ if (GNUNET_OK !=
+ TALER_amount_set_zero (currency,
+ total))
+ {
+ GNUNET_break (0);
+ GNUNET_PQ_cleanup_result (rs);
+ return GNUNET_DB_STATUS_HARD_ERROR;
+ }
+ GNUNET_PQ_cleanup_result (rs);
+ total->value = value_sum + frac_sum / TALER_AMOUNT_FRAC_BASE;
+ total->fraction = (uint32_t) (frac_sum % TALER_AMOUNT_FRAC_BASE);
+ if (GNUNET_SYSERR ==
+ TALER_amount_normalize (total))
+ {
+ /* Only reachable if the recorded refunds sum beyond the maximum
+ representable amount, which the per-order cap prevents. */
+ GNUNET_break (0);
+ return GNUNET_DB_STATUS_HARD_ERROR;
+ }
+ return qs;
+}
diff --git a/src/backenddb/insert_external_refund.c b/src/backenddb/insert_external_refund.c
@@ -0,0 +1,84 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU 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/backenddb/insert_external_refund.c
+ * @brief Implementation of the insert_external_refund function for Postgres
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/insert_external_refund.h"
+#include "helper.h"
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_insert_external_refund (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *order_id,
+ const char *refund_id,
+ const struct TALER_MerchantPostDataHashP *h_post_data,
+ const char *method,
+ const char *payment_id,
+ const struct TALER_Amount *amount,
+ const char *reason)
+{
+ struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (order_id),
+ GNUNET_PQ_query_param_string (refund_id),
+ GNUNET_PQ_query_param_auto_from_type (h_post_data),
+ GNUNET_PQ_query_param_timestamp (&now),
+ GNUNET_PQ_query_param_string (method),
+ (NULL == payment_id)
+ ? GNUNET_PQ_query_param_null ()
+ : GNUNET_PQ_query_param_string (payment_id),
+ TALER_PQ_query_param_amount_with_currency (pg->conn,
+ amount),
+ GNUNET_PQ_query_param_string (reason),
+ GNUNET_PQ_query_param_end
+ };
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ /* no preflight check here, run in transaction by caller! */
+ TMH_PQ_prepare_anon (pg,
+ "INSERT INTO merchant_refunds_external"
+ "(order_serial"
+ ",refund_id"
+ ",h_post_data"
+ ",refund_timestamp"
+ ",refund_method"
+ ",payment_id"
+ ",refund_amount"
+ ",reason"
+ ") SELECT"
+ " order_serial"
+ ",$2"
+ ",$3"
+ ",$4"
+ ",$5"
+ ",$6"
+ ",$7"
+ ",$8"
+ " FROM merchant_contract_terms"
+ " WHERE order_id=$1");
+ return GNUNET_PQ_eval_prepared_non_select (pg->conn,
+ "",
+ params);
+}
diff --git a/src/backenddb/iterate_external_refunds.c b/src/backenddb/iterate_external_refunds.c
@@ -0,0 +1,156 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU 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/backenddb/iterate_external_refunds.c
+ * @brief Implementation of the iterate_external_refunds function for Postgres
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+#include <taler/taler_pq_lib.h>
+#include "merchant-database/iterate_external_refunds.h"
+#include "helper.h"
+
+/**
+ * Closure for #iterate_external_refunds_cb().
+ */
+struct IterateExternalRefundsContext
+{
+ /**
+ * Function to call for each external refund.
+ */
+ TALER_MERCHANTDB_ExternalRefundCallback cb;
+
+ /**
+ * Closure for @e cb.
+ */
+ void *cb_cls;
+
+ /**
+ * Transaction result.
+ */
+ enum GNUNET_DB_QueryStatus qs;
+};
+
+
+/**
+ * Function to be called with the results of a SELECT statement
+ * that has returned @a num_results results.
+ *
+ * @param cls of type `struct IterateExternalRefundsContext *`
+ * @param result the postgres result
+ * @param num_results the number of results in @a result
+ */
+static void
+iterate_external_refunds_cb (void *cls,
+ PGresult *result,
+ unsigned int num_results)
+{
+ struct IterateExternalRefundsContext *ierc = cls;
+
+ for (unsigned int i = 0; i<num_results; i++)
+ {
+ char *refund_id;
+ struct GNUNET_TIME_Timestamp timestamp;
+ char *method;
+ char *payment_id = NULL;
+ struct TALER_Amount refund_amount;
+ char *reason;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_string ("refund_id",
+ &refund_id),
+ GNUNET_PQ_result_spec_timestamp ("refund_timestamp",
+ ×tamp),
+ GNUNET_PQ_result_spec_string ("refund_method",
+ &method),
+ GNUNET_PQ_result_spec_allow_null (
+ GNUNET_PQ_result_spec_string ("payment_id",
+ &payment_id),
+ NULL),
+ TALER_PQ_result_spec_amount_with_currency ("refund_amount",
+ &refund_amount),
+ GNUNET_PQ_result_spec_string ("reason",
+ &reason),
+ GNUNET_PQ_result_spec_end
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_PQ_extract_result (result,
+ rs,
+ i))
+ {
+ GNUNET_break (0);
+ ierc->qs = GNUNET_DB_STATUS_HARD_ERROR;
+ return;
+ }
+ ierc->cb (ierc->cb_cls,
+ refund_id,
+ timestamp,
+ method,
+ payment_id,
+ &refund_amount,
+ reason);
+ GNUNET_PQ_cleanup_result (rs);
+ }
+ ierc->qs = num_results;
+}
+
+
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_iterate_external_refunds (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *order_id,
+ TALER_MERCHANTDB_ExternalRefundCallback cb,
+ void *cb_cls)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (order_id),
+ GNUNET_PQ_query_param_end
+ };
+ struct IterateExternalRefundsContext ierc = {
+ .cb = cb,
+ .cb_cls = cb_cls
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ GNUNET_assert (NULL != pg->current_merchant_id);
+ GNUNET_assert (0 == strcmp (instance_id,
+ pg->current_merchant_id));
+ /* no preflight check here, run in transaction by caller! */
+ TMH_PQ_prepare_anon (pg,
+ "SELECT"
+ " refund_id"
+ ",refund_timestamp"
+ ",refund_method"
+ ",payment_id"
+ ",refund_amount"
+ ",reason"
+ " FROM merchant_refunds_external"
+ " WHERE order_serial="
+ " (SELECT order_serial"
+ " FROM merchant_contract_terms"
+ " WHERE order_id=$1)"
+ " ORDER BY refund_external_serial ASC");
+ qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn,
+ "",
+ params,
+ &iterate_external_refunds_cb,
+ &ierc);
+ if (0 >= qs)
+ return qs;
+ return ierc.qs;
+}
diff --git a/src/backenddb/meson.build b/src/backenddb/meson.build
@@ -53,6 +53,7 @@ libtalermerchantdb = library(
'insert_exchange_account.c',
'insert_exchange_keys.c',
'insert_exchange_signing_key.c',
+ 'insert_external_refund.c',
'insert_instance.c',
'insert_issued_token.c',
'insert_login_token.c',
@@ -132,6 +133,9 @@ libtalermerchantdb = library(
'get_refund_proof.c',
'iterate_refunds.c',
'iterate_refunds_detailed.c',
+ 'iterate_external_refunds.c',
+ 'get_external_refunds_total.c',
+ 'get_external_refund.c',
'iterate_used_tokens_by_order.c',
'iterate_statistic_bucket_amounts_by_range.c',
'iterate_statistic_bucket_counters_by_range.c',
diff --git a/src/backenddb/sql-schema/merchant-0046.sql b/src/backenddb/sql-schema/merchant-0046.sql
@@ -0,0 +1,81 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2026 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU 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 merchant-0046.sql
+-- @brief Add table for external refunds (DD96 partial payments)
+-- @author Bohdan Potuzhnyi
+-- @author Volodymyr Potuzhnyi
+
+BEGIN;
+
+SELECT _v.register_patch('merchant-0046', NULL, NULL);
+
+SET search_path TO merchant;
+
+CREATE PROCEDURE merchant.merchant_0046_init(s TEXT)
+ LANGUAGE plpgsql
+ AS $OUTER$
+BEGIN
+ EXECUTE format('SET LOCAL search_path TO %I', s);
+
+ CREATE TABLE merchant_refunds_external (
+ refund_external_serial INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ order_serial INT8 NOT NULL,
+ refund_id TEXT NOT NULL,
+ h_post_data BYTEA NOT NULL CHECK (LENGTH(h_post_data)=64),
+ refund_timestamp INT8 NOT NULL,
+ refund_method TEXT NOT NULL,
+ payment_id TEXT DEFAULT NULL,
+ refund_amount merchant.taler_amount_currency NOT NULL,
+ reason TEXT NOT NULL,
+ UNIQUE (order_serial, refund_id),
+ CONSTRAINT merchant_refunds_external_order_serial_fkey
+ FOREIGN KEY (order_serial)
+ REFERENCES merchant_contract_terms(order_serial) ON DELETE CASCADE
+ );
+ COMMENT ON TABLE merchant_refunds_external IS
+ 'Bookkeeping entries for refunds settled outside of Taler (DD96);'
+ ' the actual return of funds is performed by the POS or external'
+ ' payment integration';
+ COMMENT ON COLUMN merchant_refunds_external.order_serial IS
+ 'order for which the external refund was recorded';
+ COMMENT ON COLUMN merchant_refunds_external.refund_id IS
+ 'identifier of this external refund within the order, chosen by the'
+ ' merchant; unique per order and used to make recording an external'
+ ' refund idempotent';
+ COMMENT ON COLUMN merchant_refunds_external.h_post_data IS
+ 'hash of the original POST request body, used to distinguish an'
+ ' idempotent replay from a conflicting reuse of refund_id';
+ COMMENT ON COLUMN merchant_refunds_external.refund_method IS
+ 'external payment method used to return the funds, never "taler"';
+ COMMENT ON COLUMN merchant_refunds_external.payment_id IS
+ 'optional id of the amount_external entry this refund reverses';
+ COMMENT ON COLUMN merchant_refunds_external.reason IS
+ 'human-readable refund justification';
+
+ SET LOCAL search_path TO merchant;
+END
+$OUTER$;
+
+INSERT INTO merchant.instance_fixups
+ (migration_name
+ ,version)
+ VALUES
+ ('merchant_0046_init'
+ ,46);
+-- Apply new fix-up to existing instances
+CALL merchant.fixup_instance_schema (46::INT8);
+
+COMMIT;
diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build
@@ -140,6 +140,7 @@ generated_sql = [
['merchant-0043.sql'],
['merchant-0044.sql'],
['merchant-0045.sql'],
+ ['merchant-0046.sql'],
]
foreach g : generated_sql
diff --git a/src/include/merchant-database/all.h b/src/include/merchant-database/all.h
@@ -56,6 +56,7 @@
#include "merchant-database/insert_exchange_account.h"
#include "merchant-database/insert_exchange_keys.h"
#include "merchant-database/insert_exchange_signing_key.h"
+#include "merchant-database/insert_external_refund.h"
#include "merchant-database/insert_instance.h"
#include "merchant-database/insert_issued_token.h"
#include "merchant-database/insert_login_token.h"
@@ -118,6 +119,9 @@
#include "merchant-database/get_refund_proof.h"
#include "merchant-database/iterate_refunds.h"
#include "merchant-database/iterate_refunds_detailed.h"
+#include "merchant-database/iterate_external_refunds.h"
+#include "merchant-database/get_external_refunds_total.h"
+#include "merchant-database/get_external_refund.h"
#include "merchant-database/iterate_pending_reports.h"
#include "merchant-database/iterate_used_tokens_by_order.h"
#include "merchant-database/iterate_statistic_bucket_amounts.h"
diff --git a/src/include/merchant-database/get_external_refund.h b/src/include/merchant-database/get_external_refund.h
@@ -0,0 +1,53 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU 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/include/merchant-database/get_external_refund.h
+ * @brief implementation of the get_external_refund function for Postgres
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_GET_EXTERNAL_REFUND_H
+#define MERCHANT_DATABASE_GET_EXTERNAL_REFUND_H
+
+#include <taler/taler_util.h>
+#include <taler/taler_json_lib.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Retrieve the hash of the request body that recorded the external
+ * refund @a refund_id of @a order_id. Used to tell an idempotent
+ * replay apart from a conflicting reuse of the same @a refund_id.
+ *
+ * @param pg database context
+ * @param instance_id instance's identifier
+ * @param order_id order the refund was recorded for
+ * @param refund_id identifier of the refund within the order
+ * @param[out] h_post_data set to the hash of the original request body
+ * @return transaction status,
+ * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if there is no such refund
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_external_refund (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *order_id,
+ const char *refund_id,
+ struct TALER_MerchantPostDataHashP *h_post_data);
+
+#endif
diff --git a/src/include/merchant-database/get_external_refunds_total.h b/src/include/merchant-database/get_external_refunds_total.h
@@ -0,0 +1,58 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU 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/include/merchant-database/get_external_refunds_total.h
+ * @brief implementation of the get_external_refunds_total function for Postgres
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_GET_EXTERNAL_REFUNDS_TOTAL_H
+#define MERCHANT_DATABASE_GET_EXTERNAL_REFUNDS_TOTAL_H
+
+#include <taler/taler_util.h>
+#include <taler/taler_json_lib.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+
+/**
+ * Sum up the external refunds recorded for the given @a order_id.
+ *
+ * The summation happens in the database, so that a single row is
+ * returned no matter how many external refunds were recorded.
+ *
+ * @param pg database context
+ * @param instance_id instance's identifier
+ * @param order_id order to sum the external refunds of
+ * @param[out] total set to the total externally refunded amount,
+ * only set if #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT is returned
+ * @param[out] currency_mismatch set to true if the recorded refunds
+ * are not all in the same currency, in which case @a total is
+ * not set
+ * @return transaction status, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if
+ * no external refund was recorded for @a order_id
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_get_external_refunds_total (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *order_id,
+ struct TALER_Amount *total,
+ bool *currency_mismatch);
+
+#endif
diff --git a/src/include/merchant-database/insert_external_refund.h b/src/include/merchant-database/insert_external_refund.h
@@ -0,0 +1,61 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU 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/include/merchant-database/insert_external_refund.h
+ * @brief implementation of the insert_external_refund function for Postgres
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_INSERT_EXTERNAL_REFUND_H
+#define MERCHANT_DATABASE_INSERT_EXTERNAL_REFUND_H
+
+#include <taler/taler_util.h>
+#include <taler/taler_json_lib.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Record an external refund (bookkeeping entry for a refund
+ * settled outside of Taler) for the given @a order_id.
+ *
+ * @param pg database context
+ * @param instance_id instance's identifier
+ * @param order_id order the refund is recorded for
+ * @param refund_id identifier of the refund within the order
+ * @param h_post_data hash of the request body, for idempotency
+ * @param method external payment method used for the refund
+ * @param payment_id id of the original external payment entry
+ * this refund reverses, NULL if none
+ * @param amount amount returned to the customer
+ * @param reason human-readable refund justification
+ * @return transaction status,
+ * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the order is unknown
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_insert_external_refund (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *order_id,
+ const char *refund_id,
+ const struct TALER_MerchantPostDataHashP *h_post_data,
+ const char *method,
+ const char *payment_id,
+ const struct TALER_Amount *amount,
+ const char *reason);
+
+#endif
diff --git a/src/include/merchant-database/iterate_external_refunds.h b/src/include/merchant-database/iterate_external_refunds.h
@@ -0,0 +1,73 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU 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/include/merchant-database/iterate_external_refunds.h
+ * @brief implementation of the iterate_external_refunds function for Postgres
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#ifndef MERCHANT_DATABASE_ITERATE_EXTERNAL_REFUNDS_H
+#define MERCHANT_DATABASE_ITERATE_EXTERNAL_REFUNDS_H
+
+#include <taler/taler_util.h>
+#include <taler/taler_json_lib.h>
+#include "merchantdb_lib.h"
+
+
+struct TALER_MERCHANTDB_PostgresContext;
+
+/**
+ * Function called with detailed information about an external refund.
+ *
+ * @param cls closure
+ * @param refund_id identifier of the refund within the order
+ * @param refund_timestamp when was the refund recorded
+ * @param method external payment method used for the refund
+ * @param payment_id id of the original external payment entry this
+ * refund reverses, NULL if none
+ * @param amount amount returned to the customer
+ * @param reason human-readable refund justification
+ */
+typedef void
+(*TALER_MERCHANTDB_ExternalRefundCallback)(
+ void *cls,
+ const char *refund_id,
+ struct GNUNET_TIME_Timestamp refund_timestamp,
+ const char *method,
+ const char *payment_id,
+ const struct TALER_Amount *amount,
+ const char *reason);
+
+
+/**
+ * Retrieve the external refunds recorded for the given @a order_id.
+ *
+ * @param pg database context
+ * @param instance_id instance's identifier
+ * @param order_id order to look up external refunds for
+ * @param cb function to call with each external refund
+ * @param cb_cls closure for @a cb
+ * @return transaction status
+ */
+enum GNUNET_DB_QueryStatus
+TALER_MERCHANTDB_iterate_external_refunds (
+ struct TALER_MERCHANTDB_PostgresContext *pg,
+ const char *instance_id,
+ const char *order_id,
+ TALER_MERCHANTDB_ExternalRefundCallback cb,
+ void *cb_cls);
+
+#endif
diff --git a/src/include/taler/merchant/get-private-orders-ORDER_ID.h b/src/include/taler/merchant/get-private-orders-ORDER_ID.h
@@ -507,6 +507,14 @@ struct TALER_MERCHANT_GetPrivateOrderResponse
*/
int choice_index;
+ /**
+ * Refunds that were settled outside of Taler (JSON array of
+ * `ExternalRefundInfo`). Optional, NULL if the backend did
+ * not report the field.
+ * @since protocol **v42**.
+ */
+ const json_t *refunds_external;
+
} paid;
} details;
diff --git a/src/include/taler/merchant/meson.build b/src/include/taler/merchant/meson.build
@@ -60,6 +60,8 @@ talermerchantinclude_HEADERS = [
'post-private-donau.h',
'post-private-orders.h',
'post-private-orders-ORDER_ID-refund.h',
+ 'post-private-orders-ORDER_ID-refund-external.h',
+ 'post-private-orders-ORDER_ID-collect.h',
'post-private-otp-devices.h',
'post-private-products.h',
'post-private-products-PRODUCT_ID-lock.h',
diff --git a/src/include/taler/merchant/post-private-orders-ORDER_ID-collect.h b/src/include/taler/merchant/post-private-orders-ORDER_ID-collect.h
@@ -0,0 +1,148 @@
+/*
+ This file is part of TALER
+ Copyright (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 2.1, 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 Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/include/taler/merchant/post-private-orders-ORDER_ID-collect.h
+ * @brief C interface for the POST /private/orders/$ORDER_ID/collect endpoint
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#ifndef _TALER_MERCHANT__POST_PRIVATE_ORDERS_ORDER_ID_COLLECT_H
+#define _TALER_MERCHANT__POST_PRIVATE_ORDERS_ORDER_ID_COLLECT_H
+
+#include <taler/merchant/common.h>
+
+
+/**
+ * Handle for a POST /private/orders/$ORDER_ID/collect request.
+ */
+struct TALER_MERCHANT_PostPrivateOrdersCollectHandle;
+
+
+/**
+ * Response details for a POST /private/orders/$ORDER_ID/collect request.
+ */
+struct TALER_MERCHANT_PostPrivateOrdersCollectResponse
+{
+
+ /**
+ * HTTP response details.
+ */
+ struct TALER_MERCHANT_HttpResponse hr;
+
+ /**
+ * Details depending on @e hr.http_status.
+ */
+ union
+ {
+
+ /**
+ * Details for #MHD_HTTP_OK.
+ */
+ struct
+ {
+
+ /**
+ * Signature of the merchant confirming the payment, over
+ * a `TALER_PaymentResponsePS`.
+ */
+ struct TALER_MerchantSignatureP merchant_sig;
+
+ /**
+ * Text to be shown to the point-of-sale staff as proof of
+ * payment. NULL if the instance has no OTP device configured.
+ */
+ const char *pos_confirmation;
+
+ } ok;
+
+ } details;
+
+};
+
+
+/**
+ * Set up POST /private/orders/$ORDER_ID/collect operation to
+ * complete a zero-Taler order without a customer wallet.
+ * Note that you must explicitly start the operation after
+ * possibly setting options.
+ *
+ * @param ctx the context
+ * @param url base URL of the merchant backend
+ * @param order_id identifier of the order to collect
+ * @param session_id session ID to store with the payment, NULL for none
+ * @param choice_index choice to complete for v1 contracts; must be
+ * negative for v0 contracts, which have no choices
+ * @return handle to operation
+ */
+struct TALER_MERCHANT_PostPrivateOrdersCollectHandle *
+TALER_MERCHANT_post_private_orders_collect_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *order_id,
+ const char *session_id,
+ int16_t choice_index);
+
+
+#ifndef TALER_MERCHANT_POST_PRIVATE_ORDERS_COLLECT_RESULT_CLOSURE
+/**
+ * Type of the closure used by
+ * the #TALER_MERCHANT_PostPrivateOrdersCollectCallback.
+ */
+#define TALER_MERCHANT_POST_PRIVATE_ORDERS_COLLECT_RESULT_CLOSURE void
+#endif /* TALER_MERCHANT_POST_PRIVATE_ORDERS_COLLECT_RESULT_CLOSURE */
+
+/**
+ * Callback for a POST /private/orders/$ORDER_ID/collect request.
+ *
+ * @param cls closure
+ * @param cr response details
+ */
+typedef void
+(*TALER_MERCHANT_PostPrivateOrdersCollectCallback)(
+ TALER_MERCHANT_POST_PRIVATE_ORDERS_COLLECT_RESULT_CLOSURE *cls,
+ const struct TALER_MERCHANT_PostPrivateOrdersCollectResponse *cr);
+
+
+/**
+ * Start POST /private/orders/$ORDER_ID/collect operation.
+ *
+ * @param[in,out] poch operation to start
+ * @param cb function to call with the merchant's result
+ * @param cb_cls closure for @a cb
+ * @return status code, #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_post_private_orders_collect_start (
+ struct TALER_MERCHANT_PostPrivateOrdersCollectHandle *poch,
+ TALER_MERCHANT_PostPrivateOrdersCollectCallback cb,
+ TALER_MERCHANT_POST_PRIVATE_ORDERS_COLLECT_RESULT_CLOSURE *cb_cls);
+
+
+/**
+ * Cancel POST /private/orders/$ORDER_ID/collect operation. This function
+ * must not be called by clients after the
+ * TALER_MERCHANT_PostPrivateOrdersCollectCallback has been invoked (as in
+ * those cases it'll be called internally by the implementation already).
+ *
+ * @param[in] poch operation to cancel
+ */
+void
+TALER_MERCHANT_post_private_orders_collect_cancel (
+ struct TALER_MERCHANT_PostPrivateOrdersCollectHandle *poch);
+
+
+#endif /* _TALER_MERCHANT__POST_PRIVATE_ORDERS_ORDER_ID_COLLECT_H */
diff --git a/src/include/taler/merchant/post-private-orders-ORDER_ID-refund-external.h b/src/include/taler/merchant/post-private-orders-ORDER_ID-refund-external.h
@@ -0,0 +1,146 @@
+/*
+ This file is part of TALER
+ Copyright (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 2.1, 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 Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/include/taler/merchant/post-private-orders-ORDER_ID-refund-external.h
+ * @brief C interface for the POST /private/orders/$ORDER_ID/refund-external endpoint
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#ifndef _TALER_MERCHANT__POST_PRIVATE_ORDERS_ORDER_ID_REFUND_EXTERNAL_H
+#define _TALER_MERCHANT__POST_PRIVATE_ORDERS_ORDER_ID_REFUND_EXTERNAL_H
+
+#include <taler/merchant/common.h>
+
+
+/**
+ * Handle for a POST /private/orders/$ORDER_ID/refund-external request.
+ */
+struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle;
+
+
+/**
+ * Response details for a POST /private/orders/$ORDER_ID/refund-external
+ * request.
+ */
+struct TALER_MERCHANT_PostPrivateOrdersRefundExternalResponse
+{
+
+ /**
+ * HTTP response details.
+ */
+ struct TALER_MERCHANT_HttpResponse hr;
+
+ /**
+ * Details depending on the HTTP status code.
+ */
+ union
+ {
+
+ /**
+ * Details on #MHD_HTTP_OK.
+ */
+ struct
+ {
+
+ /**
+ * Identifier of the recorded external refund, as given in the
+ * request.
+ */
+ const char *refund_id;
+
+ } ok;
+
+ } details;
+
+};
+
+
+/**
+ * Set up POST /private/orders/$ORDER_ID/refund-external operation to
+ * record a refund settled outside of Taler. This is a bookkeeping
+ * operation only; the actual return of funds is performed by the POS
+ * or external payment integration.
+ * Note that you must explicitly start the operation after
+ * possibly setting options.
+ *
+ * @param ctx the context
+ * @param url base URL of the merchant backend
+ * @param order_id identifier of the order to record the refund for
+ * @param body external refund entry; must include "method", "id",
+ * "amount" and "reason", and may include "payment_id". Other
+ * fields are not stored, but they do change the request body
+ * and thus what counts as a repetition of this request
+ * @return handle to operation
+ */
+struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle *
+TALER_MERCHANT_post_private_orders_refund_external_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *order_id,
+ const json_t *body);
+
+
+#ifndef TALER_MERCHANT_POST_PRIVATE_ORDERS_REFUND_EXTERNAL_RESULT_CLOSURE
+/**
+ * Type of the closure used by
+ * the #TALER_MERCHANT_PostPrivateOrdersRefundExternalCallback.
+ */
+#define TALER_MERCHANT_POST_PRIVATE_ORDERS_REFUND_EXTERNAL_RESULT_CLOSURE void
+#endif /* TALER_MERCHANT_POST_PRIVATE_ORDERS_REFUND_EXTERNAL_RESULT_CLOSURE */
+
+/**
+ * Callback for a POST /private/orders/$ORDER_ID/refund-external request.
+ *
+ * @param cls closure
+ * @param rer response details
+ */
+typedef void
+(*TALER_MERCHANT_PostPrivateOrdersRefundExternalCallback)(
+ TALER_MERCHANT_POST_PRIVATE_ORDERS_REFUND_EXTERNAL_RESULT_CLOSURE *cls,
+ const struct TALER_MERCHANT_PostPrivateOrdersRefundExternalResponse *rer);
+
+
+/**
+ * Start POST /private/orders/$ORDER_ID/refund-external operation.
+ *
+ * @param[in,out] poreh operation to start
+ * @param cb function to call with the merchant's result
+ * @param cb_cls closure for @a cb
+ * @return status code, #TALER_EC_NONE on success
+ */
+enum TALER_ErrorCode
+TALER_MERCHANT_post_private_orders_refund_external_start (
+ struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle *poreh,
+ TALER_MERCHANT_PostPrivateOrdersRefundExternalCallback cb,
+ TALER_MERCHANT_POST_PRIVATE_ORDERS_REFUND_EXTERNAL_RESULT_CLOSURE *cb_cls);
+
+
+/**
+ * Cancel POST /private/orders/$ORDER_ID/refund-external operation. This
+ * function must not be called by clients after the
+ * TALER_MERCHANT_PostPrivateOrdersRefundExternalCallback has been invoked
+ * (as in those cases it'll be called internally by the implementation
+ * already).
+ *
+ * @param[in] poreh operation to cancel
+ */
+void
+TALER_MERCHANT_post_private_orders_refund_external_cancel (
+ struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle *poreh);
+
+
+#endif /* _TALER_MERCHANT__POST_PRIVATE_ORDERS_ORDER_ID_REFUND_EXTERNAL_H */
diff --git a/src/include/taler/taler_merchant_service.h b/src/include/taler/taler_merchant_service.h
@@ -56,6 +56,8 @@
#include <taler/merchant/post-orders-ORDER_ID-paid.h>
#include <taler/merchant/post-orders-ORDER_ID-pay.h>
#include <taler/merchant/post-private-orders-ORDER_ID-refund.h>
+#include <taler/merchant/post-private-orders-ORDER_ID-refund-external.h>
+#include <taler/merchant/post-private-orders-ORDER_ID-collect.h>
#include <taler/merchant/post-orders-ORDER_ID-refund.h>
#include <taler/merchant/patch-private-orders-ORDER_ID-forget.h>
#include <taler/merchant/get-private-products.h>
diff --git a/src/include/taler/taler_merchant_testing_lib.h b/src/include/taler/taler_merchant_testing_lib.h
@@ -877,6 +877,59 @@ TALER_TESTING_cmd_merchant_post_orders_no_claim (
/**
+ * Make the "proposal" command with external payment entries,
+ * AVOIDING claiming the order.
+ *
+ * @param label command label
+ * @param merchant_url base URL of the merchant serving
+ * the proposal request.
+ * @param http_status expected HTTP status.
+ * @param order_id the name of the order to add.
+ * @param refund_deadline the deadline for refunds on this order.
+ * @param pay_deadline the deadline for payment on this order.
+ * @param amount the Taler amount this order is for.
+ * @param amount_external JSON string with the array of external
+ * payment entries for this order.
+ * @return the command
+ */
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_post_orders_external (
+ const char *label,
+ const char *merchant_url,
+ unsigned int http_status,
+ const char *order_id,
+ struct GNUNET_TIME_Timestamp refund_deadline,
+ struct GNUNET_TIME_Timestamp pay_deadline,
+ const char *amount,
+ const char *amount_external);
+
+
+/**
+ * Make the "proposal" command for a v1 order with an explicit
+ * "choices" array, AVOIDING claiming the order.
+ *
+ * @param label command label
+ * @param merchant_url base URL of the merchant serving
+ * the proposal request.
+ * @param http_status expected HTTP status.
+ * @param order_id the name of the order to add.
+ * @param refund_deadline the deadline for refunds on this order.
+ * @param pay_deadline the deadline for payment on this order.
+ * @param choices JSON string with the array of choices for this order.
+ * @return the command
+ */
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_post_orders_v1 (
+ const char *label,
+ const char *merchant_url,
+ unsigned int http_status,
+ const char *order_id,
+ struct GNUNET_TIME_Timestamp refund_deadline,
+ struct GNUNET_TIME_Timestamp pay_deadline,
+ const char *choices);
+
+
+/**
* Make the "proposal" command.
*
* @param label command label
@@ -1552,6 +1605,96 @@ TALER_TESTING_cmd_merchant_delete_order (
unsigned int http_status);
+/**
+ * Define a "DELETE order" CMD with a force option.
+ *
+ * @param label command label.
+ * @param merchant_url base URL of the merchant serving the
+ * DELETE /private/orders/$ID request.
+ * @param order_id the ID of the order to delete
+ * @param force true to force deletion even if externally
+ * settled payments exist
+ * @param http_status expected HTTP response code.
+ * @return the command.
+ */
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_delete_order2 (
+ const char *label,
+ const char *merchant_url,
+ const char *order_id,
+ bool force,
+ unsigned int http_status);
+
+
+/**
+ * Define a CMD that checks how many external payments and external
+ * refunds an order reports through GET /private/orders/$ORDER_ID.
+ *
+ * @param label command label.
+ * @param merchant_url base URL of the merchant serving the request.
+ * @param order_id the ID of the order to check
+ * @param expected_payments entries expected in "amount_external"
+ * @param expected_refunds entries expected in "refunds_external"
+ * @param expected_choice_index choice the order must have been paid
+ * with, -1 if the contract has no choices
+ * @return the command.
+ */
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_check_order_external (
+ const char *label,
+ const char *merchant_url,
+ const char *order_id,
+ unsigned int expected_payments,
+ unsigned int expected_refunds,
+ int expected_choice_index);
+
+
+/**
+ * Define a "collect order" CMD to complete a zero-Taler
+ * order without a customer wallet.
+ *
+ * @param label command label.
+ * @param merchant_url base URL of the merchant serving the
+ * POST /private/orders/$ID/collect request.
+ * @param order_id the ID of the order to collect
+ * @param session_id session ID to store with the payment, NULL for none
+ * @param choice_index choice to complete for v1 contracts, negative
+ * for v0 contracts which have no choices
+ * @param http_status expected HTTP response code.
+ * @return the command.
+ */
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_collect_order (
+ const char *label,
+ const char *merchant_url,
+ const char *order_id,
+ const char *session_id,
+ int16_t choice_index,
+ unsigned int http_status);
+
+
+/**
+ * Define a "refund external" CMD to record a refund settled
+ * outside of Taler.
+ *
+ * @param label command label.
+ * @param merchant_url base URL of the merchant serving the
+ * POST /private/orders/$ID/refund-external request.
+ * @param order_id the ID of the order to record the refund for
+ * @param body external refund entry as JSON string; must include
+ * "method", "amount" and "reason"
+ * @param http_status expected HTTP response code.
+ * @return the command.
+ */
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_refund_external (
+ const char *label,
+ const char *merchant_url,
+ const char *order_id,
+ const char *body,
+ unsigned int http_status);
+
+
/* ******************* /transfers *************** */
diff --git a/src/include/taler/taler_merchant_util.h b/src/include/taler/taler_merchant_util.h
@@ -1471,6 +1471,13 @@ struct TALER_MERCHANT_ContractBaseTerms
*/
struct GNUNET_TIME_Timestamp max_pickup_time;
+ /**
+ * Array of external payment entries settled outside of Taler.
+ * Common to all contract versions; for v1 contracts all choices
+ * must use the currency of these entries. NULL if not given.
+ */
+ json_t *amount_external;
+
};
@@ -1882,6 +1889,76 @@ TALER_MERCHANT_taxes_array_valid (
/**
+ * Check if @a method is a valid name for a payment method handled
+ * outside of Taler: a non-empty identifier made of ASCII
+ * alphanumerics and "-", and never "taler", which is what the plain
+ * "amount" stands for.
+ *
+ * @param method method name to check
+ * @return true if @a method may be used as an external payment method
+ */
+bool
+TALER_MERCHANT_payment_method_valid (
+ const char *method);
+
+
+/**
+ * Check if @a amount_external is a valid array of external payment
+ * entries. Each entry must be an object with a "method" (ASCII
+ * identifier, never "taler"), an "id" (unique within the array) and
+ * an "amount"; all entries must agree on one currency. Any additional
+ * method-specific fields must be flat (no objects or arrays).
+ *
+ * Use #TALER_MERCHANT_amount_external_currency_valid() to check the
+ * entries against the currency of the Taler amount of an order.
+ *
+ * @param amount_external array to check
+ * @return true if @a amount_external is valid
+ */
+bool
+TALER_MERCHANT_amount_external_valid (
+ const json_t *amount_external);
+
+
+/**
+ * Check that the external payment entries in @a amount_external use
+ * the currency of @a amount. The entries have to be valid according
+ * to #TALER_MERCHANT_amount_external_valid(), so that it is enough to
+ * look at one of them.
+ *
+ * @param amount_external array to check
+ * @param amount Taler amount the entries must match in currency
+ * @return true if the currencies match, also if there are no entries
+ */
+bool
+TALER_MERCHANT_amount_external_currency_valid (
+ const json_t *amount_external,
+ const struct TALER_Amount *amount);
+
+
+/**
+ * Check that the order total stays representable: @a amount plus the
+ * amounts of all entries in @a amount_external must not exceed the
+ * largest amount Taler can express. The entries have to be valid
+ * according to #TALER_MERCHANT_amount_external_valid() and to agree
+ * with @a amount on the currency.
+ *
+ * Without this check an order could be stored whose total can never
+ * be computed again, which would only surface much later, when a
+ * refund is attempted.
+ *
+ * @param amount_external array to check
+ * @param amount Taler amount of the order or choice
+ * @return true if the total is representable, also if there are no
+ * entries
+ */
+bool
+TALER_MERCHANT_amount_external_total_valid (
+ const json_t *amount_external,
+ const struct TALER_Amount *amount);
+
+
+/**
* Provide specification to parse an JSON contract output type.
* The value is provided as a descriptive string.
*
diff --git a/src/lib/merchant_api_get-config.c b/src/lib/merchant_api_get-config.c
@@ -34,12 +34,12 @@
* Which version of the Taler protocol is implemented
* by this library? Used to determine compatibility.
*/
-#define MERCHANT_PROTOCOL_CURRENT 41
+#define MERCHANT_PROTOCOL_CURRENT 42
/**
* How many configs are we backwards-compatible with?
*/
-#define MERCHANT_PROTOCOL_AGE 17
+#define MERCHANT_PROTOCOL_AGE 18
/**
* How many exchanges do we allow at most per merchant?
diff --git a/src/lib/merchant_api_get-private-orders-ORDER_ID.c b/src/lib/merchant_api_get-private-orders-ORDER_ID.c
@@ -259,6 +259,11 @@ handle_paid (struct TALER_MERCHANT_GetPrivateOrderHandle *oph,
GNUNET_JSON_spec_array_const ("refund_details",
&refund_details),
GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_array_const (
+ "refunds_external",
+ &osr->details.ok.details.paid.refunds_external),
+ NULL),
+ GNUNET_JSON_spec_mark_optional (
GNUNET_JSON_spec_timestamp ("last_payment",
&osr->details.ok.details.paid.last_payment),
NULL),
diff --git a/src/lib/merchant_api_post-private-orders-ORDER_ID-collect.c b/src/lib/merchant_api_post-private-orders-ORDER_ID-collect.c
@@ -0,0 +1,269 @@
+/*
+ This file is part of TALER
+ Copyright (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 2.1, 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 Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/lib/merchant_api_post-private-orders-ORDER_ID-collect.c
+ * @brief Implementation of the POST /private/orders/$ORDER_ID/collect request
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+#include <curl/curl.h>
+#include <jansson.h>
+#include <microhttpd.h> /* just for HTTP status codes */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_curl_lib.h>
+#include <taler/merchant/post-private-orders-ORDER_ID-collect.h>
+#include "merchant_api_curl_defaults.h"
+#include "merchant_api_common.h"
+#include <taler/taler_json_lib.h>
+#include <taler/taler_curl_lib.h>
+
+
+/**
+ * Handle for a POST /private/orders/$ORDER_ID/collect operation.
+ */
+struct TALER_MERCHANT_PostPrivateOrdersCollectHandle
+{
+ /**
+ * Base URL of the merchant backend.
+ */
+ char *base_url;
+
+ /**
+ * The full URL for this request.
+ */
+ char *url;
+
+ /**
+ * Handle for the request.
+ */
+ struct GNUNET_CURL_Job *job;
+
+ /**
+ * Function to call with the result.
+ */
+ TALER_MERCHANT_PostPrivateOrdersCollectCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ TALER_MERCHANT_POST_PRIVATE_ORDERS_COLLECT_RESULT_CLOSURE *cb_cls;
+
+ /**
+ * Reference to the execution context.
+ */
+ struct GNUNET_CURL_Context *ctx;
+
+ /**
+ * Minor context that holds body and headers.
+ */
+ struct TALER_CURL_PostContext post_ctx;
+
+ /**
+ * Identifier of the order to collect.
+ */
+ char *order_id;
+
+ /**
+ * Session ID to store with the payment, NULL for none.
+ */
+ char *session_id;
+
+ /**
+ * Choice to complete, negative if the contract has no choices.
+ */
+ int16_t choice_index;
+};
+
+
+/**
+ * Function called when we're done processing the
+ * HTTP POST /private/orders/$ORDER_ID/collect request.
+ *
+ * @param cls the `struct TALER_MERCHANT_PostPrivateOrdersCollectHandle`
+ * @param response_code HTTP response code, 0 on error
+ * @param response response body, NULL if not in JSON
+ */
+static void
+handle_collect_finished (void *cls,
+ long response_code,
+ const void *response)
+{
+ struct TALER_MERCHANT_PostPrivateOrdersCollectHandle *poch = cls;
+ const json_t *json = response;
+ struct TALER_MERCHANT_PostPrivateOrdersCollectResponse cr = {
+ .hr.http_status = (unsigned int) response_code,
+ .hr.reply = json
+ };
+
+ poch->job = NULL;
+ switch (response_code)
+ {
+ case 0:
+ cr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ break;
+ case MHD_HTTP_OK:
+ {
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_fixed_auto ("sig",
+ &cr.details.ok.merchant_sig),
+ GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_string ("pos_confirmation",
+ &cr.details.ok.pos_confirmation),
+ NULL),
+ GNUNET_JSON_spec_end ()
+ };
+
+ /* The response is the one of paying the order; "token_sigs" is
+ never present, as orders with token outputs cannot be
+ collected. */
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (json,
+ spec,
+ NULL, NULL))
+ {
+ GNUNET_break_op (0);
+ cr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ cr.hr.http_status = 0;
+ cr.hr.hint = "sig field missing in response";
+ }
+ }
+ break;
+ case MHD_HTTP_UNAUTHORIZED:
+ cr.hr.ec = TALER_JSON_get_error_code (json);
+ cr.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ case MHD_HTTP_NOT_FOUND:
+ cr.hr.ec = TALER_JSON_get_error_code (json);
+ cr.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ case MHD_HTTP_CONFLICT:
+ cr.hr.ec = TALER_JSON_get_error_code (json);
+ cr.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ default:
+ GNUNET_break_op (0);
+ TALER_MERCHANT_parse_error_details_ (json,
+ response_code,
+ &cr.hr);
+ break;
+ }
+ poch->cb (poch->cb_cls,
+ &cr);
+ TALER_MERCHANT_post_private_orders_collect_cancel (poch);
+}
+
+
+struct TALER_MERCHANT_PostPrivateOrdersCollectHandle *
+TALER_MERCHANT_post_private_orders_collect_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *order_id,
+ const char *session_id,
+ int16_t choice_index)
+{
+ struct TALER_MERCHANT_PostPrivateOrdersCollectHandle *poch;
+
+ poch = GNUNET_new (struct TALER_MERCHANT_PostPrivateOrdersCollectHandle);
+ poch->ctx = ctx;
+ poch->base_url = GNUNET_strdup (url);
+ poch->order_id = GNUNET_strdup (order_id);
+ poch->choice_index = choice_index;
+ if (NULL != session_id)
+ poch->session_id = GNUNET_strdup (session_id);
+ return poch;
+}
+
+
+enum TALER_ErrorCode
+TALER_MERCHANT_post_private_orders_collect_start (
+ struct TALER_MERCHANT_PostPrivateOrdersCollectHandle *poch,
+ TALER_MERCHANT_PostPrivateOrdersCollectCallback cb,
+ TALER_MERCHANT_POST_PRIVATE_ORDERS_COLLECT_RESULT_CLOSURE *cb_cls)
+{
+ json_t *req_obj;
+ CURL *eh;
+
+ poch->cb = cb;
+ poch->cb_cls = cb_cls;
+ {
+ char *path;
+
+ GNUNET_asprintf (&path,
+ "private/orders/%s/collect",
+ poch->order_id);
+ poch->url = TALER_url_join (poch->base_url,
+ path,
+ NULL);
+ GNUNET_free (path);
+ }
+ if (NULL == poch->url)
+ return TALER_EC_GENERIC_CONFIGURATION_INVALID;
+ req_obj = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_string ("session_id",
+ poch->session_id)));
+ GNUNET_assert (NULL != req_obj);
+ if (0 <= poch->choice_index)
+ GNUNET_assert (0 ==
+ json_object_set_new (req_obj,
+ "choice_index",
+ json_integer (poch->choice_index)));
+ eh = TALER_MERCHANT_curl_easy_get_ (poch->url);
+ if ( (NULL == eh) ||
+ (GNUNET_OK !=
+ TALER_curl_easy_post (&poch->post_ctx,
+ eh,
+ req_obj)) )
+ {
+ GNUNET_break (0);
+ json_decref (req_obj);
+ if (NULL != eh)
+ curl_easy_cleanup (eh);
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ }
+ json_decref (req_obj);
+ poch->job = GNUNET_CURL_job_add2 (poch->ctx,
+ eh,
+ poch->post_ctx.headers,
+ &handle_collect_finished,
+ poch);
+ if (NULL == poch->job)
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ return TALER_EC_NONE;
+}
+
+
+void
+TALER_MERCHANT_post_private_orders_collect_cancel (
+ struct TALER_MERCHANT_PostPrivateOrdersCollectHandle *poch)
+{
+ if (NULL != poch->job)
+ {
+ GNUNET_CURL_job_cancel (poch->job);
+ poch->job = NULL;
+ }
+ TALER_curl_easy_post_finished (&poch->post_ctx);
+ GNUNET_free (poch->order_id);
+ GNUNET_free (poch->session_id);
+ GNUNET_free (poch->url);
+ GNUNET_free (poch->base_url);
+ GNUNET_free (poch);
+}
+
+
+/* end of merchant_api_post-private-orders-ORDER_ID-collect.c */
diff --git a/src/lib/merchant_api_post-private-orders-ORDER_ID-refund-external.c b/src/lib/merchant_api_post-private-orders-ORDER_ID-refund-external.c
@@ -0,0 +1,247 @@
+/*
+ This file is part of TALER
+ Copyright (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 2.1, 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 Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along with
+ TALER; see the file COPYING.LGPL. If not, see
+ <http://www.gnu.org/licenses/>
+*/
+/**
+ * @file src/lib/merchant_api_post-private-orders-ORDER_ID-refund-external.c
+ * @brief Implementation of the POST /private/orders/$ORDER_ID/refund-external request
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+#include <curl/curl.h>
+#include <jansson.h>
+#include <microhttpd.h> /* just for HTTP status codes */
+#include <gnunet/gnunet_util_lib.h>
+#include <gnunet/gnunet_curl_lib.h>
+#include <taler/merchant/post-private-orders-ORDER_ID-refund-external.h>
+#include "merchant_api_curl_defaults.h"
+#include "merchant_api_common.h"
+#include <taler/taler_json_lib.h>
+#include <taler/taler_curl_lib.h>
+
+
+/**
+ * Handle for a POST /private/orders/$ORDER_ID/refund-external operation.
+ */
+struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle
+{
+ /**
+ * Base URL of the merchant backend.
+ */
+ char *base_url;
+
+ /**
+ * The full URL for this request.
+ */
+ char *url;
+
+ /**
+ * Handle for the request.
+ */
+ struct GNUNET_CURL_Job *job;
+
+ /**
+ * Function to call with the result.
+ */
+ TALER_MERCHANT_PostPrivateOrdersRefundExternalCallback cb;
+
+ /**
+ * Closure for @a cb.
+ */
+ TALER_MERCHANT_POST_PRIVATE_ORDERS_REFUND_EXTERNAL_RESULT_CLOSURE *cb_cls;
+
+ /**
+ * Reference to the execution context.
+ */
+ struct GNUNET_CURL_Context *ctx;
+
+ /**
+ * Minor context that holds body and headers.
+ */
+ struct TALER_CURL_PostContext post_ctx;
+
+ /**
+ * Identifier of the order to record the refund for.
+ */
+ char *order_id;
+
+ /**
+ * External refund entry to record.
+ */
+ json_t *body;
+};
+
+
+/**
+ * Function called when we're done processing the
+ * HTTP POST /private/orders/$ORDER_ID/refund-external request.
+ *
+ * @param cls the `struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle`
+ * @param response_code HTTP response code, 0 on error
+ * @param response response body, NULL if not in JSON
+ */
+static void
+handle_refund_external_finished (void *cls,
+ long response_code,
+ const void *response)
+{
+ struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle *poreh = cls;
+ const json_t *json = response;
+ struct TALER_MERCHANT_PostPrivateOrdersRefundExternalResponse rer = {
+ .hr.http_status = (unsigned int) response_code,
+ .hr.reply = json
+ };
+
+ poreh->job = NULL;
+ switch (response_code)
+ {
+ case 0:
+ rer.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ break;
+ case MHD_HTTP_OK:
+ {
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_string ("refund_id",
+ &rer.details.ok.refund_id),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (json,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ rer.hr.http_status = 0;
+ rer.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE;
+ }
+ break;
+ }
+ case MHD_HTTP_BAD_REQUEST:
+ rer.hr.ec = TALER_JSON_get_error_code (json);
+ rer.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ case MHD_HTTP_UNAUTHORIZED:
+ rer.hr.ec = TALER_JSON_get_error_code (json);
+ rer.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ case MHD_HTTP_NOT_FOUND:
+ rer.hr.ec = TALER_JSON_get_error_code (json);
+ rer.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ case MHD_HTTP_CONFLICT:
+ rer.hr.ec = TALER_JSON_get_error_code (json);
+ rer.hr.hint = TALER_JSON_get_error_hint (json);
+ break;
+ default:
+ GNUNET_break_op (0);
+ TALER_MERCHANT_parse_error_details_ (json,
+ response_code,
+ &rer.hr);
+ break;
+ }
+ poreh->cb (poreh->cb_cls,
+ &rer);
+ TALER_MERCHANT_post_private_orders_refund_external_cancel (poreh);
+}
+
+
+struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle *
+TALER_MERCHANT_post_private_orders_refund_external_create (
+ struct GNUNET_CURL_Context *ctx,
+ const char *url,
+ const char *order_id,
+ const json_t *body)
+{
+ struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle *poreh;
+
+ poreh = GNUNET_new (
+ struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle);
+ poreh->ctx = ctx;
+ poreh->base_url = GNUNET_strdup (url);
+ poreh->order_id = GNUNET_strdup (order_id);
+ poreh->body = json_deep_copy (body);
+ GNUNET_assert (NULL != poreh->body);
+ return poreh;
+}
+
+
+enum TALER_ErrorCode
+TALER_MERCHANT_post_private_orders_refund_external_start (
+ struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle *poreh,
+ TALER_MERCHANT_PostPrivateOrdersRefundExternalCallback cb,
+ TALER_MERCHANT_POST_PRIVATE_ORDERS_REFUND_EXTERNAL_RESULT_CLOSURE *cb_cls)
+{
+ CURL *eh;
+
+ poreh->cb = cb;
+ poreh->cb_cls = cb_cls;
+ {
+ char *path;
+
+ GNUNET_asprintf (&path,
+ "private/orders/%s/refund-external",
+ poreh->order_id);
+ poreh->url = TALER_url_join (poreh->base_url,
+ path,
+ NULL);
+ GNUNET_free (path);
+ }
+ if (NULL == poreh->url)
+ return TALER_EC_GENERIC_CONFIGURATION_INVALID;
+ eh = TALER_MERCHANT_curl_easy_get_ (poreh->url);
+ if ( (NULL == eh) ||
+ (GNUNET_OK !=
+ TALER_curl_easy_post (&poreh->post_ctx,
+ eh,
+ poreh->body)) )
+ {
+ GNUNET_break (0);
+ if (NULL != eh)
+ curl_easy_cleanup (eh);
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ }
+ poreh->job = GNUNET_CURL_job_add2 (poreh->ctx,
+ eh,
+ poreh->post_ctx.headers,
+ &handle_refund_external_finished,
+ poreh);
+ if (NULL == poreh->job)
+ return TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE;
+ return TALER_EC_NONE;
+}
+
+
+void
+TALER_MERCHANT_post_private_orders_refund_external_cancel (
+ struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle *poreh)
+{
+ if (NULL != poreh->job)
+ {
+ GNUNET_CURL_job_cancel (poreh->job);
+ poreh->job = NULL;
+ }
+ TALER_curl_easy_post_finished (&poreh->post_ctx);
+ json_decref (poreh->body);
+ GNUNET_free (poreh->order_id);
+ GNUNET_free (poreh->url);
+ GNUNET_free (poreh->base_url);
+ GNUNET_free (poreh);
+}
+
+
+/* end of merchant_api_post-private-orders-ORDER_ID-refund-external.c */
diff --git a/src/lib/meson.build b/src/lib/meson.build
@@ -59,6 +59,8 @@ libtalermerchant_la_SOURCES = [
'merchant_api_post-private-categories.c',
'merchant_api_post-private-orders.c',
'merchant_api_post-private-orders-ORDER_ID-refund.c',
+ 'merchant_api_post-private-orders-ORDER_ID-refund-external.c',
+ 'merchant_api_post-private-orders-ORDER_ID-collect.c',
'merchant_api_post-private-otp-devices.c',
'merchant_api_post-private-products.c',
'merchant_api_post-private-products-PRODUCT_ID-lock.c',
diff --git a/src/testing/meson.build b/src/testing/meson.build
@@ -38,6 +38,8 @@ libtalermerchanttesting_la_SOURCES = [
'testing_api_cmd_config.c',
'testing_api_cmd_abort_order.c',
'testing_api_cmd_claim_order.c',
+ 'testing_api_cmd_check_order_external.c',
+ 'testing_api_cmd_collect_order.c',
'testing_api_cmd_depositcheck.c',
'testing_api_cmd_get_instance.c',
'testing_api_cmd_get_instances.c',
@@ -92,6 +94,7 @@ libtalermerchanttesting_la_SOURCES = [
'testing_api_cmd_post_tokenfamilies.c',
'testing_api_cmd_post_using_templates.c',
'testing_api_cmd_post_webhooks.c',
+ 'testing_api_cmd_refund_external.c',
'testing_api_cmd_refund_order.c',
'testing_api_cmd_tme.c',
'testing_api_cmd_wallet_get_order.c',
diff --git a/src/testing/test_merchant_api.c b/src/testing/test_merchant_api.c
@@ -503,6 +503,32 @@ run (void *cls,
MHD_HTTP_OK,
"create-proposal-1x",
NULL),
+ /* A claimed order is owned by the wallet that claimed it, so it
+ may only be deleted with an explicit force option. */
+ TALER_TESTING_cmd_merchant_post_orders_no_claim (
+ "create-proposal-claimed-del",
+ merchant_url,
+ MHD_HTTP_OK,
+ "claimed-del",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:5.0"),
+ /* deleting it now, while still unclaimed, would simply work */
+ TALER_TESTING_cmd_merchant_claim_order ("claim-claimed-del",
+ merchant_url,
+ MHD_HTTP_OK,
+ "create-proposal-claimed-del",
+ NULL),
+ TALER_TESTING_cmd_merchant_delete_order2 ("delete-claimed-del-noforce",
+ merchant_url,
+ "claimed-del",
+ false,
+ MHD_HTTP_CONFLICT),
+ TALER_TESTING_cmd_merchant_delete_order2 ("delete-claimed-del-force",
+ merchant_url,
+ "claimed-del",
+ true, /* force flag */
+ MHD_HTTP_NO_CONTENT),
TALER_TESTING_cmd_merchant_post_orders ("create-proposal-1-pre-exists",
cred.cfg,
merchant_url,
@@ -2992,6 +3018,44 @@ run (void *cls,
NULL,
0,
"pay-order-with-output"),
+ /* Paying a free v1 order a second time, but naming a different
+ choice, must not look like that other choice was completed. */
+ TALER_TESTING_cmd_merchant_post_orders_v1 (
+ "create-v1-double-pay",
+ merchant_url,
+ MHD_HTTP_OK,
+ "v1-double-pay",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "[{\"amount\":\"EUR:0\",\"inputs\":[],\"outputs\":[]},"
+ " {\"amount\":\"EUR:0\",\"inputs\":[],\"outputs\":[]}]"),
+ TALER_TESTING_cmd_merchant_claim_order ("claim-v1-double-pay",
+ merchant_url,
+ MHD_HTTP_OK,
+ "create-v1-double-pay",
+ NULL),
+ TALER_TESTING_cmd_merchant_pay_order_choices (
+ "pay-v1-double-choice0",
+ merchant_url,
+ MHD_HTTP_OK,
+ "claim-v1-double-pay",
+ "",
+ "EUR:0",
+ "EUR:0",
+ NULL,
+ 0,
+ NULL),
+ TALER_TESTING_cmd_merchant_pay_order_choices (
+ "pay-v1-double-choice1",
+ merchant_url,
+ MHD_HTTP_CONFLICT,
+ "claim-v1-double-pay",
+ "",
+ "EUR:0",
+ "EUR:0",
+ NULL,
+ 1,
+ NULL),
// TALER_TESTING_cmd_merchant_pay_order_choices ("idempotent-pay-order-with-input-and-output",
// merchant_url,
// MHD_HTTP_OK,
@@ -3237,6 +3301,335 @@ run (void *cls,
TALER_TESTING_cmd_end ()
};
+ struct TALER_TESTING_Command partial_payments[] = {
+ /* "taler" is reserved and must be rejected */
+ TALER_TESTING_cmd_merchant_post_orders_external (
+ "create-dd96-bad-taler",
+ merchant_url,
+ MHD_HTTP_BAD_REQUEST,
+ "dd96-bad-taler",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:2",
+ "[{\"method\":\"taler\",\"id\":\"t1\",\"amount\":\"EUR:1\"}]"),
+ /* mixed currencies must be rejected */
+ TALER_TESTING_cmd_merchant_post_orders_external (
+ "create-dd96-bad-currency",
+ merchant_url,
+ MHD_HTTP_BAD_REQUEST,
+ "dd96-bad-currency",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:2",
+ "[{\"method\":\"cash\",\"id\":\"c1\",\"amount\":\"USD:1\"}]"),
+ /* nested method-specific fields must be rejected */
+ TALER_TESTING_cmd_merchant_post_orders_external (
+ "create-dd96-bad-nested",
+ merchant_url,
+ MHD_HTTP_BAD_REQUEST,
+ "dd96-bad-nested",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:2",
+ "[{\"method\":\"cash\",\"id\":\"c1\",\"amount\":\"EUR:1\","
+ "\"details\":{\"register\":1}}]"),
+ /* valid mixed order with flat method-specific fields */
+ TALER_TESTING_cmd_merchant_post_orders_external (
+ "create-dd96-mixed",
+ merchant_url,
+ MHD_HTTP_OK,
+ "dd96-mixed",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:2",
+ "[{\"method\":\"cash\",\"id\":\"cash1\",\"amount\":\"EUR:3\","
+ "\"cashier_number\":\"7\"}]"),
+ /* normal deletion of orders with external payments is refused */
+ TALER_TESTING_cmd_merchant_delete_order2 ("delete-dd96-mixed-noforce",
+ merchant_url,
+ "dd96-mixed",
+ false,
+ MHD_HTTP_CONFLICT),
+ /* ... but forced deletion works */
+ TALER_TESTING_cmd_merchant_delete_order2 ("delete-dd96-mixed-force",
+ merchant_url,
+ "dd96-mixed",
+ true,
+ MHD_HTTP_NO_CONTENT),
+ /* collect of unknown orders fails */
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-nx",
+ merchant_url,
+ "dd96-nx",
+ NULL,
+ -1,
+ MHD_HTTP_NOT_FOUND),
+ /* collect of orders with a nonzero Taler amount fails */
+ TALER_TESTING_cmd_merchant_post_orders_external (
+ "create-dd96-nonzero",
+ merchant_url,
+ MHD_HTTP_OK,
+ "dd96-nonzero",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:2",
+ "[{\"method\":\"cash\",\"id\":\"cash1\",\"amount\":\"EUR:3\"}]"),
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-nonzero",
+ merchant_url,
+ "dd96-nonzero",
+ NULL,
+ -1,
+ MHD_HTTP_CONFLICT),
+ /* collect of orders claimed by a wallet fails */
+ TALER_TESTING_cmd_merchant_post_orders ("create-dd96-claimed",
+ cred.cfg,
+ merchant_url,
+ MHD_HTTP_OK,
+ "dd96-claimed",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:0"),
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-claimed",
+ merchant_url,
+ "dd96-claimed",
+ NULL,
+ -1,
+ MHD_HTTP_CONFLICT),
+ /* collect a zero-Taler order settled fully via cash */
+ TALER_TESTING_cmd_merchant_post_orders_external (
+ "create-dd96-free",
+ merchant_url,
+ MHD_HTTP_OK,
+ "dd96-free",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:0",
+ "[{\"method\":\"cash\",\"id\":\"cash1\",\"amount\":\"EUR:3\"}]"),
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-free",
+ merchant_url,
+ "dd96-free",
+ "pos-1",
+ -1,
+ MHD_HTTP_OK),
+ /* collect is idempotent */
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-free-again",
+ merchant_url,
+ "dd96-free",
+ "pos-1",
+ -1,
+ MHD_HTTP_OK),
+ /* v0 orders have no choices, so naming one is a client error;
+ use a fresh order as an already collected one replays instead */
+ TALER_TESTING_cmd_merchant_post_orders_external (
+ "create-dd96-v0-choice",
+ merchant_url,
+ MHD_HTTP_OK,
+ "dd96-v0-choice",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:0",
+ "[{\"method\":\"cash\",\"id\":\"cash1\",\"amount\":\"EUR:1\"}]"),
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-v0-with-choice",
+ merchant_url,
+ "dd96-v0-choice",
+ NULL,
+ 0,
+ MHD_HTTP_BAD_REQUEST),
+ /* a free v1 order with two choices to pick from */
+ TALER_TESTING_cmd_merchant_post_orders_v1 (
+ "create-dd96-v1-free",
+ merchant_url,
+ MHD_HTTP_OK,
+ "dd96-v1-free",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "[{\"amount\":\"EUR:0\",\"inputs\":[],\"outputs\":[]},"
+ " {\"amount\":\"EUR:0\",\"inputs\":[],\"outputs\":[]}]"),
+ /* the backend must not pick a choice on its own */
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-v1-no-choice",
+ merchant_url,
+ "dd96-v1-free",
+ NULL,
+ -1,
+ MHD_HTTP_BAD_REQUEST),
+ /* nor accept a choice that does not exist */
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-v1-oob",
+ merchant_url,
+ "dd96-v1-free",
+ NULL,
+ 7,
+ MHD_HTTP_BAD_REQUEST),
+ /* explicitly selecting a choice collects the order */
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-v1-choice1",
+ merchant_url,
+ "dd96-v1-free",
+ "pos-1",
+ 1,
+ MHD_HTTP_OK),
+ /* Re-collecting an order that is already paid, but naming another
+ choice, describes a payment that never happened and is refused. */
+ TALER_TESTING_cmd_merchant_collect_order ("collect-dd96-v1-other-choice",
+ merchant_url,
+ "dd96-v1-free",
+ "pos-1",
+ 0,
+ MHD_HTTP_CONFLICT),
+ /* ... the order still has to be the one that was actually paid */
+ TALER_TESTING_cmd_merchant_check_order_external (
+ "check-dd96-v1-choice",
+ merchant_url,
+ "dd96-v1-free",
+ 0,
+ 0,
+ 1),
+ /* record external refunds against the order total of EUR:3 */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-1",
+ merchant_url,
+ "dd96-free",
+ "{\"method\":\"cash\",\"id\":\"ref1\",\"amount\":\"EUR:0.6\","
+ "\"reason\":\"customer return\"}",
+ MHD_HTTP_OK),
+ /* re-recording the same refund ID with the same body is idempotent
+ and must not consume the refundable amount twice */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-1-again",
+ merchant_url,
+ "dd96-free",
+ "{\"method\":\"cash\",\"id\":\"ref1\",\"amount\":\"EUR:0.6\","
+ "\"reason\":\"customer return\"}",
+ MHD_HTTP_OK),
+ /* the same refund ID with different details is a conflict */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-1-conflict",
+ merchant_url,
+ "dd96-free",
+ "{\"method\":\"cash\",\"id\":\"ref1\",\"amount\":\"EUR:0.5\","
+ "\"reason\":\"customer return\"}",
+ MHD_HTTP_CONFLICT),
+ /* refund through a different method than the payment; without an
+ explicit ID the backend assigns one */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-2",
+ merchant_url,
+ "dd96-free",
+ "{\"method\":\"card\",\"id\":\"r1\",\"payment_id\":\"cash1\",\"amount\":\"EUR:2.4\","
+ "\"reason\":\"customer return\"}",
+ MHD_HTTP_OK),
+ /* exceeding the order total must be rejected */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-over",
+ merchant_url,
+ "dd96-free",
+ "{\"method\":\"cash\",\"id\":\"r2\",\"amount\":\"EUR:0.5\","
+ "\"reason\":\"customer return\"}",
+ MHD_HTTP_CONFLICT),
+ /* wrong currency must be rejected */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-currency",
+ merchant_url,
+ "dd96-free",
+ "{\"method\":\"cash\",\"id\":\"r3\",\"amount\":\"USD:0.5\","
+ "\"reason\":\"customer return\"}",
+ MHD_HTTP_CONFLICT),
+ /* method "taler" must be rejected */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-taler",
+ merchant_url,
+ "dd96-free",
+ "{\"method\":\"taler\",\"id\":\"r4\",\"amount\":\"EUR:0.5\","
+ "\"reason\":\"customer return\"}",
+ MHD_HTTP_BAD_REQUEST),
+ /* The order status must report the external payment it was settled
+ with and the two external refunds that were accepted for it; the
+ replayed and the rejected requests must not have added entries. */
+ TALER_TESTING_cmd_merchant_check_order_external (
+ "check-dd96-free-external",
+ merchant_url,
+ "dd96-free",
+ 1,
+ 2,
+ -1),
+ /* The combined refund cap must also hold when an external refund is
+ followed by an increase of the Taler refund. */
+ TALER_TESTING_cmd_merchant_post_orders (
+ "create-dd96-taler-refund-cap",
+ cred.cfg,
+ merchant_url,
+ MHD_HTTP_OK,
+ "dd96-taler-refund-cap",
+ GNUNET_TIME_UNIT_ZERO_TS,
+ GNUNET_TIME_UNIT_FOREVER_TS,
+ "EUR:5"),
+ TALER_TESTING_cmd_merchant_pay_order (
+ "pay-dd96-taler-refund-cap",
+ merchant_url,
+ MHD_HTTP_OK,
+ "create-dd96-taler-refund-cap",
+ "withdraw-coin-2r",
+ "EUR:5",
+ "EUR:4.99",
+ NULL),
+ TALER_TESTING_cmd_merchant_order_refund (
+ "refund-taler-dd96-cap-1",
+ merchant_url,
+ "customer return",
+ "dd96-taler-refund-cap",
+ "EUR:1",
+ MHD_HTTP_OK),
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-cap-3",
+ merchant_url,
+ "dd96-taler-refund-cap",
+ "{\"method\":\"cash\",\"id\":\"r5\",\"amount\":\"EUR:3\","
+ "\"reason\":\"returned in cash\"}",
+ MHD_HTTP_OK),
+ /* A Taler refund of EUR:3 would make the combined refund EUR:6
+ for an order whose total is only EUR:5. */
+ TALER_TESTING_cmd_merchant_order_refund (
+ "refund-taler-dd96-cap-over",
+ merchant_url,
+ "customer return",
+ "dd96-taler-refund-cap",
+ "EUR:3",
+ MHD_HTTP_CONFLICT),
+ /* external refunds also work on pure-Taler orders, e.g. for
+ bookkeeping cash returns after the Taler refund deadline;
+ order "1r" was paid with EUR:5 and refunded EUR:1 via Taler,
+ leaving EUR:4 that may still be refunded externally */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-taler-order",
+ merchant_url,
+ "1r",
+ "{\"method\":\"cash\",\"id\":\"r6\",\"amount\":\"EUR:3.5\","
+ "\"reason\":\"returned in cash after refund deadline\"}",
+ MHD_HTTP_OK),
+ /* ... but the combined cap still holds: 1 + 3.5 + 1 > 5 */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-taler-order-over",
+ merchant_url,
+ "1r",
+ "{\"method\":\"cash\",\"id\":\"r7\",\"amount\":\"EUR:1\","
+ "\"reason\":\"returned in cash after refund deadline\"}",
+ MHD_HTTP_CONFLICT),
+ /* unpaid orders must be rejected */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-unpaid",
+ merchant_url,
+ "dd96-claimed",
+ "{\"method\":\"cash\",\"id\":\"r8\",\"amount\":\"EUR:0.5\","
+ "\"reason\":\"customer return\"}",
+ MHD_HTTP_CONFLICT),
+ /* unknown orders must be rejected */
+ TALER_TESTING_cmd_merchant_refund_external (
+ "refund-external-dd96-nx",
+ merchant_url,
+ "dd96-nx",
+ "{\"method\":\"cash\",\"id\":\"r9\",\"amount\":\"EUR:0.5\","
+ "\"reason\":\"customer return\"}",
+ MHD_HTTP_NOT_FOUND),
+ TALER_TESTING_cmd_end ()
+ };
+
struct TALER_TESTING_Command commands[] = {
/* general setup */
TALER_TESTING_cmd_run_fakebank (
@@ -3607,6 +4000,8 @@ run (void *cls,
tokens),
TALER_TESTING_cmd_batch ("donau",
donau),
+ TALER_TESTING_cmd_batch ("partial-payments",
+ partial_payments),
TALER_TESTING_cmd_batch ("product-categories",
product_categories),
TALER_TESTING_cmd_merchant_get_statisticsamount ("stats-refund",
diff --git a/src/testing/test_merchant_instance_response.sh b/src/testing/test_merchant_instance_response.sh
@@ -90,7 +90,7 @@ fi
STATUS=$(curl -H "Content-Type: application/json" -X POST \
-H "Authorization: Bearer $TOKEN" \
http://localhost:9966/private/auth \
- -d '{"method":"token","password":"zxc"}' \
+ -d '{"method":"token","password":"zxc","old_password":"other_secret"}' \
-w "%{http_code}" -s -o /dev/null)
if [ "$STATUS" != "204" ]
diff --git a/src/testing/test_merchant_order_creation.sh b/src/testing/test_merchant_order_creation.sh
@@ -355,7 +355,7 @@ then
exit_fail "Expected 402 payment HTML. Got: $STATUS"
fi
assert_html_contains "$HTML_RESPONSE" 'Expensive purchase'
-assert_html_contains "$HTML_RESPONSE" 'selected in your wallet'
+assert_html_contains "$HTML_RESPONSE" 'select the amount and payment option in your wallet'
assert_html_rendered "$HTML_RESPONSE"
echo "OK"
diff --git a/src/testing/test_merchant_transfer_tracking.sh b/src/testing/test_merchant_transfer_tracking.sh
@@ -236,7 +236,7 @@ then
cat "$LAST_RESPONSE"
exit_fail "Expected 200 ok listing unwired order. got: $STATUS"
fi
-LIST_WIRED=$(jq -e -r --arg order_id "$ORDER_ID" \
+LIST_WIRED=$(jq -r --arg order_id "$ORDER_ID" \
'.orders[] | select(.order_id == $order_id) | .wired' < "$LAST_RESPONSE")
if [ "$LIST_WIRED" != "false" ]
then
diff --git a/src/testing/testing_api_cmd_check_order_external.c b/src/testing/testing_api_cmd_check_order_external.c
@@ -0,0 +1,226 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU 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/testing/testing_api_cmd_check_order_external.c
+ * @brief command to check the external payments and refunds an order
+ * reports through GET /private/orders/$ORDER_ID
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+struct CheckOrderExternalState;
+#define TALER_MERCHANT_GET_PRIVATE_ORDER_RESULT_CLOSURE \
+ struct CheckOrderExternalState
+#include <taler/taler_exchange_service.h>
+#include <taler/taler_testing_lib.h>
+#include "taler/taler_merchant_service.h"
+#include "taler/taler_merchant_testing_lib.h"
+
+
+/**
+ * State of a "check order external" CMD.
+ */
+struct CheckOrderExternalState
+{
+
+ /**
+ * Handle for the order status request.
+ */
+ struct TALER_MERCHANT_GetPrivateOrderHandle *ogh;
+
+ /**
+ * The interpreter state.
+ */
+ struct TALER_TESTING_Interpreter *is;
+
+ /**
+ * Base URL of the merchant serving the request.
+ */
+ const char *merchant_url;
+
+ /**
+ * ID of the order to check.
+ */
+ const char *order_id;
+
+ /**
+ * Number of entries expected in "amount_external" of the contract.
+ */
+ unsigned int expected_payments;
+
+ /**
+ * Number of entries expected in "refunds_external" of the status.
+ */
+ unsigned int expected_refunds;
+
+ /**
+ * Choice the order is expected to have been paid with, -1 if the
+ * contract has no choices.
+ */
+ int expected_choice_index;
+
+};
+
+
+/**
+ * Callback for the order status request.
+ *
+ * @param coes closure for this function
+ * @param osr response being processed
+ */
+static void
+check_order_external_cb (
+ struct CheckOrderExternalState *coes,
+ const struct TALER_MERCHANT_GetPrivateOrderResponse *osr)
+{
+ size_t payments;
+ size_t refunds;
+
+ coes->ogh = NULL;
+ if (MHD_HTTP_OK != osr->hr.http_status)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u (%d) to command %s\n",
+ osr->hr.http_status,
+ (int) osr->hr.ec,
+ TALER_TESTING_interpreter_get_current_label (coes->is));
+ TALER_TESTING_interpreter_fail (coes->is);
+ return;
+ }
+ if (TALER_MERCHANT_OSC_PAID != osr->details.ok.status)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Order `%s' is not paid, cannot check external payments\n",
+ coes->order_id);
+ TALER_TESTING_interpreter_fail (coes->is);
+ return;
+ }
+ payments = json_array_size (
+ json_object_get (osr->details.ok.details.paid.contract_terms,
+ "amount_external"));
+ refunds = json_array_size (osr->details.ok.details.paid.refunds_external);
+ if (coes->expected_choice_index !=
+ osr->details.ok.details.paid.choice_index)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Order `%s' was paid with choice %d, expected %d\n",
+ coes->order_id,
+ osr->details.ok.details.paid.choice_index,
+ coes->expected_choice_index);
+ TALER_TESTING_interpreter_fail (coes->is);
+ return;
+ }
+ if ( (payments != (size_t) coes->expected_payments) ||
+ (refunds != (size_t) coes->expected_refunds) )
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Order `%s' reports %u external payments and %u external refunds, expected %u and %u\n",
+ coes->order_id,
+ (unsigned int) payments,
+ (unsigned int) refunds,
+ coes->expected_payments,
+ coes->expected_refunds);
+ TALER_TESTING_interpreter_fail (coes->is);
+ return;
+ }
+ TALER_TESTING_interpreter_next (coes->is);
+}
+
+
+/**
+ * Run the "check order external" CMD.
+ *
+ * @param cls closure.
+ * @param cmd command being run now.
+ * @param is interpreter state.
+ */
+static void
+check_order_external_run (void *cls,
+ const struct TALER_TESTING_Command *cmd,
+ struct TALER_TESTING_Interpreter *is)
+{
+ struct CheckOrderExternalState *coes = cls;
+
+ coes->is = is;
+ coes->ogh = TALER_MERCHANT_get_private_order_create (
+ TALER_TESTING_interpreter_get_context (is),
+ coes->merchant_url,
+ coes->order_id);
+ GNUNET_assert (NULL != coes->ogh);
+ {
+ enum TALER_ErrorCode ec;
+
+ ec = TALER_MERCHANT_get_private_order_start (coes->ogh,
+ &check_order_external_cb,
+ coes);
+ GNUNET_assert (TALER_EC_NONE == ec);
+ }
+}
+
+
+/**
+ * Free the state of a "check order external" CMD.
+ *
+ * @param cls closure.
+ * @param cmd command being run.
+ */
+static void
+check_order_external_cleanup (void *cls,
+ const struct TALER_TESTING_Command *cmd)
+{
+ struct CheckOrderExternalState *coes = cls;
+
+ if (NULL != coes->ogh)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "GET /private/orders/$ORDER_ID operation did not complete\n");
+ TALER_MERCHANT_get_private_order_cancel (coes->ogh);
+ }
+ GNUNET_free (coes);
+}
+
+
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_check_order_external (
+ const char *label,
+ const char *merchant_url,
+ const char *order_id,
+ unsigned int expected_payments,
+ unsigned int expected_refunds,
+ int expected_choice_index)
+{
+ struct CheckOrderExternalState *coes;
+
+ coes = GNUNET_new (struct CheckOrderExternalState);
+ coes->merchant_url = merchant_url;
+ coes->order_id = order_id;
+ coes->expected_payments = expected_payments;
+ coes->expected_refunds = expected_refunds;
+ coes->expected_choice_index = expected_choice_index;
+ {
+ struct TALER_TESTING_Command cmd = {
+ .cls = coes,
+ .label = label,
+ .run = &check_order_external_run,
+ .cleanup = &check_order_external_cleanup
+ };
+
+ return cmd;
+ }
+}
diff --git a/src/testing/testing_api_cmd_collect_order.c b/src/testing/testing_api_cmd_collect_order.c
@@ -0,0 +1,205 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU 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/testing/testing_api_cmd_collect_order.c
+ * @brief command to test POST /private/orders/$ORDER_ID/collect
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+struct CollectOrderState;
+#define TALER_MERCHANT_POST_PRIVATE_ORDERS_COLLECT_RESULT_CLOSURE \
+ struct CollectOrderState
+#include <taler/taler_exchange_service.h>
+#include <taler/taler_testing_lib.h>
+#include "taler/taler_merchant_service.h"
+#include "taler/taler_merchant_testing_lib.h"
+#include <taler/merchant/post-private-orders-ORDER_ID-collect.h>
+
+
+/**
+ * State of a "POST /private/orders/$ORDER_ID/collect" CMD.
+ */
+struct CollectOrderState
+{
+
+ /**
+ * Handle for a "collect order" request.
+ */
+ struct TALER_MERCHANT_PostPrivateOrdersCollectHandle *och;
+
+ /**
+ * The interpreter state.
+ */
+ struct TALER_TESTING_Interpreter *is;
+
+ /**
+ * Base URL of the merchant serving the request.
+ */
+ const char *merchant_url;
+
+ /**
+ * ID of the order to collect.
+ */
+ const char *order_id;
+
+ /**
+ * Session ID to store with the payment, NULL for none.
+ */
+ const char *session_id;
+
+ /**
+ * Choice to complete, negative for v0 contracts.
+ */
+ int16_t choice_index;
+
+ /**
+ * Expected HTTP response code.
+ */
+ unsigned int http_status;
+
+};
+
+
+/**
+ * Callback for a POST /private/orders/$ID/collect operation.
+ *
+ * @param cos closure for this function
+ * @param cr response being processed
+ */
+static void
+collect_order_cb (
+ struct CollectOrderState *cos,
+ const struct TALER_MERCHANT_PostPrivateOrdersCollectResponse *cr)
+{
+ cos->och = NULL;
+ if (cos->http_status != cr->hr.http_status)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u (%d) to command %s\n",
+ cr->hr.http_status,
+ (int) cr->hr.ec,
+ TALER_TESTING_interpreter_get_current_label (cos->is));
+ TALER_TESTING_interpreter_fail (cos->is);
+ return;
+ }
+ switch (cr->hr.http_status)
+ {
+ case MHD_HTTP_OK:
+ break;
+ case MHD_HTTP_BAD_REQUEST:
+ break;
+ case MHD_HTTP_UNAUTHORIZED:
+ break;
+ case MHD_HTTP_NOT_FOUND:
+ break;
+ case MHD_HTTP_CONFLICT:
+ break;
+ default:
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Unhandled HTTP status %u for collect order.\n",
+ cr->hr.http_status);
+ }
+ TALER_TESTING_interpreter_next (cos->is);
+}
+
+
+/**
+ * Run the "collect order" CMD.
+ *
+ * @param cls closure.
+ * @param cmd command being run now.
+ * @param is interpreter state.
+ */
+static void
+collect_order_run (void *cls,
+ const struct TALER_TESTING_Command *cmd,
+ struct TALER_TESTING_Interpreter *is)
+{
+ struct CollectOrderState *cos = cls;
+
+ cos->is = is;
+ cos->och = TALER_MERCHANT_post_private_orders_collect_create (
+ TALER_TESTING_interpreter_get_context (is),
+ cos->merchant_url,
+ cos->order_id,
+ cos->session_id,
+ cos->choice_index);
+ {
+ enum TALER_ErrorCode ec;
+
+ ec = TALER_MERCHANT_post_private_orders_collect_start (cos->och,
+ &collect_order_cb,
+ cos);
+ GNUNET_assert (TALER_EC_NONE == ec);
+ }
+}
+
+
+/**
+ * Free the state of a "collect order" CMD, and possibly
+ * cancel a pending operation thereof.
+ *
+ * @param cls closure.
+ * @param cmd command being run.
+ */
+static void
+collect_order_cleanup (void *cls,
+ const struct TALER_TESTING_Command *cmd)
+{
+ struct CollectOrderState *cos = cls;
+
+ if (NULL != cos->och)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "POST /private/orders/$ORDER_ID/collect operation did not complete\n");
+ TALER_MERCHANT_post_private_orders_collect_cancel (cos->och);
+ }
+ GNUNET_free (cos);
+}
+
+
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_collect_order (const char *label,
+ const char *merchant_url,
+ const char *order_id,
+ const char *session_id,
+ int16_t choice_index,
+ unsigned int http_status)
+{
+ struct CollectOrderState *cos;
+
+ cos = GNUNET_new (struct CollectOrderState);
+ cos->merchant_url = merchant_url;
+ cos->order_id = order_id;
+ cos->session_id = session_id;
+ cos->choice_index = choice_index;
+ cos->http_status = http_status;
+ {
+ struct TALER_TESTING_Command cmd = {
+ .cls = cos,
+ .label = label,
+ .run = &collect_order_run,
+ .cleanup = &collect_order_cleanup
+ };
+
+ return cmd;
+ }
+}
diff --git a/src/testing/testing_api_cmd_delete_order.c b/src/testing/testing_api_cmd_delete_order.c
@@ -62,6 +62,11 @@ struct DeleteOrderState
*/
unsigned int http_status;
+ /**
+ * Force deletion even if externally settled payments exist.
+ */
+ bool force;
+
};
@@ -127,6 +132,11 @@ delete_order_run (void *cls,
TALER_TESTING_interpreter_get_context (is),
dos->merchant_url,
dos->order_id);
+ if (dos->force)
+ GNUNET_assert (GNUNET_OK ==
+ TALER_MERCHANT_delete_private_order_set_options (
+ dos->odh,
+ TALER_MERCHANT_delete_private_order_set_option_force ()));
{
enum TALER_ErrorCode ec;
@@ -167,12 +177,28 @@ TALER_TESTING_cmd_merchant_delete_order (const char *label,
const char *order_id,
unsigned int http_status)
{
+ return TALER_TESTING_cmd_merchant_delete_order2 (label,
+ merchant_url,
+ order_id,
+ false,
+ http_status);
+}
+
+
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_delete_order2 (const char *label,
+ const char *merchant_url,
+ const char *order_id,
+ bool force,
+ unsigned int http_status)
+{
struct DeleteOrderState *dos;
dos = GNUNET_new (struct DeleteOrderState);
dos->merchant_url = merchant_url;
dos->order_id = order_id;
dos->http_status = http_status;
+ dos->force = force;
{
struct TALER_TESTING_Command cmd = {
.cls = dos,
diff --git a/src/testing/testing_api_cmd_post_orders.c b/src/testing/testing_api_cmd_post_orders.c
@@ -838,6 +838,105 @@ TALER_TESTING_cmd_merchant_post_orders_no_claim (
struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_post_orders_external (
+ const char *label,
+ const char *merchant_url,
+ unsigned int http_status,
+ const char *order_id,
+ struct GNUNET_TIME_Timestamp refund_deadline,
+ struct GNUNET_TIME_Timestamp pay_deadline,
+ const char *amount,
+ const char *amount_external)
+{
+ struct OrdersState *ps;
+
+ ps = GNUNET_new (struct OrdersState);
+ make_order_json (order_id,
+ refund_deadline,
+ pay_deadline,
+ amount,
+ &ps->order_terms);
+ {
+ json_t *ae;
+
+ ae = json_loads (amount_external,
+ 0,
+ NULL);
+ GNUNET_assert (NULL != ae);
+ GNUNET_assert (0 ==
+ json_object_set_new (ps->order_terms,
+ "amount_external",
+ ae));
+ }
+ ps->http_status = http_status;
+ ps->expected_order_id = order_id;
+ ps->merchant_url = merchant_url;
+ {
+ struct TALER_TESTING_Command cmd = {
+ .cls = ps,
+ .label = label,
+ .run = &orders_run,
+ .cleanup = &orders_cleanup,
+ .traits = &orders_traits
+ };
+
+ return cmd;
+ }
+}
+
+
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_post_orders_v1 (
+ const char *label,
+ const char *merchant_url,
+ unsigned int http_status,
+ const char *order_id,
+ struct GNUNET_TIME_Timestamp refund_deadline,
+ struct GNUNET_TIME_Timestamp pay_deadline,
+ const char *choices)
+{
+ struct OrdersState *ps;
+
+ ps = GNUNET_new (struct OrdersState);
+ make_order_json (order_id,
+ refund_deadline,
+ pay_deadline,
+ NULL,
+ &ps->order_terms);
+ {
+ json_t *cs;
+
+ cs = json_loads (choices,
+ 0,
+ NULL);
+ GNUNET_assert (NULL != cs);
+ GNUNET_assert (0 ==
+ json_object_set_new (ps->order_terms,
+ "choices",
+ cs));
+ }
+ GNUNET_assert (0 ==
+ json_object_set_new (ps->order_terms,
+ "version",
+ json_integer (1)));
+ ps->http_status = http_status;
+ ps->expected_order_id = order_id;
+ ps->merchant_url = merchant_url;
+ {
+ struct TALER_TESTING_Command cmd = {
+ .cls = ps,
+ .label = label,
+ .run = &orders_run,
+ .cleanup = &orders_cleanup,
+ .traits = &orders_traits
+ };
+
+ return cmd;
+ }
+}
+
+
+struct TALER_TESTING_Command
TALER_TESTING_cmd_merchant_post_orders (
const char *label,
const struct GNUNET_CONFIGURATION_Handle *cfg,
diff --git a/src/testing/testing_api_cmd_refund_external.c b/src/testing/testing_api_cmd_refund_external.c
@@ -0,0 +1,210 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify
+ it under the terms of the GNU 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/testing/testing_api_cmd_refund_external.c
+ * @brief command to test POST /private/orders/$ORDER_ID/refund-external
+ * @author Bohdan Potuzhnyi
+ * @author Volodymyr Potuzhnyi
+ */
+#include "platform.h"
+struct RefundExternalState;
+#define TALER_MERCHANT_POST_PRIVATE_ORDERS_REFUND_EXTERNAL_RESULT_CLOSURE \
+ struct RefundExternalState
+#include <taler/taler_exchange_service.h>
+#include <taler/taler_testing_lib.h>
+#include "taler/taler_merchant_service.h"
+#include "taler/taler_merchant_testing_lib.h"
+#include <taler/merchant/post-private-orders-ORDER_ID-refund-external.h>
+
+
+/**
+ * State of a "POST /private/orders/$ORDER_ID/refund-external" CMD.
+ */
+struct RefundExternalState
+{
+
+ /**
+ * Handle for a "refund external" request.
+ */
+ struct TALER_MERCHANT_PostPrivateOrdersRefundExternalHandle *reh;
+
+ /**
+ * The interpreter state.
+ */
+ struct TALER_TESTING_Interpreter *is;
+
+ /**
+ * Base URL of the merchant serving the request.
+ */
+ const char *merchant_url;
+
+ /**
+ * ID of the order to record the refund for.
+ */
+ const char *order_id;
+
+ /**
+ * External refund entry to record.
+ */
+ json_t *body;
+
+ /**
+ * Identifier of the recorded refund, NULL if the request did not
+ * (yet) succeed.
+ */
+ char *refund_id;
+
+ /**
+ * Expected HTTP response code.
+ */
+ unsigned int http_status;
+
+};
+
+
+/**
+ * Callback for a POST /private/orders/$ID/refund-external operation.
+ *
+ * @param res closure for this function
+ * @param rer response being processed
+ */
+static void
+refund_external_cb (
+ struct RefundExternalState *res,
+ const struct TALER_MERCHANT_PostPrivateOrdersRefundExternalResponse *rer)
+{
+ res->reh = NULL;
+ if (res->http_status != rer->hr.http_status)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Unexpected response code %u (%d) to command %s\n",
+ rer->hr.http_status,
+ (int) rer->hr.ec,
+ TALER_TESTING_interpreter_get_current_label (res->is));
+ TALER_TESTING_interpreter_fail (res->is);
+ return;
+ }
+ switch (rer->hr.http_status)
+ {
+ case MHD_HTTP_OK:
+ res->refund_id = GNUNET_strdup (rer->details.ok.refund_id);
+ break;
+ case MHD_HTTP_BAD_REQUEST:
+ break;
+ case MHD_HTTP_UNAUTHORIZED:
+ break;
+ case MHD_HTTP_NOT_FOUND:
+ break;
+ case MHD_HTTP_CONFLICT:
+ break;
+ default:
+ GNUNET_break (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Unhandled HTTP status %u for refund external.\n",
+ rer->hr.http_status);
+ }
+ TALER_TESTING_interpreter_next (res->is);
+}
+
+
+/**
+ * Run the "refund external" CMD.
+ *
+ * @param cls closure.
+ * @param cmd command being run now.
+ * @param is interpreter state.
+ */
+static void
+refund_external_run (void *cls,
+ const struct TALER_TESTING_Command *cmd,
+ struct TALER_TESTING_Interpreter *is)
+{
+ struct RefundExternalState *res = cls;
+
+ res->is = is;
+ res->reh = TALER_MERCHANT_post_private_orders_refund_external_create (
+ TALER_TESTING_interpreter_get_context (is),
+ res->merchant_url,
+ res->order_id,
+ res->body);
+ {
+ enum TALER_ErrorCode ec;
+
+ ec = TALER_MERCHANT_post_private_orders_refund_external_start (
+ res->reh,
+ &refund_external_cb,
+ res);
+ GNUNET_assert (TALER_EC_NONE == ec);
+ }
+}
+
+
+/**
+ * Free the state of a "refund external" CMD, and possibly
+ * cancel a pending operation thereof.
+ *
+ * @param cls closure.
+ * @param cmd command being run.
+ */
+static void
+refund_external_cleanup (void *cls,
+ const struct TALER_TESTING_Command *cmd)
+{
+ struct RefundExternalState *res = cls;
+
+ if (NULL != res->reh)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "POST /private/orders/$ORDER_ID/refund-external operation did not complete\n");
+ TALER_MERCHANT_post_private_orders_refund_external_cancel (res->reh);
+ }
+ json_decref (res->body);
+ GNUNET_free (res->refund_id);
+ GNUNET_free (res);
+}
+
+
+struct TALER_TESTING_Command
+TALER_TESTING_cmd_merchant_refund_external (const char *label,
+ const char *merchant_url,
+ const char *order_id,
+ const char *body,
+ unsigned int http_status)
+{
+ struct RefundExternalState *res;
+
+ res = GNUNET_new (struct RefundExternalState);
+ res->merchant_url = merchant_url;
+ res->order_id = order_id;
+ res->body = json_loads (body,
+ 0,
+ NULL);
+ GNUNET_assert (NULL != res->body);
+ res->http_status = http_status;
+ {
+ struct TALER_TESTING_Command cmd = {
+ .cls = res,
+ .label = label,
+ .run = &refund_external_run,
+ .cleanup = &refund_external_cleanup
+ };
+
+ return cmd;
+ }
+}
diff --git a/src/util/base_terms_parse.c b/src/util/base_terms_parse.c
@@ -85,6 +85,10 @@ TALER_MERCHANT_base_terms_parse (
&ct->extra),
NULL),
GNUNET_JSON_spec_mark_optional (
+ GNUNET_JSON_spec_array_copy ("amount_external",
+ &ct->amount_external),
+ NULL),
+ GNUNET_JSON_spec_mark_optional (
GNUNET_JSON_spec_uint8 ("minimum_age",
&ct->minimum_age),
NULL),
@@ -146,6 +150,11 @@ TALER_MERCHANT_base_terms_free (
json_decref (ct->extra);
ct->extra = NULL;
}
+ if (NULL != ct->amount_external)
+ {
+ json_decref (ct->amount_external);
+ ct->amount_external = NULL;
+ }
if (NULL != ct->summary_i18n)
{
json_decref (ct->summary_i18n);
diff --git a/src/util/base_terms_serialize.c b/src/util/base_terms_serialize.c
@@ -71,6 +71,9 @@ TALER_MERCHANT_base_terms_serialize (
GNUNET_JSON_pack_object_incref ("extra",
ct->extra)),
GNUNET_JSON_pack_allow_null (
+ GNUNET_JSON_pack_array_incref ("amount_external",
+ ct->amount_external)),
+ GNUNET_JSON_pack_allow_null (
GNUNET_JSON_pack_uint64 ("minimum_age",
ct->minimum_age)),
GNUNET_JSON_pack_conditional (
diff --git a/src/util/contract_parse.c b/src/util/contract_parse.c
@@ -89,6 +89,35 @@ parse_contract_v0 (
"'tip' does not match currency of contract price");
return GNUNET_SYSERR;
}
+ if (NULL != contract->base->amount_external)
+ {
+ if (! TALER_MERCHANT_amount_external_valid (
+ contract->base->amount_external))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "'amount_external' is not a valid set of external payments");
+ return GNUNET_SYSERR;
+ }
+ if (! TALER_MERCHANT_amount_external_currency_valid (
+ contract->base->amount_external,
+ &contract->details.v0.brutto))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "'amount_external' does not match currency of contract price");
+ return GNUNET_SYSERR;
+ }
+ if (! TALER_MERCHANT_amount_external_total_valid (
+ contract->base->amount_external,
+ &contract->details.v0.brutto))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "total of 'amount_external' and contract price is too large");
+ return GNUNET_SYSERR;
+ }
+ }
return res;
}
@@ -134,6 +163,39 @@ parse_contract_v1 (
ename);
return GNUNET_SYSERR;
}
+ if (NULL != pc->base->amount_external)
+ {
+ if (! TALER_MERCHANT_amount_external_valid (pc->base->amount_external))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "'amount_external' is not a valid set of external payments\n");
+ return GNUNET_SYSERR;
+ }
+ for (unsigned int i = 0; i<pc->details.v1.choices_len; i++)
+ {
+ if (! TALER_MERCHANT_amount_external_currency_valid (
+ pc->base->amount_external,
+ &pc->details.v1.choices[i].amount))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "'amount_external' does not match currency of choice #%u\n",
+ i);
+ return GNUNET_SYSERR;
+ }
+ if (! TALER_MERCHANT_amount_external_total_valid (
+ pc->base->amount_external,
+ &pc->details.v1.choices[i].amount))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "total of 'amount_external' and choice #%u is too large\n",
+ i);
+ return GNUNET_SYSERR;
+ }
+ }
+ }
return res;
}
diff --git a/src/util/meson.build b/src/util/meson.build
@@ -82,7 +82,8 @@ foreach t : talermerchantutil_tests
test_tmp = executable(
t,
['@0@.c'.format(t)],
- dependencies: [gnunetutil_dep, libtalermerchantutil_dep, json_dep],
+ dependencies: [gnunetutil_dep, talerutil_dep,
+ libtalermerchantutil_dep, json_dep],
include_directories: [incdir, configuration_inc],
build_by_default: false,
install: false,
diff --git a/src/util/order_parse.c b/src/util/order_parse.c
@@ -92,6 +92,35 @@ parse_order_v0 (
"'tip' does not match currency of order price");
return GNUNET_SYSERR;
}
+ if (NULL != order->base->amount_external)
+ {
+ if (! TALER_MERCHANT_amount_external_valid (
+ order->base->amount_external))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "'amount_external' is not a valid set of external payments");
+ return GNUNET_SYSERR;
+ }
+ if (! TALER_MERCHANT_amount_external_currency_valid (
+ order->base->amount_external,
+ &order->details.v0.brutto))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "'amount_external' does not match currency of order price");
+ return GNUNET_SYSERR;
+ }
+ if (! TALER_MERCHANT_amount_external_total_valid (
+ order->base->amount_external,
+ &order->details.v0.brutto))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "total of 'amount_external' and order price is too large");
+ return GNUNET_SYSERR;
+ }
+ }
return res;
}
@@ -133,6 +162,39 @@ parse_order_v1 (
ename);
return GNUNET_SYSERR;
}
+ if (NULL != order->base->amount_external)
+ {
+ if (! TALER_MERCHANT_amount_external_valid (order->base->amount_external))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "'amount_external' is not a valid set of external payments\n");
+ return GNUNET_SYSERR;
+ }
+ for (unsigned int i = 0; i<order->details.v1.choices_len; i++)
+ {
+ if (! TALER_MERCHANT_amount_external_currency_valid (
+ order->base->amount_external,
+ &order->details.v1.choices[i].amount))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "'amount_external' does not match currency of choice #%u\n",
+ i);
+ return GNUNET_SYSERR;
+ }
+ if (! TALER_MERCHANT_amount_external_total_valid (
+ order->base->amount_external,
+ &order->details.v1.choices[i].amount))
+ {
+ GNUNET_break_op (0);
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "total of 'amount_external' and choice #%u is too large\n",
+ i);
+ return GNUNET_SYSERR;
+ }
+ }
+ }
return res;
}
diff --git a/src/util/test_contract.c b/src/util/test_contract.c
@@ -74,11 +74,33 @@ const char *contract_common = "{\n"
const char *contract_v0 = "{\n"
" \"version\": 0,\n"
" \"amount\": \"KUDOS:10\",\n"
+ " \"amount_external\": [\n"
+ " {\n"
+ " \"method\": \"cash\",\n"
+ " \"id\": \"cash1\",\n"
+ " \"amount\": \"KUDOS:30\",\n"
+ " \"cashier_number\": \"7\"\n"
+ " },\n"
+ " {\n"
+ " \"method\": \"card\",\n"
+ " \"id\": \"sumup1\",\n"
+ " \"amount\": \"KUDOS:5\",\n"
+ " \"terminal_id\": 42,\n"
+ " \"contactless\": true\n"
+ " }\n"
+ " ],\n"
" \"max_fee\": \"KUDOS:0.2\"\n"
"}";
const char *contract_v1 = "{\n"
" \"version\": 1,\n"
+ " \"amount_external\": [\n"
+ " {\n"
+ " \"method\": \"cash\",\n"
+ " \"id\": \"cash1\",\n"
+ " \"amount\": \"KUDOS:3\"\n"
+ " }\n"
+ " ],\n"
" \"choices\": [\n"
" {\n"
" \"amount\": \"KUDOS:1\",\n"
@@ -282,5 +304,130 @@ main (int argc,
json_decref (forever);
}
+ { // amount_external currency must match all v1 choices
+ json_t *common;
+ json_t *v1;
+ json_t *bad_external;
+
+ common = json_loads (contract_common, 0, NULL);
+ GNUNET_assert (NULL != common);
+ v1 = json_loads (contract_v1, 0, NULL);
+ GNUNET_assert (NULL != v1);
+ GNUNET_assert (0 == json_object_update_new (v1, common));
+ bad_external = json_loads (
+ "[{\"method\":\"cash\",\"id\":\"cash1\",\"amount\":\"EUR:3\"}]",
+ 0,
+ NULL);
+ GNUNET_assert (NULL != bad_external);
+ GNUNET_assert (0 == json_object_set_new (v1,
+ "amount_external",
+ bad_external));
+ GNUNET_assert (NULL == TALER_MERCHANT_contract_parse (v1));
+ json_decref (v1);
+ }
+
+ { // amount_external validation
+ struct TALER_Amount amount;
+ static const char *invalid[] = {
+ /* method "taler" is reserved */
+ "[{\"method\":\"taler\",\"id\":\"t1\",\"amount\":\"KUDOS:1\"}]",
+ /* entries disagreeing on a currency among themselves */
+ "[{\"method\":\"cash\",\"id\":\"cash1\",\"amount\":\"KUDOS:1\"},"
+ " {\"method\":\"card\",\"id\":\"card1\",\"amount\":\"EUR:1\"}]",
+ /* missing id */
+ "[{\"method\":\"cash\",\"amount\":\"KUDOS:1\"}]",
+ /* missing amount */
+ "[{\"method\":\"cash\",\"id\":\"cash1\"}]",
+ /* empty method name */
+ "[{\"method\":\"\",\"id\":\"cash1\",\"amount\":\"KUDOS:1\"}]",
+ /* method name with blank */
+ "[{\"method\":\"ca sh\",\"id\":\"cash1\",\"amount\":\"KUDOS:1\"}]",
+ /* duplicate id */
+ "[{\"method\":\"cash\",\"id\":\"cash1\",\"amount\":\"KUDOS:1\"},"
+ " {\"method\":\"card\",\"id\":\"cash1\",\"amount\":\"KUDOS:1\"}]",
+ /* nested method-specific field */
+ "[{\"method\":\"cash\",\"id\":\"cash1\",\"amount\":\"KUDOS:1\","
+ " \"details\":{\"register\":1}}]",
+ NULL
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount ("KUDOS:10",
+ &amount));
+ {
+ json_t *valid;
+
+ valid = json_loads (
+ "[{\"method\":\"cash\",\"id\":\"cash1\",\"amount\":\"KUDOS:30\","
+ " \"cashier_number\":\"7\"}]",
+ 0,
+ NULL);
+ GNUNET_assert (NULL != valid);
+ GNUNET_assert (TALER_MERCHANT_amount_external_valid (valid));
+ /* valid entries in the currency of the order, and not in another */
+ GNUNET_assert (TALER_MERCHANT_amount_external_currency_valid (valid,
+ &amount));
+ {
+ struct TALER_Amount other;
+
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount ("EUR:10",
+ &other));
+ GNUNET_assert (! TALER_MERCHANT_amount_external_currency_valid (valid,
+ &other));
+ }
+ GNUNET_assert (TALER_MERCHANT_amount_external_total_valid (valid,
+ &amount));
+ json_decref (valid);
+ }
+ { // the order total must stay representable
+ json_t *huge;
+ struct TALER_Amount big;
+
+ /* Two entries just below the largest representable amount:
+ each is fine on its own, their sum is not. */
+ huge = json_loads (
+ "[{\"method\":\"cash\",\"id\":\"cash1\","
+ " \"amount\":\"KUDOS:4503599627370495\"},"
+ " {\"method\":\"card\",\"id\":\"card1\","
+ " \"amount\":\"KUDOS:4503599627370495\"}]",
+ 0,
+ NULL);
+ GNUNET_assert (NULL != huge);
+ /* the entries themselves are perfectly valid */
+ GNUNET_assert (TALER_MERCHANT_amount_external_valid (huge));
+ GNUNET_assert (TALER_MERCHANT_amount_external_currency_valid (huge,
+ &amount));
+ /* ... but adding them up overflows */
+ GNUNET_assert (! TALER_MERCHANT_amount_external_total_valid (huge,
+ &amount));
+ json_decref (huge);
+
+ /* A single entry that overflows only once the Taler amount of
+ the order is added to it. */
+ huge = json_loads (
+ "[{\"method\":\"cash\",\"id\":\"cash1\","
+ " \"amount\":\"KUDOS:4503599627370495\"}]",
+ 0,
+ NULL);
+ GNUNET_assert (NULL != huge);
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount ("KUDOS:10",
+ &big));
+ GNUNET_assert (! TALER_MERCHANT_amount_external_total_valid (huge,
+ &big));
+ json_decref (huge);
+ }
+ for (unsigned int i = 0; NULL != invalid[i]; i++)
+ {
+ json_t *j;
+
+ j = json_loads (invalid[i], 0, NULL);
+ GNUNET_assert (NULL != j);
+ GNUNET_assert (! TALER_MERCHANT_amount_external_valid (j));
+ json_decref (j);
+ }
+ }
+
return 0;
}
diff --git a/src/util/util.c b/src/util/util.c
@@ -62,6 +62,192 @@ TALER_MERCHANT_taxes_array_valid (const json_t *taxes)
}
+bool
+TALER_MERCHANT_payment_method_valid (
+ const char *method)
+{
+ if ( (0 == strlen (method)) ||
+ (0 == strcasecmp ("taler",
+ method)) )
+ {
+ GNUNET_break_op (0);
+ return false;
+ }
+ for (const char *c = method; '\0' != *c; c++)
+ if ( (! isalnum ((unsigned char) *c)) &&
+ ('-' != *c) )
+ {
+ GNUNET_break_op (0);
+ return false;
+ }
+ return true;
+}
+
+
+bool
+TALER_MERCHANT_amount_external_valid (
+ const json_t *amount_external)
+{
+ struct TALER_Amount first;
+ bool have_first = false;
+ json_t *entry;
+ size_t idx;
+
+ if (! json_is_array (amount_external))
+ return false;
+ json_array_foreach (amount_external, idx, entry)
+ {
+ const char *method;
+ const char *id;
+ struct TALER_Amount ext_amount;
+ struct GNUNET_JSON_Specification spec[] = {
+ GNUNET_JSON_spec_string ("method",
+ &method),
+ GNUNET_JSON_spec_string ("id",
+ &id),
+ TALER_JSON_spec_amount_any ("amount",
+ &ext_amount),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (entry,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ return false;
+ }
+ if (! TALER_MERCHANT_payment_method_valid (method))
+ return false;
+ if (0 == strlen (id))
+ {
+ GNUNET_break_op (0);
+ return false;
+ }
+ if (! have_first)
+ {
+ first = ext_amount;
+ have_first = true;
+ }
+ else if (GNUNET_OK !=
+ TALER_amount_cmp_currency (&ext_amount,
+ &first))
+ {
+ /* the entries have to agree on a currency among themselves */
+ GNUNET_break_op (0);
+ return false;
+ }
+ for (size_t j = 0; j < idx; j++)
+ {
+ const char *other_id
+ = json_string_value (
+ json_object_get (json_array_get (amount_external,
+ j),
+ "id"));
+
+ if ( (NULL != other_id) &&
+ (0 == strcmp (id,
+ other_id)) )
+ {
+ GNUNET_break_op (0);
+ return false;
+ }
+ }
+ {
+ const char *key;
+ json_t *value;
+
+ json_object_foreach (entry, key, value)
+ {
+ if ( (json_is_object (value)) ||
+ (json_is_array (value)) ||
+ (json_is_real (value)) )
+ {
+ GNUNET_break_op (0);
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+}
+
+
+bool
+TALER_MERCHANT_amount_external_currency_valid (
+ const json_t *amount_external,
+ const struct TALER_Amount *amount)
+{
+ struct TALER_Amount ext_amount;
+ struct GNUNET_JSON_Specification spec[] = {
+ TALER_JSON_spec_amount_any ("amount",
+ &ext_amount),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (0 == json_array_size (amount_external))
+ return true; /* no external payment to agree with */
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (json_array_get (amount_external,
+ 0),
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ return false;
+ }
+ return (GNUNET_OK ==
+ TALER_amount_cmp_currency (&ext_amount,
+ amount));
+}
+
+
+bool
+TALER_MERCHANT_amount_external_total_valid (
+ const json_t *amount_external,
+ const struct TALER_Amount *amount)
+{
+ struct TALER_Amount total = *amount;
+ json_t *entry;
+ size_t idx;
+
+ json_array_foreach ((json_t *) amount_external, idx, entry)
+ {
+ struct TALER_Amount ext_amount;
+ struct GNUNET_JSON_Specification spec[] = {
+ TALER_JSON_spec_amount_any ("amount",
+ &ext_amount),
+ GNUNET_JSON_spec_end ()
+ };
+
+ if (GNUNET_OK !=
+ GNUNET_JSON_parse (entry,
+ spec,
+ NULL,
+ NULL))
+ {
+ GNUNET_break_op (0);
+ return false;
+ }
+ if (0 >
+ TALER_amount_add (&total,
+ &total,
+ &ext_amount))
+ {
+ /* The order total would not be representable; refusing here
+ keeps us from storing an order whose total can never be
+ computed again. */
+ GNUNET_break_op (0);
+ return false;
+ }
+ }
+ return true;
+}
+
+
enum TALER_MERCHANT_ContractTokenKind
TALER_MERCHANT_contract_token_kind_from_string (const char *str)
{