merchant

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

commit 8f19589b66258f79f54a4a65d4bb221f9a56a6ea
parent 1892db4afacb02dd6935367adb4c20f0dee0ca94
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Sat, 18 Oct 2025 23:09:43 +0200

[inventory] Small fix #0010506 (dd72) ¯\_(ツ)_/¯

Diffstat:
Mcontrib/ci/jobs/0-codespell/dictionary.txt | 3+++
Msrc/backend/Makefile.am | 10++++++++++
Msrc/backend/taler-merchant-httpd.c | 46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_helper.c | 378++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/backend/taler-merchant-httpd_helper.h | 125++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Asrc/backend/taler-merchant-httpd_private-delete-units-ID.c | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_private-delete-units-ID.h | 33+++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_private-get-pos.c | 30++++++++++++++++++++++++------
Msrc/backend/taler-merchant-httpd_private-get-products-ID.c | 29+++++++++++++++++++++++++----
Asrc/backend/taler-merchant-httpd_private-get-units-ID.c | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_private-get-units-ID.h | 33+++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_private-get-units.c | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_private-get-units.h | 33+++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_private-patch-products-ID.c | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Asrc/backend/taler-merchant-httpd_private-patch-units-ID.c | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_private-patch-units-ID.h | 33+++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_private-post-orders.c | 175++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/backend/taler-merchant-httpd_private-post-products-ID-lock.c | 139++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Msrc/backend/taler-merchant-httpd_private-post-products.c | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Asrc/backend/taler-merchant-httpd_private-post-units.c | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_private-post-units.h | 33+++++++++++++++++++++++++++++++++
Msrc/backenddb/Makefile.am | 6++++++
Asrc/backenddb/merchant-0027.sql | 464+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/merchantdb_helper.c | 17+++++++++++++++++
Asrc/backenddb/pg_delete_unit.c | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/pg_delete_unit.h | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/pg_insert_order_lock.c | 40++++++++++++++++++++++++++++++----------
Msrc/backenddb/pg_insert_order_lock.h | 5++++-
Msrc/backenddb/pg_insert_product.c | 11+++++++++--
Msrc/backenddb/pg_insert_product.sql | 31+++++++++++++++++++++++++++++--
Asrc/backenddb/pg_insert_unit.c | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/pg_insert_unit.h | 47+++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/pg_lock_product.c | 48+++++++++++++++++++++++++++++++++++++-----------
Msrc/backenddb/pg_lock_product.h | 3+++
Msrc/backenddb/pg_lookup_all_products.c | 20++++++++++++++++++++
Msrc/backenddb/pg_lookup_product.c | 28+++++++++++++++++++++++++++-
Asrc/backenddb/pg_lookup_units.c | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/pg_lookup_units.h | 43+++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/pg_mark_contract_paid.c | 10++++++++--
Asrc/backenddb/pg_select_unit.c | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/pg_select_unit.h | 43+++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/pg_update_product.c | 11+++++++++--
Msrc/backenddb/pg_update_product.sql | 30+++++++++++++++++++++++++++++-
Asrc/backenddb/pg_update_unit.c | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/pg_update_unit.h | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/plugin_merchantdb_postgres.c | 15+++++++++++++++
Msrc/backenddb/test_merchantdb.c | 38++++++++++++++++++++++----------------
Msrc/include/taler_merchant_service.h | 319++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/include/taler_merchant_testing_lib.h | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/taler_merchant_util.h | 7+++++++
Msrc/include/taler_merchantdb_lib.h | 9+++++++++
Msrc/include/taler_merchantdb_plugin.h | 219++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/lib/Makefile.am | 5+++++
Msrc/lib/merchant_api_common.c | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/merchant_api_common.h | 39+++++++++++++++++++++++++++++++++++++++
Asrc/lib/merchant_api_delete_unit.c | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/merchant_api_get_product.c | 13+++++++++++++
Asrc/lib/merchant_api_get_unit.c | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/merchant_api_get_units.c | 329+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/merchant_api_lock_product.c | 42++++++++++++++++++++++++++++++++++++++----
Msrc/lib/merchant_api_patch_product.c | 127++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Asrc/lib/merchant_api_patch_unit.c | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/merchant_api_post_orders.c | 10++++++++--
Msrc/lib/merchant_api_post_products.c | 151+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Asrc/lib/merchant_api_post_units.c | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/Makefile.am | 7++++++-
Msrc/testing/test_merchant_api.c | 315+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/test_merchant_order_creation.sh | 8++++----
Msrc/testing/test_merchant_product_creation.sh | 14+++++++-------
Asrc/testing/testing_api_cmd_delete_unit.c | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/testing_api_cmd_get_product.c | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Asrc/testing/testing_api_cmd_get_unit.c | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/testing/testing_api_cmd_get_units.c | 361+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/testing_api_cmd_lock_product.c | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/testing/testing_api_cmd_patch_product.c | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Asrc/testing/testing_api_cmd_patch_unit.c | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/testing_api_cmd_post_orders.c | 58++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/testing/testing_api_cmd_post_products.c | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Asrc/testing/testing_api_cmd_post_units.c | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
79 files changed, 8378 insertions(+), 279 deletions(-)

diff --git a/contrib/ci/jobs/0-codespell/dictionary.txt b/contrib/ci/jobs/0-codespell/dictionary.txt @@ -44,3 +44,5 @@ updateing wan wih decose +ue +bu +\ No newline at end of file diff --git a/src/backend/Makefile.am b/src/backend/Makefile.am @@ -90,6 +90,8 @@ taler_merchant_httpd_SOURCES = \ taler-merchant-httpd_private-delete-account-ID.h \ taler-merchant-httpd_private-delete-categories-ID.c \ taler-merchant-httpd_private-delete-categories-ID.h \ + taler-merchant-httpd_private-delete-units-ID.c \ + taler-merchant-httpd_private-delete-units-ID.h \ taler-merchant-httpd_private-delete-instances-ID.c \ taler-merchant-httpd_private-delete-instances-ID.h \ taler-merchant-httpd_private-delete-instances-ID-token.c \ @@ -114,8 +116,12 @@ taler_merchant_httpd_SOURCES = \ taler-merchant-httpd_private-get-accounts-ID.h \ taler-merchant-httpd_private-get-categories.c \ taler-merchant-httpd_private-get-categories.h \ + taler-merchant-httpd_private-get-units.c \ + taler-merchant-httpd_private-get-units.h \ taler-merchant-httpd_private-get-categories-ID.c \ taler-merchant-httpd_private-get-categories-ID.h \ + taler-merchant-httpd_private-get-units-ID.c \ + taler-merchant-httpd_private-get-units-ID.h \ taler-merchant-httpd_private-get-instances.c \ taler-merchant-httpd_private-get-instances.h \ taler-merchant-httpd_private-get-instances-ID.c \ @@ -158,6 +164,8 @@ taler_merchant_httpd_SOURCES = \ taler-merchant-httpd_private-patch-accounts-ID.h \ taler-merchant-httpd_private-patch-categories-ID.c \ taler-merchant-httpd_private-patch-categories-ID.h \ + taler-merchant-httpd_private-patch-units-ID.c \ + taler-merchant-httpd_private-patch-units-ID.h \ taler-merchant-httpd_private-patch-instances-ID.c \ taler-merchant-httpd_private-patch-instances-ID.h \ taler-merchant-httpd_private-patch-orders-ID-forget.c \ @@ -176,6 +184,8 @@ taler_merchant_httpd_SOURCES = \ taler-merchant-httpd_private-post-account.h \ taler-merchant-httpd_private-post-categories.c \ taler-merchant-httpd_private-post-categories.h \ + taler-merchant-httpd_private-post-units.c \ + taler-merchant-httpd_private-post-units.h \ taler-merchant-httpd_private-post-instances.c \ taler-merchant-httpd_private-post-instances.h \ taler-merchant-httpd_private-post-instances-ID-auth.c \ diff --git a/src/backend/taler-merchant-httpd.c b/src/backend/taler-merchant-httpd.c @@ -39,6 +39,7 @@ #include "taler-merchant-httpd_mfa.h" #include "taler-merchant-httpd_private-delete-account-ID.h" #include "taler-merchant-httpd_private-delete-categories-ID.h" +#include "taler-merchant-httpd_private-delete-units-ID.h" #include "taler-merchant-httpd_private-delete-instances-ID.h" #include "taler-merchant-httpd_private-delete-instances-ID-token.h" #include "taler-merchant-httpd_private-delete-products-ID.h" @@ -52,6 +53,8 @@ #include "taler-merchant-httpd_private-get-accounts-ID.h" #include "taler-merchant-httpd_private-get-categories.h" #include "taler-merchant-httpd_private-get-categories-ID.h" +#include "taler-merchant-httpd_private-get-units.h" +#include "taler-merchant-httpd_private-get-units-ID.h" #include "taler-merchant-httpd_private-get-incoming.h" #include "taler-merchant-httpd_private-get-instances.h" #include "taler-merchant-httpd_private-get-instances-ID.h" @@ -75,6 +78,7 @@ #include "taler-merchant-httpd_private-get-webhooks-ID.h" #include "taler-merchant-httpd_private-patch-accounts-ID.h" #include "taler-merchant-httpd_private-patch-categories-ID.h" +#include "taler-merchant-httpd_private-patch-units-ID.h" #include "taler-merchant-httpd_private-patch-instances-ID.h" #include "taler-merchant-httpd_private-patch-orders-ID-forget.h" #include "taler-merchant-httpd_private-patch-otp-devices-ID.h" @@ -84,6 +88,7 @@ #include "taler-merchant-httpd_private-patch-webhooks-ID.h" #include "taler-merchant-httpd_private-post-account.h" #include "taler-merchant-httpd_private-post-categories.h" +#include "taler-merchant-httpd_private-post-units.h" #include "taler-merchant-httpd_private-post-instances.h" #include "taler-merchant-httpd_private-post-instances-ID-auth.h" #include "taler-merchant-httpd_private-post-instances-ID-token.h" @@ -1455,6 +1460,47 @@ url_handler (void *cls, /* allow category data of up to 8 kb, that should be plenty */ .max_upload = 1024 * 8 }, + /* GET /units: */ + { + .url_prefix = "/units", + .method = MHD_HTTP_METHOD_GET, + .handler = &TMH_private_get_units + }, + /* POST /units: */ + { + .url_prefix = "/units", + .method = MHD_HTTP_METHOD_POST, + .permission = "units-write", + .handler = &TMH_private_post_units, + .max_upload = 1024 * 8 + }, + /* GET /units/$UNIT: */ + { + .url_prefix = "/units/", + .method = MHD_HTTP_METHOD_GET, + .have_id_segment = true, + .allow_deleted_instance = true, + .handler = &TMH_private_get_units_ID + }, + /* DELETE /units/$UNIT: */ + { + .url_prefix = "/units/", + .method = MHD_HTTP_METHOD_DELETE, + .permission = "units-write", + .have_id_segment = true, + .allow_deleted_instance = true, + .handler = &TMH_private_delete_units_ID + }, + /* PATCH /units/$UNIT: */ + { + .url_prefix = "/units/", + .method = MHD_HTTP_METHOD_PATCH, + .permission = "units-write", + .have_id_segment = true, + .allow_deleted_instance = true, + .handler = &TMH_private_patch_units_ID, + .max_upload = 1024 * 8 + }, /* GET /products: */ { .url_prefix = "/products", diff --git a/src/backend/taler-merchant-httpd_helper.c b/src/backend/taler-merchant-httpd_helper.c @@ -21,7 +21,6 @@ #include "platform.h" #include <gnunet/gnunet_util_lib.h> #include <gnunet/gnunet_db_lib.h> -#include <taler/taler_util.h> #include <taler/taler_json_lib.h> #include "taler-merchant-httpd_helper.h" #include <taler/taler_templating_lib.h> @@ -29,6 +28,366 @@ enum GNUNET_GenericReturnValue +TMH_parse_fractional_string (const char *value, + int64_t *integer_part, + uint32_t *fractional_part) +{ + const char *ptr; + uint64_t integer = 0; + uint32_t frac = 0; + unsigned int digits = 0; + + GNUNET_assert (NULL != integer_part); + GNUNET_assert (NULL != fractional_part); + + if (NULL == value) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + ptr = value; + if ('\0' == *ptr) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + if ('-' == *ptr) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + if (! isdigit ((unsigned char) *ptr)) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + while (isdigit ((unsigned char) *ptr)) + { + unsigned int digit = (unsigned int) (*ptr - '0'); + + if (integer > (UINT64_MAX - digit) / 10) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + integer = integer * 10 + digit; + ptr++; + } + if ('.' == *ptr) + { + ptr++; + if ('\0' == *ptr) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + while (isdigit ((unsigned char) *ptr)) + { + unsigned int digit = (unsigned int) (*ptr - '0'); + + if (digits >= MERCHANT_UNIT_FRAC_MAX_DIGITS) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + frac = (uint32_t) (frac * 10 + digit); + digits++; + ptr++; + } + while (digits < MERCHANT_UNIT_FRAC_MAX_DIGITS) + { + frac *= 10; + digits++; + } + } + if ('\0' != *ptr) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + if (integer > (uint64_t) INT64_MAX) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + *integer_part = integer; + *fractional_part = frac; + return GNUNET_OK; +} + + +enum GNUNET_GenericReturnValue +TMH_process_quantity_inputs (enum TMH_ValueKind kind, + bool allow_fractional, + bool int_missing, + int64_t int_raw, + bool str_missing, + const char *str_raw, + uint64_t *int_out, + uint32_t *frac_out, + const char **error_param) +{ + static char errbuf[128]; + int64_t parsed_int = 0; + uint32_t parsed_frac = 0; + const char *int_field = (TMH_VK_STOCK == kind) + ? "total_stock" + : "quantity"; + const char *str_field = (TMH_VK_STOCK == kind) + ? "unit_total_stock" + : "unit_quantity"; + + *error_param = NULL; + + if (int_missing && str_missing) + { + GNUNET_snprintf (errbuf, + sizeof (errbuf), + "missing %s and %s", + int_field, + str_field); + *error_param = errbuf; + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + + if (! str_missing) + { + if ( (TMH_VK_STOCK == kind) && + (0 == strcmp ("-1", + str_raw)) ) + { + parsed_int = -1; + parsed_frac = 0; + } + else + { + if (GNUNET_OK != + TMH_parse_fractional_string (str_raw, + &parsed_int, + &parsed_frac)) + { + GNUNET_snprintf (errbuf, + sizeof (errbuf), + "malformed %s", + str_field); + *error_param = errbuf; + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + } + } + + if ( (! int_missing) && (! str_missing) ) + { + if ( (parsed_int != int_raw) || (0 != parsed_frac) ) + { + GNUNET_snprintf (errbuf, + sizeof (errbuf), + "%s/%s mismatch", + int_field, + str_field); + *error_param = errbuf; + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + } + else if (int_missing) + { + int_raw = parsed_int; + } + + if ( (TMH_VK_STOCK == kind) && (-1 == int_raw) ) + { + if ( (! str_missing) && (0 != parsed_frac) ) + { + GNUNET_snprintf (errbuf, + sizeof (errbuf), + "fractional part forbidden with %s='-1'", + str_field); + *error_param = errbuf; + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + *int_out = INT64_MAX; + *frac_out = INT32_MAX; + return GNUNET_OK; + } + + if (int_raw < 0) + { + GNUNET_snprintf (errbuf, + sizeof (errbuf), + "%s must be non-negative", + int_field); + *error_param = errbuf; + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + + if (! allow_fractional) + { + if ( (! str_missing) && (0 != parsed_frac) ) + { + GNUNET_snprintf (errbuf, + sizeof (errbuf), + "fractional part not allowed for %s", + str_field); + *error_param = errbuf; + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + parsed_frac = 0; + } + else if (! str_missing) + { + if (parsed_frac >= MERCHANT_UNIT_FRAC_BASE) + { + GNUNET_snprintf (errbuf, + sizeof (errbuf), + "%s fractional part exceeds base %u", + str_field, + MERCHANT_UNIT_FRAC_BASE); + *error_param = errbuf; + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + } + + *int_out = (uint64_t) int_raw; + *frac_out = parsed_frac; + return GNUNET_OK; +} + + +void +TMH_format_fractional_string (enum TMH_ValueKind kind, + uint64_t integer, + uint32_t fractional, + size_t buffer_length, + char buffer[static buffer_length]) +{ + GNUNET_assert (0 < buffer_length); + + if ( (TMH_VK_STOCK == kind) && + (INT64_MAX == (int64_t) integer) && + (INT32_MAX == (int32_t) fractional) ) + { + GNUNET_snprintf (buffer, + buffer_length, + "-1"); + return; + } + + GNUNET_assert ( (TMH_VK_QUANTITY != kind) || + ((INT64_MAX != (int64_t) integer) && + (INT32_MAX != (int32_t) fractional)) ); + GNUNET_assert (fractional < MERCHANT_UNIT_FRAC_BASE); + + if (0 == fractional) + { + GNUNET_snprintf (buffer, + buffer_length, + "%lu", + integer); + return; + } + { + char frac_buf[MERCHANT_UNIT_FRAC_MAX_DIGITS + 1]; + size_t idx; + + GNUNET_snprintf (frac_buf, + sizeof (frac_buf), + "%0*u", + MERCHANT_UNIT_FRAC_MAX_DIGITS, + (unsigned int) fractional); + for (idx = strlen (frac_buf); idx > 0; idx--) + { + if ('0' != frac_buf[idx - 1]) + break; + frac_buf[idx - 1] = '\0'; + } + GNUNET_snprintf (buffer, + buffer_length, + "%lu.%s", + integer, + frac_buf); + } +} + + +void +TMH_quantity_defaults_from_unit (const struct TMH_MerchantInstance *mi, + const char *unit, + bool *allow_fractional, + uint32_t *precision_level) +{ + GNUNET_assert (NULL != allow_fractional); + GNUNET_assert (NULL != precision_level); + if (GNUNET_OK != + TMH_unit_defaults_for_instance (mi, + unit, + allow_fractional, + precision_level)) + { + *allow_fractional = false; + *precision_level = 0; + } +} + + +enum GNUNET_GenericReturnValue +TMH_unit_defaults_for_instance (const struct TMH_MerchantInstance *mi, + const char *unit, + bool *allow_fractional, + uint32_t *precision_level) +{ + struct TALER_MERCHANTDB_UnitDetails ud = { 0 }; + enum GNUNET_DB_QueryStatus qs; + bool allow = false; + uint32_t precision = 0; + + GNUNET_assert (NULL != allow_fractional); + GNUNET_assert (NULL != precision_level); + + qs = TMH_db->select_unit (TMH_db->cls, + mi->settings.id, + unit, + &ud); + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + case GNUNET_DB_STATUS_SOFT_ERROR: + TALER_MERCHANTDB_unit_details_free (&ud); + return GNUNET_SYSERR; + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + allow = ud.unit_allow_fraction; + precision = ud.unit_precision_level; + break; + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + break; + default: + GNUNET_break (0); + TALER_MERCHANTDB_unit_details_free (&ud); + return GNUNET_SYSERR; + } + TALER_MERCHANTDB_unit_details_free (&ud); + + /* This is definitely not supposed to happen + combination of allow -> false, and precision > 0 + yet let's fix it */ + if (! allow) + { + GNUNET_break (0); + precision = 0; + } + *allow_fractional = allow; + *precision_level = precision; + return GNUNET_OK; +} + + +enum GNUNET_GenericReturnValue TMH_cmp_wire_account ( const json_t *account, const struct TMH_WireMethod *wm) @@ -307,6 +666,9 @@ TMH_products_array_valid (const json_t *products) const char *product_id = NULL; const char *description; uint64_t quantity = 0; + bool quantity_missing = true; + const char *unit_quantity = NULL; + bool unit_quantity_missing = true; const char *unit = NULL; struct TALER_Amount price = { .value = 0 }; const char *image_data_url = NULL; @@ -322,7 +684,11 @@ TMH_products_array_valid (const json_t *products) GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_uint64 ("quantity", &quantity), - NULL), + &quantity_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("unit_quantity", + &unit_quantity), + &unit_quantity_missing), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_string ("unit", &unit), @@ -360,6 +726,14 @@ TMH_products_array_valid (const json_t *products) ename); return false; } + if (quantity_missing) + { + if (unit_quantity_missing) + { + GNUNET_break_op (0); + valid = false; + } + } if ( (NULL != image_data_url) && (! TMH_image_data_url_valid (image_data_url)) ) { diff --git a/src/backend/taler-merchant-httpd_helper.h b/src/backend/taler-merchant-httpd_helper.h @@ -23,6 +23,8 @@ #ifndef TALER_EXCHANGE_HTTPD_HELPER_H #define TALER_EXCHANGE_HTTPD_HELPER_H +#define TMH_MAX_FRACTIONAL_PRECISION_LEVEL 6 + #include "taler-merchant-httpd.h" @@ -73,6 +75,115 @@ TMH_products_array_valid (const json_t *products); /** + * Parse decimal quantity expressed as string for request handling. + * + * @param value string to parse + * @param[out] integer_part result integer component + * @param[out] fractional_part result fractional component (0..MERCHANT_UNIT_FRAC_BASE-1) + * @return #GNUNET_OK on success, #GNUNET_SYSERR on validation failure + */ +enum GNUNET_GenericReturnValue +TMH_parse_fractional_string (const char *value, + int64_t *integer_part, + uint32_t *fractional_part); + +/** + * Kind of fixed-decimal value the helpers operate on. + * Primarily distinguishes how special sentinel values (such as "-1" + * meaning infinity for stock) must be encoded. + */ +enum TMH_ValueKind +{ + TMH_VK_QUANTITY, /* -1 is illegal */ + TMH_VK_STOCK /* -1 means "infinity" */ +}; + +/** + * Extract a fixed-decimal number that may be supplied either + * - as pure integer (e.g. "total_stock"), or + * - as decimal text (e.g. "unit_total_stock"). + * + * Rules: + * - If both forms are missing -> error. + * - If both are present -> they must match and the decimal must have no fraction. + * - For kind == TMH_VK_STOCK the integer value -1 represents infinity. + * + * @param kind See #TMH_ValueKind + * @param allow_fractional False: any fractional part is rejected + * @param int_missing True if client omitted the integer field + * @param int_raw Raw integer (undefined if @a int_missing is true) + * @param str_missing True if client omitted the string field + * @param str_raw Raw UTF-8 string (undefined if @a str_missing is true) + * @param[out] int_out Canonicalised integer part + * @param[out] frac_out Canonicalised fractional part + * @param[out] error_param Set to offending field name on failure + * @param int_field Integer field name (for error reporting) + * @param str_field String field name (for error reporting) + * @return #GNUNET_OK on success, #GNUNET_SYSERR otherwise + */ +enum GNUNET_GenericReturnValue +TMH_process_quantity_inputs (enum TMH_ValueKind kind, + bool allow_fractional, + bool int_missing, + int64_t int_raw, + bool str_missing, + const char *str_raw, + uint64_t *int_out, + uint32_t *frac_out, + const char **error_param); + +/** + * Lookup the defaults for @a unit within @a mi and fall back to sane + * values (disallow fractional quantities, zero precision) if no data + * is available. + * + * @param mi merchant instance whose defaults should be consulted (must not be NULL) + * @param unit textual unit name (must not be NULL or empty) + * @param allow_fractional updated with whether fractional quantities are allowed (must not be NULL) + * @param precision_level updated with the supported precision (must not be NULL) + */ +void +TMH_quantity_defaults_from_unit (const struct TMH_MerchantInstance *mi, + const char *unit, + bool *allow_fractional, + uint32_t *precision_level); + +/** + * Query the database for precision defaults tied to @a unit within + * @a mi. Returns #GNUNET_OK even if no unit information exists, in + * which case the out-parameters remain at their implicit defaults. + * + * @param mi merchant instance whose unit table is inspected (must not be NULL) + * @param unit textual unit name (must not be NULL or empty) + * @param allow_fractional updated with whether fractional quantities are allowed (must not be NULL) + * @param precision_level updated with the supported precision (must not be NULL) + * @return #GNUNET_OK on success, #GNUNET_SYSERR on database failure + */ +enum GNUNET_GenericReturnValue +TMH_unit_defaults_for_instance (const struct TMH_MerchantInstance *mi, + const char *unit, + bool *allow_fractional, + uint32_t *precision_level); + +/** + * Format a fixed-decimal pair into canonical string representation. + * Recognises INT64_MAX / INT32_MAX as the "-1" sentinel used for + * infinite stock if @a kind equals #TMH_VK_STOCK. + * + * @param kind specifies whether sentinel values are permitted + * @param integer integer portion + * @param fractional fractional portion (0..MERCHANT_UNIT_FRAC_BASE-1 or sentinel) + * @param buffer output buffer + * @param buffer_length length of @a buffer + */ +void +TMH_format_fractional_string (enum TMH_ValueKind kind, + uint64_t integer, + uint32_t fractional, + size_t buffer_length, + char buffer[static buffer_length]); + +/** * Check if @a image_data_url is a valid image * data URL. Does not validate the actual payload, * only the syntax and that it properly claims to @@ -238,13 +349,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_private-delete-units-ID.c b/src/backend/taler-merchant-httpd_private-delete-units-ID.c @@ -0,0 +1,81 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-delete-units-ID.c + * @brief implement DELETE /private/units/$UNIT + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_private-delete-units-ID.h" + + +MHD_RESULT +TMH_private_delete_units_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + enum GNUNET_DB_QueryStatus qs; + bool no_instance = false; + bool no_unit = false; + bool builtin_conflict = false; + + GNUNET_assert (NULL != hc->infix); + qs = TMH_db->delete_unit (TMH_db->cls, + hc->instance->settings.id, + hc->infix, + &no_instance, + &no_unit, + &builtin_conflict); + switch (qs) + { + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + case GNUNET_DB_STATUS_SOFT_ERROR: + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_SOFT_FAILURE, + "delete_unit"); + case GNUNET_DB_STATUS_HARD_ERROR: + default: + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "delete_unit"); + } + if (no_instance) + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN, + hc->instance->settings.id); + if (no_unit) + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_UNIT_UNKNOWN, + hc->infix); + if (builtin_conflict) + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_GENERIC_UNIT_BUILTIN, + hc->infix); + return TALER_MHD_reply_static (connection, + MHD_HTTP_NO_CONTENT, + NULL, + NULL, + 0); +} + + +/* end of taler-merchant-httpd_private-delete-units-ID.c */ diff --git a/src/backend/taler-merchant-httpd_private-delete-units-ID.h b/src/backend/taler-merchant-httpd_private-delete-units-ID.h @@ -0,0 +1,33 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-delete-units-ID.h + * @brief implement DELETE /private/units/$UNIT + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_PRIVATE_DELETE_UNITS_ID_H +#define TALER_MERCHANT_HTTPD_PRIVATE_DELETE_UNITS_ID_H + +#include "taler-merchant-httpd.h" + + +MHD_RESULT +TMH_private_delete_units_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +/* end of taler-merchant-httpd_private-delete-units-ID.h */ +#endif diff --git a/src/backend/taler-merchant-httpd_private-get-pos.c b/src/backend/taler-merchant-httpd_private-get-pos.c @@ -21,6 +21,7 @@ #include "platform.h" #include "taler-merchant-httpd_private-get-pos.h" #include <taler/taler_json_lib.h> +#include "taler-merchant-httpd_helper.h" /** * Closure for add_product(). @@ -96,6 +97,8 @@ add_product (void *cls, struct Context *ctx = cls; json_t *pa = ctx->pa; json_t *cata; + int64_t total_stock_api; + char unit_total_stock_buf[64]; cata = json_array (); GNUNET_assert (NULL != cata); @@ -112,6 +115,16 @@ add_product (void *cls, cata, json_integer (0))); } + if (INT64_MAX == pd->total_stock) + total_stock_api = -1; + else + total_stock_api = (int64_t) pd->total_stock; + TMH_format_fractional_string (TMH_VK_STOCK, + pd->total_stock, + pd->total_stock_frac, + sizeof (unit_total_stock_buf), + unit_total_stock_buf); + GNUNET_assert ( 0 == json_array_append_new ( @@ -127,6 +140,9 @@ add_product (void *cls, pd->unit), TALER_JSON_pack_amount ("price", &pd->price), + TALER_JSON_pack_amount_array ("unit_price", + pd->price_array_length, + pd->price_array), GNUNET_JSON_pack_allow_null ( GNUNET_JSON_pack_string ("image", pd->image)), @@ -135,12 +151,14 @@ add_product (void *cls, GNUNET_JSON_pack_allow_null ( GNUNET_JSON_pack_array_incref ("taxes", (json_t *) pd->taxes)), - (INT64_MAX == pd->total_stock) - ? GNUNET_JSON_pack_int64 ("total_stock", - pd->total_stock) - : GNUNET_JSON_pack_allow_null ( - GNUNET_JSON_pack_string ("total_stock", - NULL)), + GNUNET_JSON_pack_int64 ("total_stock", + total_stock_api), + GNUNET_JSON_pack_string ("unit_total_stock", + unit_total_stock_buf), + GNUNET_JSON_pack_bool ("unit_allow_fraction", + pd->allow_fractional_quantity), + GNUNET_JSON_pack_uint64 ("unit_precision_level", + pd->fractional_precision_level), GNUNET_JSON_pack_uint64 ("minimum_age", pd->minimum_age), GNUNET_JSON_pack_uint64 ("product_serial", diff --git a/src/backend/taler-merchant-httpd_private-get-products-ID.c b/src/backend/taler-merchant-httpd_private-get-products-ID.c @@ -21,7 +21,8 @@ #include "platform.h" #include "taler-merchant-httpd_private-get-products-ID.h" #include <taler/taler_json_lib.h> - +#include "taler-merchant-httpd_helper.h" +#include <taler/taler_util.h> /** * Handle a GET "/products/$ID" request. @@ -78,6 +79,19 @@ TMH_private_get_products_ID ( { MHD_RESULT ret; + int64_t total_stock_api; + char unit_total_stock_buf[64]; + + if (INT64_MAX == pd.total_stock) + total_stock_api = -1; + else + total_stock_api = (int64_t) pd.total_stock; + + TMH_format_fractional_string (TMH_VK_STOCK, + pd.total_stock, + pd.total_stock_frac, + sizeof (unit_total_stock_buf), + unit_total_stock_buf); ret = TALER_MHD_REPLY_JSON_PACK ( connection, @@ -101,9 +115,16 @@ TMH_private_get_products_ID ( GNUNET_JSON_pack_array_incref ("taxes", pd.taxes)), GNUNET_JSON_pack_int64 ("total_stock", - (INT64_MAX == pd.total_stock) - ? -1LL - : pd.total_stock), + total_stock_api), + GNUNET_JSON_pack_string ("unit_total_stock", + unit_total_stock_buf), + GNUNET_JSON_pack_bool ("unit_allow_fraction", + pd.allow_fractional_quantity), + GNUNET_JSON_pack_uint64 ("unit_precision_level", + pd.fractional_precision_level), + TALER_JSON_pack_amount_array ("unit_price", + pd.price_array_length, + pd.price_array), GNUNET_JSON_pack_uint64 ("total_sold", pd.total_sold), GNUNET_JSON_pack_uint64 ("total_lost", diff --git a/src/backend/taler-merchant-httpd_private-get-units-ID.c b/src/backend/taler-merchant-httpd_private-get-units-ID.c @@ -0,0 +1,88 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-get-units-ID.c + * @brief implement GET /private/units/$UNIT + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_private-get-units-ID.h" + + +MHD_RESULT +TMH_private_get_units_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct TALER_MERCHANTDB_UnitDetails ud = { 0 }; + enum GNUNET_DB_QueryStatus qs; + MHD_RESULT ret; + + (void) rh; + GNUNET_assert (NULL != hc->infix); + qs = TMH_db->select_unit (TMH_db->cls, + hc->instance->settings.id, + hc->infix, + &ud); + switch (qs) + { + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_UNIT_UNKNOWN, + hc->infix); + case GNUNET_DB_STATUS_SOFT_ERROR: + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "unit"); + } + + ret = TALER_MHD_REPLY_JSON_PACK (connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_uint64 ("unit_serial", + ud.unit_serial), + GNUNET_JSON_pack_string ("unit", + ud.unit), + GNUNET_JSON_pack_string ("unit_name_long", + ud.unit_name_long), + GNUNET_JSON_pack_object_incref ( + "unit_name_long_i18n", + ud.unit_name_long_i18n), + GNUNET_JSON_pack_string ("unit_name_short", + ud.unit_name_short), + GNUNET_JSON_pack_object_incref ( + "unit_name_short_i18n", + ud.unit_name_short_i18n), + GNUNET_JSON_pack_bool ("unit_allow_fraction", + ud.unit_allow_fraction), + GNUNET_JSON_pack_uint64 ( + "unit_precision_level", + ud.unit_precision_level), + GNUNET_JSON_pack_bool ("unit_active", + ud.unit_active), + GNUNET_JSON_pack_bool ("unit_builtin", + ud.unit_builtin)); + TALER_MERCHANTDB_unit_details_free (&ud); + return ret; +} + + +/* end of taler-merchant-httpd_private-get-units-ID.c */ diff --git a/src/backend/taler-merchant-httpd_private-get-units-ID.h b/src/backend/taler-merchant-httpd_private-get-units-ID.h @@ -0,0 +1,33 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-get-units-ID.h + * @brief implement GET /private/units/$UNIT + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_PRIVATE_GET_UNITS_ID_H +#define TALER_MERCHANT_HTTPD_PRIVATE_GET_UNITS_ID_H + +#include "taler-merchant-httpd.h" + + +MHD_RESULT +TMH_private_get_units_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +/* end of taler-merchant-httpd_private-get-units-ID.h */ +#endif diff --git a/src/backend/taler-merchant-httpd_private-get-units.c b/src/backend/taler-merchant-httpd_private-get-units.c @@ -0,0 +1,91 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-get-units.c + * @brief implement GET /private/units + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_private-get-units.h" + + +static void +add_unit (void *cls, + uint64_t unit_serial, + const struct TALER_MERCHANTDB_UnitDetails *ud) +{ + json_t *ua = cls; + + GNUNET_assert ( + 0 == + json_array_append_new ( + ua, + GNUNET_JSON_PACK ( + GNUNET_JSON_pack_uint64 ("unit_serial", + unit_serial), + GNUNET_JSON_pack_string ("unit", + ud->unit), + GNUNET_JSON_pack_string ("unit_name_long", + ud->unit_name_long), + GNUNET_JSON_pack_object_incref ("unit_name_long_i18n", + (json_t *) ud->unit_name_long_i18n), + GNUNET_JSON_pack_string ("unit_name_short", + ud->unit_name_short), + GNUNET_JSON_pack_object_incref ("unit_name_short_i18n", + (json_t *) ud->unit_name_short_i18n), + GNUNET_JSON_pack_bool ("unit_allow_fraction", + ud->unit_allow_fraction), + GNUNET_JSON_pack_uint64 ("unit_precision_level", + ud->unit_precision_level), + GNUNET_JSON_pack_bool ("unit_active", + ud->unit_active), + GNUNET_JSON_pack_bool ("unit_builtin", + ud->unit_builtin)))); +} + + +MHD_RESULT +TMH_private_get_units (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + json_t *ua; + enum GNUNET_DB_QueryStatus qs; + + (void) rh; + ua = json_array (); + GNUNET_assert (NULL != ua); + qs = TMH_db->lookup_units (TMH_db->cls, + hc->instance->settings.id, + &add_unit, + ua); + if (0 > qs) + { + GNUNET_break (0); + json_decref (ua); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + NULL); + } + return TALER_MHD_REPLY_JSON_PACK (connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_array_steal ("units", + ua)); +} + + +/* end of taler-merchant-httpd_private-get-units.c */ diff --git a/src/backend/taler-merchant-httpd_private-get-units.h b/src/backend/taler-merchant-httpd_private-get-units.h @@ -0,0 +1,33 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-get-units.h + * @brief implement GET /private/units + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_PRIVATE_GET_UNITS_H +#define TALER_MERCHANT_HTTPD_PRIVATE_GET_UNITS_H + +#include "taler-merchant-httpd.h" + + +MHD_RESULT +TMH_private_get_units (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +/* end of taler-merchant-httpd_private-get-units.h */ +#endif diff --git a/src/backend/taler-merchant-httpd_private-patch-products-ID.c b/src/backend/taler-merchant-httpd_private-patch-products-ID.c @@ -47,6 +47,15 @@ TMH_private_patch_products_ID ( struct TALER_MERCHANTDB_ProductDetails pd = {0}; const json_t *categories = NULL; int64_t total_stock; + const char *unit_total_stock = NULL; + bool unit_total_stock_missing; + bool total_stock_missing; + bool price_missing; + bool unit_price_missing; + bool unit_allow_fraction; + bool unit_allow_fraction_missing; + uint32_t unit_precision_level; + bool unit_precision_missing; enum GNUNET_DB_QueryStatus qs; struct GNUNET_JSON_Specification spec[] = { /* new in protocol v20, thus optional for backwards-compatibility */ @@ -62,8 +71,10 @@ TMH_private_patch_products_ID ( NULL), GNUNET_JSON_spec_string ("unit", (const char **) &pd.unit), - TALER_JSON_spec_amount_any ("price", - &pd.price), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_any ("price", + &pd.price), + &price_missing), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_string ("image", (const char **) &pd.image), @@ -76,8 +87,27 @@ TMH_private_patch_products_ID ( GNUNET_JSON_spec_array_const ("categories", &categories), NULL), - GNUNET_JSON_spec_int64 ("total_stock", - &total_stock), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("unit_total_stock", + &unit_total_stock), + &unit_total_stock_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_int64 ("total_stock", + &total_stock), + &total_stock_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_bool ("unit_allow_fraction", + &unit_allow_fraction), + &unit_allow_fraction_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_uint32 ("unit_precision_level", + &unit_precision_level), + &unit_precision_missing), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_any_array ("unit_price", + &pd.price_array_length, + &pd.price_array), + &unit_price_missing), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_uint64 ("total_lost", &pd.total_lost), @@ -124,19 +154,100 @@ TMH_private_patch_products_ID ( if (NULL == pd.product_name) pd.product_name = pd.description; } - if (total_stock < -1) + if (! unit_price_missing) { - GNUNET_break_op (0); - ret = TALER_MHD_reply_with_error (connection, - MHD_HTTP_BAD_REQUEST, - TALER_EC_GENERIC_PARAMETER_MALFORMED, - "total_stock"); - goto cleanup; + if (! price_missing) + { + if (0 != TALER_amount_cmp (&pd.price, + &pd.price_array[0])) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "price,unit_price mismatch"); + goto cleanup; + } + } + else + { + pd.price = pd.price_array[0]; + price_missing = false; + } } - if (-1 == total_stock) - pd.total_stock = INT64_MAX; else - pd.total_stock = (uint64_t) total_stock; + { + if (price_missing) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "price missing"); + goto cleanup; + } + pd.price_array = GNUNET_new_array (1, + struct TALER_Amount); + pd.price_array[0] = pd.price; + pd.price_array_length = 1; + } + if (! unit_precision_missing) + { + if (unit_precision_level > TMH_MAX_FRACTIONAL_PRECISION_LEVEL) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "unit_precision_level"); + goto cleanup; + } + } + { + bool default_allow_fractional; + uint32_t default_precision_level; + + if (GNUNET_OK != + TMH_unit_defaults_for_instance (mi, + pd.unit, + &default_allow_fractional, + &default_precision_level)) + { + GNUNET_break (0); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "unit defaults"); + goto cleanup; + } + if (unit_allow_fraction_missing) + unit_allow_fraction = default_allow_fractional; + if (unit_precision_missing) + unit_precision_level = default_precision_level; + + if (! unit_allow_fraction) + unit_precision_level = 0; + pd.fractional_precision_level = unit_precision_level; + } + { + const char *eparam; + if (GNUNET_OK != + TMH_process_quantity_inputs (TMH_VK_STOCK, + unit_allow_fraction, + total_stock_missing, + total_stock, + unit_total_stock_missing, + unit_total_stock, + &pd.total_stock, + &pd.total_stock_frac, + &eparam)) + { + ret = TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + eparam); + goto cleanup; + } + pd.allow_fractional_quantity = unit_allow_fraction; + } if (NULL == pd.address) pd.address = json_object (); @@ -325,6 +436,7 @@ TMH_private_patch_products_ID ( 0); cleanup: GNUNET_free (cats); + GNUNET_free (pd.price_array); GNUNET_JSON_parse_free (spec); return ret; } diff --git a/src/backend/taler-merchant-httpd_private-patch-units-ID.c b/src/backend/taler-merchant-httpd_private-patch-units-ID.c @@ -0,0 +1,237 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-patch-units-ID.c + * @brief implement PATCH /private/units/$UNIT + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_private-patch-units-ID.h" +#include "taler-merchant-httpd_helper.h" +#include <taler/taler_json_lib.h> + +#define TMH_MAX_UNIT_PRECISION_LEVEL 6 + + +MHD_RESULT +TMH_private_patch_units_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct TMH_MerchantInstance *mi = hc->instance; + const char *unit_id = hc->infix; + struct TALER_MERCHANTDB_UnitDetails nud = { 0 }; + bool unit_allow_fraction_missing = true; + bool unit_precision_missing = true; + bool unit_active_missing = true; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("unit_name_long", + (const char **) &nud.unit_name_long), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_json ("unit_name_long_i18n", + &nud.unit_name_long_i18n), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("unit_name_short", + (const char **) &nud.unit_name_short), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_json ("unit_name_short_i18n", + &nud.unit_name_short_i18n), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_bool ("unit_allow_fraction", + &nud.unit_allow_fraction), + &unit_allow_fraction_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_uint32 ("unit_precision_level", + &nud.unit_precision_level), + &unit_precision_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_bool ("unit_active", + &nud.unit_active), + &unit_active_missing), + GNUNET_JSON_spec_end () + }; + enum GNUNET_GenericReturnValue res; + const bool *unit_allow_fraction_ptr = NULL; + const uint32_t *unit_precision_ptr = NULL; + const bool *unit_active_ptr = NULL; + enum GNUNET_DB_QueryStatus qs; + bool no_instance = false; + bool no_unit = false; + bool builtin_conflict = false; + MHD_RESULT ret = MHD_YES; + + (void) rh; + GNUNET_assert (NULL != mi); + GNUNET_assert (NULL != unit_id); + + res = TALER_MHD_parse_json_data (connection, + hc->request_body, + spec); + if (GNUNET_OK != res) + return (GNUNET_NO == res) ? MHD_YES : MHD_NO; + + if (NULL == nud.unit_name_long && + NULL == nud.unit_name_long_i18n && + NULL == nud.unit_name_short && + NULL == nud.unit_name_short_i18n && + unit_allow_fraction_missing && + unit_precision_missing && + unit_active_missing) + { + ret = TALER_MHD_reply_static (connection, + MHD_HTTP_NO_CONTENT, + NULL, + NULL, + 0); + goto cleanup; + } + + if (! unit_precision_missing) + { + if (nud.unit_precision_level > TMH_MAX_UNIT_PRECISION_LEVEL) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "unit_precision_level"); + goto cleanup; + } + unit_precision_ptr = &nud.unit_precision_level; + } + + if (! unit_allow_fraction_missing) + { + unit_allow_fraction_ptr = &nud.unit_allow_fraction; + if (! nud.unit_allow_fraction) + { + nud.unit_precision_level = 0; + unit_precision_missing = false; + unit_precision_ptr = &nud.unit_precision_level; + } + } + + if (! unit_active_missing) + unit_active_ptr = &nud.unit_active; + + if (NULL != nud.unit_name_long_i18n) + { + if (! TALER_JSON_check_i18n (nud.unit_name_long_i18n)) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "unit_name_long_i18n"); + goto cleanup; + } + } + + if (NULL != nud.unit_name_short_i18n) + { + if (! TALER_JSON_check_i18n (nud.unit_name_short_i18n)) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "unit_name_short_i18n"); + goto cleanup; + } + } + + qs = TMH_db->update_unit (TMH_db->cls, + mi->settings.id, + unit_id, + nud.unit_name_long, + nud.unit_name_long_i18n, + nud.unit_name_short, + nud.unit_name_short_i18n, + unit_allow_fraction_ptr, + unit_precision_ptr, + unit_active_ptr, + &no_instance, + &no_unit, + &builtin_conflict); + switch (qs) + { + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + case GNUNET_DB_STATUS_SOFT_ERROR: + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_SOFT_FAILURE, + "update_unit"); + goto cleanup; + case GNUNET_DB_STATUS_HARD_ERROR: + default: + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "update_unit"); + goto cleanup; + } + + if (no_instance) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN, + mi->settings.id); + goto cleanup; + } + if (no_unit) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_UNIT_UNKNOWN, + unit_id); + goto cleanup; + } + if (builtin_conflict) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_GENERIC_UNIT_BUILTIN, + unit_id); + goto cleanup; + } + + ret = TALER_MHD_reply_static (connection, + MHD_HTTP_NO_CONTENT, + NULL, + NULL, + 0); + +cleanup: + if (NULL != nud.unit_name_long_i18n) + { + json_decref (nud.unit_name_long_i18n); + nud.unit_name_long_i18n = NULL; + } + if (NULL != nud.unit_name_short_i18n) + { + json_decref (nud.unit_name_short_i18n); + nud.unit_name_short_i18n = NULL; + } + GNUNET_JSON_parse_free (spec); + return ret; +} + + +/* end of taler-merchant-httpd_private-patch-units-ID.c */ diff --git a/src/backend/taler-merchant-httpd_private-patch-units-ID.h b/src/backend/taler-merchant-httpd_private-patch-units-ID.h @@ -0,0 +1,33 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-patch-units-ID.h + * @brief implement PATCH /private/units/$UNIT + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_PRIVATE_PATCH_UNITS_ID_H +#define TALER_MERCHANT_HTTPD_PRIVATE_PATCH_UNITS_ID_H + +#include "taler-merchant-httpd.h" + + +MHD_RESULT +TMH_private_patch_units_ID (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +/* end of taler-merchant-httpd_private-patch-units-ID.h */ +#endif diff --git a/src/backend/taler-merchant-httpd_private-post-orders.c b/src/backend/taler-merchant-httpd_private-post-orders.c @@ -78,7 +78,7 @@ * refuses a forced download. */ #define MAX_KEYS_WAIT \ - GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MILLISECONDS, 2500) + GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MILLISECONDS, 2500) /** * Generate the base URL for the given merchant instance. @@ -116,9 +116,29 @@ struct InventoryProduct const char *product_id; /** - * Number of units of the product to add to the order. + * Number of units of the product to add to the order (integer part). */ - uint32_t quantity; + uint64_t quantity; + + /** + * Fractional part of the quantity in units of 1/100000000 of the base value. + */ + uint32_t quantity_frac; + + /** + * True if the integer quantity field was missing in the request. + */ + bool quantity_missing; + + /** + * String representation of the quantity, if supplied. + */ + const char *unit_quantity; + + /** + * True if the string quantity field was missing in the request. + */ + bool unit_quantity_missing; }; @@ -1023,6 +1043,64 @@ clean_order (void *cls) /* ***************** ORDER_PHASE_EXECUTE_ORDER **************** */ /** + * Compute remaining stock (integer and fractional parts) for a product. + * + * @param pd product details with current totals/sold/lost + * @param[out] available_value remaining whole units (normalized, non-negative) + * @param[out] available_frac remaining fractional units (0..MERCHANT_UNIT_FRAC_BASE-1) + */ +static void +compute_available_quantity (const struct TALER_MERCHANTDB_ProductDetails *pd, + uint64_t *available_value, + uint32_t *available_frac) +{ + int64_t value; + int64_t frac; + + GNUNET_assert (NULL != available_value); + GNUNET_assert (NULL != available_frac); + + if ( (INT64_MAX == pd->total_stock) && + (INT32_MAX == pd->total_stock_frac) ) + { + *available_value = pd->total_stock; + *available_frac = pd->total_stock_frac; + return; + } + + value = (int64_t) pd->total_stock + - (int64_t) pd->total_sold + - (int64_t) pd->total_lost; + frac = (int64_t) pd->total_stock_frac + - (int64_t) pd->total_sold_frac + - (int64_t) pd->total_lost_frac; + + if (frac < 0) + { + int64_t borrow = ((-frac) + MERCHANT_UNIT_FRAC_BASE - 1) + / MERCHANT_UNIT_FRAC_BASE; + value -= borrow; + frac += borrow * (int64_t) MERCHANT_UNIT_FRAC_BASE; + } + else if (frac >= MERCHANT_UNIT_FRAC_BASE) + { + int64_t carry = frac / MERCHANT_UNIT_FRAC_BASE; + value += carry; + frac -= carry * (int64_t) MERCHANT_UNIT_FRAC_BASE; + } + + if (value < 0) + { + value = 0; + frac = 0; + } + + *available_value = (uint64_t) value; + *available_frac = (uint32_t) frac; +} + + +/** * Execute the database transaction to setup the order. * * @param[in,out] oc order context @@ -1122,7 +1200,8 @@ execute_transaction (struct OrderContext *oc) oc->hc->instance->settings.id, oc->parse_order.order_id, oc->parse_request.inventory_products[i].product_id, - oc->parse_request.inventory_products[i].quantity); + oc->parse_request.inventory_products[i].quantity, + oc->parse_request.inventory_products[i].quantity_frac); if (qs < 0) { TMH_db->rollback (TMH_db->cls); @@ -1295,6 +1374,10 @@ phase_execute_order (struct OrderContext *oc) const struct InventoryProduct *ip; size_t num_categories = 0; uint64_t *categories = NULL; + uint64_t available_quantity; + uint32_t available_quantity_frac; + char requested_quantity_buf[64]; + char available_quantity_buf[64]; ip = &oc->parse_request.inventory_products[ oc->execute_order.out_of_stock_index]; @@ -1314,6 +1397,20 @@ phase_execute_order (struct OrderContext *oc) GNUNET_free (categories); GNUNET_log (GNUNET_ERROR_TYPE_INFO, "Order creation failed: product out of stock\n"); + + compute_available_quantity (&pd, + &available_quantity, + &available_quantity_frac); + TMH_format_fractional_string (TMH_VK_QUANTITY, + ip->quantity, + ip->quantity_frac, + sizeof (requested_quantity_buf), + requested_quantity_buf); + TMH_format_fractional_string (TMH_VK_QUANTITY, + available_quantity, + available_quantity_frac, + sizeof (available_quantity_buf), + available_quantity_buf); ret = TALER_MHD_REPLY_JSON_PACK ( oc->connection, MHD_HTTP_GONE, @@ -1323,9 +1420,15 @@ phase_execute_order (struct OrderContext *oc) GNUNET_JSON_pack_uint64 ( "requested_quantity", ip->quantity), + GNUNET_JSON_pack_string ( + "unit_requested_quantity", + requested_quantity_buf), GNUNET_JSON_pack_uint64 ( "available_quantity", - pd.total_stock - pd.total_sold - pd.total_lost), + available_quantity), + GNUNET_JSON_pack_string ( + "unit_available_quantity", + available_quantity_buf), GNUNET_JSON_pack_allow_null ( GNUNET_JSON_pack_timestamp ( "restock_expected", @@ -3186,7 +3289,7 @@ phase_merge_inventory (struct OrderContext *oc) GNUNET_assert (NULL != oc->merge_inventory.products); for (unsigned int i = 0; i<oc->parse_request.inventory_products_length; i++) { - const struct InventoryProduct *ip + struct InventoryProduct *ip = &oc->parse_request.inventory_products[i]; struct TALER_MERCHANTDB_ProductDetails pd; enum GNUNET_DB_QueryStatus qs; @@ -3238,7 +3341,48 @@ phase_merge_inventory (struct OrderContext *oc) = GNUNET_MAX (oc->parse_order.minimum_age, pd.minimum_age); { + const char *eparam; + + if ( (! ip->quantity_missing) && + (ip->quantity > (uint64_t) INT64_MAX) ) + { + GNUNET_break_op (0); + reply_with_error (oc, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "quantity"); + TALER_MERCHANTDB_product_details_free (&pd); + return; + } + if (GNUNET_OK != + TMH_process_quantity_inputs (TMH_VK_QUANTITY, + pd.allow_fractional_quantity, + ip->quantity_missing, + (int64_t) ip->quantity, + ip->unit_quantity_missing, + ip->unit_quantity, + &ip->quantity, + &ip->quantity_frac, + &eparam)) + { + GNUNET_break_op (0); + reply_with_error (oc, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + eparam); + TALER_MERCHANTDB_product_details_free (&pd); + return; + } + } + { json_t *p; + char unit_quantity_buf[64]; + + TMH_format_fractional_string (TMH_VK_QUANTITY, + ip->quantity, + ip->quantity_frac, + sizeof (unit_quantity_buf), + unit_quantity_buf); p = GNUNET_JSON_PACK ( GNUNET_JSON_pack_string ("product_name", @@ -3257,7 +3401,9 @@ phase_merge_inventory (struct OrderContext *oc) pd.image), GNUNET_JSON_pack_uint64 ( "quantity", - ip->quantity)); + ip->quantity), + GNUNET_JSON_pack_string ("unit_quantity", + unit_quantity_buf)); GNUNET_assert (NULL != p); GNUNET_assert (0 == json_array_append_new (oc->merge_inventory.products, @@ -4299,8 +4445,14 @@ phase_parse_request (struct OrderContext *oc) struct GNUNET_JSON_Specification ispec[] = { GNUNET_JSON_spec_string ("product_id", &ipr->product_id), - GNUNET_JSON_spec_uint32 ("quantity", - &ipr->quantity), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_uint64 ("quantity", + &ipr->quantity), + &ipr->quantity_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("unit_quantity", + &ipr->unit_quantity), + &ipr->unit_quantity_missing), GNUNET_JSON_spec_end () }; @@ -4323,6 +4475,11 @@ phase_parse_request (struct OrderContext *oc) "inventory_products"); return; } + if (ipr->quantity_missing && ipr->unit_quantity_missing) + { + ipr->quantity = 1; + ipr->quantity_missing = false; + } } } diff --git a/src/backend/taler-merchant-httpd_private-post-products-ID-lock.c b/src/backend/taler-merchant-httpd_private-post-products-ID-lock.c @@ -39,15 +39,24 @@ TMH_private_post_products_ID_lock ( enum GNUNET_DB_QueryStatus qs; const char *uuids; struct GNUNET_Uuid uuid; - uint32_t quantity; + uint64_t quantity; + bool quantity_missing; + const char *unit_quantity = NULL; + bool unit_quantity_missing = true; struct GNUNET_TIME_Relative duration; struct GNUNET_JSON_Specification spec[] = { GNUNET_JSON_spec_string ("lock_uuid", &uuids), GNUNET_JSON_spec_relative_time ("duration", &duration), - GNUNET_JSON_spec_uint32 ("quantity", - &quantity), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_uint64 ("quantity", + &quantity), + &quantity_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("unit_quantity", + &unit_quantity), + &unit_quantity_missing), GNUNET_JSON_spec_end () }; @@ -67,15 +76,102 @@ TMH_private_post_products_ID_lock ( TMH_uuid_from_string (uuids, &uuid); TMH_db->expire_locks (TMH_db->cls); - qs = TMH_db->lock_product (TMH_db->cls, - mi->settings.id, - product_id, - &uuid, - quantity, - GNUNET_TIME_relative_to_timestamp (duration)); + { + struct TALER_MERCHANTDB_ProductDetails pd = { 0 }; + size_t num_categories; + uint64_t *categories; + uint64_t normalized_quantity = 0; + uint32_t normalized_quantity_frac = 0; + + if (quantity_missing && unit_quantity_missing) + { + quantity = 1; + quantity_missing = false; + } + else if ( (! quantity_missing) && + (quantity > (uint64_t) INT64_MAX) ) + { + GNUNET_break_op (0); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "quantity"); + } + + qs = TMH_db->lookup_product (TMH_db->cls, + mi->settings.id, + product_id, + &pd, + &num_categories, + &categories); + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + TALER_MERCHANTDB_product_details_free (&pd); + GNUNET_free (categories); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "lookup_product"); + case GNUNET_DB_STATUS_SOFT_ERROR: + GNUNET_break (0); + TALER_MERCHANTDB_product_details_free (&pd); + GNUNET_free (categories); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_SOFT_FAILURE, + "lookup_product"); + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + GNUNET_break_op (0); + TALER_MERCHANTDB_product_details_free (&pd); + GNUNET_free (categories); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_PRODUCT_UNKNOWN, + product_id); + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + } + GNUNET_free (categories); + { + const char *eparam; + if (GNUNET_OK != + TMH_process_quantity_inputs (TMH_VK_QUANTITY, + pd.allow_fractional_quantity, + quantity_missing, + (int64_t) quantity, + unit_quantity_missing, + unit_quantity, + &normalized_quantity, + &normalized_quantity_frac, + &eparam)) + { + TALER_MERCHANTDB_product_details_free (&pd); + GNUNET_break_op (0); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + eparam); + } + } + quantity = normalized_quantity; + qs = TMH_db->lock_product (TMH_db->cls, + mi->settings.id, + product_id, + &uuid, + quantity, + normalized_quantity_frac, + GNUNET_TIME_relative_to_timestamp (duration)); + TALER_MERCHANTDB_product_details_free (&pd); + } switch (qs) { case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); return TALER_MHD_reply_with_error ( connection, MHD_HTTP_INTERNAL_SERVER_ERROR, @@ -89,30 +185,7 @@ TMH_private_post_products_ID_lock ( TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE, "Serialization error for single-statment request"); case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: - { - size_t num_categories = 0; - uint64_t *categories = NULL; - - qs = TMH_db->lookup_product (TMH_db->cls, - mi->settings.id, - product_id, - 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, - product_id); - GNUNET_free (categories); - } + GNUNET_break_op (0); return TALER_MHD_reply_with_error ( connection, MHD_HTTP_GONE, diff --git a/src/backend/taler-merchant-httpd_private-post-products.c b/src/backend/taler-merchant-httpd_private-post-products.c @@ -27,7 +27,6 @@ #include "taler-merchant-httpd_helper.h" #include <taler/taler_json_lib.h> - MHD_RESULT TMH_private_post_products (const struct TMH_RequestHandler *rh, struct MHD_Connection *connection, @@ -38,6 +37,15 @@ TMH_private_post_products (const struct TMH_RequestHandler *rh, const json_t *categories = NULL; const char *product_id; int64_t total_stock; + const char *unit_total_stock = NULL; + bool unit_total_stock_missing; + bool total_stock_missing; + bool unit_price_missing; + bool unit_allow_fraction; + bool unit_allow_fraction_missing; + uint32_t unit_precision_level; + bool unit_precision_missing; + bool price_missing; struct GNUNET_JSON_Specification spec[] = { GNUNET_JSON_spec_string ("product_id", &product_id), @@ -54,8 +62,10 @@ TMH_private_post_products (const struct TMH_RequestHandler *rh, NULL), GNUNET_JSON_spec_string ("unit", (const char **) &pd.unit), - TALER_JSON_spec_amount_any ("price", - &pd.price), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_any ("price", + &pd.price), + &price_missing), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_string ("image", (const char **) &pd.image), @@ -68,8 +78,27 @@ TMH_private_post_products (const struct TMH_RequestHandler *rh, GNUNET_JSON_spec_array_const ("categories", &categories), NULL), - GNUNET_JSON_spec_int64 ("total_stock", - &total_stock), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_string ("unit_total_stock", + &unit_total_stock), + &unit_total_stock_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_int64 ("total_stock", + &total_stock), + &total_stock_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_bool ("unit_allow_fraction", + &unit_allow_fraction), + &unit_allow_fraction_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_uint32 ("unit_precision_level", + &unit_precision_level), + &unit_precision_missing), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_any_array ("unit_price", + &pd.price_array_length, + &pd.price_array), + &unit_price_missing), GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_json ("address", &pd.address), @@ -110,15 +139,100 @@ TMH_private_post_products (const struct TMH_RequestHandler *rh, product name; remove once we make product_name mandatory. */ if (NULL == pd.product_name) pd.product_name = pd.description; + + if (! unit_price_missing) + { + if (! price_missing) + { + if (0 != TALER_amount_cmp (&pd.price, + &pd.price_array[0])) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "price,unit_price mismatch"); + goto cleanup; + } + } + else + { + pd.price = pd.price_array[0]; + price_missing = false; + } + } + else + { + if (price_missing) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "price and unit_price missing"); + goto cleanup; + } + pd.price_array = GNUNET_new_array (1, + struct TALER_Amount); + pd.price_array[0] = pd.price; + pd.price_array_length = 1; + } } - if (total_stock < -1) + if (! unit_precision_missing) { - GNUNET_break_op (0); - ret = TALER_MHD_reply_with_error (connection, - MHD_HTTP_BAD_REQUEST, - TALER_EC_GENERIC_PARAMETER_MALFORMED, - "total_stock"); - goto cleanup; + if (unit_precision_level > TMH_MAX_FRACTIONAL_PRECISION_LEVEL) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "unit_precision_level"); + goto cleanup; + } + } + { + bool default_allow_fractional; + uint32_t default_precision_level; + + if (GNUNET_OK != + TMH_unit_defaults_for_instance (mi, + pd.unit, + &default_allow_fractional, + &default_precision_level)) + { + GNUNET_break (0); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_FETCH_FAILED, + "unit defaults"); + goto cleanup; + } + if (unit_allow_fraction_missing) + unit_allow_fraction = default_allow_fractional; + if (unit_precision_missing) + unit_precision_level = default_precision_level; + } + if (! unit_allow_fraction) + unit_precision_level = 0; + pd.fractional_precision_level = unit_precision_level; + { + const char *eparam; + if (GNUNET_OK != + TMH_process_quantity_inputs (TMH_VK_STOCK, + unit_allow_fraction, + total_stock_missing, + total_stock, + unit_total_stock_missing, + unit_total_stock, + &pd.total_stock, + &pd.total_stock_frac, + &eparam)) + { + ret = TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + eparam); + goto cleanup; + } + pd.allow_fractional_quantity = unit_allow_fraction; } num_cats = json_array_size (categories); cats = GNUNET_new_array (num_cats, @@ -142,11 +256,6 @@ TMH_private_post_products (const struct TMH_RequestHandler *rh, } } - if (-1 == total_stock) - pd.total_stock = INT64_MAX; - else - pd.total_stock = (uint64_t) total_stock; - if (NULL == pd.address) pd.address = json_object (); if (NULL == pd.description_i18n) @@ -270,6 +379,7 @@ TMH_private_post_products (const struct TMH_RequestHandler *rh, 0); cleanup: GNUNET_JSON_parse_free (spec); + GNUNET_free (pd.price_array); GNUNET_free (cats); return ret; } diff --git a/src/backend/taler-merchant-httpd_private-post-units.c b/src/backend/taler-merchant-httpd_private-post-units.c @@ -0,0 +1,222 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-post-units.c + * @brief implement POST /private/units + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include "taler-merchant-httpd_private-post-units.h" +#include "taler-merchant-httpd_helper.h" +#include <taler/taler_json_lib.h> + +/** + * Maximum fractional precision level accepted from clients. + */ +#define TMH_MAX_UNIT_PRECISION_LEVEL 6 + +MHD_RESULT +TMH_private_post_units (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc) +{ + struct TMH_MerchantInstance *mi = hc->instance; + struct TALER_MERCHANTDB_UnitDetails nud = { 0 }; + bool allow_fraction_missing = true; + bool unit_precision_missing = true; + bool unit_active_missing = true; + enum GNUNET_GenericReturnValue res; + enum GNUNET_DB_QueryStatus qs; + MHD_RESULT ret; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("unit", + (const char **) &nud.unit), + GNUNET_JSON_spec_string ("unit_name_long", + (const char **) &nud.unit_name_long), + GNUNET_JSON_spec_string ("unit_name_short", + (const char **) &nud.unit_name_short), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_json ("unit_name_long_i18n", + &nud.unit_name_long_i18n), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_json ("unit_name_short_i18n", + &nud.unit_name_short_i18n), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_bool ("unit_allow_fraction", + &nud.unit_allow_fraction), + &allow_fraction_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_uint32 ("unit_precision_level", + &nud.unit_precision_level), + &unit_precision_missing), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_bool ("unit_active", + &nud.unit_active), + &unit_active_missing), + GNUNET_JSON_spec_end () + }; + + + GNUNET_assert (NULL != mi); + res = TALER_MHD_parse_json_data (connection, + hc->request_body, + spec); + (void) rh; + + if (GNUNET_OK != res) + { + GNUNET_break_op (0); + return (GNUNET_NO == res) + ? MHD_YES + : MHD_NO; + } + + if (allow_fraction_missing) + { + nud.unit_allow_fraction = false; + nud.unit_precision_level = 0; + } + else + { + if (! nud.unit_allow_fraction) + { + nud.unit_precision_level = 0; + unit_precision_missing = false; + } + else if (unit_precision_missing) + { + nud.unit_precision_level = 0; + } + } + if (nud.unit_precision_level > TMH_MAX_UNIT_PRECISION_LEVEL) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "unit_precision_level"); + goto cleanup; + } + if (unit_active_missing) + nud.unit_active = true; + + if (NULL == nud.unit_name_long_i18n) + nud.unit_name_long_i18n = json_object (); + if (NULL == nud.unit_name_short_i18n) + nud.unit_name_short_i18n = json_object (); + + if (! TALER_JSON_check_i18n (nud.unit_name_long_i18n)) + { + GNUNET_break_op (0); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "unit_name_long_i18n"); + goto cleanup; + } + if (! TALER_JSON_check_i18n (nud.unit_name_short_i18n)) + { + GNUNET_break_op (0); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "unit_name_short_i18n"); + goto cleanup; + } + + nud.unit_builtin = false; + + { + bool no_instance = false; + bool conflict = false; + uint64_t unit_serial = 0; + + qs = TMH_db->insert_unit (TMH_db->cls, + mi->settings.id, + &nud, + &no_instance, + &conflict, + &unit_serial); + + switch (qs) + { + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + NULL); + goto cleanup; + case GNUNET_DB_STATUS_SOFT_ERROR: + GNUNET_break (0); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_SOFT_FAILURE, + NULL); + goto cleanup; + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + GNUNET_break (0); + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_INVARIANT_FAILURE, + "insert_unit"); + goto cleanup; + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: + break; + } + + if (no_instance) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN, + mi->settings.id); + goto cleanup; + } + if (conflict) + { + ret = TALER_MHD_reply_with_error (connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_GENERIC_UNIT_BUILTIN, + nud.unit); + goto cleanup; + } + + ret = TALER_MHD_reply_static (connection, + MHD_HTTP_NO_CONTENT, + NULL, + NULL, + 0); + } + +cleanup: + if (NULL != nud.unit_name_long_i18n) + { + json_decref (nud.unit_name_long_i18n); + nud.unit_name_long_i18n = NULL; + } + if (NULL != nud.unit_name_short_i18n) + { + json_decref (nud.unit_name_short_i18n); + nud.unit_name_short_i18n = NULL; + } + GNUNET_JSON_parse_free (spec); + return ret; +} + + +/* end of taler-merchant-httpd_private-post-units.c */ diff --git a/src/backend/taler-merchant-httpd_private-post-units.h b/src/backend/taler-merchant-httpd_private-post-units.h @@ -0,0 +1,33 @@ +/* + This file is part of TALER + (C) 2025 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 taler-merchant-httpd_private-post-units.h + * @brief implement POST /private/units + * @author Bohdan Potuzhnyi + */ +#ifndef TALER_MERCHANT_HTTPD_PRIVATE_POST_UNITS_H +#define TALER_MERCHANT_HTTPD_PRIVATE_POST_UNITS_H + +#include "taler-merchant-httpd.h" + + +MHD_RESULT +TMH_private_post_units (const struct TMH_RequestHandler *rh, + struct MHD_Connection *connection, + struct TMH_HandlerContext *hc); + +/* end of taler-merchant-httpd_private-post-units.h */ +#endif diff --git a/src/backenddb/Makefile.am b/src/backenddb/Makefile.am @@ -43,6 +43,7 @@ sql_DATA = \ merchant-0024.sql \ merchant-0025.sql \ merchant-0026.sql \ + merchant-0027.sql \ drop.sql BUILT_SOURCES = \ @@ -114,6 +115,7 @@ libtaler_plugin_merchantdb_postgres_la_SOURCES = \ pg_increase_refund.h pg_increase_refund.c \ pg_insert_account.h pg_insert_account.c \ pg_insert_category.h pg_insert_category.c \ + pg_insert_unit.h pg_insert_unit.c \ pg_insert_contract_terms.h pg_insert_contract_terms.c \ pg_insert_deposit.h pg_insert_deposit.c \ pg_insert_deposit_confirmation.h pg_insert_deposit_confirmation.c \ @@ -132,11 +134,15 @@ libtaler_plugin_merchantdb_postgres_la_SOURCES = \ pg_insert_refund_proof.h pg_insert_refund_proof.c \ pg_insert_spent_token.h pg_insert_spent_token.c \ pg_insert_template.h pg_insert_template.c \ + pg_update_unit.h pg_update_unit.c \ pg_insert_token_family.h pg_insert_token_family.c \ pg_insert_token_family_key.h pg_insert_token_family_key.c \ pg_insert_transfer.h pg_insert_transfer.c \ pg_insert_transfer_details.h pg_insert_transfer_details.c \ pg_insert_webhook.h pg_insert_webhook.c \ + pg_delete_unit.h pg_delete_unit.c \ + pg_lookup_units.h pg_lookup_units.c \ + pg_select_unit.h pg_select_unit.c \ pg_lookup_mfa_challenge.h pg_lookup_mfa_challenge.c \ pg_solve_mfa_challenge.h pg_solve_mfa_challenge.c \ pg_update_mfa_challenge.h pg_update_mfa_challenge.c \ diff --git a/src/backenddb/merchant-0027.sql b/src/backenddb/merchant-0027.sql @@ -0,0 +1,464 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2025 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-0027.sql +-- @brief Add fractional stock support to merchant_inventory +-- @author Bohdan Potuzhnyi + +BEGIN; + +-- Check patch versioning is in place. +SELECT _v.register_patch('merchant-0027', NULL, NULL); + +SET search_path TO merchant; + +ALTER TABLE merchant_inventory + ADD COLUMN price_array taler_amount_currency[] + NOT NULL + DEFAULT ARRAY[]::taler_amount_currency[]; +COMMENT ON COLUMN merchant_inventory.price_array + IS 'List of unit prices available for the product (multiple tiers supported).'; + +UPDATE merchant_inventory +SET price_array = ARRAY[price]::taler_amount_currency[] +WHERE price IS NOT NULL; -- theoretically all objects, but just to be sure + +-- I assume we want to make drop price column at some point of time + +ALTER TABLE merchant_inventory + ADD COLUMN total_stock_frac INT4 NOT NULL DEFAULT 0; +COMMENT ON COLUMN merchant_inventory.total_stock_frac + IS 'Fractional part of stock in units of 1/100000000 of the base value'; + +ALTER TABLE merchant_inventory + ADD COLUMN total_sold_frac INT4 NOT NULL DEFAULT 0; +COMMENT ON COLUMN merchant_inventory.total_sold_frac + IS 'Fractional part of units sold in units of 1/100000000 of the base value'; + +ALTER TABLE merchant_inventory + ADD COLUMN total_lost_frac INT4 NOT NULL DEFAULT 0; +COMMENT ON COLUMN merchant_inventory.total_lost_frac + IS 'Fractional part of units lost in units of 1/100000000 of the base value'; + +ALTER TABLE merchant_inventory + ADD COLUMN allow_fractional_quantity BOOL NOT NULL DEFAULT FALSE; +COMMENT ON COLUMN merchant_inventory.allow_fractional_quantity + IS 'Whether fractional stock (total_stock_frac) should be honored for this product'; + +ALTER TABLE merchant_inventory + ADD COLUMN fractional_precision_level INT4 NOT NULL DEFAULT 0; +COMMENT ON COLUMN merchant_inventory.fractional_precision_level + IS 'Preset number of decimal places for fractional quantities'; + +ALTER TABLE merchant_inventory_locks + ADD COLUMN total_locked_frac INT4 NOT NULL DEFAULT 0; +COMMENT ON COLUMN merchant_inventory_locks.total_locked_frac + IS 'Fractional part of locked stock in units of 1/100000000 of the base value'; + +ALTER TABLE merchant_order_locks + ADD COLUMN total_locked_frac INT4 NOT NULL DEFAULT 0; +COMMENT ON COLUMN merchant_order_locks.total_locked_frac + IS 'Fractional part of locked stock associated with orders in units of 1/100000000 of the base value'; + +CREATE TABLE merchant_builtin_units +( + unit_serial BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + unit TEXT NOT NULL UNIQUE, + unit_name_long TEXT NOT NULL, + unit_name_short TEXT NOT NULL, + unit_name_long_i18n BYTEA NOT NULL DEFAULT convert_to('{}','UTF8'), + unit_name_short_i18n BYTEA NOT NULL DEFAULT convert_to('{}','UTF8'), + unit_allow_fraction BOOLEAN NOT NULL DEFAULT FALSE, + unit_precision_level INT4 NOT NULL DEFAULT 0 CHECK (unit_precision_level BETWEEN 0 AND 6), + unit_active BOOLEAN NOT NULL DEFAULT TRUE +); +COMMENT ON TABLE merchant_builtin_units + IS 'Global catalogue of builtin measurement units.'; +COMMENT ON COLUMN merchant_builtin_units.unit_active + IS 'Default visibility for the builtin unit; instances may override.'; + +CREATE TABLE merchant_custom_units +( + unit_serial BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + merchant_serial BIGINT NOT NULL REFERENCES merchant_instances (merchant_serial) ON DELETE CASCADE, + unit TEXT NOT NULL, + unit_name_long TEXT NOT NULL, + unit_name_short TEXT NOT NULL, + unit_name_long_i18n BYTEA NOT NULL DEFAULT convert_to('{}','UTF8'), + unit_name_short_i18n BYTEA NOT NULL DEFAULT convert_to('{}','UTF8'), + unit_allow_fraction BOOLEAN NOT NULL DEFAULT FALSE, + unit_precision_level INT4 NOT NULL DEFAULT 0 CHECK (unit_precision_level BETWEEN 0 AND 6), + unit_active BOOLEAN NOT NULL DEFAULT TRUE, + UNIQUE (merchant_serial, unit) +); +COMMENT ON TABLE merchant_custom_units + IS 'Per-instance custom measurement units.'; + +CREATE TABLE merchant_builtin_unit_overrides +( + merchant_serial BIGINT NOT NULL REFERENCES merchant_instances (merchant_serial) ON DELETE CASCADE, + builtin_unit_serial BIGINT NOT NULL REFERENCES merchant_builtin_units (unit_serial) ON DELETE CASCADE, + override_allow_fraction BOOLEAN, + override_precision_level INT4 CHECK (override_precision_level BETWEEN 0 AND 6), + override_active BOOLEAN, + PRIMARY KEY (merchant_serial, builtin_unit_serial) +); +COMMENT ON TABLE merchant_builtin_unit_overrides + IS 'Per-instance overrides for builtin units (fraction policy and visibility).'; + +INSERT INTO merchant_builtin_units (unit, unit_name_long, unit_name_short, unit_allow_fraction, unit_precision_level, unit_active) +VALUES + ('Piece', 'piece', 'pc', FALSE, 0, TRUE), + ('Set', 'set', 'set', FALSE, 0, TRUE), + ('SizeUnitCm', 'centimetre', 'cm', TRUE, 1, TRUE), + ('SizeUnitDm', 'decimetre', 'dm', TRUE, 3, TRUE), + ('SizeUnitFoot', 'foot', 'ft', TRUE, 3, TRUE), + ('SizeUnitInch', 'inch', 'in', TRUE, 2, TRUE), + ('SizeUnitM', 'metre', 'm', TRUE, 3, TRUE), + ('SizeUnitMm', 'millimetre', 'mm', FALSE, 0, TRUE), + ('SurfaceUnitCm2', 'square centimetre', 'cm²', TRUE, 2, TRUE), + ('SurfaceUnitDm2', 'square decimetre', 'dm²', TRUE, 3, TRUE), + ('SurfaceUnitFoot2', 'square foot', 'ft²', TRUE, 3, TRUE), + ('SurfaceUnitInch2', 'square inch', 'in²', TRUE, 4, TRUE), + ('SurfaceUnitM2', 'square metre', 'm²', TRUE, 4, TRUE), + ('SurfaceUnitMm2', 'square millimetre', 'mm²', TRUE, 1, TRUE), + ('TimeUnitDay', 'day', 'd', TRUE, 3, TRUE), + ('TimeUnitHour', 'hour', 'h', TRUE, 2, TRUE), + ('TimeUnitMinute', 'minute', 'min', TRUE, 3, TRUE), + ('TimeUnitMonth', 'month', 'mo', TRUE, 2, TRUE), + ('TimeUnitSecond', 'second', 's', TRUE, 3, TRUE), + ('TimeUnitWeek', 'week', 'wk', TRUE, 3, TRUE), + ('TimeUnitYear', 'year', 'yr', TRUE, 4, TRUE), + ('VolumeUnitCm3', 'cubic centimetre', 'cm³', TRUE, 3, TRUE), + ('VolumeUnitDm3', 'cubic decimetre', 'dm³', TRUE, 5, TRUE), + ('VolumeUnitFoot3', 'cubic foot', 'ft³', TRUE, 5, TRUE), + ('VolumeUnitGallon', 'gallon', 'gal', TRUE, 3, TRUE), + ('VolumeUnitInch3', 'cubic inch', 'in³', TRUE, 2, TRUE), + ('VolumeUnitLitre', 'litre', 'L', TRUE, 3, TRUE), + ('VolumeUnitM3', 'cubic metre', 'm³', TRUE, 6, TRUE), + ('VolumeUnitMm3', 'cubic millimetre', 'mm³', TRUE, 1, TRUE), + ('VolumeUnitOunce', 'fluid ounce', 'fl oz', TRUE, 2, TRUE), + ('WeightUnitG', 'gram', 'g', TRUE, 1, TRUE), + ('WeightUnitKg', 'kilogram', 'kg', TRUE, 3, TRUE), + ('WeightUnitMg', 'milligram', 'mg', FALSE, 0, TRUE), + ('WeightUnitOunce', 'ounce', 'oz', TRUE, 2, TRUE), + ('WeightUnitPound', 'pound', 'lb', TRUE, 3, TRUE), + ('WeightUnitTon', 'metric tonne', 't', TRUE, 3, TRUE); + +DROP FUNCTION IF EXISTS merchant_do_insert_unit; +CREATE FUNCTION merchant_do_insert_unit ( + IN in_instance_id TEXT, + IN in_unit TEXT, + IN in_unit_name_long TEXT, + IN in_unit_name_short TEXT, + IN in_unit_name_long_i18n BYTEA, + IN in_unit_name_short_i18n BYTEA, + IN in_unit_allow_fraction BOOL, + IN in_unit_precision_level INT4, + IN in_unit_active BOOL, + OUT out_no_instance BOOL, + OUT out_conflict BOOL, + OUT out_unit_serial INT8) + LANGUAGE plpgsql +AS $$ +DECLARE + my_merchant_id INT8; +BEGIN + SELECT merchant_serial + INTO my_merchant_id + FROM merchant_instances + WHERE merchant_id = in_instance_id; + + IF NOT FOUND THEN + out_no_instance := TRUE; + out_conflict := FALSE; + out_unit_serial := NULL; + RETURN; + END IF; + + out_no_instance := FALSE; + + -- Reject attempts to shadow builtin identifiers. + IF EXISTS ( + SELECT 1 FROM merchant_builtin_units bu WHERE bu.unit = in_unit + ) THEN + out_conflict := TRUE; + out_unit_serial := NULL; + RETURN; + END IF; + + INSERT INTO merchant_custom_units ( + merchant_serial, + unit, + unit_name_long, + unit_name_short, + unit_name_long_i18n, + unit_name_short_i18n, + unit_allow_fraction, + unit_precision_level, + unit_active) + VALUES ( + my_merchant_id, + in_unit, + in_unit_name_long, + in_unit_name_short, + in_unit_name_long_i18n, + in_unit_name_short_i18n, + in_unit_allow_fraction, + in_unit_precision_level, + in_unit_active) + ON CONFLICT (merchant_serial, unit) DO NOTHING + RETURNING unit_serial + INTO out_unit_serial; + + IF FOUND THEN + out_conflict := FALSE; + RETURN; + END IF; + + -- Conflict: custom unit already exists. + SELECT unit_serial + INTO out_unit_serial + FROM merchant_custom_units + WHERE merchant_serial = my_merchant_id + AND unit = in_unit; + + out_conflict := TRUE; +END $$; + +DROP FUNCTION IF EXISTS merchant_do_update_unit; +CREATE FUNCTION merchant_do_update_unit ( + IN in_instance_id TEXT, + IN in_unit_id TEXT, + IN in_unit_name_long TEXT, + IN in_unit_name_long_i18n BYTEA, + IN in_unit_name_short TEXT, + IN in_unit_name_short_i18n BYTEA, + IN in_unit_allow_fraction BOOL, + IN in_unit_precision_level INT4, + IN in_unit_active BOOL, + OUT out_no_instance BOOL, + OUT out_no_unit BOOL, + OUT out_builtin_conflict BOOL) + LANGUAGE plpgsql +AS $$ +DECLARE + my_merchant_id INT8; + my_custom merchant_custom_units%ROWTYPE; + my_builtin merchant_builtin_units%ROWTYPE; + my_override merchant_builtin_unit_overrides%ROWTYPE; + new_unit_name_long TEXT; + new_unit_name_short TEXT; + new_unit_name_long_i18n BYTEA; + new_unit_name_short_i18n BYTEA; + new_unit_allow_fraction BOOL; + new_unit_precision_level INT4; + new_unit_active BOOL; + old_unit_allow_fraction BOOL; + old_unit_precision_level INT4; + old_unit_active BOOL; +BEGIN + out_no_instance := FALSE; + out_no_unit := FALSE; + out_builtin_conflict := FALSE; + + SELECT merchant_serial + INTO my_merchant_id + FROM merchant_instances + WHERE merchant_id = in_instance_id; + + IF NOT FOUND THEN + out_no_instance := TRUE; + RETURN; + END IF; + + SELECT * + INTO my_custom + FROM merchant_custom_units + WHERE merchant_serial = my_merchant_id + AND unit = in_unit_id + FOR UPDATE; + + IF FOUND THEN + old_unit_allow_fraction := my_custom.unit_allow_fraction; + old_unit_precision_level := my_custom.unit_precision_level; + old_unit_active := my_custom.unit_active; + + new_unit_name_long := COALESCE (in_unit_name_long, my_custom.unit_name_long); + new_unit_name_short := COALESCE (in_unit_name_short, my_custom.unit_name_short); + new_unit_name_long_i18n := COALESCE (in_unit_name_long_i18n, + my_custom.unit_name_long_i18n); + new_unit_name_short_i18n := COALESCE (in_unit_name_short_i18n, + my_custom.unit_name_short_i18n); + new_unit_allow_fraction := COALESCE (in_unit_allow_fraction, + my_custom.unit_allow_fraction); + new_unit_precision_level := COALESCE (in_unit_precision_level, + my_custom.unit_precision_level); + IF NOT new_unit_allow_fraction THEN + new_unit_precision_level := 0; + END IF; + + new_unit_active := COALESCE (in_unit_active, my_custom.unit_active); + + UPDATE merchant_custom_units SET + unit_name_long = new_unit_name_long + ,unit_name_long_i18n = new_unit_name_long_i18n + ,unit_name_short = new_unit_name_short + ,unit_name_short_i18n = new_unit_name_short_i18n + ,unit_allow_fraction = new_unit_allow_fraction + ,unit_precision_level = new_unit_precision_level + ,unit_active = new_unit_active + WHERE unit_serial = my_custom.unit_serial; + + ASSERT FOUND,'SELECTED it earlier, should UPDATE it now'; + + IF old_unit_allow_fraction IS DISTINCT FROM new_unit_allow_fraction + OR old_unit_precision_level IS DISTINCT FROM new_unit_precision_level + THEN + UPDATE merchant_inventory SET + allow_fractional_quantity = new_unit_allow_fraction + , fractional_precision_level = new_unit_precision_level + WHERE merchant_serial = my_merchant_id + AND unit = in_unit_id + AND allow_fractional_quantity = old_unit_allow_fraction + AND fractional_precision_level = old_unit_precision_level; + END IF; + RETURN; + END IF; + + -- Try builtin with overrides. + SELECT * + INTO my_builtin + FROM merchant_builtin_units + WHERE unit = in_unit_id; + + IF NOT FOUND THEN + out_no_unit := TRUE; + RETURN; + END IF; + + SELECT * + INTO my_override + FROM merchant_builtin_unit_overrides + WHERE merchant_serial = my_merchant_id + AND builtin_unit_serial = my_builtin.unit_serial + FOR UPDATE; + + old_unit_allow_fraction := COALESCE (my_override.override_allow_fraction, + my_builtin.unit_allow_fraction); + old_unit_precision_level := COALESCE (my_override.override_precision_level, + my_builtin.unit_precision_level); + old_unit_active := COALESCE (my_override.override_active, + my_builtin.unit_active); + + -- Only policy flags can change for builtin units. + IF in_unit_name_long IS NOT NULL + OR in_unit_name_short IS NOT NULL + OR in_unit_name_long_i18n IS NOT NULL + OR in_unit_name_short_i18n IS NOT NULL THEN + out_builtin_conflict := TRUE; + RETURN; + END IF; + + new_unit_allow_fraction := COALESCE (in_unit_allow_fraction, + old_unit_allow_fraction); + new_unit_precision_level := COALESCE (in_unit_precision_level, + old_unit_precision_level); + IF NOT new_unit_allow_fraction THEN + new_unit_precision_level := 0; + END IF; + new_unit_active := COALESCE (in_unit_active, old_unit_active); + + INSERT INTO merchant_builtin_unit_overrides ( + merchant_serial, + builtin_unit_serial, + override_allow_fraction, + override_precision_level, + override_active) + VALUES (my_merchant_id, + my_builtin.unit_serial, + new_unit_allow_fraction, + new_unit_precision_level, + new_unit_active) + ON CONFLICT (merchant_serial, builtin_unit_serial) + DO UPDATE SET override_allow_fraction = EXCLUDED.override_allow_fraction + , override_precision_level = EXCLUDED.override_precision_level + , override_active = EXCLUDED.override_active; + + IF old_unit_allow_fraction IS DISTINCT FROM new_unit_allow_fraction + OR old_unit_precision_level IS DISTINCT FROM new_unit_precision_level + THEN + UPDATE merchant_inventory SET + allow_fractional_quantity = new_unit_allow_fraction + , fractional_precision_level = new_unit_precision_level + WHERE merchant_serial = my_merchant_id + AND unit = in_unit_id + AND allow_fractional_quantity = old_unit_allow_fraction + AND fractional_precision_level = old_unit_precision_level; + END IF; + + RETURN; +END $$; + +DROP FUNCTION IF EXISTS merchant_do_delete_unit; +CREATE FUNCTION merchant_do_delete_unit ( + IN in_instance_id TEXT, + IN in_unit_id TEXT, + OUT out_no_instance BOOL, + OUT out_no_unit BOOL, + OUT out_builtin_conflict BOOL) + LANGUAGE plpgsql +AS $$ +DECLARE + my_merchant_id INT8; + my_unit merchant_custom_units%ROWTYPE; +BEGIN + out_no_instance := FALSE; + out_no_unit := FALSE; + out_builtin_conflict := FALSE; + + SELECT merchant_serial + INTO my_merchant_id + FROM merchant_instances + WHERE merchant_id = in_instance_id; + + IF NOT FOUND THEN + out_no_instance := TRUE; + RETURN; + END IF; + + SELECT * + INTO my_unit + FROM merchant_custom_units + WHERE merchant_serial = my_merchant_id + AND unit = in_unit_id + FOR UPDATE; + + IF NOT FOUND THEN + IF EXISTS (SELECT 1 FROM merchant_builtin_units bu WHERE bu.unit = in_unit_id) THEN + out_builtin_conflict := TRUE; + ELSE + out_no_unit := TRUE; + END IF; + RETURN; + END IF; + + DELETE FROM merchant_custom_units + WHERE unit_serial = my_unit.unit_serial; + + RETURN; +END $$; + +COMMIT; diff --git a/src/backenddb/merchantdb_helper.c b/src/backenddb/merchantdb_helper.c @@ -38,6 +38,9 @@ TALER_MERCHANTDB_product_details_free ( GNUNET_free (pd->image); json_decref (pd->address); pd->address = NULL; + GNUNET_free (pd->price_array); + pd->price_array = NULL; + pd->price_array_length = 0; } @@ -102,4 +105,18 @@ TALER_MERCHANTDB_category_details_free ( } +void +TALER_MERCHANTDB_unit_details_free ( + struct TALER_MERCHANTDB_UnitDetails *ud) +{ + GNUNET_free (ud->unit); + GNUNET_free (ud->unit_name_long); + GNUNET_free (ud->unit_name_short); + json_decref (ud->unit_name_long_i18n); + ud->unit_name_long_i18n = NULL; + json_decref (ud->unit_name_short_i18n); + ud->unit_name_short_i18n = NULL; +} + + /* end of merchantdb_helper.c */ diff --git a/src/backenddb/pg_delete_unit.c b/src/backenddb/pg_delete_unit.c @@ -0,0 +1,68 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 <http://www.gnu.org/licenses/> + */ +/** + * @file backenddb/pg_delete_unit.c + * @brief Implementation of the delete_unit function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_error_codes.h> +#include <taler/taler_dbevents.h> +#include <taler/taler_pq_lib.h> +#include "pg_delete_unit.h" +#include "pg_helper.h" + + +enum GNUNET_DB_QueryStatus +TMH_PG_delete_unit (void *cls, + const char *instance_id, + const char *unit_id, + bool *no_instance, + bool *no_unit, + bool *builtin_conflict) +{ + struct PostgresClosure *pg = cls; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (instance_id), + GNUNET_PQ_query_param_string (unit_id), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_bool ("out_no_instance", + no_instance), + GNUNET_PQ_result_spec_bool ("out_no_unit", + no_unit), + GNUNET_PQ_result_spec_bool ("out_builtin_conflict", + builtin_conflict), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_DB_QueryStatus qs; + + check_connection (pg); + PREPARE (pg, + "delete_unit", + "SELECT" + " out_no_instance" + " ,out_no_unit" + " ,out_builtin_conflict" + " FROM merchant_do_delete_unit($1,$2);"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "delete_unit", + params, + rs); + GNUNET_PQ_cleanup_query_params_closures (params); + return qs; +} diff --git a/src/backenddb/pg_delete_unit.h b/src/backenddb/pg_delete_unit.h @@ -0,0 +1,45 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 backenddb/pg_delete_unit.h + * @brief implementation of the delete_unit function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef PG_DELETE_UNIT_H +#define PG_DELETE_UNIT_H + +#include "taler_merchantdb_plugin.h" + +/** + * Delete a measurement unit. + * + * @param cls closure + * @param instance_id instance to delete unit from + * @param unit_id symbolic identifier + * @param[out] no_instance set to true if @a instance_id is unknown + * @param[out] no_unit set to true if the unit does not exist + * @param[out] builtin_conflict set to true if the unit cannot be deleted + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TMH_PG_delete_unit (void *cls, + const char *instance_id, + const char *unit_id, + bool *no_instance, + bool *no_unit, + bool *builtin_conflict); + +#endif diff --git a/src/backenddb/pg_insert_order_lock.c b/src/backenddb/pg_insert_order_lock.c @@ -30,7 +30,8 @@ TMH_PG_insert_order_lock (void *cls, const char *instance_id, const char *order_id, const char *product_id, - uint64_t quantity) + uint64_t quantity, + uint32_t quantity_frac) { struct PostgresClosure *pg = cls; struct GNUNET_PQ_QueryParam params[] = { @@ -38,6 +39,7 @@ TMH_PG_insert_order_lock (void *cls, GNUNET_PQ_query_param_string (order_id), GNUNET_PQ_query_param_string (product_id), GNUNET_PQ_query_param_uint64 (&quantity), + GNUNET_PQ_query_param_uint32 (&quantity_frac), GNUNET_PQ_query_param_end }; @@ -49,8 +51,12 @@ TMH_PG_insert_order_lock (void *cls, " product_serial" " ,merchant_serial" " ,total_stock" + " ,total_stock_frac" " ,total_sold" + " ,total_sold_frac" " ,total_lost" + " ,total_lost_frac" + " ,allow_fractional_quantity" " FROM merchant_inventory" " WHERE product_id=$3" " AND merchant_serial=" @@ -60,18 +66,32 @@ TMH_PG_insert_order_lock (void *cls, " INSERT INTO merchant_order_locks" " (product_serial" " ,total_locked" + " ,total_locked_frac" " ,order_serial)" - " SELECT tmp.product_serial, $4, order_serial" + " SELECT tmp.product_serial, $4::INT8, $5::INT4, order_serial" " FROM merchant_orders" " JOIN tmp USING(merchant_serial)" - " WHERE order_id=$2 AND" - " tmp.total_stock - tmp.total_sold - tmp.total_lost - $4 >= " - " (SELECT COALESCE(SUM(total_locked), 0)" - " FROM merchant_inventory_locks" - " WHERE product_serial=tmp.product_serial) + " - " (SELECT COALESCE(SUM(total_locked), 0)" - " FROM merchant_order_locks" - " WHERE product_serial=tmp.product_serial)"); + " WHERE order_id=$2" + " AND (tmp.allow_fractional_quantity OR $5 = 0)" + " AND (tmp.total_stock = 9223372036854775807" + " OR (" + " (tmp.total_stock::NUMERIC * 100000000" + " + tmp.total_stock_frac::NUMERIC)" + " - (tmp.total_sold::NUMERIC * 100000000" + " + tmp.total_sold_frac::NUMERIC)" + " - (tmp.total_lost::NUMERIC * 100000000" + " + tmp.total_lost_frac::NUMERIC)" + " >= " + " (($4::NUMERIC * 100000000) + $5::NUMERIC)" + " + (SELECT COALESCE(SUM(total_locked::NUMERIC * 100000000" + " + total_locked_frac::NUMERIC), 0)" + " FROM merchant_inventory_locks mil" + " WHERE mil.product_serial = tmp.product_serial)" + " + (SELECT COALESCE(SUM(total_locked::NUMERIC * 100000000" + " + total_locked_frac::NUMERIC), 0)" + " FROM merchant_order_locks mol" + " WHERE mol.product_serial = tmp.product_serial)" + " ))"); return GNUNET_PQ_eval_prepared_non_select (pg->conn, "insert_order_lock", params); diff --git a/src/backenddb/pg_insert_order_lock.h b/src/backenddb/pg_insert_order_lock.h @@ -33,6 +33,8 @@ * @param order_id alphanumeric string that uniquely identifies the order * @param product_id uniquely identifies the product to be locked * @param quantity how many units should be locked to the @a order_id + * @param quantity_frac fractional component of the quantity in units of + * 1/100000000 of the base value * @return transaction status, * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS means there are insufficient stocks * #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT indicates success @@ -42,6 +44,7 @@ TMH_PG_insert_order_lock (void *cls, const char *instance_id, const char *order_id, const char *product_id, - uint64_t quantity); + uint64_t quantity, + uint32_t quantity_frac); #endif diff --git a/src/backenddb/pg_insert_product.c b/src/backenddb/pg_insert_product.c @@ -49,7 +49,14 @@ TMH_PG_insert_product (void *cls, TALER_PQ_query_param_json (pd->taxes), TALER_PQ_query_param_amount_with_currency (pg->conn, &pd->price), + TALER_PQ_query_param_array_amount_with_currency ( + pd->price_array_length, + pd->price_array, + pg->conn), GNUNET_PQ_query_param_uint64 (&pd->total_stock), + GNUNET_PQ_query_param_uint32 (&pd->total_stock_frac), + GNUNET_PQ_query_param_bool (pd->allow_fractional_quantity), + GNUNET_PQ_query_param_uint32 (&pd->fractional_precision_level), TALER_PQ_query_param_json (pd->address), GNUNET_PQ_query_param_timestamp (&pd->next_restock), GNUNET_PQ_query_param_uint32 (&pd->minimum_age), @@ -79,11 +86,11 @@ TMH_PG_insert_product (void *cls, "insert_product", "SELECT" " out_conflict AS conflict" - ",out_no_cat AS no_cat" ",out_no_instance AS no_instance" + ",out_no_cat AS no_cat" " FROM merchant_do_insert_product" "($1, $2, $3, $4::TEXT::JSONB, $5, $6, $7::TEXT::JSONB, $8" - ",$9, $10::TEXT::JSONB, $11, $12, $13, $14);"); + ",$9, $10, $11, $12, $13, $14::TEXT::JSONB, $15, $16, $17, $18);"); qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, "insert_product", params, diff --git a/src/backenddb/pg_insert_product.sql b/src/backenddb/pg_insert_product.sql @@ -25,7 +25,11 @@ CREATE FUNCTION merchant_do_insert_product ( IN in_image TEXT, IN in_taxes JSONB, IN in_price taler_amount_currency, + IN ina_price_list taler_amount_currency[], IN in_total_stock INT8, + IN in_total_stock_frac INT4, + IN in_allow_fractional_quantity BOOL, + IN in_fractional_precision_level INT4, IN in_address JSONB, IN in_next_restock INT8, IN in_minimum_age INT4, @@ -41,6 +45,8 @@ DECLARE my_product_serial INT8; i INT8; ini_cat INT8; + my_price taler_amount_currency; + my_price_array taler_amount_currency[]; BEGIN -- Which instance are we using? @@ -58,6 +64,14 @@ THEN END IF; out_no_instance=FALSE; +IF COALESCE (array_length(ina_price_list,1),0) = 0 +THEN + my_price_array := ARRAY[in_price]::taler_amount_currency[]; +ELSE + my_price_array := ina_price_list; +END IF; +my_price := my_price_array[1]; + INSERT INTO merchant_inventory (merchant_serial ,product_id @@ -69,7 +83,11 @@ INSERT INTO merchant_inventory ,image_hash ,taxes ,price + ,price_array ,total_stock + ,total_stock_frac + ,allow_fractional_quantity + ,fractional_precision_level ,address ,next_restock ,minimum_age @@ -89,8 +107,12 @@ INSERT INTO merchant_inventory 'hex') END ,in_taxes - ,in_price + ,my_price + ,my_price_array ,in_total_stock + ,in_total_stock_frac + ,in_allow_fractional_quantity + ,in_fractional_precision_level ,in_address ,in_next_restock ,in_minimum_age) @@ -113,8 +135,13 @@ THEN AND unit=in_unit AND image=in_image AND taxes=in_taxes - AND price=in_price + AND price=my_price + AND to_jsonb(COALESCE(price_array, ARRAY[]::taler_amount_currency[])) + = to_jsonb(COALESCE(my_price_array, ARRAY[]::taler_amount_currency[])) AND total_stock=in_total_stock + AND total_stock_frac=in_total_stock_frac + AND allow_fractional_quantity=in_allow_fractional_quantity + AND fractional_precision_level=in_fractional_precision_level AND address=in_address AND next_restock=in_next_restock AND minimum_age=in_minimum_age; diff --git a/src/backenddb/pg_insert_unit.c b/src/backenddb/pg_insert_unit.c @@ -0,0 +1,84 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 backenddb/pg_insert_unit.c + * @brief Implementation of the insert_unit function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_error_codes.h> +#include <taler/taler_dbevents.h> +#include <taler/taler_pq_lib.h> +#include "pg_insert_unit.h" +#include "pg_helper.h" + + +enum GNUNET_DB_QueryStatus +TMH_PG_insert_unit (void *cls, + const char *instance_id, + const struct TALER_MERCHANTDB_UnitDetails *ud, + bool *no_instance, + bool *conflict, + uint64_t *unit_serial) +{ + struct PostgresClosure *pg = cls; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (instance_id), + GNUNET_PQ_query_param_string (ud->unit), + GNUNET_PQ_query_param_string (ud->unit_name_long), + GNUNET_PQ_query_param_string (ud->unit_name_short), + TALER_PQ_query_param_json (ud->unit_name_long_i18n), + TALER_PQ_query_param_json (ud->unit_name_short_i18n), + GNUNET_PQ_query_param_bool (ud->unit_allow_fraction), + GNUNET_PQ_query_param_uint32 (&ud->unit_precision_level), + GNUNET_PQ_query_param_bool (ud->unit_active), + GNUNET_PQ_query_param_end + }; + bool unit_serial_present = true; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_bool ("no_instance", + no_instance), + GNUNET_PQ_result_spec_bool ("conflict", + conflict), + GNUNET_PQ_result_spec_allow_null ( + GNUNET_PQ_result_spec_uint64 ("unit_serial", + unit_serial), + &unit_serial_present), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_DB_QueryStatus qs; + + *no_instance = false; + *conflict = false; + + check_connection (pg); + PREPARE (pg, + "insert_unit", + "SELECT" + " out_no_instance AS no_instance" + " ,out_conflict AS conflict" + " ,out_unit_serial AS unit_serial" + " FROM merchant_do_insert_unit" + " ($1,$2,$3,$4,$5,$6,$7,$8,$9);"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "insert_unit", + params, + rs); + GNUNET_PQ_cleanup_query_params_closures (params); + if (! unit_serial_present) + *unit_serial = 0; + return qs; +} diff --git a/src/backenddb/pg_insert_unit.h b/src/backenddb/pg_insert_unit.h @@ -0,0 +1,47 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 backenddb/pg_insert_unit.h + * @brief implementation of the insert_unit function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef PG_INSERT_UNIT_H +#define PG_INSERT_UNIT_H + +#include <taler/taler_util.h> +#include <taler/taler_json_lib.h> +#include "taler_merchantdb_plugin.h" + +/** + * Insert a measurement unit definition. + * + * @param cls closure + * @param instance_id instance to insert unit for + * @param ud unit definition to store (unit_serial ignored) + * @param[out] no_instance set to true if the instance is unknown + * @param[out] conflict set to true if a conflicting unit already exists + * @param[out] unit_serial set to the generated serial on success (or the existing serial for idempotent inserts) + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TMH_PG_insert_unit (void *cls, + const char *instance_id, + const struct TALER_MERCHANTDB_UnitDetails *ud, + bool *no_instance, + bool *conflict, + uint64_t *unit_serial); + +#endif diff --git a/src/backenddb/pg_lock_product.c b/src/backenddb/pg_lock_product.c @@ -31,6 +31,7 @@ TMH_PG_lock_product (void *cls, const char *product_id, const struct GNUNET_Uuid *uuid, uint64_t quantity, + uint32_t quantity_frac, struct GNUNET_TIME_Timestamp expiration_time) { struct PostgresClosure *pg = cls; @@ -39,6 +40,7 @@ TMH_PG_lock_product (void *cls, GNUNET_PQ_query_param_string (product_id), GNUNET_PQ_query_param_auto_from_type (uuid), GNUNET_PQ_query_param_uint64 (&quantity), + GNUNET_PQ_query_param_uint32 (&quantity_frac), GNUNET_PQ_query_param_timestamp (&expiration_time), GNUNET_PQ_query_param_end }; @@ -54,22 +56,46 @@ TMH_PG_lock_product (void *cls, " (SELECT merchant_serial" " FROM merchant_instances" " WHERE merchant_id=$1))" + ",tmp AS" + " (SELECT" + " mi.product_serial" + " ,mi.total_stock" + " ,mi.total_stock_frac" + " ,mi.total_sold" + " ,mi.total_sold_frac" + " ,mi.total_lost" + " ,mi.total_lost_frac" + " ,mi.allow_fractional_quantity" + " FROM merchant_inventory mi" + " JOIN ps USING (product_serial))" "INSERT INTO merchant_inventory_locks" "(product_serial" ",lock_uuid" ",total_locked" + ",total_locked_frac" ",expiration)" - " SELECT product_serial, $3, $4, $5" - " FROM merchant_inventory" - " JOIN ps USING (product_serial)" - " WHERE " - " total_stock - total_sold - total_lost - $4 >= " - " (SELECT COALESCE(SUM(total_locked), 0)" - " FROM merchant_inventory_locks" - " WHERE product_serial=ps.product_serial) + " - " (SELECT COALESCE(SUM(total_locked), 0)" - " FROM merchant_order_locks" - " WHERE product_serial=ps.product_serial)"); + " SELECT tmp.product_serial, $3, $4::INT8, $5::INT4, $6" + " FROM tmp" + " WHERE (tmp.allow_fractional_quantity OR $5 = 0)" + " AND (tmp.total_stock = 9223372036854775807" + " OR (" + " (tmp.total_stock::NUMERIC * 1000000" + " + tmp.total_stock_frac::NUMERIC)" + " - (tmp.total_sold::NUMERIC * 1000000" + " + tmp.total_sold_frac::NUMERIC)" + " - (tmp.total_lost::NUMERIC * 1000000" + " + tmp.total_lost_frac::NUMERIC)" + " >= " + " (($4::NUMERIC * 1000000) + $5::NUMERIC)" + " + (SELECT COALESCE(SUM(total_locked::NUMERIC * 1000000" + " + total_locked_frac::NUMERIC), 0)" + " FROM merchant_inventory_locks mil" + " WHERE mil.product_serial = tmp.product_serial)" + " + (SELECT COALESCE(SUM(total_locked::NUMERIC * 1000000" + " + total_locked_frac::NUMERIC), 0)" + " FROM merchant_order_locks mol" + " WHERE mol.product_serial = tmp.product_serial)" + " ))"); return GNUNET_PQ_eval_prepared_non_select (pg->conn, "lock_product", params); diff --git a/src/backenddb/pg_lock_product.h b/src/backenddb/pg_lock_product.h @@ -34,6 +34,8 @@ * @param product_id product to lookup * @param uuid the UUID that holds the lock * @param quantity how many units should be locked + * @param quantity_frac fractional component of quantity in units of + * 1/100000000 of the base value * @param expiration_time when should the lock expire * @return database result code, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the * product is unknown OR if there insufficient stocks remaining @@ -44,6 +46,7 @@ TMH_PG_lock_product (void *cls, const char *product_id, const struct GNUNET_Uuid *uuid, uint64_t quantity, + uint32_t quantity_frac, struct GNUNET_TIME_Timestamp expiration_time); #endif diff --git a/src/backenddb/pg_lookup_all_products.c b/src/backenddb/pg_lookup_all_products.c @@ -90,14 +90,28 @@ lookup_products_cb (void *cls, &pd.unit), TALER_PQ_result_spec_amount_with_currency ("price", &pd.price), + TALER_PQ_result_spec_array_amount_with_currency (pg->conn, + "price_array", + &pd.price_array_length, + &pd.price_array), TALER_PQ_result_spec_json ("taxes", &pd.taxes), GNUNET_PQ_result_spec_uint64 ("total_stock", &pd.total_stock), + GNUNET_PQ_result_spec_uint32 ("total_stock_frac", + &pd.total_stock_frac), + GNUNET_PQ_result_spec_bool ("allow_fractional_quantity", + &pd.allow_fractional_quantity), + GNUNET_PQ_result_spec_uint32 ("fractional_precision_level", + &pd.fractional_precision_level), GNUNET_PQ_result_spec_uint64 ("total_sold", &pd.total_sold), + GNUNET_PQ_result_spec_uint32 ("total_sold_frac", + &pd.total_sold_frac), GNUNET_PQ_result_spec_uint64 ("total_lost", &pd.total_lost), + GNUNET_PQ_result_spec_uint32 ("total_lost_frac", + &pd.total_lost_frac), GNUNET_PQ_result_spec_string ("image", &pd.image), TALER_PQ_result_spec_json ("address", @@ -162,10 +176,16 @@ TMH_PG_lookup_all_products (void *cls, ",product_name" ",unit" ",price" + ",price_array" ",taxes::TEXT" ",total_stock" + ",total_stock_frac" + ",allow_fractional_quantity" + ",fractional_precision_level" ",total_sold" + ",total_sold_frac" ",total_lost" + ",total_lost_frac" ",image" ",minv.address::TEXT" ",next_restock" diff --git a/src/backenddb/pg_lookup_product.c b/src/backenddb/pg_lookup_product.c @@ -49,10 +49,16 @@ TMH_PG_lookup_product (void *cls, ",mi.product_name" ",mi.unit" ",mi.price" + ",mi.price_array" ",mi.taxes::TEXT" - ",mi.total_stock" + ",mi.total_stock" + ",mi.total_stock_frac" + ",mi.allow_fractional_quantity" + ",mi.fractional_precision_level" ",mi.total_sold" + ",mi.total_sold_frac" ",mi.total_lost" + ",mi.total_lost_frac" ",mi.image" ",mi.address::TEXT" ",mi.next_restock" @@ -93,6 +99,8 @@ TMH_PG_lookup_product (void *cls, json_t *my_address = NULL; json_t *my_taxes = NULL; uint64_t *my_categories = NULL; + struct TALER_Amount *my_price_array = NULL; + size_t my_price_array_length = 0; struct GNUNET_PQ_ResultSpec rs[] = { GNUNET_PQ_result_spec_string ("description", &my_description), @@ -104,14 +112,28 @@ TMH_PG_lookup_product (void *cls, &my_unit), TALER_PQ_result_spec_amount_with_currency ("price", &pd->price), + TALER_PQ_result_spec_array_amount_with_currency (pg->conn, + "price_array", + &my_price_array_length, + &my_price_array), TALER_PQ_result_spec_json ("taxes", &my_taxes), GNUNET_PQ_result_spec_uint64 ("total_stock", &pd->total_stock), + GNUNET_PQ_result_spec_uint32 ("total_stock_frac", + &pd->total_stock_frac), + GNUNET_PQ_result_spec_bool ("allow_fractional_quantity", + &pd->allow_fractional_quantity), + GNUNET_PQ_result_spec_uint32 ("fractional_precision_level", + &pd->fractional_precision_level), GNUNET_PQ_result_spec_uint64 ("total_sold", &pd->total_sold), + GNUNET_PQ_result_spec_uint32 ("total_sold_frac", + &pd->total_sold_frac), GNUNET_PQ_result_spec_uint64 ("total_lost", &pd->total_lost), + GNUNET_PQ_result_spec_uint32 ("total_lost_frac", + &pd->total_lost_frac), GNUNET_PQ_result_spec_string ("image", &my_image), TALER_PQ_result_spec_json ("address", @@ -140,6 +162,8 @@ TMH_PG_lookup_product (void *cls, pd->taxes = my_taxes; pd->image = my_image; pd->address = my_address; + pd->price_array = my_price_array; + pd->price_array_length = my_price_array_length; *categories = my_categories; /* Clear original pointers to that cleanup_result doesn't squash them */ my_name = NULL; @@ -149,6 +173,8 @@ TMH_PG_lookup_product (void *cls, my_taxes = NULL; my_image = NULL; my_address = NULL; + my_price_array = NULL; + my_price_array_length = 0; my_categories = NULL; GNUNET_PQ_cleanup_result (rs); return qs; diff --git a/src/backenddb/pg_lookup_units.c b/src/backenddb/pg_lookup_units.c @@ -0,0 +1,152 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 backenddb/pg_lookup_units.c + * @brief Implementation of the lookup_units function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_error_codes.h> +#include <taler/taler_dbevents.h> +#include <taler/taler_pq_lib.h> +#include "pg_lookup_units.h" +#include "pg_helper.h" + + +/** + * Context used for TMH_PG_lookup_units(). + */ +struct LookupUnitsContext +{ + TALER_MERCHANTDB_UnitsCallback cb; + void *cb_cls; + bool extract_failed; +}; + + +static void +lookup_units_cb (void *cls, + PGresult *result, + unsigned int num_results) +{ + struct LookupUnitsContext *luc = cls; + + for (unsigned int i = 0; i<num_results; i++) + { + struct TALER_MERCHANTDB_UnitDetails ud = { 0 }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("unit_serial", + &ud.unit_serial), + GNUNET_PQ_result_spec_string ("unit", + &ud.unit), + GNUNET_PQ_result_spec_string ("unit_name_long", + &ud.unit_name_long), + GNUNET_PQ_result_spec_string ("unit_name_short", + &ud.unit_name_short), + TALER_PQ_result_spec_json ("unit_name_long_i18n", + &ud.unit_name_long_i18n), + TALER_PQ_result_spec_json ("unit_name_short_i18n", + &ud.unit_name_short_i18n), + GNUNET_PQ_result_spec_bool ("unit_allow_fraction", + &ud.unit_allow_fraction), + GNUNET_PQ_result_spec_uint32 ("unit_precision_level", + &ud.unit_precision_level), + GNUNET_PQ_result_spec_bool ("unit_active", + &ud.unit_active), + GNUNET_PQ_result_spec_bool ("unit_builtin", + &ud.unit_builtin), + GNUNET_PQ_result_spec_end + }; + + if (GNUNET_OK != + GNUNET_PQ_extract_result (result, + rs, + i)) + { + GNUNET_break (0); + luc->extract_failed = true; + return; + } + luc->cb (luc->cb_cls, + ud.unit_serial, + &ud); + GNUNET_PQ_cleanup_result (rs); + } +} + + +enum GNUNET_DB_QueryStatus +TMH_PG_lookup_units (void *cls, + const char *instance_id, + TALER_MERCHANTDB_UnitsCallback cb, + void *cb_cls) +{ + struct PostgresClosure *pg = cls; + struct LookupUnitsContext luc = { + .cb = cb, + .cb_cls = cb_cls, + .extract_failed = false + }; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (instance_id), + GNUNET_PQ_query_param_end + }; + enum GNUNET_DB_QueryStatus qs; + + check_connection (pg); + PREPARE (pg, + "lookup_units", + "WITH mi AS (" + " SELECT merchant_serial FROM merchant_instances WHERE merchant_id=$1" + ")" + "SELECT cu.unit_serial" + " ,cu.unit" + " ,cu.unit_name_long" + " ,cu.unit_name_short" + " ,cu.unit_name_long_i18n" + " ,cu.unit_name_short_i18n" + " ,cu.unit_allow_fraction" + " ,cu.unit_precision_level" + " ,cu.unit_active" + " ,FALSE AS unit_builtin" + " FROM merchant_custom_units cu" + " JOIN mi ON cu.merchant_serial = mi.merchant_serial" + " UNION ALL " + "SELECT bu.unit_serial" + " ,bu.unit" + " ,bu.unit_name_long" + " ,bu.unit_name_short" + " ,bu.unit_name_long_i18n" + " ,bu.unit_name_short_i18n" + " ,COALESCE(bo.override_allow_fraction, bu.unit_allow_fraction)" + " ,COALESCE(bo.override_precision_level, bu.unit_precision_level)" + " ,COALESCE(bo.override_active, bu.unit_active)" + " ,TRUE AS unit_builtin" + " FROM merchant_builtin_units bu" + " JOIN mi ON TRUE" + " LEFT JOIN merchant_builtin_unit_overrides bo" + " ON bo.builtin_unit_serial = bu.unit_serial" + " AND bo.merchant_serial = mi.merchant_serial" + " ORDER BY unit"); + qs = GNUNET_PQ_eval_prepared_multi_select (pg->conn, + "lookup_units", + params, + &lookup_units_cb, + &luc); + if (luc.extract_failed) + return GNUNET_DB_STATUS_HARD_ERROR; + return qs; +} diff --git a/src/backenddb/pg_lookup_units.h b/src/backenddb/pg_lookup_units.h @@ -0,0 +1,43 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 backenddb/pg_lookup_units.h + * @brief implementation of the lookup_units function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef PG_LOOKUP_UNITS_H +#define PG_LOOKUP_UNITS_H + +#include <taler/taler_util.h> +#include <taler/taler_json_lib.h> +#include "taler_merchantdb_plugin.h" + +/** + * Lookup all measurement units for an instance. + * + * @param cls closure + * @param instance_id instance to fetch units for + * @param cb function to call with each unit + * @param cb_cls closure for @a cb + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TMH_PG_lookup_units (void *cls, + const char *instance_id, + TALER_MERCHANTDB_UnitsCallback cb, + void *cb_cls); + +#endif diff --git a/src/backenddb/pg_mark_contract_paid.c b/src/backenddb/pg_mark_contract_paid.c @@ -78,8 +78,14 @@ TMH_PG_mark_contract_paid ( PREPARE (pg, "mark_inventory_sold", "UPDATE merchant_inventory SET" - " total_sold=total_sold + order_locks.total_locked" - " FROM (SELECT total_locked,product_serial" + " total_sold = total_sold" + " + order_locks.total_locked" + " + ((merchant_inventory.total_sold_frac::BIGINT" + " + order_locks.total_locked_frac::BIGINT) / 100000000)" + " ,total_sold_frac =" + " ((merchant_inventory.total_sold_frac::BIGINT" + " + order_locks.total_locked_frac::BIGINT) % 100000000)::INT4" + " FROM (SELECT total_locked,total_locked_frac,product_serial" " FROM merchant_order_locks" " WHERE order_serial=" " (SELECT order_serial" diff --git a/src/backenddb/pg_select_unit.c b/src/backenddb/pg_select_unit.c @@ -0,0 +1,135 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 backenddb/pg_select_unit.c + * @brief Implementation of the select_unit function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_error_codes.h> +#include <taler/taler_dbevents.h> +#include <taler/taler_pq_lib.h> +#include "pg_select_unit.h" +#include "pg_helper.h" + + +enum GNUNET_DB_QueryStatus +TMH_PG_select_unit (void *cls, + const char *instance_id, + const char *unit_id, + struct TALER_MERCHANTDB_UnitDetails *ud) +{ + enum GNUNET_DB_QueryStatus qs; + struct PostgresClosure *pg = cls; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (instance_id), + GNUNET_PQ_query_param_string (unit_id), + GNUNET_PQ_query_param_end + }; + + if (NULL == ud) + { + struct GNUNET_PQ_ResultSpec rs_null[] = { + GNUNET_PQ_result_spec_end + }; + + check_connection (pg); + return GNUNET_PQ_eval_prepared_singleton_select ( + pg->conn, + "select_unit", + params, + rs_null); + } + else + { + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_uint64 ("unit_serial", + &ud->unit_serial), + GNUNET_PQ_result_spec_string ("unit", + &ud->unit), + GNUNET_PQ_result_spec_string ("unit_name_long", + &ud->unit_name_long), + GNUNET_PQ_result_spec_string ("unit_name_short", + &ud->unit_name_short), + TALER_PQ_result_spec_json ("unit_name_long_i18n", + &ud->unit_name_long_i18n), + TALER_PQ_result_spec_json ("unit_name_short_i18n", + &ud->unit_name_short_i18n), + GNUNET_PQ_result_spec_bool ("unit_allow_fraction", + &ud->unit_allow_fraction), + GNUNET_PQ_result_spec_uint32 ("unit_precision_level", + &ud->unit_precision_level), + GNUNET_PQ_result_spec_bool ("unit_active", + &ud->unit_active), + GNUNET_PQ_result_spec_bool ("unit_builtin", + &ud->unit_builtin), + GNUNET_PQ_result_spec_end + }; + + check_connection (pg); + + PREPARE (pg, + "select_unit_custom", + "SELECT" + " cu.unit_serial" + " ,cu.unit" + " ,cu.unit_name_long" + " ,cu.unit_name_short" + " ,cu.unit_name_long_i18n" + " ,cu.unit_name_short_i18n" + " ,cu.unit_allow_fraction" + " ,cu.unit_precision_level" + " ,cu.unit_active" + " ,FALSE AS unit_builtin" + " FROM merchant_custom_units cu" + " JOIN merchant_instances inst" + " USING (merchant_serial)" + " WHERE inst.merchant_id=$1" + " AND cu.unit=$2"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "select_unit_custom", + params, + rs); + if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != qs) + return qs; + + PREPARE (pg, + "select_unit_builtin", + "SELECT" + " bu.unit_serial" + " ,bu.unit" + " ,bu.unit_name_long" + " ,bu.unit_name_short" + " ,bu.unit_name_long_i18n" + " ,bu.unit_name_short_i18n" + " ,COALESCE(bo.override_allow_fraction, bu.unit_allow_fraction)" + " ,COALESCE(bo.override_precision_level, bu.unit_precision_level)" + " ,COALESCE(bo.override_active, bu.unit_active)" + " ,TRUE AS unit_builtin" + " FROM merchant_builtin_units bu" + " JOIN merchant_instances inst" + " ON TRUE" + " LEFT JOIN merchant_builtin_unit_overrides bo" + " ON bo.builtin_unit_serial = bu.unit_serial" + " AND bo.merchant_serial = inst.merchant_serial" + " WHERE inst.merchant_id=$1" + " AND bu.unit=$2"); + return GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "select_unit_builtin", + params, + rs); + } +} diff --git a/src/backenddb/pg_select_unit.h b/src/backenddb/pg_select_unit.h @@ -0,0 +1,43 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 backenddb/pg_select_unit.h + * @brief implementation of the select_unit function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef PG_SELECT_UNIT_H +#define PG_SELECT_UNIT_H + +#include <taler/taler_util.h> +#include <taler/taler_json_lib.h> +#include "taler_merchantdb_plugin.h" + +/** + * Lookup a measurement unit by identifier. + * + * @param cls closure + * @param instance_id instance owning the unit + * @param unit_id symbolic identifier + * @param[out] ud unit details on success; may be NULL to test for existence + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TMH_PG_select_unit (void *cls, + const char *instance_id, + const char *unit_id, + struct TALER_MERCHANTDB_UnitDetails *ud); + +#endif diff --git a/src/backenddb/pg_update_product.c b/src/backenddb/pg_update_product.c @@ -52,7 +52,14 @@ TMH_PG_update_product (void *cls, TALER_PQ_query_param_json (pd->taxes), TALER_PQ_query_param_amount_with_currency (pg->conn, &pd->price), /* $8 */ - GNUNET_PQ_query_param_uint64 (&pd->total_stock), /* $9 */ + TALER_PQ_query_param_array_amount_with_currency ( + pd->price_array_length, + pd->price_array, + pg->conn), /* $9 */ + GNUNET_PQ_query_param_uint64 (&pd->total_stock), /* $10 */ + GNUNET_PQ_query_param_uint32 (&pd->total_stock_frac), /* $11 */ + GNUNET_PQ_query_param_bool (pd->allow_fractional_quantity), /* $12 */ + GNUNET_PQ_query_param_uint32 (&pd->fractional_precision_level), GNUNET_PQ_query_param_uint64 (&pd->total_lost), TALER_PQ_query_param_json (pd->address), GNUNET_PQ_query_param_timestamp (&pd->next_restock), @@ -103,7 +110,7 @@ TMH_PG_update_product (void *cls, ",out_no_instance AS no_instance" " FROM merchant_do_update_product" "($1,$2,$3,$4::TEXT::JSONB,$5,$6,$7::TEXT::JSONB,$8,$9" - ",$10,$11::TEXT::JSONB,$12,$13,$14,$15);"); + ",$10,$11,$12,$13,$14,$15::TEXT::JSONB,$16,$17,$18,$19);"); qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, "update_product", params, diff --git a/src/backenddb/pg_update_product.sql b/src/backenddb/pg_update_product.sql @@ -25,7 +25,11 @@ CREATE FUNCTION merchant_do_update_product ( IN in_image TEXT, IN in_taxes JSONB, IN in_price taler_amount_currency, + IN ina_price_list taler_amount_currency[], IN in_total_stock INT8, + IN in_total_stock_frac INT4, + IN in_allow_fractional_quantity BOOL, + IN in_fractional_precision_level INT4, IN in_total_lost INT8, IN in_address JSONB, IN in_next_restock INT8, @@ -46,6 +50,8 @@ DECLARE i INT8; ini_cat INT8; rec RECORD; + my_price taler_amount_currency; + my_price_array taler_amount_currency[]; BEGIN out_no_instance=FALSE; @@ -69,7 +75,9 @@ END IF; -- Check existing entry satisfies constraints SELECT total_stock + ,total_stock_frac ,total_lost + ,allow_fractional_quantity ,product_serial INTO rec FROM merchant_inventory @@ -84,6 +92,14 @@ END IF; my_product_serial = rec.product_serial; +IF COALESCE (array_length(ina_price_list,1),0) = 0 +THEN + my_price_array := ARRAY[in_price]::taler_amount_currency[]; +ELSE + my_price_array := ina_price_list; +END IF; +my_price := my_price_array[1]; + IF rec.total_stock > in_total_stock THEN out_stocked_reduced=TRUE; @@ -95,6 +111,14 @@ THEN out_lost_reduced=TRUE; RETURN; END IF; +IF rec.allow_fractional_quantity + AND (NOT in_allow_fractional_quantity) +THEN + DELETE + FROM merchant_inventory_locks + WHERE product_serial = my_product_serial + AND total_locked_frac <> 0; +END IF; -- Remove old categories DELETE FROM merchant_product_categories @@ -134,8 +158,12 @@ UPDATE merchant_inventory SET 'hex') END ,taxes=in_taxes - ,price=in_price + ,price=my_price + ,price_array=my_price_array ,total_stock=in_total_stock + ,total_stock_frac=in_total_stock_frac + ,allow_fractional_quantity=in_allow_fractional_quantity + ,fractional_precision_level=in_fractional_precision_level ,total_lost=in_total_lost ,address=in_address ,next_restock=in_next_restock diff --git a/src/backenddb/pg_update_unit.c b/src/backenddb/pg_update_unit.c @@ -0,0 +1,97 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 backenddb/pg_update_unit.c + * @brief Implementation of the update_unit function for Postgres + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_error_codes.h> +#include <taler/taler_dbevents.h> +#include <taler/taler_pq_lib.h> +#include "pg_update_unit.h" +#include "pg_helper.h" + + +enum GNUNET_DB_QueryStatus +TMH_PG_update_unit (void *cls, + const char *instance_id, + const char *unit_id, + const char *unit_name_long, + const json_t *unit_name_long_i18n, + const char *unit_name_short, + const json_t *unit_name_short_i18n, + const bool *unit_allow_fraction, + const uint32_t *unit_precision_level, + const bool *unit_active, + bool *no_instance, + bool *no_unit, + bool *builtin_conflict) +{ + struct PostgresClosure *pg = cls; + struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (instance_id), + GNUNET_PQ_query_param_string (unit_id), + (NULL == unit_name_long) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_string (unit_name_long), + (NULL == unit_name_long_i18n) + ? GNUNET_PQ_query_param_null () + : TALER_PQ_query_param_json ((json_t *) unit_name_long_i18n), + (NULL == unit_name_short) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_string (unit_name_short), + (NULL == unit_name_short_i18n) + ? GNUNET_PQ_query_param_null () + : TALER_PQ_query_param_json ((json_t *) unit_name_short_i18n), + (NULL == unit_allow_fraction) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_bool (*unit_allow_fraction), + (NULL == unit_precision_level) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_uint32 ((uint32_t *) unit_precision_level), + (NULL == unit_active) + ? GNUNET_PQ_query_param_null () + : GNUNET_PQ_query_param_bool (*unit_active), + GNUNET_PQ_query_param_end + }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_bool ("out_no_instance", + no_instance), + GNUNET_PQ_result_spec_bool ("out_no_unit", + no_unit), + GNUNET_PQ_result_spec_bool ("out_builtin_conflict", + builtin_conflict), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_DB_QueryStatus qs; + + check_connection (pg); + PREPARE (pg, + "update_unit", + "SELECT" + " out_no_instance" + " ,out_no_unit" + " ,out_builtin_conflict" + " FROM merchant_do_update_unit" + "($1,$2,$3,$4,$5,$6,$7,$8,$9);"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "update_unit", + params, + rs); + GNUNET_PQ_cleanup_query_params_closures (params); + return qs; +} diff --git a/src/backenddb/pg_update_unit.h b/src/backenddb/pg_update_unit.h @@ -0,0 +1,61 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 backenddb/pg_update_unit.h + * @brief implementation of the update_unit function for Postgres + * @author Bohdan Potuzhnyi + */ +#ifndef PG_UPDATE_UNIT_H +#define PG_UPDATE_UNIT_H + +#include <taler/taler_util.h> +#include <taler/taler_json_lib.h> +#include "taler_merchantdb_plugin.h" + +/** + * Update an existing measurement unit definition. + * + * @param cls closure + * @param instance_id instance owning the unit + * @param unit_id symbolic unit identifier + * @param unit_name_long optional new long name (NULL to keep existing) + * @param unit_name_long_i18n optional new long-name translations + * @param unit_name_short optional new short name + * @param unit_name_short_i18n optional new short-name translations + * @param unit_allow_fraction optional new fractional toggle + * @param unit_precision_level optional new fractional precision + * @param unit_active optional new visibility flag + * @param[out] no_instance set if instance unknown + * @param[out] no_unit set if unit unknown + * @param[out] builtin_conflict set if immutable builtin fields touched + * @return database result code + */ +enum GNUNET_DB_QueryStatus +TMH_PG_update_unit (void *cls, + const char *instance_id, + const char *unit_id, + const char *unit_name_long, + const json_t *unit_name_long_i18n, + const char *unit_name_short, + const json_t *unit_name_short_i18n, + const bool *unit_allow_fraction, + const uint32_t *unit_precision_level, + const bool *unit_active, + bool *no_instance, + bool *no_unit, + bool *builtin_conflict); + +#endif diff --git a/src/backenddb/plugin_merchantdb_postgres.c b/src/backenddb/plugin_merchantdb_postgres.c @@ -50,10 +50,15 @@ #include "pg_lookup_transfers.h" #include "pg_lookup_pending_deposits.h" #include "pg_lookup_categories.h" +#include "pg_lookup_units.h" #include "pg_select_category.h" #include "pg_update_category.h" #include "pg_insert_category.h" #include "pg_delete_category.h" +#include "pg_select_unit.h" +#include "pg_insert_unit.h" +#include "pg_update_unit.h" +#include "pg_delete_unit.h" #include "pg_update_wirewatch_progress.h" #include "pg_select_wirewatch_accounts.h" #include "pg_select_open_transfers.h" @@ -622,6 +627,8 @@ libtaler_plugin_merchantdb_postgres_init (void *cls) = &TMH_PG_update_pending_webhook; plugin->lookup_categories = &TMH_PG_lookup_categories; + plugin->lookup_units + = &TMH_PG_lookup_units; plugin->select_category_by_name = &TMH_PG_select_category_by_name; plugin->get_kyc_status @@ -632,12 +639,20 @@ libtaler_plugin_merchantdb_postgres_init (void *cls) = &TMH_PG_get_kyc_limits; plugin->select_category = &TMH_PG_select_category; + plugin->select_unit + = &TMH_PG_select_unit; plugin->update_category = &TMH_PG_update_category; + plugin->update_unit + = &TMH_PG_update_unit; plugin->insert_category = &TMH_PG_insert_category; + plugin->insert_unit + = &TMH_PG_insert_unit; plugin->delete_category = &TMH_PG_delete_category; + plugin->delete_unit + = &TMH_PG_delete_unit; plugin->delete_exchange_accounts = &TMH_PG_delete_exchange_accounts; plugin->select_accounts_by_exchange diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -44,28 +44,28 @@ static struct TALER_MERCHANTDB_Plugin *plugin; * @param test 0 on success, non-zero on failure */ #define TEST_WITH_FAIL_CLAUSE(test, on_fail) \ - if ((test)) \ - { \ - GNUNET_break (0); \ - on_fail \ - } + if ((test)) \ + { \ + GNUNET_break (0); \ + on_fail \ + } #define TEST_COND_RET_ON_FAIL(cond, msg) \ - if (! (cond)) \ - { \ - GNUNET_break (0); \ - GNUNET_log (GNUNET_ERROR_TYPE_ERROR, \ - msg); \ - return 1; \ - } + if (! (cond)) \ + { \ + GNUNET_break (0); \ + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, \ + msg); \ + return 1; \ + } /** * @param __test 0 on success, non-zero on failure */ #define TEST_RET_ON_FAIL(__test) \ - TEST_WITH_FAIL_CLAUSE (__test, \ - return 1; \ - ) + TEST_WITH_FAIL_CLAUSE (__test, \ + return 1; \ + ) /* ********** Instances ********** */ @@ -717,6 +717,9 @@ static void make_product (const char *id, struct ProductData *product) { + memset (product, + 0, + sizeof (*product)); product->id = id; product->product.product_name = "Test product"; product->product.description = "This is a test product"; @@ -1326,6 +1329,7 @@ run_test_products (struct TestProducts_Closure *cls) cls->products[0].id, &uuid, 256, + 0, refund_deadline)) { GNUNET_log (GNUNET_ERROR_TYPE_ERROR, @@ -1337,6 +1341,7 @@ run_test_products (struct TestProducts_Closure *cls) cls->products[0].id, &uuid, 1, + 0, refund_deadline)) { GNUNET_log (GNUNET_ERROR_TYPE_ERROR, @@ -2247,7 +2252,8 @@ run_test_orders (struct TestOrders_Closure *cls) cls->instance.instance.id, cls->orders[0].id, cls->product.id, - 5)) + 5, + 0)) { GNUNET_log (GNUNET_ERROR_TYPE_ERROR, "Insert order lock failed\n"); diff --git a/src/include/taler_merchant_service.h b/src/include/taler_merchant_service.h @@ -1622,6 +1622,16 @@ struct TALER_MERCHANT_ProductGetResponse struct TALER_Amount price; /** + * Optional list of price tiers as provided by the backend. + */ + const struct TALER_Amount *unit_price; + + /** + * Size of unit_price array + */ + size_t unit_price_len; + + /** * base64-encoded product image, can be NULL if none is set. */ const char *image; @@ -1640,6 +1650,21 @@ struct TALER_MERCHANT_ProductGetResponse int64_t total_stock; /** + * Set to true if fractional quantities are allowed for this product. + */ + bool unit_allow_fraction; + + /** + * Suggested fractional precision for fractional quantities. + */ + uint32_t unit_precision_level; + + /** + * Stock level encoded as a decimal string. Preferred source of truth for fractional stock. + */ + const char *unit_total_stock; + + /** * in @e units, total number of @e unit of product sold */ uint64_t total_sold; @@ -1942,6 +1967,61 @@ TALER_MERCHANT_products_post3 ( /** + * Make a POST /products request to add a product to the + * inventory. + * + * @param ctx the context + * @param backend_url HTTP base URL for the backend + * @param product_id identifier to use for the product + * @param description description of the product + * @param description_i18n Map from IETF BCP 47 language tags to localized descriptions + * @param unit unit in which the product is measured (liters, kilograms, packages, etc.) + * @param unit_prices array of price tiers (at least one entry) + * @param unit_price_len length of @a unit_prices + * @param image base64-encoded product image + * @param taxes list of taxes paid by the merchant + * @param total_stock integer quantity to use when @a unit_allow_fraction is false; + * set to -1 for unlimited stock + * @param total_stock_frac fractional component (0-#MERCHANT_UNIT_FRAC_BASE) to use when + * @a unit_allow_fraction is true; ignored otherwise. Clients should use 0 + * when there is no fractional part or when @a total_stock is -1 (unlimited). + * @param unit_allow_fraction whether fractional quantity purchases are allowed + * @param unit_precision_level optional override for the fractional precision; pass NULL to use the default derived from @a unit + * @param address where the product is in stock + * @param next_restock when the next restocking is expected to happen, 0 for unknown + * @param minimum_age minimum age the buyer must have + * @param num_cats length of the @a cats array + * @param cats array of categories the product is in + * @param cb function to call with the backend's result + * @param cb_cls closure for @a cb + * @return the request handle; NULL upon error + */ +struct TALER_MERCHANT_ProductsPostHandle * +TALER_MERCHANT_products_post4 ( + struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *product_id, + const char *description, + const json_t *description_i18n, + const char *unit, + const struct TALER_Amount *unit_prices, + size_t unit_price_len, + const char *image, + const json_t *taxes, + int64_t total_stock, + uint32_t total_stock_frac, + bool unit_allow_fraction, + const uint32_t *unit_precision_level, + const json_t *address, + struct GNUNET_TIME_Timestamp next_restock, + uint32_t minimum_age, + unsigned int num_cats, + const uint64_t *cats, + TALER_MERCHANT_ProductsPostCallback cb, + void *cb_cls); + + +/** * Cancel POST /products operation. * * @param pph operation to cancel @@ -2018,6 +2098,55 @@ TALER_MERCHANT_product_patch ( /** + * Make a PATCH /products request to update a product using the + * extended inventory fields. + * + * @param ctx the context + * @param backend_url HTTP base URL for the backend + * @param product_id identifier of the product to modify + * @param description description of the product + * @param description_i18n localized descriptions + * @param unit sales unit + * @param unit_prices array of price tiers (at least one entry) + * @param unit_price_len number of entries in @a unit_prices + * @param image base64-encoded image + * @param taxes list of taxes + * @param total_stock integer quantity when @a unit_allow_fraction is false + * @param total_stock_frac fractional quantity component (0-#MERCHANT_UNIT_FRAC_BASE) when + * @a unit_allow_fraction is true; ignored otherwise + * @param unit_allow_fraction whether fractional quantity purchases are allowed + * @param unit_precision_level optional override for the fractional precision; pass NULL to use the default derived from @a unit + * @param total_lost stock lost so far + * @param address inventory address + * @param next_restock expected restock date + * @param cb function to call with the backend's result + * @param cb_cls closure for @a cb + * @return the request handle; NULL upon error + */ +struct TALER_MERCHANT_ProductPatchHandle * +TALER_MERCHANT_product_patch2 ( + struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *product_id, + const char *description, + const json_t *description_i18n, + const char *unit, + const struct TALER_Amount *unit_prices, + size_t unit_price_len, + const char *image, + const json_t *taxes, + int64_t total_stock, + uint32_t total_stock_frac, + bool unit_allow_fraction, + const uint32_t *unit_precision_level, + uint64_t total_lost, + const json_t *address, + struct GNUNET_TIME_Timestamp next_restock, + TALER_MERCHANT_ProductPatchCallback cb, + void *cb_cls); + + +/** * Cancel PATCH /products operation. * * @param pph operation to cancel @@ -2054,12 +2183,34 @@ typedef void * @param product_id identifier of the product * @param uuid UUID that identifies the client holding the lock * @param duration how long should the lock be held - * @param quantity how much product should be locked + * @param quantity how much product should be locked (integer part) + * @param quantity_frac fractional component to lock when + * @a use_fractional_quantity is true; value is expressed in units of + * 1/100000000 of the base unit + * @param use_fractional_quantity set to true when @a quantity_frac is used * @param cb function to call with the backend's lock status * @param cb_cls closure for @a cb * @return the request handle; NULL upon error */ struct TALER_MERCHANT_ProductLockHandle * +TALER_MERCHANT_product_lock2 ( + struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *product_id, + const char *uuid, + struct GNUNET_TIME_Relative duration, + uint64_t quantity, + uint32_t quantity_frac, + bool use_fractional_quantity, + TALER_MERCHANT_ProductLockCallback cb, + void *cb_cls); + + +/** + * Legacy helper that locks products using integer quantities only. + * Prefer using #TALER_MERCHANT_product_lock2 for fractional quantities. + */ +struct TALER_MERCHANT_ProductLockHandle * TALER_MERCHANT_product_lock ( struct GNUNET_CURL_Context *ctx, const char *backend_url, @@ -2397,11 +2548,24 @@ struct TALER_MERCHANT_PostOrdersReply uint64_t requested_quantity; /** + * Fractional component of the requested quantity in units of + * 1/#TALER_AMOUNT_FRAC_BASE. Absent or zero when the request did not + * include a fractional part. + */ + uint32_t requested_quantity_frac; + + /** * How many units are actually still in stock. */ uint64_t available_quantity; /** + * Fractional component of the available quantity in units of + * 1/#TALER_AMOUNT_FRAC_BASE. + */ + uint32_t available_quantity_frac; + + /** * When does the backend expect the stock to be * restocked? 0 for unknown. */ @@ -2464,7 +2628,18 @@ struct TALER_MERCHANT_InventoryProduct /** * How many units of this product should be ordered. */ - unsigned int quantity; + uint64_t quantity; + + /** + * Fractional component of the quantity in units of 1/100000000 of the + * base value. + */ + uint32_t quantity_frac; + + /** + * Set to true if this product uses fractional quantity fields. + */ + bool use_fractional_quantity; }; @@ -5173,6 +5348,146 @@ TALER_MERCHANT_otp_device_delete_cancel ( struct TALER_MERCHANT_OtpDeviceDeleteHandle *tdh); +/* ********************* /units *********************** */ + +struct TALER_MERCHANT_UnitEntry +{ + const char *unit; + const char *unit_name_long; + const char *unit_name_short; + const json_t *unit_name_long_i18n; + const json_t *unit_name_short_i18n; + bool unit_allow_fraction; + uint32_t unit_precision_level; + bool unit_active; + bool unit_builtin; +}; + +struct TALER_MERCHANT_UnitsGetResponse +{ + struct TALER_MERCHANT_HttpResponse hr; + union + { + struct + { + struct TALER_MERCHANT_UnitEntry *units; + unsigned int units_length; + } ok; + } details; +}; + +struct TALER_MERCHANT_UnitGetResponse +{ + struct TALER_MERCHANT_HttpResponse hr; + union + { + struct + { + struct TALER_MERCHANT_UnitEntry unit; + } ok; + } details; +}; + +typedef void +(*TALER_MERCHANT_UnitsGetCallback)( + void *cls, + const struct TALER_MERCHANT_UnitsGetResponse *ugr); + +typedef void +(*TALER_MERCHANT_UnitGetCallback)( + void *cls, + const struct TALER_MERCHANT_UnitGetResponse *ugr); + +typedef void +(*TALER_MERCHANT_UnitsPostCallback)( + void *cls, + const struct TALER_MERCHANT_HttpResponse *hr); + +typedef void +(*TALER_MERCHANT_UnitPatchCallback)( + void *cls, + const struct TALER_MERCHANT_HttpResponse *hr); + +typedef void +(*TALER_MERCHANT_UnitDeleteCallback)( + void *cls, + const struct TALER_MERCHANT_HttpResponse *hr); + +struct TALER_MERCHANT_UnitsGetHandle; +struct TALER_MERCHANT_UnitGetHandle; +struct TALER_MERCHANT_UnitsPostHandle; +struct TALER_MERCHANT_UnitPatchHandle; +struct TALER_MERCHANT_UnitDeleteHandle; + +struct TALER_MERCHANT_UnitsGetHandle * +TALER_MERCHANT_units_get (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + TALER_MERCHANT_UnitsGetCallback cb, + void *cb_cls); + +void +TALER_MERCHANT_units_get_cancel ( + struct TALER_MERCHANT_UnitsGetHandle *ugh); + +struct TALER_MERCHANT_UnitGetHandle * +TALER_MERCHANT_unit_get (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *unit_id, + TALER_MERCHANT_UnitGetCallback cb, + void *cb_cls); + +void +TALER_MERCHANT_unit_get_cancel ( + struct TALER_MERCHANT_UnitGetHandle *ugh); + +struct TALER_MERCHANT_UnitsPostHandle * +TALER_MERCHANT_units_post (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *unit_id, + const char *unit_name_long, + const char *unit_name_short, + bool unit_allow_fraction, + uint32_t unit_precision_level, + bool unit_active, + const json_t *unit_name_long_i18n, + const json_t *unit_name_short_i18n, + TALER_MERCHANT_UnitsPostCallback cb, + void *cb_cls); + +void +TALER_MERCHANT_units_post_cancel ( + struct TALER_MERCHANT_UnitsPostHandle *uph); + +struct TALER_MERCHANT_UnitPatchHandle * +TALER_MERCHANT_unit_patch (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *unit_id, + const char *unit_name_long, + const char *unit_name_short, + const json_t *unit_name_long_i18n, + const json_t *unit_name_short_i18n, + const bool *unit_allow_fraction, + const uint32_t *unit_precision_level, + const bool *unit_active, + TALER_MERCHANT_UnitPatchCallback cb, + void *cb_cls); + +void +TALER_MERCHANT_unit_patch_cancel ( + struct TALER_MERCHANT_UnitPatchHandle *uph); + +struct TALER_MERCHANT_UnitDeleteHandle * +TALER_MERCHANT_unit_delete (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *unit_id, + TALER_MERCHANT_UnitDeleteCallback cb, + void *cb_cls); + +void +TALER_MERCHANT_unit_delete_cancel ( + struct TALER_MERCHANT_UnitDeleteHandle *udh); + + /* ********************* /templates *********************** */ diff --git a/src/include/taler_merchant_testing_lib.h b/src/include/taler_merchant_testing_lib.h @@ -394,6 +394,26 @@ TALER_TESTING_cmd_merchant_post_products2 ( unsigned int http_status); +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_post_products3 ( + const char *label, + const char *merchant_url, + const char *product_id, + const char *description, + json_t *description_i18n, + const char *unit, + const char *price, + const char *image, + json_t *taxes, + int64_t total_stock, + uint32_t total_stock_frac, + bool unit_allow_fraction, + uint32_t minimum_age, + json_t *address, + struct GNUNET_TIME_Timestamp next_restock, + unsigned int http_status); + + /** * Define a "POST /products" CMD, simple version * @@ -459,6 +479,26 @@ TALER_TESTING_cmd_merchant_patch_product ( unsigned int http_status); +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_patch_product2 ( + const char *label, + const char *merchant_url, + const char *product_id, + const char *description, + json_t *description_i18n, + const char *unit, + const char *price, + const char *image, + json_t *taxes, + int64_t total_stock, + uint32_t total_stock_frac, + bool unit_allow_fraction, + uint64_t total_lost, + json_t *address, + struct GNUNET_TIME_Timestamp next_restock, + unsigned int http_status); + + /** * Define a "GET /products" CMD. * @@ -477,6 +517,32 @@ TALER_TESTING_cmd_merchant_get_products (const char *label, unsigned int http_status, ...); +/** + * Expectations for fractional unit fields when checking a product. + */ +struct TALER_TESTING_ProductUnitExpectations +{ + /** + * Whether @e unit_allow_fraction is provided. + */ + bool have_unit_allow_fraction; + + /** + * Expected fractional flag. + */ + bool unit_allow_fraction; + + /** + * Whether @e unit_precision_level is provided. + */ + bool have_unit_precision_level; + + /** + * Expected fractional precision. + */ + uint32_t unit_precision_level; +}; + /** * Define a "GET product" CMD. @@ -488,9 +554,23 @@ TALER_TESTING_cmd_merchant_get_products (const char *label, * @param http_status expected HTTP response code. * @param product_reference reference to a "POST /products" or "PATCH /products/$ID" CMD * that will provide what we expect the backend to return to us + * @param unit_expectations optional explicit expectations for the fractional fields * @return the command. */ struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_get_product2 ( + const char *label, + const char *merchant_url, + const char *product_id, + unsigned int http_status, + const char *product_reference, + const struct TALER_TESTING_ProductUnitExpectations *unit_expectations); + + +/** + * Legacy helper kept for compatibility. + */ +struct TALER_TESTING_Command TALER_TESTING_cmd_merchant_get_product (const char *label, const char *merchant_url, const char *product_id, @@ -527,6 +607,18 @@ TALER_TESTING_cmd_merchant_lock_product ( unsigned int http_status); +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_lock_product2 ( + const char *label, + const char *merchant_url, + const char *product_id, + struct GNUNET_TIME_Relative duration, + uint32_t quantity, + uint32_t quantity_frac, + bool use_fractional_quantity, + unsigned int http_status); + + /** * Define a "DELETE product" CMD. * @@ -1522,6 +1614,118 @@ TALER_TESTING_cmd_merchant_delete_otp_device (const char *label, /* ****** Templates ******* */ /** + * Define a "POST /units" CMD. + * + * @param label command label. + * @param merchant_url base URL of the merchant serving the request. + * @param unit_id identifier of the unit. + * @param unit_name_long long label to store. + * @param unit_name_short short label to store. + * @param unit_allow_fraction whether fractional quantities are allowed. + * @param unit_precision_level fractional precision level. + * @param unit_active whether the unit starts active. + * @param unit_name_long_i18n optional translations for the long label. + * @param unit_name_short_i18n optional translations for the short label. + * @param http_status expected HTTP response code. + * @return the command. + */ +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_post_units (const char *label, + const char *merchant_url, + const char *unit_id, + const char *unit_name_long, + const char *unit_name_short, + bool unit_allow_fraction, + uint32_t unit_precision_level, + bool unit_active, + json_t *unit_name_long_i18n, + json_t *unit_name_short_i18n, + unsigned int http_status); + + +/** + * Define a "PATCH /units/$ID" CMD. + * + * @param label command label. + * @param merchant_url base URL of the merchant serving the request. + * @param unit_id identifier of the unit to patch. + * @param unit_name_long optional new long label (NULL to skip). + * @param unit_name_short optional new short label (NULL to skip). + * @param unit_name_long_i18n optional new long label translations (NULL to skip). + * @param unit_name_short_i18n optional new short label translations (NULL to skip). + * @param unit_allow_fraction optional pointer to new fractional flag (NULL to skip). + * @param unit_precision_level optional pointer to new precision level (NULL to skip). + * @param unit_active optional pointer to new active flag (NULL to skip). + * @param http_status expected HTTP response code. + * @return the command. + */ +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_patch_unit ( + const char *label, + const char *merchant_url, + const char *unit_id, + const char *unit_name_long, + const char *unit_name_short, + json_t *unit_name_long_i18n, + json_t *unit_name_short_i18n, + const bool unit_allow_fraction, + const uint32_t unit_precision_level, + const bool unit_active, + unsigned int http_status); + + +/** + * Define a "GET /units" CMD. + * + * @param label command label. + * @param merchant_url base URL of the merchant serving the request. + * @param http_status expected HTTP response code. + * @param ... NULL-terminated list of labels (const char *) whose units must + * appear in the response (verified when @a http_status is #MHD_HTTP_OK). + * @return the command. + */ +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_get_units (const char *label, + const char *merchant_url, + unsigned int http_status, + ...); + + +/** + * Define a "GET /units/$ID" CMD. + * + * @param label command label. + * @param merchant_url base URL of the merchant serving the request. + * @param unit_id identifier to fetch. + * @param http_status expected HTTP response code. + * @param reference optional label of a command providing expected unit traits. + * @return the command. + */ +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_get_unit (const char *label, + const char *merchant_url, + const char *unit_id, + unsigned int http_status, + const char *reference); + + +/** + * Define a "DELETE /units/$ID" CMD. + * + * @param label command label. + * @param merchant_url base URL of the merchant serving the request. + * @param unit_id identifier to delete. + * @param http_status expected HTTP response code. + * @return the command. + */ +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_delete_unit (const char *label, + const char *merchant_url, + const char *unit_id, + unsigned int http_status); + + +/** * Define a "POST /templates" CMD. * * @param label command label. @@ -2062,8 +2266,20 @@ TALER_TESTING_cmd_merchant_get_statisticsamount (const char *label, op (product_description, const char) \ op (product_image, const char) \ op (product_stock, const int64_t) \ + op (product_unit_total_stock, const char) \ + op (product_unit_precision_level, const uint32_t) \ + op (product_unit_allow_fraction, const bool) \ op (product_unit, const char) \ op (product_id, const char) \ + op (unit_id, const char) \ + op (unit_name_long, const char) \ + op (unit_name_short, const char) \ + op (unit_allow_fraction, const bool) \ + op (unit_precision_level, const uint32_t) \ + op (unit_active, const bool) \ + op (unit_builtin, const bool) \ + op (unit_name_long_i18n, const json_t) \ + op (unit_name_short_i18n, const json_t) \ op (reason, const char) \ op (lock_uuid, const char) \ op (auth_token, const char) \ diff --git a/src/include/taler_merchant_util.h b/src/include/taler_merchant_util.h @@ -29,6 +29,13 @@ #include <jansson.h> /** + * Fixed-point base for inventory quantities (powers of 10). + * Six decimal digits are supported to match the maximum unit precision. + */ +#define MERCHANT_UNIT_FRAC_BASE 1000000U +#define MERCHANT_UNIT_FRAC_MAX_DIGITS 6U + +/** * Return default project data used by Taler merchant. */ const struct GNUNET_OS_ProjectData * diff --git a/src/include/taler_merchantdb_lib.h b/src/include/taler_merchantdb_lib.h @@ -107,6 +107,15 @@ void TALER_MERCHANTDB_category_details_free ( struct TALER_MERCHANTDB_CategoryDetails *cd); +/** + * Free members of @a ud, but not @a ud itself. + * + * @param[in] ud unit details to clean up + */ +void +TALER_MERCHANTDB_unit_details_free ( + struct TALER_MERCHANTDB_UnitDetails *ud); + #endif /* MERCHANT_DB_H */ /* end of taler_merchantdb_lib.h */ diff --git a/src/include/taler_merchantdb_plugin.h b/src/include/taler_merchantdb_plugin.h @@ -350,6 +350,17 @@ struct TALER_MERCHANTDB_ProductDetails struct TALER_Amount price; /** + * Optional list of per-unit prices. When NULL or empty, @e price + * must be used as the canonical single price. + */ + struct TALER_Amount *price_array; + + /** + * Number of entries in @e price_array. + */ + size_t price_array_length; + + /** * Base64-encoded product image, or an empty string. */ char *image; @@ -368,16 +379,42 @@ struct TALER_MERCHANTDB_ProductDetails uint64_t total_stock; /** + * Fractional part of stock in units of 1/100000000 of the base value. + */ + uint32_t total_stock_frac; + + /** + * Honor fractional stock if TRUE, else only integer stock. + */ + bool allow_fractional_quantity; + + /** + * Precision level (number of decimal places) to apply when + * fractional quantities are enabled. + */ + uint32_t fractional_precision_level; + + /** * Number of units of the product in sold, in product-specific units. */ uint64_t total_sold; /** + * Fractional part of units sold in units of 1/100000000 of the base value. + */ + uint32_t total_sold_frac; + + /** * Number of units of stock lost. */ uint64_t total_lost; /** + * Fractional part of lost units in units of 1/100000000 of the base value. + */ + uint32_t total_lost_frac; + + /** * Identifies where the product is in stock, possibly an empty map. */ json_t *address; @@ -398,6 +435,64 @@ struct TALER_MERCHANTDB_ProductDetails /** + * Details about an inventory measurement unit. + */ +struct TALER_MERCHANTDB_UnitDetails +{ + + /** + * Database serial. + */ + uint64_t unit_serial; + + /** + * Backend identifier used in product payloads. + */ + char *unit; + + /** + * Default long label (fallback string). + */ + char *unit_name_long; + + /** + * Default short label (fallback string). + */ + char *unit_name_short; + + /** + * Internationalised long labels. + */ + json_t *unit_name_long_i18n; + + /** + * Internationalised short labels. + */ + json_t *unit_name_short_i18n; + + /** + * Whether fractional quantities are enabled by default. + */ + bool unit_allow_fraction; + + /** + * Maximum number of fractional digits honoured by default. + */ + uint32_t unit_precision_level; + + /** + * Hidden from selectors when false. + */ + bool unit_active; + + /** + * Built-in units cannot be deleted. + */ + bool unit_builtin; +}; + + +/** * Typically called by `lookup_all_products`. * * @param cls a `json_t *` JSON array to build @@ -543,6 +638,20 @@ typedef void /** + * Typically called by `lookup_units`. + * + * @param cls closure + * @param unit_serial database identifier + * @param ud measurement unit details (borrowed) + */ +typedef void +(*TALER_MERCHANTDB_UnitsCallback)( + void *cls, + uint64_t unit_serial, + const struct TALER_MERCHANTDB_UnitDetails *ud); + + +/** * Details about a product category. */ struct TALER_MERCHANTDB_CategoryDetails @@ -2215,7 +2324,6 @@ struct TALER_MERCHANTDB_Plugin struct TALER_MERCHANTDB_ProductDetails *pd, size_t *num_categories, uint64_t **categories); - /** * Lookup product image by its hash. * @@ -2316,6 +2424,8 @@ struct TALER_MERCHANTDB_Plugin * @param product_id product to lookup * @param uuid the UUID that holds the lock * @param quantity how many units should be locked + * @param quantity_frac fractional component of units to lock, in units of + * 1/100000000 of the base value * @param expiration_time when should the lock expire * @return database result code, #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS if the * product is unknown OR if there insufficient stocks remaining @@ -2326,6 +2436,7 @@ struct TALER_MERCHANTDB_Plugin const char *product_id, const struct GNUNET_Uuid *uuid, uint64_t quantity, + uint32_t quantity_frac, struct GNUNET_TIME_Timestamp expiration_time); @@ -2486,6 +2597,8 @@ struct TALER_MERCHANTDB_Plugin * @param order_id alphanumeric string that uniquely identifies the order * @param product_id uniquely identifies the product to be locked * @param quantity how many units should be locked to the @a order_id + * @param quantity_frac fractional component of the quantity to be locked, + * in units of 1/100000000 of the base value * @return transaction status, * #GNUNET_DB_STATUS_SUCCESS_NO_RESULTS means there are insufficient stocks * #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT indicates success @@ -2495,7 +2608,8 @@ struct TALER_MERCHANTDB_Plugin const char *instance_id, const char *order_id, const char *product_id, - uint64_t quantity); + uint64_t quantity, + uint32_t quantity_frac); /** @@ -3687,6 +3801,107 @@ struct TALER_MERCHANTDB_Plugin const char *otp_id, uint64_t *serial); + /** + * Delete information about a measurement unit. + * + * @param cls closure + * @param instance_id instance to delete unit from + * @param unit_id symbolic identifier of the unit + * @param[out] no_instance set to true if @a instance_id is unknown + * @param[out] no_unit set to true if the unit does not exist + * @param[out] builtin_conflict set to true if the unit is builtin and may not be deleted + * @return DB status code + */ + enum GNUNET_DB_QueryStatus + (*delete_unit)(void *cls, + const char *instance_id, + const char *unit_id, + bool *no_instance, + bool *no_unit, + bool *builtin_conflict); + + /** + * Insert a measurement unit definition. + * + * @param cls closure + * @param instance_id instance to insert unit for + * @param ud unit details to store (unit_serial ignored) + * @param[out] no_instance set to true if @a instance_id is unknown + * @param[out] conflict set to true if a conflicting unit already exists + * @param[out] unit_serial set to the inserted serial on success + * @return database result code + */ + enum GNUNET_DB_QueryStatus + (*insert_unit)(void *cls, + const char *instance_id, + const struct TALER_MERCHANTDB_UnitDetails *ud, + bool *no_instance, + bool *conflict, + uint64_t *unit_serial); + + /** + * Update a measurement unit definition. + * + * @param cls closure + * @param instance_id instance owning the unit + * @param unit_id symbolic identifier of the unit + * @param unit_name_long optional new long label (NULL to keep current) + * @param unit_name_long_i18n optional new long-label translations (NULL to keep current) + * @param unit_name_short optional new short label (NULL to keep current) + * @param unit_name_short_i18n optional new short-label translations (NULL to keep current) + * @param unit_allow_fraction optional new fractional toggle (NULL to keep current) + * @param unit_precision_level optional new fractional precision (NULL to keep current) + * @param unit_active optional new visibility flag (NULL to keep current) + * @param[out] no_instance set if instance unknown + * @param[out] no_unit set if unit unknown + * @param[out] builtin_conflict set if immutable builtin fields touched + * @return database result code + */ + enum GNUNET_DB_QueryStatus + (*update_unit)(void *cls, + const char *instance_id, + const char *unit_id, + const char *unit_name_long, + const json_t *unit_name_long_i18n, + const char *unit_name_short, + const json_t *unit_name_short_i18n, + const bool *unit_allow_fraction, + const uint32_t *unit_precision_level, + const bool *unit_active, + bool *no_instance, + bool *no_unit, + bool *builtin_conflict); + + /** + * Lookup all measurement units of an instance. + * + * @param cls closure + * @param instance_id instance to fetch units for + * @param cb function to call per unit + * @param cb_cls closure for @a cb + * @return database result code + */ + enum GNUNET_DB_QueryStatus + (*lookup_units)(void *cls, + const char *instance_id, + TALER_MERCHANTDB_UnitsCallback cb, + void *cb_cls); + + /** + * Lookup a single measurement unit. + * + * @param cls closure + * @param instance_id instance to fetch unit for + * @param unit_id symbolic identifier + * @param[out] ud unit details on success; may be NULL to test existence + * @return database result code + */ + enum GNUNET_DB_QueryStatus + (*select_unit)(void *cls, + const char *instance_id, + const char *unit_id, + struct TALER_MERCHANTDB_UnitDetails *ud); + /** * Delete information about a product category. diff --git a/src/lib/Makefile.am b/src/lib/Makefile.am @@ -23,6 +23,7 @@ libtalermerchant_la_SOURCES = \ merchant_api_delete_otp_device.c \ merchant_api_delete_product.c \ merchant_api_delete_template.c \ + merchant_api_delete_unit.c \ merchant_api_delete_transfer.c \ merchant_api_delete_webhook.c \ merchant_api_get_account.c \ @@ -40,6 +41,8 @@ libtalermerchant_la_SOURCES = \ merchant_api_get_statistics.c \ merchant_api_get_transfers.c \ merchant_api_get_template.c \ + merchant_api_get_unit.c \ + merchant_api_get_units.c \ merchant_api_get_templates.c \ merchant_api_get_webhook.c \ merchant_api_get_webhooks.c \ @@ -51,6 +54,7 @@ libtalermerchant_la_SOURCES = \ merchant_api_patch_otp_device.c \ merchant_api_patch_product.c \ merchant_api_patch_template.c \ + merchant_api_patch_unit.c \ merchant_api_patch_webhook.c \ merchant_api_post_account.c \ merchant_api_post_instance_auth.c \ @@ -64,6 +68,7 @@ libtalermerchant_la_SOURCES = \ merchant_api_post_order_refund.c \ merchant_api_post_otp_devices.c \ merchant_api_post_products.c \ + merchant_api_post_units.c \ merchant_api_post_transfers.c \ merchant_api_post_templates.c \ merchant_api_post_tokenfamilies.c \ diff --git a/src/lib/merchant_api_common.c b/src/lib/merchant_api_common.c @@ -27,6 +27,87 @@ #include "merchant_api_common.h" #include <gnunet/gnunet_uri_lib.h> #include <taler/taler_json_lib.h> +#include "taler_merchant_util.h" + + +static void +TALER_MERCHANT_format_fractional_string (uint64_t integer, + uint32_t fractional, + char *buffer, + size_t buffer_length) +{ + GNUNET_assert (NULL != buffer); + GNUNET_assert (0 < buffer_length); + GNUNET_assert (fractional < MERCHANT_UNIT_FRAC_BASE); + + if (0 == fractional) + { + GNUNET_snprintf (buffer, + buffer_length, + "%lu", + integer); + return; + } + { + char frac_buf[MERCHANT_UNIT_FRAC_MAX_DIGITS + 1]; + size_t idx; + + GNUNET_snprintf (frac_buf, + sizeof (frac_buf), + "%0*u", + MERCHANT_UNIT_FRAC_MAX_DIGITS, + (unsigned int) fractional); + for (idx = strlen (frac_buf); idx > 0; idx--) + { + if ('0' != frac_buf[idx - 1]) + break; + frac_buf[idx - 1] = '\0'; + } + if ('\0' == frac_buf[0]) + GNUNET_strlcpy (frac_buf, + "0", + sizeof (frac_buf)); + GNUNET_snprintf (buffer, + buffer_length, + "%lu.%s", + integer, + frac_buf); + } +} + + +void +TALER_MERCHANT_format_quantity_string (uint64_t quantity, + uint32_t quantity_frac, + char *buffer, + size_t buffer_length) +{ + TALER_MERCHANT_format_fractional_string (quantity, + quantity_frac, + buffer, + buffer_length); +} + + +void +TALER_MERCHANT_format_stock_string (uint64_t total_stock, + uint32_t total_stock_frac, + char *buffer, + size_t buffer_length) +{ + if ( (INT64_MAX == (int64_t) total_stock) && + (INT32_MAX == (int32_t) total_stock_frac) ) + { + GNUNET_snprintf (buffer, + buffer_length, + "-1"); + return; + } + TALER_MERCHANT_format_fractional_string (total_stock, + total_stock_frac, + buffer, + buffer_length); +} void @@ -447,6 +528,8 @@ TALER_MERCHANT_handle_order_creation_response_ ( case MHD_HTTP_GONE: /* The quantity of some product requested was not available. */ { + bool rq_frac_missing; + bool aq_frac_missing; struct GNUNET_JSON_Specification spec[] = { GNUNET_JSON_spec_string ( @@ -455,10 +538,20 @@ TALER_MERCHANT_handle_order_creation_response_ ( GNUNET_JSON_spec_uint64 ( "requested_quantity", &por.details.gone.requested_quantity), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_uint32 ( + "requested_quantity_frac", + &por.details.gone.requested_quantity_frac), + &rq_frac_missing), GNUNET_JSON_spec_uint64 ( "available_quantity", &por.details.gone.available_quantity), GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_uint32 ( + "available_quantity_frac", + &por.details.gone.available_quantity_frac), + &aq_frac_missing), + GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_timestamp ( "restock_expected", &por.details.gone.restock_expected), @@ -475,6 +568,13 @@ TALER_MERCHANT_handle_order_creation_response_ ( por.hr.http_status = 0; por.hr.ec = TALER_EC_GENERIC_REPLY_MALFORMED; } + else + { + if (rq_frac_missing) + por.details.gone.requested_quantity_frac = 0; + if (aq_frac_missing) + por.details.gone.available_quantity_frac = 0; + } break; } case MHD_HTTP_UNAVAILABLE_FOR_LEGAL_REASONS: diff --git a/src/lib/merchant_api_common.h b/src/lib/merchant_api_common.h @@ -58,4 +58,43 @@ TALER_MERCHANT_parse_error_details_ (const json_t *response, struct TALER_MERCHANT_HttpResponse *hr); +enum GNUNET_GenericReturnValue +TALER_MERCHANT_parse_fractional_string (bool allow_negative, + const char *value, + int64_t *integer_part, + uint32_t *fractional_part); + + +/** + * Format a quantity into its decimal string representation using the merchant + * fixed-point base (MERCHANT_UNIT_FRAC_BASE). + * + * @param quantity integer part + * @param quantity_frac fractional part (0..MERCHANT_UNIT_FRAC_BASE-1) + * @param[out] buffer output buffer + * @param buffer_length size of @a buffer + */ +void +TALER_MERCHANT_format_quantity_string (uint64_t quantity, + uint32_t quantity_frac, + char *buffer, + size_t buffer_length); + + +/** + * Format a stock value into its decimal string representation using the + * merchant fixed-point base (MERCHANT_UNIT_FRAC_BASE). + * + * @param total_stock integer part + * @param total_stock_frac fractional part (0..MERCHANT_UNIT_FRAC_BASE-1) + * @param[out] buffer output buffer + * @param buffer_length size of @a buffer + */ +void +TALER_MERCHANT_format_stock_string (uint64_t total_stock, + uint32_t total_stock_frac, + char *buffer, + size_t buffer_length); + + #endif diff --git a/src/lib/merchant_api_delete_unit.c b/src/lib/merchant_api_delete_unit.c @@ -0,0 +1,177 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 merchant_api_delete_unit.c + * @brief Implementation of DELETE /private/units/$ID + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include "taler_merchant_service.h" +#include "merchant_api_curl_defaults.h" +#include "merchant_api_common.h" +#include <taler/taler_json_lib.h> + + +/** + * Handle for a DELETE /private/units/$ID operation. + */ +struct TALER_MERCHANT_UnitDeleteHandle +{ + /** + * Fully qualified request URL. + */ + char *url; + + /** + * In-flight CURL job. + */ + struct GNUNET_CURL_Job *job; + + /** + * Completion callback. + */ + TALER_MERCHANT_UnitDeleteCallback cb; + + /** + * Closure for @a cb. + */ + void *cb_cls; + + /** + * Execution context. + */ + struct GNUNET_CURL_Context *ctx; +}; + + +/** + * Called when the HTTP request finishes. + * + * @param cls operation handle + * @param response_code HTTP status (0 on failure) + * @param response parsed JSON reply (NULL if unavailable) + */ +static void +handle_delete_unit_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_UnitDeleteHandle *udh = cls; + const json_t *json = response; + struct TALER_MERCHANT_HttpResponse hr = { + .http_status = (unsigned int) response_code, + .reply = json + }; + + udh->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "DELETE /private/units finished with status %u\n", + (unsigned int) response_code); + switch (response_code) + { + case MHD_HTTP_NO_CONTENT: + break; + case MHD_HTTP_BAD_REQUEST: + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_CONFLICT: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + hr.ec = TALER_JSON_get_error_code (json); + hr.hint = TALER_JSON_get_error_hint (json); + break; + case 0: + hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response %u/%d for DELETE /private/units\n", + (unsigned int) response_code, + (int) hr.ec); + GNUNET_break_op (0); + break; + } + udh->cb (udh->cb_cls, + &hr); + TALER_MERCHANT_unit_delete_cancel (udh); +} + + +struct TALER_MERCHANT_UnitDeleteHandle * +TALER_MERCHANT_unit_delete (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *unit_id, + TALER_MERCHANT_UnitDeleteCallback cb, + void *cb_cls) +{ + struct TALER_MERCHANT_UnitDeleteHandle *udh; + CURL *eh; + char *path; + + GNUNET_asprintf (&path, + "private/units/%s", + unit_id); + udh = GNUNET_new (struct TALER_MERCHANT_UnitDeleteHandle); + udh->ctx = ctx; + udh->cb = cb; + udh->cb_cls = cb_cls; + udh->url = TALER_url_join (backend_url, + path, + NULL); + GNUNET_free (path); + if (NULL == udh->url) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Failed to build /private/units/%s URL\n", + unit_id); + GNUNET_free (udh); + return NULL; + } + GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, + "Requesting DELETE on '%s'\n", + udh->url); + eh = TALER_MERCHANT_curl_easy_get_ (udh->url); + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, + CURLOPT_CUSTOMREQUEST, + MHD_HTTP_METHOD_DELETE)); + udh->job = GNUNET_CURL_job_add (ctx, + eh, + &handle_delete_unit_finished, + udh); + return udh; +} + + +void +TALER_MERCHANT_unit_delete_cancel (struct TALER_MERCHANT_UnitDeleteHandle *udh) +{ + if (NULL != udh->job) + GNUNET_CURL_job_cancel (udh->job); + GNUNET_free (udh->url); + GNUNET_free (udh); +} + + +/* end of merchant_api_delete_unit.c */ diff --git a/src/lib/merchant_api_get_product.c b/src/lib/merchant_api_get_product.c @@ -105,6 +105,10 @@ handle_get_product_finished (void *cls, GNUNET_JSON_spec_string ( "unit", &pgr.details.ok.unit), + TALER_JSON_spec_amount_any_array ( + "unit_price", + &pgr.details.ok.unit_price_len, + (struct TALER_Amount **) &pgr.details.ok.unit_price), TALER_JSON_spec_amount_any ( "price", &pgr.details.ok.price), @@ -121,6 +125,15 @@ handle_get_product_finished (void *cls, GNUNET_JSON_spec_int64 ( "total_stock", &pgr.details.ok.total_stock), + GNUNET_JSON_spec_string ( + "unit_total_stock", + &pgr.details.ok.unit_total_stock), + GNUNET_JSON_spec_bool ( + "unit_allow_fraction", + &pgr.details.ok.unit_allow_fraction), + GNUNET_JSON_spec_uint32 ( + "unit_precision_level", + &pgr.details.ok.unit_precision_level), GNUNET_JSON_spec_uint64 ( "total_sold", &pgr.details.ok.total_sold), diff --git a/src/lib/merchant_api_get_unit.c b/src/lib/merchant_api_get_unit.c @@ -0,0 +1,249 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 merchant_api_get_unit.c + * @brief Implementation of GET /private/units/$ID + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include "taler_merchant_service.h" +#include "merchant_api_curl_defaults.h" +#include <taler/taler_json_lib.h> + + +/** + * Handle for a GET /private/units/$ID operation. + */ +struct TALER_MERCHANT_UnitGetHandle +{ + /** + * Fully qualified request URL. + */ + char *url; + + /** + * In-flight job handle. + */ + struct GNUNET_CURL_Job *job; + + /** + * Callback to invoke with the response. + */ + TALER_MERCHANT_UnitGetCallback cb; + + /** + * Closure for @a cb. + */ + void *cb_cls; + + /** + * Execution context. + */ + struct GNUNET_CURL_Context *ctx; +}; + + +/** + * Parse the JSON response into @a ugr. + * + * @param json full JSON reply + * @param ugr response descriptor to populate + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +parse_unit (const json_t *json, + struct TALER_MERCHANT_UnitGetResponse *ugr) +{ + struct TALER_MERCHANT_UnitEntry *entry = &ugr->details.ok.unit; + const char *unit; + const char *unit_name_long; + const char *unit_name_short; + const json_t *unit_name_long_i18n = NULL; + const json_t *unit_name_short_i18n = NULL; + bool unit_allow_fraction; + bool unit_active; + bool unit_builtin; + uint32_t unit_precision_level; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("unit", + &unit), + GNUNET_JSON_spec_string ("unit_name_long", + &unit_name_long), + GNUNET_JSON_spec_string ("unit_name_short", + &unit_name_short), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_object_const ("unit_name_long_i18n", + &unit_name_long_i18n), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_object_const ("unit_name_short_i18n", + &unit_name_short_i18n), + NULL), + GNUNET_JSON_spec_bool ("unit_allow_fraction", + &unit_allow_fraction), + GNUNET_JSON_spec_uint32 ("unit_precision_level", + &unit_precision_level), + GNUNET_JSON_spec_bool ("unit_active", + &unit_active), + GNUNET_JSON_spec_bool ("unit_builtin", + &unit_builtin), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (json, + spec, + NULL, + NULL)) + { + GNUNET_break_op (0); + GNUNET_JSON_parse_free (spec); + return GNUNET_SYSERR; + } + GNUNET_JSON_parse_free (spec); + entry->unit = unit; + entry->unit_name_long = unit_name_long; + entry->unit_name_short = unit_name_short; + entry->unit_name_long_i18n = unit_name_long_i18n; + entry->unit_name_short_i18n = unit_name_short_i18n; + entry->unit_allow_fraction = unit_allow_fraction; + entry->unit_precision_level = unit_precision_level; + entry->unit_active = unit_active; + entry->unit_builtin = unit_builtin; + return GNUNET_OK; +} + + +/** + * Called once the HTTP request completes. + * + * @param cls operation handle + * @param response_code HTTP status (0 on client-side errors) + * @param response parsed JSON reply (NULL if parsing failed) + */ +static void +handle_get_unit_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_UnitGetHandle *ugh = cls; + const json_t *json = response; + struct TALER_MERCHANT_UnitGetResponse ugr = { + .hr.http_status = (unsigned int) response_code, + .hr.reply = json + }; + + ugh->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, + "GET /private/units/$ID finished with status %u\n", + (unsigned int) response_code); + switch (response_code) + { + case MHD_HTTP_OK: + if (GNUNET_OK != + parse_unit (json, + &ugr)) + { + ugr.hr.http_status = 0; + ugr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + } + ugh->cb (ugh->cb_cls, + &ugr); + TALER_MERCHANT_unit_get_cancel (ugh); + return; + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + ugr.hr.ec = TALER_JSON_get_error_code (json); + ugr.hr.hint = TALER_JSON_get_error_hint (json); + break; + case 0: + ugr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + default: + ugr.hr.ec = TALER_JSON_get_error_code (json); + ugr.hr.hint = TALER_JSON_get_error_hint (json); + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Unexpected response %u/%d for GET /private/units/$ID\n", + (unsigned int) response_code, + (int) ugr.hr.ec); + break; + } + ugh->cb (ugh->cb_cls, + &ugr); + TALER_MERCHANT_unit_get_cancel (ugh); +} + + +struct TALER_MERCHANT_UnitGetHandle * +TALER_MERCHANT_unit_get (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *unit_id, + TALER_MERCHANT_UnitGetCallback cb, + void *cb_cls) +{ + struct TALER_MERCHANT_UnitGetHandle *ugh; + CURL *eh; + char *path; + + GNUNET_asprintf (&path, + "private/units/%s", + unit_id); + ugh = GNUNET_new (struct TALER_MERCHANT_UnitGetHandle); + ugh->ctx = ctx; + ugh->cb = cb; + ugh->cb_cls = cb_cls; + ugh->url = TALER_url_join (backend_url, + path, + NULL); + GNUNET_free (path); + if (NULL == ugh->url) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Failed to build /private/units/%s URL\n", + unit_id); + GNUNET_free (ugh); + return NULL; + } + GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, + "Requesting URL '%s'\n", + ugh->url); + eh = TALER_MERCHANT_curl_easy_get_ (ugh->url); + ugh->job = GNUNET_CURL_job_add (ctx, + eh, + &handle_get_unit_finished, + ugh); + return ugh; +} + + +void +TALER_MERCHANT_unit_get_cancel (struct TALER_MERCHANT_UnitGetHandle *ugh) +{ + if (NULL != ugh->job) + GNUNET_CURL_job_cancel (ugh->job); + GNUNET_free (ugh->url); + GNUNET_free (ugh); +} + + +/* end of merchant_api_get_unit.c */ diff --git a/src/lib/merchant_api_get_units.c b/src/lib/merchant_api_get_units.c @@ -0,0 +1,329 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 merchant_api_get_units.c + * @brief Implementation of GET /private/units + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> +#include <gnunet/gnunet_util_lib.h> +#include <gnunet/gnunet_curl_lib.h> +#include "taler_merchant_service.h" +#include "merchant_api_curl_defaults.h" +#include <taler/taler_json_lib.h> + + +/** + * Maximum number of units returned in a single response. + */ +#define MAX_UNITS 1024 + + +/** + * Handle for a GET /private/units operation. + */ +struct TALER_MERCHANT_UnitsGetHandle +{ + /** + * Fully qualified request URL. + */ + char *url; + + /** + * In-flight job handle. + */ + struct GNUNET_CURL_Job *job; + + /** + * Callback to invoke with the outcome. + */ + TALER_MERCHANT_UnitsGetCallback cb; + + /** + * Closure for @e cb. + */ + void *cb_cls; + + /** + * Execution context. + */ + struct GNUNET_CURL_Context *ctx; +}; + + +/** + * Parse an individual unit entry from @a value. + * + * @param value JSON object describing the unit + * @param[out] ue set to the parsed values + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +parse_unit_entry (const json_t *value, + struct TALER_MERCHANT_UnitEntry *ue) +{ + const char *unit; + const char *unit_name_long; + const char *unit_name_short; + const json_t *unit_name_long_i18n = NULL; + const json_t *unit_name_short_i18n = NULL; + bool unit_allow_fraction; + bool unit_active; + bool unit_builtin; + uint32_t unit_precision_level; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_string ("unit", + &unit), + GNUNET_JSON_spec_string ("unit_name_long", + &unit_name_long), + GNUNET_JSON_spec_string ("unit_name_short", + &unit_name_short), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_object_const ("unit_name_long_i18n", + &unit_name_long_i18n), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_object_const ("unit_name_short_i18n", + &unit_name_short_i18n), + NULL), + GNUNET_JSON_spec_bool ("unit_allow_fraction", + &unit_allow_fraction), + GNUNET_JSON_spec_uint32 ("unit_precision_level", + &unit_precision_level), + GNUNET_JSON_spec_bool ("unit_active", + &unit_active), + GNUNET_JSON_spec_bool ("unit_builtin", + &unit_builtin), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (value, + spec, + NULL, + NULL)) + { + GNUNET_break_op (0); + GNUNET_JSON_parse_free (spec); + return GNUNET_SYSERR; + } + GNUNET_JSON_parse_free (spec); + ue->unit = unit; + ue->unit_name_long = unit_name_long; + ue->unit_name_short = unit_name_short; + ue->unit_name_long_i18n = unit_name_long_i18n; + ue->unit_name_short_i18n = unit_name_short_i18n; + ue->unit_allow_fraction = unit_allow_fraction; + ue->unit_precision_level = unit_precision_level; + ue->unit_active = unit_active; + ue->unit_builtin = unit_builtin; + return GNUNET_OK; +} + + +/** + * Parse the list of units from @a units and call the callback. + * + * @param json complete response JSON + * @param units array of units + * @param ugh ongoing operation handle + * @return #GNUNET_OK on success + */ +static enum GNUNET_GenericReturnValue +parse_units (const json_t *json, + const json_t *units, + struct TALER_MERCHANT_UnitsGetHandle *ugh) +{ + size_t len; + + len = json_array_size (units); + if (len > MAX_UNITS) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + + { + struct TALER_MERCHANT_UnitEntry entries[GNUNET_NZL (len)]; + size_t idx; + json_t *value; + + json_array_foreach (units, idx, value) { + if (GNUNET_OK != + parse_unit_entry (value, + &entries[idx])) + { + GNUNET_break_op (0); + return GNUNET_SYSERR; + } + } + { + struct TALER_MERCHANT_UnitsGetResponse ugr = { + .hr.http_status = MHD_HTTP_OK, + .hr.reply = json, + .details = { + .ok = { + .units = entries, + .units_length = (unsigned int) len + } + + + } + + + }; + + ugh->cb (ugh->cb_cls, + &ugr); + } + } + return GNUNET_OK; +} + + +/** + * Called when the HTTP transfer finishes. + * + * @param cls closure, the operation handle + * @param response_code HTTP status (0 on network errors) + * @param response parsed JSON body (NULL if parsing failed) + */ +static void +handle_get_units_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_UnitsGetHandle *ugh = cls; + const json_t *json = response; + struct TALER_MERCHANT_UnitsGetResponse ugr = { + .hr.http_status = (unsigned int) response_code, + .hr.reply = json + }; + + ugh->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, + "GET /private/units finished with status %u\n", + (unsigned int) response_code); + switch (response_code) + { + case MHD_HTTP_OK: + { + const json_t *units; + struct GNUNET_JSON_Specification spec[] = { + GNUNET_JSON_spec_array_const ("units", + &units), + GNUNET_JSON_spec_end () + }; + + if (GNUNET_OK != + GNUNET_JSON_parse (json, + spec, + NULL, + NULL)) + { + GNUNET_break_op (0); + ugr.hr.http_status = 0; + ugr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + } + if (GNUNET_OK == + parse_units (json, + units, + ugh)) + { + TALER_MERCHANT_units_get_cancel (ugh); + return; + } + ugr.hr.http_status = 0; + ugr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + } + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_CONFLICT: + ugr.hr.ec = TALER_JSON_get_error_code (json); + ugr.hr.hint = TALER_JSON_get_error_hint (json); + break; + case 0: + ugr.hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + default: + ugr.hr.ec = TALER_JSON_get_error_code (json); + ugr.hr.hint = TALER_JSON_get_error_hint (json); + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Unexpected response code %u/%d for GET /private/units\n", + (unsigned int) response_code, + (int) ugr.hr.ec); + break; + } + ugh->cb (ugh->cb_cls, + &ugr); + TALER_MERCHANT_units_get_cancel (ugh); +} + + +struct TALER_MERCHANT_UnitsGetHandle * +TALER_MERCHANT_units_get (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + TALER_MERCHANT_UnitsGetCallback cb, + void *cb_cls) +{ + struct TALER_MERCHANT_UnitsGetHandle *ugh; + CURL *eh; + + ugh = GNUNET_new (struct TALER_MERCHANT_UnitsGetHandle); + ugh->ctx = ctx; + ugh->cb = cb; + ugh->cb_cls = cb_cls; + ugh->url = TALER_url_join (backend_url, + "private/units", + NULL); + if (NULL == ugh->url) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Failed to build /private/units URL\n"); + GNUNET_free (ugh); + return NULL; + } + GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, + "Requesting URL '%s'\n", + ugh->url); + eh = TALER_MERCHANT_curl_easy_get_ (ugh->url); + ugh->job = GNUNET_CURL_job_add (ctx, + eh, + &handle_get_units_finished, + ugh); + return ugh; +} + + +void +TALER_MERCHANT_units_get_cancel (struct TALER_MERCHANT_UnitsGetHandle *ugh) +{ + if (NULL != ugh->job) + GNUNET_CURL_job_cancel (ugh->job); + GNUNET_free (ugh->url); + GNUNET_free (ugh); +} + + +/* end of merchant_api_get_units.c */ diff --git a/src/lib/merchant_api_lock_product.c b/src/lib/merchant_api_lock_product.c @@ -157,26 +157,36 @@ handle_lock_product_finished (void *cls, struct TALER_MERCHANT_ProductLockHandle * -TALER_MERCHANT_product_lock ( +TALER_MERCHANT_product_lock2 ( struct GNUNET_CURL_Context *ctx, const char *backend_url, const char *product_id, const char *uuid, struct GNUNET_TIME_Relative duration, - uint32_t quantity, + uint64_t quantity, + uint32_t quantity_frac, + bool use_fractional_quantity, TALER_MERCHANT_ProductLockCallback cb, void *cb_cls) { struct TALER_MERCHANT_ProductLockHandle *plh; json_t *req_obj; + char unit_quantity_buf[64]; + + TALER_MERCHANT_format_quantity_string (quantity, + quantity_frac, + unit_quantity_buf, + sizeof (unit_quantity_buf)); req_obj = GNUNET_JSON_PACK ( GNUNET_JSON_pack_string ("lock_uuid", uuid), GNUNET_JSON_pack_time_rel ("duration", duration), - GNUNET_JSON_pack_uint64 ("quantity", - quantity)); + GNUNET_JSON_pack_string ("unit_quantity", + unit_quantity_buf)); + (void) use_fractional_quantity; + GNUNET_assert ( (0 == quantity_frac) || use_fractional_quantity); plh = GNUNET_new (struct TALER_MERCHANT_ProductLockHandle); plh->ctx = ctx; plh->cb = cb; @@ -242,4 +252,28 @@ TALER_MERCHANT_product_lock_cancel ( } +struct TALER_MERCHANT_ProductLockHandle * +TALER_MERCHANT_product_lock ( + struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *product_id, + const char *uuid, + struct GNUNET_TIME_Relative duration, + uint32_t quantity, + TALER_MERCHANT_ProductLockCallback cb, + void *cb_cls) +{ + return TALER_MERCHANT_product_lock2 (ctx, + backend_url, + product_id, + uuid, + duration, + quantity, + 0, + false, + cb, + cb_cls); +} + + /* end of merchant_api_lock_product.c */ diff --git a/src/lib/merchant_api_patch_product.c b/src/lib/merchant_api_patch_product.c @@ -157,17 +157,21 @@ handle_patch_product_finished (void *cls, struct TALER_MERCHANT_ProductPatchHandle * -TALER_MERCHANT_product_patch ( +TALER_MERCHANT_product_patch2 ( struct GNUNET_CURL_Context *ctx, const char *backend_url, const char *product_id, const char *description, const json_t *description_i18n, const char *unit, - const struct TALER_Amount *price, + const struct TALER_Amount *unit_prices, + size_t unit_price_len, const char *image, const json_t *taxes, int64_t total_stock, + uint32_t total_stock_frac, + bool unit_allow_fraction, + const uint32_t *unit_precision_level, uint64_t total_lost, const json_t *address, struct GNUNET_TIME_Timestamp next_restock, @@ -176,32 +180,61 @@ TALER_MERCHANT_product_patch ( { struct TALER_MERCHANT_ProductPatchHandle *pph; json_t *req_obj; + char unit_total_stock_buf[64]; - req_obj = GNUNET_JSON_PACK ( - /* FIXME: once we move to the new-style API, - allow applications to set the product name properly! */ - GNUNET_JSON_pack_string ("product_name", - description), - GNUNET_JSON_pack_string ("description", - description), - GNUNET_JSON_pack_object_incref ("description_i18n", - (json_t *) description_i18n), - GNUNET_JSON_pack_string ("unit", - unit), - TALER_JSON_pack_amount ("price", - price), - GNUNET_JSON_pack_string ("image", - image), - GNUNET_JSON_pack_array_incref ("taxes", - (json_t *) taxes), - GNUNET_JSON_pack_uint64 ("total_stock", - total_stock), - GNUNET_JSON_pack_uint64 ("total_lost", - total_lost), - GNUNET_JSON_pack_object_incref ("address", - (json_t *) address), - GNUNET_JSON_pack_timestamp ("next_restock", - next_restock)); + TALER_MERCHANT_format_stock_string (total_stock, + total_stock_frac, + unit_total_stock_buf, + sizeof (unit_total_stock_buf)); + + { + req_obj = GNUNET_JSON_PACK ( + /* FIXME: once we move to the new-style API, + allow applications to set the product name properly! */ + GNUNET_JSON_pack_string ("product_name", + description), + GNUNET_JSON_pack_string ("description", + description), + GNUNET_JSON_pack_object_incref ("description_i18n", + (json_t *) description_i18n), + GNUNET_JSON_pack_string ("unit", + unit), + TALER_JSON_pack_amount_array ("unit_price", + unit_price_len, + unit_prices), + GNUNET_JSON_pack_string ("image", + image), + GNUNET_JSON_pack_array_incref ("taxes", + (json_t *) taxes), + GNUNET_JSON_pack_string ("unit_total_stock", + unit_total_stock_buf), + GNUNET_JSON_pack_bool ("unit_allow_fraction", + unit_allow_fraction), + GNUNET_JSON_pack_uint64 ("total_lost", + total_lost), + GNUNET_JSON_pack_object_incref ("address", + (json_t *) address), + GNUNET_JSON_pack_timestamp ("next_restock", + next_restock)); + } + if (NULL != unit_precision_level) + { + GNUNET_assert (0 == + json_object_set_new (req_obj, + "unit_precision_level", + json_integer ( + *unit_precision_level))); + } + if (! unit_allow_fraction) + { + GNUNET_assert (0 == + json_object_del (req_obj, + "unit_allow_fraction")); + if (NULL != unit_precision_level) + GNUNET_assert (0 == + json_object_del (req_obj, + "unit_precision_level")); + } pph = GNUNET_new (struct TALER_MERCHANT_ProductPatchHandle); pph->ctx = ctx; pph->cb = cb; @@ -255,6 +288,46 @@ TALER_MERCHANT_product_patch ( } +struct TALER_MERCHANT_ProductPatchHandle * +TALER_MERCHANT_product_patch ( + struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *product_id, + const char *description, + const json_t *description_i18n, + const char *unit, + const struct TALER_Amount *price, + const char *image, + const json_t *taxes, + int64_t total_stock, + uint64_t total_lost, + const json_t *address, + struct GNUNET_TIME_Timestamp next_restock, + TALER_MERCHANT_ProductPatchCallback cb, + void *cb_cls) +{ + return TALER_MERCHANT_product_patch2 (ctx, + backend_url, + product_id, + description, + description_i18n, + unit, + price, + 1, + image, + taxes, + total_stock, + 0, + false, + NULL, + total_lost, + address, + next_restock, + cb, + cb_cls); +} + + void TALER_MERCHANT_product_patch_cancel ( struct TALER_MERCHANT_ProductPatchHandle *pph) diff --git a/src/lib/merchant_api_patch_unit.c b/src/lib/merchant_api_patch_unit.c @@ -0,0 +1,291 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 merchant_api_patch_unit.c + * @brief Implementation of PATCH /private/units/$ID + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> +#include <gnunet/gnunet_util_lib.h> +#include "taler_merchant_service.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 PATCH /private/units/$ID operation. + */ +struct TALER_MERCHANT_UnitPatchHandle +{ + /** + * Fully qualified request URL. + */ + char *url; + + /** + * In-flight CURL job. + */ + struct GNUNET_CURL_Job *job; + + /** + * Completion callback. + */ + TALER_MERCHANT_UnitPatchCallback cb; + + /** + * Closure for @a cb. + */ + void *cb_cls; + + /** + * Execution context. + */ + struct GNUNET_CURL_Context *ctx; + + /** + * Keeps POST body and headers alive. + */ + struct TALER_CURL_PostContext post_ctx; +}; + + +/** + * Called when the HTTP transfer finishes. + * + * @param cls operation handle + * @param response_code HTTP status (0 on failure) + * @param response parsed JSON reply (NULL if unavailable) + */ +static void +handle_patch_unit_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_UnitPatchHandle *uph = cls; + const json_t *json = response; + struct TALER_MERCHANT_HttpResponse hr = { + .http_status = (unsigned int) response_code, + .reply = json + }; + + uph->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "PATCH /private/units completed with status %u\n", + (unsigned int) response_code); + switch (response_code) + { + case MHD_HTTP_NO_CONTENT: + break; + case MHD_HTTP_BAD_REQUEST: + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_CONFLICT: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + hr.ec = TALER_JSON_get_error_code (json); + hr.hint = TALER_JSON_get_error_hint (json); + break; + case 0: + hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response %u/%d for PATCH /private/units\n", + (unsigned int) response_code, + (int) hr.ec); + GNUNET_break_op (0); + break; + } + uph->cb (uph->cb_cls, + &hr); + TALER_MERCHANT_unit_patch_cancel (uph); +} + + +struct TALER_MERCHANT_UnitPatchHandle * +TALER_MERCHANT_unit_patch (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *unit_id, + const char *unit_name_long, + const char *unit_name_short, + const json_t *unit_name_long_i18n, + const json_t *unit_name_short_i18n, + const bool *unit_allow_fraction, + const uint32_t *unit_precision_level, + const bool *unit_active, + TALER_MERCHANT_UnitPatchCallback cb, + void *cb_cls) +{ + struct TALER_MERCHANT_UnitPatchHandle *uph; + json_t *req_obj; + char *path; + + req_obj = json_object (); + if (NULL == req_obj) + return NULL; + if (NULL != unit_name_long) + { + if (0 != json_object_set_new (req_obj, + "unit_name_long", + json_string (unit_name_long))) + { + json_decref (req_obj); + return NULL; + } + } + if (NULL != unit_name_short) + { + if (0 != json_object_set_new (req_obj, + "unit_name_short", + json_string (unit_name_short))) + { + json_decref (req_obj); + return NULL; + } + } + if (NULL != unit_name_long_i18n) + { + if (0 != json_object_set_new (req_obj, + "unit_name_long_i18n", + json_incref ((json_t *) unit_name_long_i18n))) + { + json_decref (req_obj); + return NULL; + } + } + if (NULL != unit_name_short_i18n) + { + if (0 != json_object_set_new (req_obj, + "unit_name_short_i18n", + json_incref ( + (json_t *) unit_name_short_i18n))) + { + json_decref (req_obj); + return NULL; + } + } + if (NULL != unit_allow_fraction) + { + if (0 != json_object_set_new (req_obj, + "unit_allow_fraction", + json_boolean (*unit_allow_fraction))) + { + json_decref (req_obj); + return NULL; + } + } + if (NULL != unit_precision_level) + { + if (0 != json_object_set_new (req_obj, + "unit_precision_level", + json_integer ( + (json_int_t) *unit_precision_level))) + { + json_decref (req_obj); + return NULL; + } + } + if (NULL != unit_active) + { + if (0 != json_object_set_new (req_obj, + "unit_active", + json_boolean (*unit_active))) + { + json_decref (req_obj); + return NULL; + } + } + if (0 == json_object_size (req_obj)) + { + json_decref (req_obj); + GNUNET_break (0); + return NULL; + } + + GNUNET_asprintf (&path, + "private/units/%s", + unit_id); + uph = GNUNET_new (struct TALER_MERCHANT_UnitPatchHandle); + uph->ctx = ctx; + uph->cb = cb; + uph->cb_cls = cb_cls; + uph->url = TALER_url_join (backend_url, + path, + NULL); + GNUNET_free (path); + if (NULL == uph->url) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Failed to build /private/units/%s URL\n", + unit_id); + json_decref (req_obj); + GNUNET_free (uph); + return NULL; + } + { + CURL *eh; + + eh = TALER_MERCHANT_curl_easy_get_ (uph->url); + if (GNUNET_OK != + TALER_curl_easy_post (&uph->post_ctx, + eh, + req_obj)) + { + GNUNET_break (0); + curl_easy_cleanup (eh); + json_decref (req_obj); + GNUNET_free (uph->url); + GNUNET_free (uph); + return NULL; + } + json_decref (req_obj); + GNUNET_assert (CURLE_OK == + curl_easy_setopt (eh, + CURLOPT_CUSTOMREQUEST, + MHD_HTTP_METHOD_PATCH)); + uph->job = GNUNET_CURL_job_add2 (ctx, + eh, + uph->post_ctx.headers, + &handle_patch_unit_finished, + uph); + } + return uph; +} + + +void +TALER_MERCHANT_unit_patch_cancel (struct TALER_MERCHANT_UnitPatchHandle *uph) +{ + if (NULL != uph->job) + { + GNUNET_CURL_job_cancel (uph->job); + uph->job = NULL; + } + TALER_curl_easy_post_finished (&uph->post_ctx); + GNUNET_free (uph->url); + GNUNET_free (uph); +} + + +/* end of merchant_api_patch_unit.c */ diff --git a/src/lib/merchant_api_post_orders.c b/src/lib/merchant_api_post_orders.c @@ -208,12 +208,18 @@ TALER_MERCHANT_orders_post3 ( for (unsigned int i = 0; i<inventory_products_length; i++) { json_t *ip; + char unit_quantity_buf[64]; + + TALER_MERCHANT_format_quantity_string (inventory_products[i].quantity, + inventory_products[i].quantity_frac, + unit_quantity_buf, + sizeof (unit_quantity_buf)); ip = GNUNET_JSON_PACK ( GNUNET_JSON_pack_string ("product_id", inventory_products[i].product_id), - GNUNET_JSON_pack_uint64 ("quantity", - inventory_products[i].quantity)); + GNUNET_JSON_pack_string ("unit_quantity", + unit_quantity_buf)); GNUNET_assert (NULL != ip); GNUNET_assert (0 == json_array_append_new (ipa, diff --git a/src/lib/merchant_api_post_products.c b/src/lib/merchant_api_post_products.c @@ -159,17 +159,21 @@ handle_post_products_finished (void *cls, struct TALER_MERCHANT_ProductsPostHandle * -TALER_MERCHANT_products_post3 ( +TALER_MERCHANT_products_post4 ( struct GNUNET_CURL_Context *ctx, const char *backend_url, const char *product_id, const char *description, const json_t *description_i18n, const char *unit, - const struct TALER_Amount *price, + const struct TALER_Amount *unit_prices, + size_t unit_price_len, const char *image, const json_t *taxes, int64_t total_stock, + uint32_t total_stock_frac, + bool unit_allow_fraction, + const uint32_t *unit_precision_level, const json_t *address, struct GNUNET_TIME_Timestamp next_restock, uint32_t minimum_age, @@ -181,6 +185,12 @@ TALER_MERCHANT_products_post3 ( struct TALER_MERCHANT_ProductsPostHandle *pph; json_t *req_obj; json_t *categories; + char unit_total_stock_buf[64]; + + TALER_MERCHANT_format_stock_string (total_stock, + total_stock_frac, + unit_total_stock_buf, + sizeof (unit_total_stock_buf)); if (0 == num_cats) { @@ -195,41 +205,64 @@ TALER_MERCHANT_products_post3 ( json_array_append_new (categories, json_integer (cats[i]))); } - req_obj = GNUNET_JSON_PACK ( - GNUNET_JSON_pack_string ("product_id", - product_id), - /* FIXME: once we move to the new-style API, - allow applications to set the product name properly! */ - GNUNET_JSON_pack_string ("product_name", - description), - GNUNET_JSON_pack_string ("description", - description), - GNUNET_JSON_pack_allow_null ( - GNUNET_JSON_pack_object_incref ("description_i18n", - (json_t *) description_i18n)), - GNUNET_JSON_pack_allow_null ( - GNUNET_JSON_pack_array_steal ("categories", - categories)), - GNUNET_JSON_pack_string ("unit", - unit), - TALER_JSON_pack_amount ("price", - price), - GNUNET_JSON_pack_string ("image", - image), - GNUNET_JSON_pack_allow_null ( - GNUNET_JSON_pack_array_incref ("taxes", - (json_t *) taxes)), - GNUNET_JSON_pack_uint64 ("total_stock", - total_stock), - GNUNET_JSON_pack_allow_null ( - GNUNET_JSON_pack_uint64 ("minimum_age", - minimum_age)), - GNUNET_JSON_pack_allow_null ( - GNUNET_JSON_pack_object_incref ("address", - (json_t *) address)), - GNUNET_JSON_pack_allow_null ( - GNUNET_JSON_pack_timestamp ("next_restock", - next_restock))); + { + req_obj = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("product_id", + product_id), + /* FIXME: once we move to the new-style API, + allow applications to set the product name properly! */ + GNUNET_JSON_pack_string ("product_name", + description), + GNUNET_JSON_pack_string ("description", + description), + GNUNET_JSON_pack_allow_null ( + GNUNET_JSON_pack_object_incref ("description_i18n", + (json_t *) description_i18n)), + GNUNET_JSON_pack_allow_null ( + GNUNET_JSON_pack_array_steal ("categories", + categories)), + GNUNET_JSON_pack_string ("unit", + unit), + TALER_JSON_pack_amount_array ("unit_price", + unit_price_len, + unit_prices), + GNUNET_JSON_pack_string ("image", + image), + GNUNET_JSON_pack_allow_null ( + GNUNET_JSON_pack_array_incref ("taxes", + (json_t *) taxes)), + GNUNET_JSON_pack_string ("unit_total_stock", + unit_total_stock_buf), + GNUNET_JSON_pack_bool ("unit_allow_fraction", + unit_allow_fraction), + GNUNET_JSON_pack_allow_null ( + GNUNET_JSON_pack_uint64 ("minimum_age", + minimum_age)), + GNUNET_JSON_pack_allow_null ( + GNUNET_JSON_pack_object_incref ("address", + (json_t *) address)), + GNUNET_JSON_pack_allow_null ( + GNUNET_JSON_pack_timestamp ("next_restock", + next_restock))); + } + if (NULL != unit_precision_level) + { + GNUNET_assert (0 == + json_object_set_new (req_obj, + "unit_precision_level", + json_integer ( + *unit_precision_level))); + } + if (! unit_allow_fraction) + { + GNUNET_assert (0 == + json_object_del (req_obj, + "unit_allow_fraction")); + if (NULL != unit_precision_level) + GNUNET_assert (0 == + json_object_del (req_obj, + "unit_precision_level")); + } pph = GNUNET_new (struct TALER_MERCHANT_ProductsPostHandle); pph->ctx = ctx; pph->cb = cb; @@ -266,6 +299,50 @@ TALER_MERCHANT_products_post3 ( struct TALER_MERCHANT_ProductsPostHandle * +TALER_MERCHANT_products_post3 ( + struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *product_id, + const char *description, + const json_t *description_i18n, + const char *unit, + const struct TALER_Amount *price, + const char *image, + const json_t *taxes, + int64_t total_stock, + const json_t *address, + struct GNUNET_TIME_Timestamp next_restock, + uint32_t minimum_age, + unsigned int num_cats, + const uint64_t *cats, + TALER_MERCHANT_ProductsPostCallback cb, + void *cb_cls) +{ + return TALER_MERCHANT_products_post4 (ctx, + backend_url, + product_id, + description, + description_i18n, + unit, + price, + 1, + image, + taxes, + total_stock, + 0, + false, + NULL, + address, + next_restock, + minimum_age, + num_cats, + cats, + cb, + cb_cls); +} + + +struct TALER_MERCHANT_ProductsPostHandle * TALER_MERCHANT_products_post2 ( struct GNUNET_CURL_Context *ctx, const char *backend_url, diff --git a/src/lib/merchant_api_post_units.c b/src/lib/merchant_api_post_units.c @@ -0,0 +1,218 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 merchant_api_post_units.c + * @brief Implementation of POST /private/units + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <curl/curl.h> +#include <jansson.h> +#include <microhttpd.h> +#include <gnunet/gnunet_util_lib.h> +#include "taler_merchant_service.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/units operation. + */ +struct TALER_MERCHANT_UnitsPostHandle +{ + /** + * Fully qualified request URL. + */ + char *url; + + /** + * CURL job handle. + */ + struct GNUNET_CURL_Job *job; + + /** + * Callback to invoke with the result. + */ + TALER_MERCHANT_UnitsPostCallback cb; + + /** + * Closure for @a cb. + */ + void *cb_cls; + + /** + * Execution context. + */ + struct GNUNET_CURL_Context *ctx; + + /** + * Helper keeping POST body and headers alive. + */ + struct TALER_CURL_PostContext post_ctx; +}; + + +/** + * Called when the HTTP transfer finishes. + * + * @param cls operation handle + * @param response_code HTTP status (0 on network / parsing failures) + * @param response parsed JSON reply (NULL if unavailable) + */ +static void +handle_post_units_finished (void *cls, + long response_code, + const void *response) +{ + struct TALER_MERCHANT_UnitsPostHandle *uph = cls; + const json_t *json = response; + struct TALER_MERCHANT_HttpResponse hr = { + .http_status = (unsigned int) response_code, + .reply = json + }; + + uph->job = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "POST /private/units completed with status %u\n", + (unsigned int) response_code); + switch (response_code) + { + case MHD_HTTP_NO_CONTENT: + break; + case MHD_HTTP_BAD_REQUEST: + case MHD_HTTP_UNAUTHORIZED: + case MHD_HTTP_FORBIDDEN: + case MHD_HTTP_NOT_FOUND: + case MHD_HTTP_CONFLICT: + case MHD_HTTP_INTERNAL_SERVER_ERROR: + hr.ec = TALER_JSON_get_error_code (json); + hr.hint = TALER_JSON_get_error_hint (json); + break; + case 0: + hr.ec = TALER_EC_GENERIC_INVALID_RESPONSE; + break; + default: + TALER_MERCHANT_parse_error_details_ (json, + response_code, + &hr); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unexpected response %u/%d for POST /private/units\n", + (unsigned int) response_code, + (int) hr.ec); + GNUNET_break_op (0); + break; + } + uph->cb (uph->cb_cls, + &hr); + TALER_MERCHANT_units_post_cancel (uph); +} + + +struct TALER_MERCHANT_UnitsPostHandle * +TALER_MERCHANT_units_post (struct GNUNET_CURL_Context *ctx, + const char *backend_url, + const char *unit_id, + const char *unit_name_long, + const char *unit_name_short, + bool unit_allow_fraction, + uint32_t unit_precision_level, + bool unit_active, + const json_t *unit_name_long_i18n, + const json_t *unit_name_short_i18n, + TALER_MERCHANT_UnitsPostCallback cb, + void *cb_cls) +{ + struct TALER_MERCHANT_UnitsPostHandle *uph; + json_t *req_obj; + + req_obj = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("unit", + unit_id), + GNUNET_JSON_pack_string ("unit_name_long", + unit_name_long), + GNUNET_JSON_pack_string ("unit_name_short", + unit_name_short), + GNUNET_JSON_pack_bool ("unit_allow_fraction", + unit_allow_fraction), + GNUNET_JSON_pack_uint64 ("unit_precision_level", + (uint64_t) unit_precision_level), + GNUNET_JSON_pack_bool ("unit_active", + unit_active), + GNUNET_JSON_pack_allow_null ( + GNUNET_JSON_pack_object_incref ("unit_name_long_i18n", + (json_t *) unit_name_long_i18n)), + GNUNET_JSON_pack_allow_null ( + GNUNET_JSON_pack_object_incref ("unit_name_short_i18n", + (json_t *) unit_name_short_i18n))); + uph = GNUNET_new (struct TALER_MERCHANT_UnitsPostHandle); + uph->ctx = ctx; + uph->cb = cb; + uph->cb_cls = cb_cls; + uph->url = TALER_url_join (backend_url, + "private/units", + NULL); + if (NULL == uph->url) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Failed to build /private/units URL\n"); + json_decref (req_obj); + GNUNET_free (uph); + return NULL; + } + { + CURL *eh; + + eh = TALER_MERCHANT_curl_easy_get_ (uph->url); + if (GNUNET_OK != + TALER_curl_easy_post (&uph->post_ctx, + eh, + req_obj)) + { + GNUNET_break (0); + curl_easy_cleanup (eh); + json_decref (req_obj); + GNUNET_free (uph->url); + GNUNET_free (uph); + return NULL; + } + json_decref (req_obj); + uph->job = GNUNET_CURL_job_add2 (ctx, + eh, + uph->post_ctx.headers, + &handle_post_units_finished, + uph); + } + return uph; +} + + +void +TALER_MERCHANT_units_post_cancel (struct TALER_MERCHANT_UnitsPostHandle *uph) +{ + if (NULL != uph->job) + { + GNUNET_CURL_job_cancel (uph->job); + uph->job = NULL; + } + TALER_curl_easy_post_finished (&uph->post_ctx); + GNUNET_free (uph->url); + GNUNET_free (uph); +} + + +/* end of merchant_api_post_units.c */ diff --git a/src/testing/Makefile.am b/src/testing/Makefile.am @@ -1,5 +1,5 @@ # This Makefile.am is in the public domain -AM_CPPFLAGS = -I$(top_srcdir)/src/include -I$(top_srcdir)/src/backend +AM_CPPFLAGS = -I$(top_srcdir)/src/include -I$(top_srcdir)/src/backend -I$(top_srcdir)/src/lib if USE_COVERAGE AM_CFLAGS = --coverage -O0 @@ -45,6 +45,8 @@ libtalermerchanttesting_la_SOURCES = \ testing_api_cmd_get_transfers.c \ testing_api_cmd_get_templates.c \ testing_api_cmd_get_template.c \ + testing_api_cmd_get_unit.c \ + testing_api_cmd_get_units.c \ testing_api_cmd_get_webhooks.c \ testing_api_cmd_get_webhook.c \ testing_api_cmd_delete_account.c \ @@ -53,6 +55,7 @@ libtalermerchanttesting_la_SOURCES = \ testing_api_cmd_delete_otp_device.c \ testing_api_cmd_delete_product.c \ testing_api_cmd_delete_template.c \ + testing_api_cmd_delete_unit.c \ testing_api_cmd_delete_webhook.c \ testing_api_cmd_delete_transfer.c \ testing_api_cmd_forget_order.c \ @@ -65,6 +68,7 @@ libtalermerchanttesting_la_SOURCES = \ testing_api_cmd_patch_otp_device.c \ testing_api_cmd_patch_product.c \ testing_api_cmd_patch_template.c \ + testing_api_cmd_patch_unit.c \ testing_api_cmd_patch_webhook.c \ testing_api_cmd_pay_order.c \ testing_api_cmd_post_account.c \ @@ -75,6 +79,7 @@ libtalermerchanttesting_la_SOURCES = \ testing_api_cmd_post_products.c \ testing_api_cmd_post_transfers.c \ testing_api_cmd_post_templates.c \ + testing_api_cmd_post_units.c \ testing_api_cmd_post_tokenfamilies.c \ testing_api_cmd_post_using_templates.c \ testing_api_cmd_post_webhooks.c \ diff --git a/src/testing/test_merchant_api.c b/src/testing/test_merchant_api.c @@ -36,6 +36,7 @@ #include <taler/taler_fakebank_lib.h> #include <taler/taler_testing_lib.h> #include <taler/taler_error_codes.h> +#include "taler_merchant_util.h" #include "taler_merchant_testing_lib.h" #ifdef HAVE_DONAU_DONAU_SERVICE_H @@ -142,6 +143,22 @@ static const char *order_1_forgets_3[] = { NULL }; +static const struct TALER_TESTING_ProductUnitExpectations + expect_unicorn_defaults = { + .have_unit_allow_fraction = true, + .unit_allow_fraction = true, + .have_unit_precision_level = true, + .unit_precision_level = 1 +}; + +static const struct TALER_TESTING_ProductUnitExpectations + expect_unicorn_patched = { + .have_unit_allow_fraction = true, + .unit_allow_fraction = false, + .have_unit_precision_level = true, + .unit_precision_level = 0 +}; + /** * Execute the taler-merchant-webhook command with * our configuration file. @@ -628,6 +645,30 @@ run (void *cls, NULL, GNUNET_TIME_UNIT_FOREVER_TS, MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_post_products3 ("post-products-frac", + merchant_url, + "product-frac", + "fractional product", + json_pack ("{s:s}", + "en", + "fractional product"), + "kg", + "EUR:1.5", + "", + json_array (), + 1, + (MERCHANT_UNIT_FRAC_BASE * 3) + / 4, + true, + 0, + NULL, + GNUNET_TIME_UNIT_ZERO_TS, + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_get_product ("get-product-frac-post", + merchant_url, + "product-frac", + MHD_HTTP_OK, + "post-products-frac"), TALER_TESTING_cmd_merchant_patch_product ("patch-products-p3", merchant_url, "product-3", @@ -644,12 +685,286 @@ run (void *cls, ( GNUNET_TIME_UNIT_MINUTES), MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_patch_product2 ("patch-product-frac", + merchant_url, + "product-frac", + "fractional product patched", + json_pack ("{s:s}", + "en", + "fractional product patched"), + "kg", + "EUR:1.6", + "", + json_array (), + 2, + MERCHANT_UNIT_FRAC_BASE / 4, + true, + 0, + json_object (), + GNUNET_TIME_relative_to_timestamp ( + GNUNET_TIME_UNIT_MINUTES), + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_get_product ("get-product-frac-patched", + merchant_url, + "product-frac", + MHD_HTTP_OK, + "patch-product-frac"), + TALER_TESTING_cmd_merchant_delete_unit ("delete-unit-piece-denied", + merchant_url, + "Piece", + MHD_HTTP_CONFLICT), + TALER_TESTING_cmd_merchant_post_units ("post-unit-piece-conflict", + merchant_url, + "Piece", + "piece", + "pc", + false, + 0, + true, + NULL, + NULL, + MHD_HTTP_CONFLICT), + TALER_TESTING_cmd_merchant_post_units ("post-unit-bucket", + merchant_url, + "BucketCustomUnit", + "bucket", + "bkt", + false, + 0, + true, + json_pack ("{s:s}", "en", "bucket"), + json_pack ("{s:s}", "en", "bkt"), + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_get_unit ("get-unit-bucket", + merchant_url, + "BucketCustomUnit", + MHD_HTTP_OK, + "post-unit-bucket"), + TALER_TESTING_cmd_merchant_get_units ("get-units-bucket-list", + merchant_url, + MHD_HTTP_OK, + "post-unit-bucket", + NULL), + TALER_TESTING_cmd_merchant_patch_unit ("patch-unit-bucket", + merchant_url, + "BucketCustomUnit", + "bucket-updated", + "bkt-upd", + json_pack ("{s:s}", "en", + "bucket-updated"), + json_pack ("{s:s}", "en", "bkt-upd"), + true, + 0, + false, + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_get_unit ("get-unit-bucket-patched", + merchant_url, + "BucketCustomUnit", + MHD_HTTP_OK, + "patch-unit-bucket"), + TALER_TESTING_cmd_merchant_get_units ("get-units-bucket-updated", + merchant_url, + MHD_HTTP_OK, + "patch-unit-bucket", + NULL), + TALER_TESTING_cmd_merchant_delete_unit ("delete-unit-bucket", + merchant_url, + "BucketCustomUnit", + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_get_unit ("get-unit-bucket-deleted", + merchant_url, + "BucketCustomUnit", + MHD_HTTP_NOT_FOUND, + NULL), + TALER_TESTING_cmd_merchant_post_units ("post-unit-unicorn", + merchant_url, + "UnitUnicorn", + "unicorn", + "uni", + true, + 1, + true, + json_pack ("{s:s}", "en", "unicorn"), + json_pack ("{s:s}", "en", "uni"), + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_post_products2 ("post-product-unicorn", + merchant_url, + "product-unicorn", + "a unicorn snack", + json_pack ("{s:s}", + "en", + "a unicorn snack"), + "UnitUnicorn", + "EUR:3.5", + "", + json_array (), + 7, + 0, + json_object (), + GNUNET_TIME_UNIT_ZERO_TS, + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_get_product2 ("get-product-unicorn-default", + merchant_url, + "product-unicorn", + MHD_HTTP_OK, + "post-product-unicorn", + &expect_unicorn_defaults), + TALER_TESTING_cmd_merchant_patch_unit ("patch-unit-unicorn", + merchant_url, + "UnitUnicorn", + NULL, + NULL, + NULL, + NULL, + false, + 0, + true, + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_get_product2 ("get-product-unicorn-patched", + merchant_url, + "product-unicorn", + MHD_HTTP_OK, + "post-product-unicorn", + &expect_unicorn_patched), + TALER_TESTING_cmd_merchant_delete_product ("delete-product-unicorn", + merchant_url, + "product-unicorn", + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_delete_unit ("delete-unit-unicorn", + merchant_url, + "UnitUnicorn", + MHD_HTTP_NO_CONTENT), TALER_TESTING_cmd_merchant_lock_product ("lock-product-p3", merchant_url, "product-3", GNUNET_TIME_UNIT_MINUTES, 2, MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_lock_product2 ( + "lock-product-p3-float-denied", + merchant_url, + "product-3", + GNUNET_TIME_UNIT_MINUTES, + 1, + MERCHANT_UNIT_FRAC_BASE / 2, + true, + MHD_HTTP_BAD_REQUEST), + TALER_TESTING_cmd_merchant_post_orders2 ("create-proposal-p3-float-denied", + cred.cfg, + merchant_url, + MHD_HTTP_BAD_REQUEST, + "order-p3-float-denied", + GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, + true, + "EUR:5.0", + "x-taler-bank", + "product-3/1.5", + "", + NULL), + TALER_TESTING_cmd_merchant_patch_product2 ( + "patch-product-3-allow-float", + merchant_url, + "product-3", + "a product allow fractional", + json_pack ("{s:s}", + "en", + "a product allow fractional"), + "can", + "EUR:1", + "data:image/jpeg;base64,RAWDATA", + json_array (), + 5, + 0, + true, + 0, + json_object (), + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_MINUTES), + MHD_HTTP_NO_CONTENT), + cmd_transfer_to_exchange ("create-reserve-p3-float", + "EUR:5.01"), + cmd_exec_wirewatch ("wirewatch-p3-float"), + TALER_TESTING_cmd_check_bank_admin_transfer ( + "check_bank_transfer-p3-float", + "EUR:5.01", + payer_payto, + exchange_payto, + "create-reserve-p3-float"), + TALER_TESTING_cmd_withdraw_amount ("withdraw-coin-p3-float", + "create-reserve-p3-float", + "EUR:5", + 0, + MHD_HTTP_OK), + TALER_TESTING_cmd_merchant_lock_product2 ( + "lock-product-p3-float", + merchant_url, + "product-3", + GNUNET_TIME_UNIT_MINUTES, + 1, + MERCHANT_UNIT_FRAC_BASE / 2, + true, + MHD_HTTP_NO_CONTENT), + TALER_TESTING_cmd_merchant_post_orders2 ("create-proposal-p3-float", + cred.cfg, + merchant_url, + MHD_HTTP_OK, + "order-p3-float", + GNUNET_TIME_UNIT_ZERO_TS, + GNUNET_TIME_UNIT_FOREVER_TS, + true, + "EUR:5.0", + "x-taler-bank", + "product-3/1.5", + "", + NULL), + TALER_TESTING_cmd_merchant_claim_order ("claim-order-p3-float", + merchant_url, + MHD_HTTP_OK, + "create-proposal-p3-float", + NULL), + TALER_TESTING_cmd_merchant_pay_order ("pay-order-p3-float", + merchant_url, + MHD_HTTP_OK, + "create-proposal-p3-float", + "withdraw-coin-p3-float", + "EUR:5", + "EUR:4.99", + "session-p3-float"), + TALER_TESTING_cmd_sleep ( + "Wait for wire transfer deadline-p3-float", + 3), + CMD_EXEC_AGGREGATOR ("run-aggregator-p3-float"), + TALER_TESTING_cmd_check_bank_transfer ("check_bank_transfer-p3-float", + EXCHANGE_URL, + "EUR:4.98", + exchange_payto, + merchant_payto), + TALER_TESTING_cmd_merchant_get_order ("get-order-merchant-p3-float-paid", + merchant_url, + "create-proposal-p3-float", + TALER_MERCHANT_OSC_PAID, + false, + MHD_HTTP_OK, + NULL), + TALER_TESTING_cmd_merchant_patch_product2 ( + "patch-product-3-restore", + merchant_url, + "product-3", + "a product", + json_pack ("{s:s}", + "en", + "a product"), + "can", + "EUR:1", + "data:image/jpeg;base64,RAWDATA", + json_array (), + 5, + 0, + false, + 0, + json_object (), + GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_MINUTES), + MHD_HTTP_NO_CONTENT), TALER_TESTING_cmd_merchant_post_orders2 ("create-proposal-p3-wm-nx", cred.cfg, merchant_url, diff --git a/src/testing/test_merchant_order_creation.sh b/src/testing/test_merchant_order_creation.sh @@ -391,7 +391,7 @@ echo "OK" echo -n "Creating product..." STATUS=$(curl 'http://localhost:9966/private/products' \ - -d '{"product_id":"2","description":"product with id 2 and price :15","price":"TESTKUDOS:15","total_stock":2,"description_i18n":{},"unit":"","image":"'$RANDOM_IMG'","taxes":[],"address":{},"next_restock":{"t_s":"never"}}' \ + -d '{"product_id":"2","description":"product with id 2 and price :15","unit_price":["TESTKUDOS:15"],"unit_total_stock":"2","description_i18n":{},"unit":"","image":"'$RANDOM_IMG'","taxes":[],"address":{},"next_restock":{"t_s":"never"}}' \ -w "%{http_code}" -s -o /dev/null) if [ "$STATUS" != "204" ] @@ -402,7 +402,7 @@ echo "OK" echo -n "Creating order with inventory products..." STATUS=$(curl 'http://localhost:9966/private/orders' \ - -d '{"order":{"amount":"TESTKUDOS:7","summary":"3"},"inventory_products":[{"product_id":"2","quantity":1}]}' \ + -d '{"order":{"amount":"TESTKUDOS:7","summary":"3"},"inventory_products":[{"product_id":"2","unit_quantity":"1"}]}' \ -w "%{http_code}" -s -o "$LAST_RESPONSE") @@ -440,7 +440,7 @@ echo "OK" STATUS=$(curl 'http://localhost:9966/private/products' \ - -d '{"product_id":"1","description":"product with id 1 and price :15","price":"USD:15","total_stock":1,"description_i18n":{},"unit":"","image":"","taxes":[],"address":{},"next_restock":{"t_s":"never"}}' \ + -d '{"product_id":"1","description":"product with id 1 and price :15","unit_price":["USD:15"],"unit_total_stock":"1","description_i18n":{},"unit":"","image":"","taxes":[],"address":{},"next_restock":{"t_s":"never"}}' \ -w "%{http_code}" -s -o /dev/null) if [ "$STATUS" != "204" ] @@ -454,7 +454,7 @@ fi echo -n "Creating order to be paid..." STATUS=$(curl 'http://localhost:9966/private/orders' \ - -d '{"order":{"amount":"TESTKUDOS:1","summary":"payme"},"inventory_products":[{"product_id":"2","quantity":1}]}' \ + -d '{"order":{"amount":"TESTKUDOS:1","summary":"payme"},"inventory_products":[{"product_id":"2","unit_quantity":"1"}]}' \ -w "%{http_code}" -s -o "$LAST_RESPONSE") if [ "$STATUS" != "200" ] diff --git a/src/testing/test_merchant_product_creation.sh b/src/testing/test_merchant_product_creation.sh @@ -89,8 +89,8 @@ fi echo "OK" RANDOM_IMG='data:image/png;base64,abcdefg' -INFINITE_PRODUCT_TEMPLATE='{"product_id":"2","product_name":"stuff","description":"product with id 2 and price :15","price":"TESTKUDOS:15","total_stock":-1,"unit":"","image":"'"$RANDOM_IMG"'","taxes":[]}' -MANAGED_PRODUCT_TEMPLATE='{"product_id":"3","product_name":"more stuff","description":"product with id 3 and price :10","price":"TESTKUDOS:150","total_stock":2,"unit":"","image":"'"$RANDOM_IMG"'","taxes":[]}' +INFINITE_PRODUCT_TEMPLATE='{"product_id":"2","product_name":"stuff","description":"product with id 2 and price :15","unit_price":["TESTKUDOS:15"],"unit_total_stock":"-1","unit":"","image":"'"$RANDOM_IMG"'","taxes":[]}' +MANAGED_PRODUCT_TEMPLATE='{"product_id":"3","product_name":"more stuff","description":"product with id 3 and price :10","unit_price":["TESTKUDOS:150"],"unit_total_stock":"2","unit":"","image":"'"$RANDOM_IMG"'","taxes":[]}' echo -n "Creating products..." STATUS=$(curl 'http://localhost:9966/private/products' \ @@ -143,7 +143,7 @@ MANAGED_PRODUCT_ID=$(echo "$MANAGED_PRODUCT_TEMPLATE" | jq -r '.product_id') echo -n "Locking inventory ..." STATUS=$(curl "http://localhost:9966/private/products/${MANAGED_PRODUCT_ID}/lock" \ - -d '{"lock_uuid":"luck","duration":{"d_us": 100000000},"quantity":10}' \ + -d '{"lock_uuid":"luck","duration":{"d_us": 100000000},"unit_quantity":"10"}' \ -w "%{http_code}" -s -o "$LAST_RESPONSE") if [ "$STATUS" != "410" ] @@ -155,7 +155,7 @@ fi echo -n "." STATUS=$(curl "http://localhost:9966/private/products/${MANAGED_PRODUCT_ID}/lock" \ - -d '{"lock_uuid":"luck","duration":{"d_us": 100000000},"quantity":1}' \ + -d '{"lock_uuid":"luck","duration":{"d_us": 100000000},"unit_quantity":"1"}' \ -w "%{http_code}" -s -o "$LAST_RESPONSE") if [ "$STATUS" != "204" ] @@ -167,7 +167,7 @@ echo " OK" echo -n "Creating order to be paid..." STATUS=$(curl 'http://localhost:9966/private/orders' \ - -d '{"order":{"amount":"TESTKUDOS:1","summary":"payme"},"inventory_products":[{"product_id":"'"${MANAGED_PRODUCT_ID}"'","quantity":1}]}' \ + -d '{"order":{"amount":"TESTKUDOS:1","summary":"payme"},"inventory_products":[{"product_id":"'"${MANAGED_PRODUCT_ID}"'","unit_quantity":"1"}]}' \ -w "%{http_code}" -s -o "$LAST_RESPONSE") if [ "$STATUS" != "200" ] @@ -192,7 +192,7 @@ PAY_URL=$(jq -e -r .taler_pay_uri < "$LAST_RESPONSE") echo -n "." STATUS=$(curl 'http://localhost:9966/private/orders' \ - -d '{"order":{"amount":"TESTKUDOS:1","summary":"payme"},"inventory_products":[{"product_id":"'"${MANAGED_PRODUCT_ID}"'","quantity":1}]}' \ + -d '{"order":{"amount":"TESTKUDOS:1","summary":"payme"},"inventory_products":[{"product_id":"'"${MANAGED_PRODUCT_ID}"'","unit_quantity":"1"}]}' \ -w "%{http_code}" -s -o "$LAST_RESPONSE") if [ "$STATUS" != "410" ] @@ -205,7 +205,7 @@ echo -n "." # Using the 'luck' inventory lock, order creation should work. STATUS=$(curl 'http://localhost:9966/private/orders' \ - -d '{"order":{"amount":"TESTKUDOS:1","summary":"payme"},"lock_uuids":["luck"],"inventory_products":[{"product_id":"'"$MANAGED_PRODUCT_ID"'","quantity":1}]}' \ + -d '{"order":{"amount":"TESTKUDOS:1","summary":"payme"},"lock_uuids":["luck"],"inventory_products":[{"product_id":"'"$MANAGED_PRODUCT_ID"'","unit_quantity":"1"}]}' \ -w "%{http_code}" -s -o "$LAST_RESPONSE") if [ "$STATUS" != "200" ] diff --git a/src/testing/testing_api_cmd_delete_unit.c b/src/testing/testing_api_cmd_delete_unit.c @@ -0,0 +1,176 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 testing_api_cmd_delete_unit.c + * @brief command to test DELETE /private/units/$ID + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_testing_lib.h> +#include "taler_merchant_service.h" +#include "taler_merchant_testing_lib.h" + + +/** + * State for a DELETE /private/units/$ID command. + */ +struct DeleteUnitState +{ + /** + * In-flight request handle. + */ + struct TALER_MERCHANT_UnitDeleteHandle *udh; + + /** + * Interpreter context. + */ + struct TALER_TESTING_Interpreter *is; + + /** + * Merchant backend base URL. + */ + const char *merchant_url; + + /** + * Unit identifier to delete. + */ + const char *unit_id; + + /** + * Expected HTTP status. + */ + unsigned int http_status; +}; + + +/** + * Completion callback. + */ +static void +delete_unit_cb (void *cls, + const struct TALER_MERCHANT_HttpResponse *hr) +{ + struct DeleteUnitState *dus = cls; + + dus->udh = NULL; + if (dus->http_status != hr->http_status) + { + TALER_TESTING_unexpected_status_with_body (dus->is, + hr->http_status, + dus->http_status, + hr->reply); + return; + } + TALER_TESTING_interpreter_next (dus->is); +} + + +/** + * Issue DELETE request. + */ +static void +delete_unit_run (void *cls, + const struct TALER_TESTING_Command *cmd, + struct TALER_TESTING_Interpreter *is) +{ + struct DeleteUnitState *dus = cls; + + dus->is = is; + dus->udh = TALER_MERCHANT_unit_delete ( + TALER_TESTING_interpreter_get_context (is), + dus->merchant_url, + dus->unit_id, + &delete_unit_cb, + dus); + if (NULL == dus->udh) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (is); + } +} + + +/** + * Provide traits. + */ +static enum GNUNET_GenericReturnValue +delete_unit_traits (void *cls, + const void **ret, + const char *trait, + unsigned int index) +{ + struct DeleteUnitState *dus = cls; + struct TALER_TESTING_Trait traits[] = { + TALER_TESTING_make_trait_unit_id (dus->unit_id), + TALER_TESTING_trait_end () + }; + + return TALER_TESTING_get_trait (traits, + ret, + trait, + index); +} + + +/** + * Cleanup. + */ +static void +delete_unit_cleanup (void *cls, + const struct TALER_TESTING_Command *cmd) +{ + struct DeleteUnitState *dus = cls; + + if (NULL != dus->udh) + { + TALER_MERCHANT_unit_delete_cancel (dus->udh); + dus->udh = NULL; + } + GNUNET_free (dus); +} + + +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_delete_unit (const char *label, + const char *merchant_url, + const char *unit_id, + unsigned int http_status) +{ + struct DeleteUnitState *dus; + + dus = GNUNET_new (struct DeleteUnitState); + dus->merchant_url = merchant_url; + dus->unit_id = unit_id; + dus->http_status = http_status; + + { + struct TALER_TESTING_Command cmd = { + .cls = dus, + .label = label, + .run = &delete_unit_run, + .cleanup = &delete_unit_cleanup, + .traits = &delete_unit_traits + }; + + return cmd; + } +} + + +/* end of testing_api_cmd_delete_unit.c */ diff --git a/src/testing/testing_api_cmd_get_product.c b/src/testing/testing_api_cmd_get_product.c @@ -64,6 +64,11 @@ struct GetProductState */ unsigned int http_status; + /** + * Optional overrides for fractional fields. + */ + const struct TALER_TESTING_ProductUnitExpectations *unit_expectations; + }; @@ -79,6 +84,8 @@ get_product_cb (void *cls, { struct GetProductState *gis = cls; const struct TALER_TESTING_Command *product_cmd; + const struct TALER_TESTING_ProductUnitExpectations *ue = + gis->unit_expectations; gis->igh = NULL; if (gis->http_status != pgr->hr.http_status) @@ -149,6 +156,112 @@ get_product_cb (void *cls, } } { + const bool *expected_allow; + bool have_allow = GNUNET_OK == + TALER_TESTING_get_trait_product_unit_allow_fraction ( + product_cmd, + &expected_allow); + bool override_allow = (NULL != ue) && + ue->have_unit_allow_fraction; + + if (override_allow) + { + if (pgr->details.ok.unit_allow_fraction != + ue->unit_allow_fraction) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product fractional flag does not match expectation\n"); + TALER_TESTING_interpreter_fail (gis->is); + return; + } + } + else if (! have_allow) + { + if (pgr->details.ok.unit_allow_fraction) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product fractional flag unexpected\n"); + TALER_TESTING_interpreter_fail (gis->is); + return; + } + } + else + { + if (pgr->details.ok.unit_allow_fraction != *expected_allow) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product fractional flag does not match\n"); + TALER_TESTING_interpreter_fail (gis->is); + return; + } + { + const char *expected_unit_total_stock; + + if (GNUNET_OK != + TALER_TESTING_get_trait_product_unit_total_stock ( + product_cmd, + &expected_unit_total_stock)) + TALER_TESTING_interpreter_fail (gis->is); + else if (0 != strcmp (pgr->details.ok.unit_total_stock, + expected_unit_total_stock)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product stock string does not match\n"); + TALER_TESTING_interpreter_fail (gis->is); + return; + } + } + } + } + { + const uint32_t *expected_precision; + bool have_precision = GNUNET_OK == + TALER_TESTING_get_trait_product_unit_precision_level ( + product_cmd, + &expected_precision); + bool override_precision = (NULL != ue) && + ue->have_unit_precision_level; + + if (override_precision) + { + if (pgr->details.ok.unit_precision_level != + ue->unit_precision_level) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product fractional precision does not match expectation\n"); + TALER_TESTING_interpreter_fail (gis->is); + return; + } + } + else if (have_precision) + { + if (pgr->details.ok.unit_precision_level != *expected_precision) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product fractional precision does not match\n"); + TALER_TESTING_interpreter_fail (gis->is); + return; + } + } + else if (! pgr->details.ok.unit_allow_fraction) + { + if (0 != pgr->details.ok.unit_precision_level) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product fractional precision should be zero when disallowed\n"); + TALER_TESTING_interpreter_fail (gis->is); + return; + } + } + else if (pgr->details.ok.unit_precision_level > 8) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product fractional precision exceeds supported range\n"); + TALER_TESTING_interpreter_fail (gis->is); + return; + } + } + { const char *expected_image; if (GNUNET_OK != @@ -203,13 +316,16 @@ get_product_cb (void *cls, TALER_TESTING_get_trait_address (product_cmd, &expected_location)) TALER_TESTING_interpreter_fail (gis->is); - if (1 != json_equal (pgr->details.ok.location, - expected_location)) + if (NULL != expected_location) { - GNUNET_log (GNUNET_ERROR_TYPE_ERROR, - "Product location does not match\n"); - TALER_TESTING_interpreter_fail (gis->is); - return; + if (1 != json_equal (pgr->details.ok.location, + expected_location)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product location does not match\n"); + TALER_TESTING_interpreter_fail (gis->is); + return; + } } } { @@ -314,6 +430,24 @@ TALER_TESTING_cmd_merchant_get_product (const char *label, unsigned int http_status, const char *product_reference) { + return TALER_TESTING_cmd_merchant_get_product2 (label, + merchant_url, + product_id, + http_status, + product_reference, + NULL); +} + + +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_get_product2 ( + const char *label, + const char *merchant_url, + const char *product_id, + unsigned int http_status, + const char *product_reference, + const struct TALER_TESTING_ProductUnitExpectations *unit_expectations) +{ struct GetProductState *gis; gis = GNUNET_new (struct GetProductState); @@ -321,6 +455,7 @@ TALER_TESTING_cmd_merchant_get_product (const char *label, gis->product_id = product_id; gis->http_status = http_status; gis->product_reference = product_reference; + gis->unit_expectations = unit_expectations; { struct TALER_TESTING_Command cmd = { .cls = gis, diff --git a/src/testing/testing_api_cmd_get_unit.c b/src/testing/testing_api_cmd_get_unit.c @@ -0,0 +1,352 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 testing_api_cmd_get_unit.c + * @brief command to test GET /private/units/$ID + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <jansson.h> +#include <taler/taler_testing_lib.h> +#include "taler_merchant_service.h" +#include "taler_merchant_testing_lib.h" + + +/** + * State for a GET /private/units/$ID command. + */ +struct GetUnitState +{ + /** + * In-flight request handle. + */ + struct TALER_MERCHANT_UnitGetHandle *ugh; + + /** + * Interpreter context. + */ + struct TALER_TESTING_Interpreter *is; + + /** + * Merchant backend base URL. + */ + const char *merchant_url; + + /** + * Unit identifier to fetch. + */ + const char *unit_id; + + /** + * Expected HTTP status. + */ + unsigned int http_status; + + /** + * Optional command label providing expected traits. + */ + const char *reference; +}; + + +/** + * Compare response @a entry with traits from @a ref_cmd. + */ +static bool +unit_matches_reference (const struct TALER_MERCHANT_UnitEntry *entry, + const struct TALER_TESTING_Command *ref_cmd) +{ + const char *unit_id; + + if (GNUNET_OK != + TALER_TESTING_get_trait_unit_id (ref_cmd, + &unit_id)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit trait resolution failed for reference `%s'\n", + ref_cmd->label); + return false; + } + if (0 != strcmp (entry->unit, + unit_id)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit mismatch: expected id %s got %s\n", + unit_id, + entry->unit); + return false; + } + + { + const char *unit_name_long = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_name_long (ref_cmd, + &unit_name_long)) + { + if ( (NULL != unit_name_long) && + (0 != strcmp (entry->unit_name_long, + unit_name_long)) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: expected long label '%s' got '%s'\n", + entry->unit, + unit_name_long, + entry->unit_name_long); + return false; + } + } + } + { + const char *unit_name_short = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_name_short (ref_cmd, + &unit_name_short)) + { + if ( (NULL != unit_name_short) && + (0 != strcmp (entry->unit_name_short, + unit_name_short)) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: expected short label '%s' got '%s'\n", + entry->unit, + unit_name_short, + entry->unit_name_short); + return false; + } + } + } + { + const bool *unit_allow_fraction = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_allow_fraction (ref_cmd, + &unit_allow_fraction)) + { + if ( (NULL != unit_allow_fraction) && + (*unit_allow_fraction != entry->unit_allow_fraction) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: expected allow_fraction %d got %d\n", + entry->unit, + (int) *unit_allow_fraction, + (int) entry->unit_allow_fraction); + return false; + } + } + } + { + const uint32_t *unit_precision_level = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_precision_level (ref_cmd, + &unit_precision_level)) + { + if ( (NULL != unit_precision_level) && + (*unit_precision_level != entry->unit_precision_level) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: expected precision %u got %u\n", + entry->unit, + (unsigned int) *unit_precision_level, + (unsigned int) entry->unit_precision_level); + return false; + } + } + } + { + const bool *unit_active = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_active (ref_cmd, + &unit_active)) + { + if ( (NULL != unit_active) && + (*unit_active != entry->unit_active) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: expected active flag %d got %d\n", + entry->unit, + (int) *unit_active, + (int) entry->unit_active); + return false; + } + } + } + { + const json_t *unit_name_long_i18n = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_name_long_i18n (ref_cmd, + &unit_name_long_i18n)) + { + if ( (NULL != unit_name_long_i18n) && + ( (NULL == entry->unit_name_long_i18n) || + (1 != json_equal (unit_name_long_i18n, + entry->unit_name_long_i18n)) ) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: long_i18n differs\n", + entry->unit); + return false; + } + } + } + { + const json_t *unit_name_short_i18n = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_name_short_i18n (ref_cmd, + &unit_name_short_i18n)) + { + if ( (NULL != unit_name_short_i18n) && + ( (NULL == entry->unit_name_short_i18n) || + (1 != json_equal (unit_name_short_i18n, + entry->unit_name_short_i18n)) ) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: short_i18n differs\n", + entry->unit); + return false; + } + } + } + return true; +} + + +/** + * Completion callback. + */ +static void +get_unit_cb (void *cls, + const struct TALER_MERCHANT_UnitGetResponse *ugr) +{ + struct GetUnitState *gug = cls; + + gug->ugh = NULL; + if (gug->http_status != ugr->hr.http_status) + { + TALER_TESTING_unexpected_status_with_body (gug->is, + ugr->hr.http_status, + gug->http_status, + ugr->hr.reply); + return; + } + if ( (MHD_HTTP_OK == ugr->hr.http_status) && + (NULL != gug->reference) ) + { + const struct TALER_TESTING_Command *ref_cmd; + + ref_cmd = TALER_TESTING_interpreter_lookup_command (gug->is, + gug->reference); + if (NULL == ref_cmd) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (gug->is); + return; + } + if (! unit_matches_reference (&ugr->details.ok.unit, + ref_cmd)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "GET /private/units/%s response does not match expectation from `%s'\n", + gug->unit_id, + gug->reference); + TALER_TESTING_interpreter_fail (gug->is); + return; + } + } + TALER_TESTING_interpreter_next (gug->is); +} + + +/** + * Issue GET request. + */ +static void +get_unit_run (void *cls, + const struct TALER_TESTING_Command *cmd, + struct TALER_TESTING_Interpreter *is) +{ + struct GetUnitState *gug = cls; + + gug->is = is; + gug->ugh = TALER_MERCHANT_unit_get ( + TALER_TESTING_interpreter_get_context (is), + gug->merchant_url, + gug->unit_id, + &get_unit_cb, + gug); + if (NULL == gug->ugh) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (is); + } +} + + +/** + * Cleanup. + */ +static void +get_unit_cleanup (void *cls, + const struct TALER_TESTING_Command *cmd) +{ + struct GetUnitState *gug = cls; + + if (NULL != gug->ugh) + { + TALER_MERCHANT_unit_get_cancel (gug->ugh); + gug->ugh = NULL; + } + GNUNET_free (gug); +} + + +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_get_unit (const char *label, + const char *merchant_url, + const char *unit_id, + unsigned int http_status, + const char *reference) +{ + struct GetUnitState *gug; + + gug = GNUNET_new (struct GetUnitState); + gug->merchant_url = merchant_url; + gug->unit_id = unit_id; + gug->http_status = http_status; + gug->reference = reference; + + { + struct TALER_TESTING_Command cmd = { + .cls = gug, + .label = label, + .run = &get_unit_run, + .cleanup = &get_unit_cleanup + }; + + return cmd; + } +} + + +/* end of testing_api_cmd_get_unit.c */ diff --git a/src/testing/testing_api_cmd_get_units.c b/src/testing/testing_api_cmd_get_units.c @@ -0,0 +1,361 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 testing_api_cmd_get_units.c + * @brief command to test GET /private/units + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <jansson.h> +#include <taler/taler_testing_lib.h> +#include "taler_merchant_service.h" +#include "taler_merchant_testing_lib.h" + + +/** + * State for a GET /private/units command. + */ +struct GetUnitsState +{ + /** + * In-flight request handle. + */ + struct TALER_MERCHANT_UnitsGetHandle *ugh; + + /** + * Interpreter context. + */ + struct TALER_TESTING_Interpreter *is; + + /** + * Merchant backend base URL. + */ + const char *merchant_url; + + /** + * Expected HTTP status. + */ + unsigned int http_status; + + /** + * Expected references that must appear in the listing. + */ + const char **references; + + /** + * Length of @e references. + */ + unsigned int references_length; +}; + + +/** + * Verify that @a entry matches the traits from @a ref_cmd. + */ +static enum GNUNET_GenericReturnValue +check_unit_matches (const struct TALER_MERCHANT_UnitEntry *entry, + const struct TALER_TESTING_Command *ref_cmd) +{ + const char *unit_id = NULL; + + if (GNUNET_OK != + TALER_TESTING_get_trait_unit_id (ref_cmd, + &unit_id)) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Internal error: command `%s' lacks unit_id trait\n", + ref_cmd->label); + return GNUNET_SYSERR; + } + if (0 != strcmp (entry->unit, + unit_id)) + return GNUNET_NO; + + { + const char *unit_name_long = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_name_long (ref_cmd, + &unit_name_long)) + { + if ( (NULL != unit_name_long) && + (0 != strcmp (entry->unit_name_long, + unit_name_long)) ) + return GNUNET_SYSERR; + } + } + { + const char *unit_name_short = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_name_short (ref_cmd, + &unit_name_short)) + { + if ( (NULL != unit_name_short) && + (0 != strcmp (entry->unit_name_short, + unit_name_short)) ) + return GNUNET_SYSERR; + } + } + { + const bool *unit_allow_fraction = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_allow_fraction (ref_cmd, + &unit_allow_fraction)) + { + if ( (NULL != unit_allow_fraction) && + (*unit_allow_fraction != entry->unit_allow_fraction) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: expected allow_fraction %d got %d\n", + entry->unit, + (int) *unit_allow_fraction, + (int) entry->unit_allow_fraction); + return GNUNET_SYSERR; + } + } + } + { + const uint32_t *unit_precision_level = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_precision_level (ref_cmd, + &unit_precision_level)) + { + if ( (NULL != unit_precision_level) && + (*unit_precision_level != entry->unit_precision_level) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: expected precision %u got %u\n", + entry->unit, + (unsigned int) *unit_precision_level, + (unsigned int) entry->unit_precision_level); + return GNUNET_SYSERR; + } + } + } + { + const bool *unit_active = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_active (ref_cmd, + &unit_active)) + { + if ( (NULL != unit_active) && + (*unit_active != entry->unit_active) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: expected active %d got %d\n", + entry->unit, + (int) *unit_active, + (int) entry->unit_active); + return GNUNET_SYSERR; + } + } + } + { + const json_t *unit_name_long_i18n = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_name_long_i18n (ref_cmd, + &unit_name_long_i18n)) + { + if ( (NULL != unit_name_long_i18n) && + ( (NULL == entry->unit_name_long_i18n) || + (1 != json_equal (unit_name_long_i18n, + entry->unit_name_long_i18n)) ) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: long_i18n differs\n", + entry->unit); + return GNUNET_SYSERR; + } + } + } + { + const json_t *unit_name_short_i18n = NULL; + + if (GNUNET_OK == + TALER_TESTING_get_trait_unit_name_short_i18n (ref_cmd, + &unit_name_short_i18n)) + { + if ( (NULL != unit_name_short_i18n) && + ( (NULL == entry->unit_name_short_i18n) || + (1 != json_equal (unit_name_short_i18n, + entry->unit_name_short_i18n)) ) ) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit %s mismatch: short_i18n differs\n", + entry->unit); + return GNUNET_SYSERR; + } + } + } + return GNUNET_OK; +} + + +/** + * Completion callback for GET /private/units. + */ +static void +get_units_cb (void *cls, + const struct TALER_MERCHANT_UnitsGetResponse *ugr) +{ + struct GetUnitsState *gus = cls; + + gus->ugh = NULL; + if (gus->http_status != ugr->hr.http_status) + { + TALER_TESTING_unexpected_status_with_body (gus->is, + ugr->hr.http_status, + gus->http_status, + ugr->hr.reply); + return; + } + if (MHD_HTTP_OK == ugr->hr.http_status) + { + for (unsigned int i = 0; i < gus->references_length; ++i) + { + const char *label = gus->references[i]; + const struct TALER_TESTING_Command *ref_cmd; + enum GNUNET_GenericReturnValue match = GNUNET_NO; + + ref_cmd = TALER_TESTING_interpreter_lookup_command (gus->is, + label); + if (NULL == ref_cmd) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (gus->is); + return; + } + for (unsigned int j = 0; + j < ugr->details.ok.units_length; + ++j) + { + match = check_unit_matches (&ugr->details.ok.units[j], + ref_cmd); + if (GNUNET_SYSERR == match) + break; + if (GNUNET_OK == match) + break; + } + if (GNUNET_SYSERR == match) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (gus->is); + return; + } + if (GNUNET_OK != match) + { + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Unit referenced by `%s' not found in GET /private/units response\n", + label); + TALER_TESTING_interpreter_fail (gus->is); + return; + } + } + } + TALER_TESTING_interpreter_next (gus->is); +} + + +/** + * Issue the GET request. + */ +static void +get_units_run (void *cls, + const struct TALER_TESTING_Command *cmd, + struct TALER_TESTING_Interpreter *is) +{ + struct GetUnitsState *gus = cls; + + gus->is = is; + gus->ugh = TALER_MERCHANT_units_get ( + TALER_TESTING_interpreter_get_context (is), + gus->merchant_url, + &get_units_cb, + gus); + if (NULL == gus->ugh) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (is); + } +} + + +/** + * Cleanup. + */ +static void +get_units_cleanup (void *cls, + const struct TALER_TESTING_Command *cmd) +{ + struct GetUnitsState *gus = cls; + + if (NULL != gus->ugh) + { + TALER_MERCHANT_units_get_cancel (gus->ugh); + gus->ugh = NULL; + } + GNUNET_array_grow (gus->references, + gus->references_length, + 0); + GNUNET_free (gus); +} + + +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_get_units (const char *label, + const char *merchant_url, + unsigned int http_status, + ...) +{ + struct GetUnitsState *gus; + va_list ap; + const char *ref; + + gus = GNUNET_new (struct GetUnitsState); + gus->merchant_url = merchant_url; + gus->http_status = http_status; + + va_start (ap, http_status); + while (NULL != (ref = va_arg (ap, const char *))) + { + GNUNET_array_append (gus->references, + gus->references_length, + ref); + } + va_end (ap); + + { + struct TALER_TESTING_Command cmd = { + .cls = gus, + .label = label, + .run = &get_units_run, + .cleanup = &get_units_cleanup + }; + + return cmd; + } +} + + +/* end of testing_api_cmd_get_units.c */ diff --git a/src/testing/testing_api_cmd_lock_product.c b/src/testing/testing_api_cmd_lock_product.c @@ -70,6 +70,17 @@ struct LockProductState uint32_t quantity; /** + * Fractional component of the quantity (units of 1/MERCHANT_UNIT_FRAC_BASE) when + * @e use_fractional_quantity is true. + */ + uint32_t quantity_frac; + + /** + * Set to true if @e quantity_frac should be sent along with @e quantity. + */ + bool use_fractional_quantity; + + /** * Expected HTTP response code. */ unsigned int http_status; @@ -135,15 +146,32 @@ lock_product_run (void *cls, struct LockProductState *pis = cls; pis->is = is; - pis->iph = TALER_MERCHANT_product_lock ( - TALER_TESTING_interpreter_get_context (is), - pis->merchant_url, - pis->product_id, - pis->uuid, - pis->duration, - pis->quantity, - &lock_product_cb, - pis); + if (pis->use_fractional_quantity) + { + pis->iph = TALER_MERCHANT_product_lock2 ( + TALER_TESTING_interpreter_get_context (is), + pis->merchant_url, + pis->product_id, + pis->uuid, + pis->duration, + pis->quantity, + pis->quantity_frac, + true, + &lock_product_cb, + pis); + } + else + { + pis->iph = TALER_MERCHANT_product_lock ( + TALER_TESTING_interpreter_get_context (is), + pis->merchant_url, + pis->product_id, + pis->uuid, + pis->duration, + pis->quantity, + &lock_product_cb, + pis); + } GNUNET_assert (NULL != pis->iph); } @@ -223,6 +251,8 @@ TALER_TESTING_cmd_merchant_lock_product ( sizeof (uuid)); pis->duration = duration; pis->quantity = quantity; + pis->quantity_frac = 0; + pis->use_fractional_quantity = false; { struct TALER_TESTING_Command cmd = { @@ -238,4 +268,31 @@ TALER_TESTING_cmd_merchant_lock_product ( } +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_lock_product2 ( + const char *label, + const char *merchant_url, + const char *product_id, + struct GNUNET_TIME_Relative duration, + uint32_t quantity, + uint32_t quantity_frac, + bool use_fractional_quantity, + unsigned int http_status) +{ + struct LockProductState *pis; + struct TALER_TESTING_Command cmd; + + cmd = TALER_TESTING_cmd_merchant_lock_product (label, + merchant_url, + product_id, + duration, + quantity, + http_status); + pis = cmd.cls; + pis->quantity_frac = quantity_frac; + pis->use_fractional_quantity = use_fractional_quantity; + return cmd; +} + + /* end of testing_api_cmd_lock_product.c */ diff --git a/src/testing/testing_api_cmd_patch_product.c b/src/testing/testing_api_cmd_patch_product.c @@ -26,6 +26,7 @@ #include <taler/taler_testing_lib.h> #include "taler_merchant_service.h" #include "taler_merchant_testing_lib.h" +#include "merchant_api_common.h" /** @@ -90,6 +91,31 @@ struct PatchProductState int64_t total_stock; /** + * Fractional stock component when fractional quantities are enabled. + */ + uint32_t total_stock_frac; + + /** + * Fractional precision level associated with fractional quantities. + */ + uint32_t unit_precision_level; + + /** + * whether fractional quantities are allowed for this product. + */ + bool unit_allow_fraction; + + /** + * Cached string representation of the stock level. + */ + char unit_total_stock[64]; + + /** + * set to true if we should use the extended fractional API. + */ + bool use_fractional; + + /** * in @e units. */ int64_t total_lost; @@ -111,6 +137,85 @@ struct PatchProductState }; +static void +patch_product_update_unit_total_stock (struct PatchProductState *pps) +{ + uint64_t stock; + uint32_t frac; + + if (-1 == pps->total_stock) + { + stock = (uint64_t) INT64_MAX; + frac = (uint32_t) INT32_MAX; + } + else + { + stock = (uint64_t) pps->total_stock; + frac = pps->unit_allow_fraction ? pps->total_stock_frac : 0; + } + TALER_MERCHANT_format_stock_string (stock, + frac, + pps->unit_total_stock, + sizeof (pps->unit_total_stock)); +} + + +static uint32_t +default_precision_from_unit (const char *unit) +{ + struct PrecisionRule + { + const char *unit; + uint32_t precision; + }; + static const struct PrecisionRule rules[] = { + { "WeightUnitMg", 0 }, + { "SizeUnitMm", 0 }, + { "WeightUnitG", 1 }, + { "SizeUnitCm", 1 }, + { "SurfaceUnitMm2", 1 }, + { "VolumeUnitMm3", 1 }, + { "WeightUnitOunce", 2 }, + { "SizeUnitInch", 2 }, + { "SurfaceUnitCm2", 2 }, + { "VolumeUnitOunce", 2 }, + { "VolumeUnitInch3", 2 }, + { "TimeUnitHour", 2 }, + { "TimeUnitMonth", 2 }, + { "WeightUnitTon", 3 }, + { "WeightUnitKg", 3 }, + { "WeightUnitPound", 3 }, + { "SizeUnitM", 3 }, + { "SizeUnitDm", 3 }, + { "SizeUnitFoot", 3 }, + { "SurfaceUnitDm2", 3 }, + { "SurfaceUnitFoot2", 3 }, + { "VolumeUnitCm3", 3 }, + { "VolumeUnitLitre", 3 }, + { "VolumeUnitGallon", 3 }, + { "TimeUnitSecond", 3 }, + { "TimeUnitMinute", 3 }, + { "TimeUnitDay", 3 }, + { "TimeUnitWeek", 3 }, + { "SurfaceUnitM2", 4 }, + { "SurfaceUnitInch2", 4 }, + { "TimeUnitYear", 4 }, + { "VolumeUnitDm3", 5 }, + { "VolumeUnitFoot3", 5 }, + { "VolumeUnitM3", 6 } + }; + + const size_t rules_len = sizeof (rules) / sizeof (rules[0]); + if (NULL == unit) + return 0; + + for (size_t i = 0; i<rules_len; i++) + if (0 == strcmp (unit, + rules[i].unit)) + return rules[i].precision; + return 0; +} + /** * Callback for a PATCH /products/$ID operation. @@ -172,22 +277,50 @@ patch_product_run (void *cls, struct PatchProductState *pis = cls; pis->is = is; - pis->iph = TALER_MERCHANT_product_patch ( - TALER_TESTING_interpreter_get_context (is), - pis->merchant_url, - pis->product_id, - pis->description, - pis->description_i18n, - pis->unit, - &pis->price, - pis->image, - pis->taxes, - pis->total_stock, - pis->total_lost, - pis->address, - pis->next_restock, - &patch_product_cb, - pis); + if (pis->use_fractional) + { + pis->iph = TALER_MERCHANT_product_patch2 ( + TALER_TESTING_interpreter_get_context (is), + pis->merchant_url, + pis->product_id, + pis->description, + pis->description_i18n, + pis->unit, + &pis->price, + 1, + pis->image, + pis->taxes, + pis->total_stock, + pis->total_stock_frac, + pis->unit_allow_fraction, + pis->use_fractional + ? &pis->unit_precision_level + : NULL, + pis->total_lost, + pis->address, + pis->next_restock, + &patch_product_cb, + pis); + } + else + { + pis->iph = TALER_MERCHANT_product_patch ( + TALER_TESTING_interpreter_get_context (is), + pis->merchant_url, + pis->product_id, + pis->description, + pis->description_i18n, + pis->unit, + &pis->price, + pis->image, + pis->taxes, + pis->total_stock, + pis->total_lost, + pis->address, + pis->next_restock, + &patch_product_cb, + pis); + } GNUNET_assert (NULL != pis->iph); } @@ -217,6 +350,12 @@ patch_product_traits (void *cls, TALER_TESTING_make_trait_product_image (pps->image), TALER_TESTING_make_trait_taxes (pps->taxes), TALER_TESTING_make_trait_product_stock (&pps->total_stock), + TALER_TESTING_make_trait_product_unit_total_stock ( + pps->unit_total_stock), + TALER_TESTING_make_trait_product_unit_precision_level ( + &pps->unit_precision_level), + TALER_TESTING_make_trait_product_unit_allow_fraction ( + &pps->unit_allow_fraction), TALER_TESTING_make_trait_address (pps->address), TALER_TESTING_make_trait_timestamp (0, &pps->next_restock), @@ -286,15 +425,20 @@ TALER_TESTING_cmd_merchant_patch_product ( pis->description = description; pis->description_i18n = description_i18n; /* ownership taken */ pis->unit = unit; + pis->unit_precision_level = default_precision_from_unit (unit); GNUNET_assert (GNUNET_OK == TALER_string_to_amount (price, &pis->price)); pis->image = GNUNET_strdup (image); pis->taxes = taxes; /* ownership taken */ pis->total_stock = total_stock; + pis->total_stock_frac = 0; + pis->unit_allow_fraction = false; + pis->use_fractional = false; pis->total_lost = total_lost; pis->address = address; /* ownership taken */ pis->next_restock = next_restock; + patch_product_update_unit_total_stock (pis); { struct TALER_TESTING_Command cmd = { .cls = pis, @@ -309,4 +453,51 @@ TALER_TESTING_cmd_merchant_patch_product ( } +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_patch_product2 ( + const char *label, + const char *merchant_url, + const char *product_id, + const char *description, + json_t *description_i18n, + const char *unit, + const char *price, + const char *image, + json_t *taxes, + int64_t total_stock, + uint32_t total_stock_frac, + bool unit_allow_fraction, + uint64_t total_lost, + json_t *address, + struct GNUNET_TIME_Timestamp next_restock, + unsigned int http_status) +{ + struct TALER_TESTING_Command cmd; + + cmd = TALER_TESTING_cmd_merchant_patch_product (label, + merchant_url, + product_id, + description, + description_i18n, + unit, + price, + image, + taxes, + total_stock, + total_lost, + address, + next_restock, + http_status); + { + struct PatchProductState *pps = cmd.cls; + + pps->total_stock_frac = total_stock_frac; + pps->unit_allow_fraction = unit_allow_fraction; + pps->use_fractional = true; + patch_product_update_unit_total_stock (pps); + } + return cmd; +} + + /* end of testing_api_cmd_patch_product.c */ diff --git a/src/testing/testing_api_cmd_patch_unit.c b/src/testing/testing_api_cmd_patch_unit.c @@ -0,0 +1,278 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 testing_api_cmd_patch_unit.c + * @brief command to test PATCH /private/units/$ID + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_testing_lib.h> +#include "taler_merchant_service.h" +#include "taler_merchant_testing_lib.h" + + +/** + * State for a PATCH /private/units command. + */ +struct PatchUnitState +{ + /** + * In-flight request handle. + */ + struct TALER_MERCHANT_UnitPatchHandle *uph; + + /** + * Interpreter context. + */ + struct TALER_TESTING_Interpreter *is; + + /** + * Merchant backend base URL. + */ + const char *merchant_url; + + /** + * Unit identifier. + */ + const char *unit_id; + + /** + * Optional new long label. + */ + const char *unit_name_long; + + /** + * Optional new short label. + */ + const char *unit_name_short; + + /** + * Optional long label translations. + */ + json_t *unit_name_long_i18n; + + /** + * Optional short label translations. + */ + json_t *unit_name_short_i18n; + + /** + * Whether a new fractional flag was provided. + */ + bool have_unit_allow_fraction; + + /** + * New fractional flag value. + */ + bool unit_allow_fraction; + + /** + * Whether a new precision level was provided. + */ + bool have_unit_precision_level; + + /** + * New precision level. + */ + uint32_t unit_precision_level; + + /** + * Whether a new active flag was provided. + */ + bool have_unit_active; + + /** + * New active flag value. + */ + bool unit_active; + + /** + * Expected HTTP status. + */ + unsigned int http_status; +}; + + +/** + * Completion callback for PATCH /private/units. + */ +static void +patch_unit_cb (void *cls, + const struct TALER_MERCHANT_HttpResponse *hr) +{ + struct PatchUnitState *pus = cls; + + pus->uph = NULL; + if (pus->http_status != hr->http_status) + { + TALER_TESTING_unexpected_status_with_body (pus->is, + hr->http_status, + pus->http_status, + hr->reply); + return; + } + TALER_TESTING_interpreter_next (pus->is); +} + + +/** + * Issue the PATCH request. + */ +static void +patch_unit_run (void *cls, + const struct TALER_TESTING_Command *cmd, + struct TALER_TESTING_Interpreter *is) +{ + struct PatchUnitState *pus = cls; + const bool *allow_ptr = pus->have_unit_allow_fraction + ? &pus->unit_allow_fraction + : NULL; + const uint32_t *precision_ptr = pus->have_unit_precision_level + ? &pus->unit_precision_level + : NULL; + const bool *active_ptr = pus->have_unit_active + ? &pus->unit_active + : NULL; + + pus->is = is; + pus->uph = TALER_MERCHANT_unit_patch ( + TALER_TESTING_interpreter_get_context (is), + pus->merchant_url, + pus->unit_id, + pus->unit_name_long, + pus->unit_name_short, + pus->unit_name_long_i18n, + pus->unit_name_short_i18n, + allow_ptr, + precision_ptr, + active_ptr, + &patch_unit_cb, + pus); + if (NULL == pus->uph) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (is); + } +} + + +/** + * Provide traits to other commands. + */ +static enum GNUNET_GenericReturnValue +patch_unit_traits (void *cls, + const void **ret, + const char *trait, + unsigned int index) +{ + struct PatchUnitState *pus = cls; + struct TALER_TESTING_Trait traits[] = { + TALER_TESTING_make_trait_unit_id (pus->unit_id), + TALER_TESTING_make_trait_unit_name_long (pus->unit_name_long), + TALER_TESTING_make_trait_unit_name_short (pus->unit_name_short), + TALER_TESTING_make_trait_unit_name_long_i18n (pus->unit_name_long_i18n), + TALER_TESTING_make_trait_unit_name_short_i18n (pus->unit_name_short_i18n), + TALER_TESTING_make_trait_unit_allow_fraction ( + pus->have_unit_allow_fraction + ? &pus->unit_allow_fraction + : NULL), + TALER_TESTING_make_trait_unit_precision_level ( + pus->have_unit_precision_level + ? &pus->unit_precision_level + : NULL), + TALER_TESTING_make_trait_unit_active ( + pus->have_unit_active + ? &pus->unit_active + : NULL), + TALER_TESTING_trait_end () + }; + + return TALER_TESTING_get_trait (traits, + ret, + trait, + index); +} + + +/** + * Cleanup. + */ +static void +patch_unit_cleanup (void *cls, + const struct TALER_TESTING_Command *cmd) +{ + struct PatchUnitState *pus = cls; + + if (NULL != pus->uph) + { + TALER_MERCHANT_unit_patch_cancel (pus->uph); + pus->uph = NULL; + } + if (NULL != pus->unit_name_long_i18n) + json_decref (pus->unit_name_long_i18n); + if (NULL != pus->unit_name_short_i18n) + json_decref (pus->unit_name_short_i18n); + GNUNET_free (pus); +} + + +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_patch_unit (const char *label, + const char *merchant_url, + const char *unit_id, + const char *unit_name_long, + const char *unit_name_short, + json_t *unit_name_long_i18n, + json_t *unit_name_short_i18n, + const bool unit_allow_fraction, + const uint32_t unit_precision_level, + const bool unit_active, + unsigned int http_status) +{ + struct PatchUnitState *pus; + + pus = GNUNET_new (struct PatchUnitState); + pus->merchant_url = merchant_url; + pus->unit_id = unit_id; + pus->unit_name_long = unit_name_long; + pus->unit_name_short = unit_name_short; + pus->unit_name_long_i18n = unit_name_long_i18n; + pus->unit_name_short_i18n = unit_name_short_i18n; + pus->unit_allow_fraction = unit_allow_fraction; + pus->have_unit_allow_fraction = true; + pus->unit_precision_level = unit_precision_level; + pus->have_unit_precision_level = true; + pus->unit_active = unit_active; + pus->have_unit_active = true; + pus->http_status = http_status; + { + struct TALER_TESTING_Command cmd = { + .cls = pus, + .label = label, + .run = &patch_unit_run, + .cleanup = &patch_unit_cleanup, + .traits = &patch_unit_traits + }; + + return cmd; + } +} + + +/* end of testing_api_cmd_patch_unit.c */ diff --git a/src/testing/testing_api_cmd_post_orders.c b/src/testing/testing_api_cmd_post_orders.c @@ -28,6 +28,9 @@ #include <gnunet/gnunet_time_lib.h> #include <jansson.h> #include <stdint.h> +#include "taler_merchant_util.h" +#include <stdlib.h> +#include <math.h> #include <taler/taler_exchange_service.h> #include <taler/taler_testing_lib.h> #include "taler_merchant_service.h" @@ -490,6 +493,7 @@ orders_run2 (void *cls, { char *ctok; struct TALER_MERCHANT_InventoryProduct pd; + double quantity_double = 0.0; /* Token syntax is "[product_id]/[quantity]" */ ctok = strchr (token, '/'); @@ -497,17 +501,59 @@ orders_run2 (void *cls, { *ctok = '\0'; ctok++; - if (1 != sscanf (ctok, - "%u", - &pd.quantity)) { - GNUNET_break (0); - break; + char *endptr; + + quantity_double = strtod (ctok, + &endptr); + if ( (endptr == ctok) || ('\0' != *endptr) || + (! isfinite (quantity_double)) || (quantity_double < 0.0)) + { + GNUNET_break (0); + break; + } } } else { - pd.quantity = 1; + quantity_double = 1.0; + } + if (quantity_double <= 0.0) + { + GNUNET_break (0); + break; + } + + { + double quantity_floor; + double frac; + uint64_t quantity_int; + uint32_t quantity_frac_local = 0; + long long scaled; + + quantity_floor = floor (quantity_double); + frac = quantity_double - quantity_floor; + quantity_int = (uint64_t) quantity_floor; + if (frac < 0.0) + { + GNUNET_break (0); + break; + } + scaled = llround (frac * (double) MERCHANT_UNIT_FRAC_BASE); + if (scaled < 0) + { + GNUNET_break (0); + break; + } + if (scaled >= (long long) MERCHANT_UNIT_FRAC_BASE) + { + quantity_int++; + scaled -= MERCHANT_UNIT_FRAC_BASE; + } + quantity_frac_local = (uint32_t) scaled; + pd.quantity = quantity_int; + pd.quantity_frac = quantity_frac_local; + pd.use_fractional_quantity = (0 != quantity_frac_local); } pd.product_id = token; diff --git a/src/testing/testing_api_cmd_post_products.c b/src/testing/testing_api_cmd_post_products.c @@ -26,6 +26,7 @@ #include <taler/taler_testing_lib.h> #include "taler_merchant_service.h" #include "taler_merchant_testing_lib.h" +#include "merchant_api_common.h" /** @@ -90,6 +91,31 @@ struct PostProductsState int64_t total_stock; /** + * Fractional stock component when fractional quantities are enabled. + */ + uint32_t total_stock_frac; + + /** + * Fractional precision level associated with fractional quantities. + */ + uint32_t unit_precision_level; + + /** + * whether fractional quantities are allowed for this product. + */ + bool unit_allow_fraction; + + /** + * Cached string representation of the stock level. + */ + char unit_total_stock[64]; + + /** + * set to true if we should use the extended fractional API. + */ + bool use_fractional; + + /** * where the product is in stock */ json_t *address; @@ -111,6 +137,85 @@ struct PostProductsState }; +static void +post_products_update_unit_total_stock (struct PostProductsState *pps) +{ + uint64_t stock; + uint32_t frac; + + if (-1 == pps->total_stock) + { + stock = (uint64_t) INT64_MAX; + frac = (uint32_t) INT32_MAX; + } + else + { + stock = (uint64_t) pps->total_stock; + frac = pps->unit_allow_fraction ? pps->total_stock_frac : 0; + } + TALER_MERCHANT_format_stock_string (stock, + frac, + pps->unit_total_stock, + sizeof (pps->unit_total_stock)); +} + + +static uint32_t +default_precision_from_unit (const char *unit) +{ + struct PrecisionRule + { + const char *unit; + uint32_t precision; + }; + static const struct PrecisionRule rules[] = { + { "WeightUnitMg", 0 }, + { "SizeUnitMm", 0 }, + { "WeightUnitG", 1 }, + { "SizeUnitCm", 1 }, + { "SurfaceUnitMm2", 1 }, + { "VolumeUnitMm3", 1 }, + { "WeightUnitOunce", 2 }, + { "SizeUnitInch", 2 }, + { "SurfaceUnitCm2", 2 }, + { "VolumeUnitOunce", 2 }, + { "VolumeUnitInch3", 2 }, + { "TimeUnitHour", 2 }, + { "TimeUnitMonth", 2 }, + { "WeightUnitTon", 3 }, + { "WeightUnitKg", 3 }, + { "WeightUnitPound", 3 }, + { "SizeUnitM", 3 }, + { "SizeUnitDm", 3 }, + { "SizeUnitFoot", 3 }, + { "SurfaceUnitDm2", 3 }, + { "SurfaceUnitFoot2", 3 }, + { "VolumeUnitCm3", 3 }, + { "VolumeUnitLitre", 3 }, + { "VolumeUnitGallon", 3 }, + { "TimeUnitSecond", 3 }, + { "TimeUnitMinute", 3 }, + { "TimeUnitDay", 3 }, + { "TimeUnitWeek", 3 }, + { "SurfaceUnitM2", 4 }, + { "SurfaceUnitInch2", 4 }, + { "TimeUnitYear", 4 }, + { "VolumeUnitDm3", 5 }, + { "VolumeUnitFoot3", 5 }, + { "VolumeUnitM3", 6 } + }; + + const size_t rules_len = sizeof (rules) / sizeof (rules[0]); + if (NULL == unit) + return 0; + + for (size_t i = 0; i<rules_len; i++) + if (0 == strcmp (unit, + rules[i].unit)) + return rules[i].precision; + return 0; +} + /** * Callback for a POST /products operation. @@ -173,22 +278,52 @@ post_products_run (void *cls, struct PostProductsState *pis = cls; pis->is = is; - pis->iph = TALER_MERCHANT_products_post2 ( - TALER_TESTING_interpreter_get_context (is), - pis->merchant_url, - pis->product_id, - pis->description, - pis->description_i18n, - pis->unit, - &pis->price, - pis->image, - pis->taxes, - pis->total_stock, - pis->address, - pis->next_restock, - pis->minimum_age, - &post_products_cb, - pis); + if (pis->use_fractional) + { + pis->iph = TALER_MERCHANT_products_post4 ( + TALER_TESTING_interpreter_get_context (is), + pis->merchant_url, + pis->product_id, + pis->description, + pis->description_i18n, + pis->unit, + &pis->price, + 1, + pis->image, + pis->taxes, + pis->total_stock, + pis->total_stock_frac, + pis->unit_allow_fraction, + pis->use_fractional + ? &pis->unit_precision_level + : NULL, + pis->address, + pis->next_restock, + pis->minimum_age, + 0, + NULL, + &post_products_cb, + pis); + } + else + { + pis->iph = TALER_MERCHANT_products_post2 ( + TALER_TESTING_interpreter_get_context (is), + pis->merchant_url, + pis->product_id, + pis->description, + pis->description_i18n, + pis->unit, + &pis->price, + pis->image, + pis->taxes, + pis->total_stock, + pis->address, + pis->next_restock, + pis->minimum_age, + &post_products_cb, + pis); + } GNUNET_assert (NULL != pis->iph); } @@ -218,6 +353,12 @@ post_products_traits (void *cls, TALER_TESTING_make_trait_product_image (pps->image), TALER_TESTING_make_trait_taxes (pps->taxes), TALER_TESTING_make_trait_product_stock (&pps->total_stock), + TALER_TESTING_make_trait_product_unit_total_stock ( + pps->unit_total_stock), + TALER_TESTING_make_trait_product_unit_precision_level ( + &pps->unit_precision_level), + TALER_TESTING_make_trait_product_unit_allow_fraction ( + &pps->unit_allow_fraction), TALER_TESTING_make_trait_address (pps->address), TALER_TESTING_make_trait_timestamp (0, &pps->next_restock), @@ -289,15 +430,20 @@ TALER_TESTING_cmd_merchant_post_products2 ( pis->description = description; pis->description_i18n = description_i18n; /* ownership taken */ pis->unit = unit; + pis->unit_precision_level = default_precision_from_unit (unit); GNUNET_assert (GNUNET_OK == TALER_string_to_amount (price, &pis->price)); pis->image = GNUNET_strdup (image); pis->taxes = taxes; /* ownership taken */ pis->total_stock = total_stock; + pis->total_stock_frac = 0; + pis->unit_allow_fraction = false; + pis->use_fractional = false; pis->minimum_age = minimum_age; pis->address = address; /* ownership taken */ pis->next_restock = next_restock; + post_products_update_unit_total_stock (pis); { struct TALER_TESTING_Command cmd = { .cls = pis, @@ -313,6 +459,53 @@ TALER_TESTING_cmd_merchant_post_products2 ( struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_post_products3 ( + const char *label, + const char *merchant_url, + const char *product_id, + const char *description, + json_t *description_i18n, + const char *unit, + const char *price, + const char *image, + json_t *taxes, + int64_t total_stock, + uint32_t total_stock_frac, + bool unit_allow_fraction, + uint32_t minimum_age, + json_t *address, + struct GNUNET_TIME_Timestamp next_restock, + unsigned int http_status) +{ + struct TALER_TESTING_Command cmd; + + cmd = TALER_TESTING_cmd_merchant_post_products2 (label, + merchant_url, + product_id, + description, + description_i18n, + unit, + price, + image, + taxes, + total_stock, + minimum_age, + address, + next_restock, + http_status); + { + struct PostProductsState *pis = cmd.cls; + + pis->total_stock_frac = total_stock_frac; + pis->unit_allow_fraction = unit_allow_fraction; + pis->use_fractional = true; + post_products_update_unit_total_stock (pis); + } + return cmd; +} + + +struct TALER_TESTING_Command TALER_TESTING_cmd_merchant_post_products ( const char *label, const char *merchant_url, diff --git a/src/testing/testing_api_cmd_post_units.c b/src/testing/testing_api_cmd_post_units.c @@ -0,0 +1,242 @@ +/* + This file is part of TALER + Copyright (C) 2025 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 testing_api_cmd_post_units.c + * @brief command to test POST /private/units + * @author Bohdan Potuzhnyi + */ +#include "platform.h" +#include <taler/taler_testing_lib.h> +#include "taler_merchant_service.h" +#include "taler_merchant_testing_lib.h" + + +/** + * State for a POST /private/units command. + */ +struct PostUnitState +{ + /** + * In-flight request handle. + */ + struct TALER_MERCHANT_UnitsPostHandle *uph; + + /** + * Interpreter context. + */ + struct TALER_TESTING_Interpreter *is; + + /** + * Merchant backend base URL. + */ + const char *merchant_url; + + /** + * Unit identifier. + */ + const char *unit_id; + + /** + * Long label. + */ + const char *unit_name_long; + + /** + * Short label. + */ + const char *unit_name_short; + + /** + * Optional translations (reference counted). + */ + json_t *unit_name_long_i18n; + + /** + * Optional translations (reference counted). + */ + json_t *unit_name_short_i18n; + + /** + * Whether fractional quantities are allowed. + */ + bool unit_allow_fraction; + + /** + * Precision level for fractional quantities. + */ + uint32_t unit_precision_level; + + /** + * Whether the unit should be active. + */ + bool unit_active; + + /** + * Expected HTTP status. + */ + unsigned int http_status; +}; + + +/** + * Completion callback for POST /private/units. + */ +static void +post_unit_cb (void *cls, + const struct TALER_MERCHANT_HttpResponse *hr) +{ + struct PostUnitState *pus = cls; + + pus->uph = NULL; + if (pus->http_status != hr->http_status) + { + TALER_TESTING_unexpected_status_with_body (pus->is, + hr->http_status, + pus->http_status, + hr->reply); + return; + } + TALER_TESTING_interpreter_next (pus->is); +} + + +/** + * Issue the POST /private/units request. + */ +static void +post_unit_run (void *cls, + const struct TALER_TESTING_Command *cmd, + struct TALER_TESTING_Interpreter *is) +{ + struct PostUnitState *pus = cls; + + pus->is = is; + pus->uph = TALER_MERCHANT_units_post ( + TALER_TESTING_interpreter_get_context (is), + pus->merchant_url, + pus->unit_id, + pus->unit_name_long, + pus->unit_name_short, + pus->unit_allow_fraction, + pus->unit_precision_level, + pus->unit_active, + pus->unit_name_long_i18n, + pus->unit_name_short_i18n, + &post_unit_cb, + pus); + if (NULL == pus->uph) + { + GNUNET_break (0); + TALER_TESTING_interpreter_fail (is); + } +} + + +/** + * Provide traits for other commands. + */ +static enum GNUNET_GenericReturnValue +post_unit_traits (void *cls, + const void **ret, + const char *trait, + unsigned int index) +{ + struct PostUnitState *pus = cls; + struct TALER_TESTING_Trait traits[] = { + TALER_TESTING_make_trait_unit_id (pus->unit_id), + TALER_TESTING_make_trait_unit_name_long (pus->unit_name_long), + TALER_TESTING_make_trait_unit_name_short (pus->unit_name_short), + TALER_TESTING_make_trait_unit_allow_fraction (&pus->unit_allow_fraction), + TALER_TESTING_make_trait_unit_precision_level (&pus->unit_precision_level), + TALER_TESTING_make_trait_unit_active (&pus->unit_active), + TALER_TESTING_make_trait_unit_name_long_i18n (pus->unit_name_long_i18n), + TALER_TESTING_make_trait_unit_name_short_i18n (pus->unit_name_short_i18n), + TALER_TESTING_trait_end () + }; + + return TALER_TESTING_get_trait (traits, + ret, + trait, + index); +} + + +/** + * Cleanup / cancel pending request. + */ +static void +post_unit_cleanup (void *cls, + const struct TALER_TESTING_Command *cmd) +{ + struct PostUnitState *pus = cls; + + if (NULL != pus->uph) + { + TALER_MERCHANT_units_post_cancel (pus->uph); + pus->uph = NULL; + } + if (NULL != pus->unit_name_long_i18n) + json_decref (pus->unit_name_long_i18n); + if (NULL != pus->unit_name_short_i18n) + json_decref (pus->unit_name_short_i18n); + GNUNET_free (pus); +} + + +struct TALER_TESTING_Command +TALER_TESTING_cmd_merchant_post_units (const char *label, + const char *merchant_url, + const char *unit_id, + const char *unit_name_long, + const char *unit_name_short, + bool unit_allow_fraction, + uint32_t unit_precision_level, + bool unit_active, + json_t *unit_name_long_i18n, + json_t *unit_name_short_i18n, + unsigned int http_status) +{ + struct PostUnitState *pus; + + pus = GNUNET_new (struct PostUnitState); + pus->merchant_url = merchant_url; + pus->unit_id = unit_id; + pus->unit_name_long = unit_name_long; + pus->unit_name_short = unit_name_short; + pus->unit_allow_fraction = unit_allow_fraction; + pus->unit_precision_level = unit_precision_level; + pus->unit_active = unit_active; + pus->unit_name_long_i18n = unit_name_long_i18n; + pus->unit_name_short_i18n = unit_name_short_i18n; + pus->http_status = http_status; + { + struct TALER_TESTING_Command cmd = { + .cls = pus, + .label = label, + .run = &post_unit_run, + .cleanup = &post_unit_cleanup, + .traits = &post_unit_traits + }; + + return cmd; + } +} + + +/* end of testing_api_cmd_post_units.c */