commit 3674e6606c3ced341e9edf80f39cf8cb71602b6c
parent 4ea89c32b30dac40b82ac7934285b2504ab55f45
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 4 Jun 2026 22:22:29 +0200
fix #11405 (implementing protocol v32)
Diffstat:
9 files changed, 220 insertions(+), 80 deletions(-)
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
@@ -43,19 +43,14 @@ TMH_private_delete_orders_ID (const struct TMH_RequestHandler *rh,
{
struct TMH_MerchantInstance *mi = hc->instance;
enum GNUNET_DB_QueryStatus qs;
- const char *force_s;
bool force;
(void) rh;
- force_s = MHD_lookup_connection_value (connection,
- MHD_GET_ARGUMENT_KIND,
- "force");
- if (NULL == force_s)
- force_s = "no";
- force = (0 == strcasecmp (force_s,
- "yes"));
-
GNUNET_assert (NULL != mi);
+ TALER_MHD_parse_request_bool (connection,
+ "force",
+ true,
+ &force);
qs = TALER_MERCHANTDB_delete_order (TMH_db,
mi->settings.id,
hc->infix,
diff --git a/src/backend/taler-merchant-httpd_delete-private-products-PRODUCT_ID.c b/src/backend/taler-merchant-httpd_delete-private-products-PRODUCT_ID.c
@@ -1,6 +1,6 @@
/*
This file is part of TALER
- (C) 2020 Taler Systems SA
+ (C) 2020, 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
@@ -22,7 +22,6 @@
#include "taler-merchant-httpd_delete-private-products-PRODUCT_ID.h"
#include <taler/taler_json_lib.h>
#include "merchant-database/delete_product.h"
-#include "merchant-database/lookup_product.h"
/**
@@ -40,64 +39,65 @@ TMH_private_delete_products_ID (const struct TMH_RequestHandler *rh,
{
struct TMH_MerchantInstance *mi = hc->instance;
enum GNUNET_DB_QueryStatus qs;
+ bool force;
+ bool not_found = false;
+ bool locked = false;
(void) rh;
GNUNET_assert (NULL != mi);
GNUNET_assert (NULL != hc->infix);
+ TALER_MHD_parse_request_bool (connection,
+ "force",
+ true,
+ &force);
qs = TALER_MERCHANTDB_delete_product (TMH_db,
mi->settings.id,
- hc->infix);
+ hc->infix,
+ force,
+ ¬_found,
+ &locked);
switch (qs)
{
case GNUNET_DB_STATUS_HARD_ERROR:
- return TALER_MHD_reply_with_error (connection,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_STORE_FAILED,
- "delete_product");
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_DB_STORE_FAILED,
+ "delete_product");
case GNUNET_DB_STATUS_SOFT_ERROR:
GNUNET_break (0);
- return TALER_MHD_reply_with_error (connection,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
- "delete_product (soft)");
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
+ "delete_product (soft)");
case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
- {
- size_t num_categories = 0;
- uint64_t *categories = NULL;
-
- /* check if deletion must have failed because of locks by
- checking if the product exists */
- qs = TALER_MERCHANTDB_lookup_product (TMH_db,
- mi->settings.id,
- hc->infix,
- NULL,
- &num_categories,
- &categories);
- if (GNUNET_DB_STATUS_HARD_ERROR == qs)
- return TALER_MHD_reply_with_error (connection,
- MHD_HTTP_INTERNAL_SERVER_ERROR,
- TALER_EC_GENERIC_DB_STORE_FAILED,
- "lookup_product");
- if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs)
- return TALER_MHD_reply_with_error (connection,
- MHD_HTTP_NOT_FOUND,
- TALER_EC_MERCHANT_GENERIC_PRODUCT_UNKNOWN,
- hc->infix);
- GNUNET_free (categories);
- }
+ GNUNET_break (0);
return TALER_MHD_reply_with_error (
connection,
- MHD_HTTP_CONFLICT,
- TALER_EC_MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK,
- hc->infix);
+ MHD_HTTP_INTERNAL_SERVER_ERROR,
+ TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
+ "delete_product (no result)");
case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ if (not_found)
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_NOT_FOUND,
+ TALER_EC_MERCHANT_GENERIC_PRODUCT_UNKNOWN,
+ hc->infix);
+ if (locked)
+ return TALER_MHD_reply_with_error (
+ connection,
+ MHD_HTTP_CONFLICT,
+ TALER_EC_MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK,
+ hc->infix);
return TALER_MHD_reply_static (connection,
MHD_HTTP_NO_CONTENT,
NULL,
NULL,
0);
}
- GNUNET_assert (0);
+ GNUNET_break (0);
return MHD_NO;
}
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 "30:0:18"
+#define MERCHANT_PROTOCOL_VERSION "32:0:20"
/**
diff --git a/src/backenddb/delete_product.c b/src/backenddb/delete_product.c
@@ -28,26 +28,38 @@ enum GNUNET_DB_QueryStatus
TALER_MERCHANTDB_delete_product (
struct TALER_MERCHANTDB_PostgresContext *pg,
const char *instance_id,
- const char *product_id)
+ const char *product_id,
+ bool force,
+ bool *not_found,
+ bool *locked)
{
struct GNUNET_PQ_QueryParam params[] = {
GNUNET_PQ_query_param_string (product_id),
+ GNUNET_PQ_query_param_bool (force),
GNUNET_PQ_query_param_end
};
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_bool ("not_found",
+ not_found),
+ GNUNET_PQ_result_spec_bool ("locked",
+ locked),
+ GNUNET_PQ_result_spec_end
+ };
GNUNET_assert (NULL != pg->current_merchant_id);
GNUNET_assert (0 == strcmp (instance_id,
pg->current_merchant_id));
check_connection (pg);
TMH_PQ_prepare_anon (pg,
- "DELETE"
- " FROM merchant_inventory"
- " WHERE product_id=$1"
- " AND product_serial NOT IN "
- " (SELECT product_serial FROM merchant_order_locks)"
- " AND product_serial NOT IN "
- " (SELECT product_serial FROM merchant_inventory_locks)");
- return GNUNET_PQ_eval_prepared_non_select (pg->conn,
- "",
- params);
+ "SELECT"
+ " out_not_found AS not_found"
+ ",out_locked AS locked"
+ " FROM merchant_do_delete_product"
+ " ($1, $2);");
+ *not_found = false;
+ *locked = false;
+ return GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs);
}
diff --git a/src/backenddb/delete_product.sql b/src/backenddb/delete_product.sql
@@ -0,0 +1,75 @@
+--
+-- 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/>
+--
+
+DROP FUNCTION IF EXISTS merchant_do_delete_product;
+CREATE FUNCTION merchant_do_delete_product (
+ IN in_product_id TEXT,
+ IN in_force BOOLEAN,
+ OUT out_not_found BOOLEAN,
+ OUT out_locked BOOLEAN)
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ my_product_serial INT8;
+ my_has_locks BOOLEAN;
+BEGIN
+ SELECT product_serial
+ INTO my_product_serial
+ FROM merchant_inventory
+ WHERE product_id = in_product_id;
+ IF NOT FOUND
+ THEN
+ out_not_found = TRUE;
+ out_locked = FALSE;
+ RETURN;
+ END IF;
+ out_not_found = FALSE;
+
+ my_has_locks = (
+ EXISTS (SELECT 1
+ FROM merchant_order_locks
+ WHERE product_serial = my_product_serial)
+ OR EXISTS (SELECT 1
+ FROM merchant_inventory_locks
+ WHERE product_serial = my_product_serial));
+ IF (my_has_locks AND NOT in_force)
+ THEN
+ -- Refuse: the product is locked and the caller did not force deletion.
+ out_locked = TRUE;
+ RETURN;
+ END IF;
+ out_locked = FALSE;
+
+ IF (my_has_locks)
+ THEN
+ -- Release any locks first. merchant_order_locks has no ON DELETE CASCADE
+ -- on product_serial, so its rows would otherwise block the deletion;
+ -- merchant_inventory_locks rows would cascade, but we delete them
+ -- explicitly so that the total_locked counter trigger stays consistent.
+ DELETE FROM merchant_order_locks
+ WHERE product_serial = my_product_serial;
+ DELETE FROM merchant_inventory_locks
+ WHERE product_serial = my_product_serial;
+ END IF;
+ DELETE FROM merchant_inventory
+ WHERE product_serial = my_product_serial;
+END $$;
+
+COMMENT ON FUNCTION merchant_do_delete_product(TEXT, BOOLEAN)
+ IS 'Deletes a product. Returns out_not_found=TRUE if the product does not'
+ ' exist. If the product is locked and in_force is FALSE, returns'
+ ' out_locked=TRUE without deleting. If in_force is TRUE, any locks'
+ ' are released and the product is deleted.';
diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build
@@ -35,6 +35,7 @@ sql_instance_procedures = [
'../pg_statistics_helpers.sql',
'../pg_do_handle_inventory_changes.sql',
'../pg_do_handle_category_changes.sql',
+ '../delete_product.sql',
'../delete_unit.sql',
'../insert_unit.sql',
'../update_unit.sql',
diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c
@@ -1217,20 +1217,38 @@ test_lookup_products (const struct InstanceData *instance,
*
* @param instance the instance to delete the product from.
* @param product the product that should be deleted.
- * @param expected_result the result that we expect the DB to return.
+ * @param force whether to force deletion of a locked product.
+ * @param expect_not_found whether we expect the product to be reported as
+ * unknown.
+ * @param expect_locked whether we expect deletion to be refused because the
+ * product is locked.
* @return 0 when successful, 1 otherwise.
*/
static int
test_delete_product (const struct InstanceData *instance,
const struct ProductData *product,
- enum GNUNET_DB_QueryStatus expected_result)
-{
- TEST_SET_INSTANCE (instance->instance.id, expected_result);
- TEST_COND_RET_ON_FAIL (expected_result ==
- TALER_MERCHANTDB_delete_product (pg,
- instance->instance.id,
- product->id),
+ bool force,
+ bool expect_not_found,
+ bool expect_locked)
+{
+ bool not_found = false;
+ bool locked = false;
+ enum GNUNET_DB_QueryStatus qs;
+
+ TEST_SET_INSTANCE (instance->instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ qs = TALER_MERCHANTDB_delete_product (pg,
+ instance->instance.id,
+ product->id,
+ force,
+ ¬_found,
+ &locked);
+ TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == qs,
"Delete product failed\n");
+ TEST_COND_RET_ON_FAIL (expect_not_found == not_found,
+ "Delete product 'not_found' mismatch\n");
+ TEST_COND_RET_ON_FAIL (expect_locked == locked,
+ "Delete product 'locked' mismatch\n");
return 0;
}
@@ -1506,14 +1524,18 @@ run_test_products (struct TestProducts_Closure *cls)
"Lock product failed\n");
return 1;
}
- /* Test product deletion */
+ /* Test product deletion of an unlocked product */
TEST_RET_ON_FAIL (test_delete_product (&cls->instance,
&cls->products[1],
- GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
- /* Test double deletion fails */
+ false,
+ false,
+ false));
+ /* Test double deletion reports 'not found' */
TEST_RET_ON_FAIL (test_delete_product (&cls->instance,
&cls->products[1],
- GNUNET_DB_STATUS_SUCCESS_NO_RESULTS));
+ false,
+ true,
+ false));
TEST_RET_ON_FAIL (test_lookup_products (&cls->instance,
1,
cls->products));
@@ -1532,9 +1554,34 @@ run_test_products (struct TestProducts_Closure *cls)
"Unlock inventory failed\n");
return 1;
}
+ /* Re-lock products[0] to exercise deletion of a locked product */
+ if (1 != TALER_MERCHANTDB_lock_product (pg,
+ cls->instance.instance.id,
+ cls->products[0].id,
+ &uuid,
+ 1,
+ 0,
+ refund_deadline))
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Lock product failed\n");
+ return 1;
+ }
+ /* Deletion without force must be refused while the product is locked */
TEST_RET_ON_FAIL (test_delete_product (&cls->instance,
&cls->products[0],
- GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ false,
+ false,
+ true));
+ TEST_RET_ON_FAIL (test_lookup_products (&cls->instance,
+ 1,
+ cls->products));
+ /* Forced deletion releases the lock and removes the product */
+ TEST_RET_ON_FAIL (test_delete_product (&cls->instance,
+ &cls->products[0],
+ true,
+ false,
+ false));
TEST_RET_ON_FAIL (test_lookup_products (&cls->instance,
0,
NULL));
diff --git a/src/include/merchant-database/delete_product.h b/src/include/merchant-database/delete_product.h
@@ -28,18 +28,28 @@
struct TALER_MERCHANTDB_PostgresContext;
/**
- * Delete information about a product. Note that the transaction must
- * enforce that no stocks are currently locked.
+ * Delete information about a product. Unless @a force is true, deletion
+ * fails if any stocks of the product are currently locked (by shopping
+ * carts or unpaid orders). If @a force is true, such locks are released
+ * and the product is deleted regardless.
*
* @param pg database context
* @param instance_id instance to delete product of
* @param product_id product to delete
- * @return DB status code, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS
- * if locks prevent deletion OR product unknown
+ * @param force if true, delete even if the product is locked
+ * @param[out] not_found set to true if the product does not exist
+ * @param[out] locked set to true if deletion was refused because the
+ * product is locked and @a force was false
+ * @return DB status code, #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT if the
+ * stored procedure ran (inspect @a not_found and @a locked to
+ * learn the outcome)
*/
enum GNUNET_DB_QueryStatus
TALER_MERCHANTDB_delete_product (struct TALER_MERCHANTDB_PostgresContext *pg,
const char *instance_id,
- const char *product_id);
+ const char *product_id,
+ bool force,
+ bool *not_found,
+ bool *locked);
#endif
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 30
+#define MERCHANT_PROTOCOL_CURRENT 32
/**
* How many configs are we backwards-compatible with?
*/
-#define MERCHANT_PROTOCOL_AGE 6
+#define MERCHANT_PROTOCOL_AGE 8
/**
* How many exchanges do we allow at most per merchant?