merchant

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

commit c4ed28a2e3c4539d284999201bbdb1b9ef34b5a4
parent 03b6d9a18feec688d7cfd4146eb821c04aaddac6
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Mon, 20 Jul 2026 09:28:34 +0200

[mixed payments] initial version

Diffstat:
Mmeson.build | 8++++----
Msrc/backend/meson.build | 2++
Msrc/backend/taler-merchant-httpd_delete-private-orders-ORDER_ID.c | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_dispatcher.c | 26++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_get-config.c | 2+-
Msrc/backend/taler-merchant-httpd_get-private-orders-ORDER_ID.c | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_helper.c | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_helper.h | 34+++++++++++++++++++++++++++-------
Msrc/backend/taler-merchant-httpd_post-orders-ORDER_ID-claim.c | 37+++++++++++--------------------------
Msrc/backend/taler-merchant-httpd_post-orders-ORDER_ID-claim.h | 27+++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_post-orders-ORDER_ID-pay.c | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-collect.c | 432+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-collect.h | 45+++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c | 513+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.h | 46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund.c | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Asrc/backenddb/get_external_refund.c | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/get_external_refunds_total.c | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/insert_external_refund.c | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/backenddb/iterate_external_refunds.c | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/meson.build | 4++++
Asrc/backenddb/sql-schema/merchant-0046.sql | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/sql-schema/meson.build | 1+
Msrc/include/merchant-database/all.h | 4++++
Asrc/include/merchant-database/get_external_refund.h | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/get_external_refunds_total.h | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/insert_external_refund.h | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/merchant-database/iterate_external_refunds.h | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/taler/merchant/get-private-orders-ORDER_ID.h | 8++++++++
Msrc/include/taler/merchant/meson.build | 2++
Asrc/include/taler/merchant/post-private-orders-ORDER_ID-collect.h | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/include/taler/merchant/post-private-orders-ORDER_ID-refund-external.h | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/taler/taler_merchant_service.h | 2++
Msrc/include/taler/taler_merchant_testing_lib.h | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/include/taler/taler_merchant_util.h | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/merchant_api_get-config.c | 4++--
Msrc/lib/merchant_api_get-private-orders-ORDER_ID.c | 5+++++
Asrc/lib/merchant_api_post-private-orders-ORDER_ID-collect.c | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/merchant_api_post-private-orders-ORDER_ID-refund-external.c | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/meson.build | 2++
Msrc/testing/meson.build | 3+++
Msrc/testing/test_merchant_api.c | 395+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/test_merchant_instance_response.sh | 2+-
Msrc/testing/test_merchant_order_creation.sh | 2+-
Msrc/testing/test_merchant_transfer_tracking.sh | 2+-
Asrc/testing/testing_api_cmd_check_order_external.c | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/testing/testing_api_cmd_collect_order.c | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/testing/testing_api_cmd_delete_order.c | 26++++++++++++++++++++++++++
Msrc/testing/testing_api_cmd_post_orders.c | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/testing/testing_api_cmd_refund_external.c | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/util/base_terms_parse.c | 9+++++++++
Msrc/util/base_terms_serialize.c | 3+++
Msrc/util/contract_parse.c | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/util/meson.build | 3++-
Msrc/util/order_parse.c | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/util/test_contract.c | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/util/util.c | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
57 files changed, 4969 insertions(+), 52 deletions(-)

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