merchant

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

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:
Msrc/backend/taler-merchant-httpd_delete-private-orders-ORDER_ID.c | 13++++---------
Msrc/backend/taler-merchant-httpd_delete-private-products-PRODUCT_ID.c | 78+++++++++++++++++++++++++++++++++++++++---------------------------------------
Msrc/backend/taler-merchant-httpd_get-config.c | 2+-
Msrc/backenddb/delete_product.c | 34+++++++++++++++++++++++-----------
Asrc/backenddb/delete_product.sql | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/sql-schema/meson.build | 1+
Msrc/backenddb/test_merchantdb.c | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/include/merchant-database/delete_product.h | 20+++++++++++++++-----
Msrc/lib/merchant_api_get-config.c | 4++--
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, + &not_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, + &not_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?