merchant

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

commit 9617dc0e1e8de0550d59c197e822c7e7e5709bae
parent a5919709416909e9c193489cfca56013fa3958f2
Author: Christian Grothoff <grothoff@gnunet.org>
Date:   Fri, 23 Jan 2026 23:38:25 +0900

misc minor bugfixes and first test for paivana templates

Diffstat:
Msrc/backend/taler-merchant-httpd_private-get-orders-ID.c | 10++++++++++
Msrc/include/taler_merchant_util.h | 9+++++----
Msrc/testing/Makefile.am | 9+++++----
Msrc/testing/test_merchant_order_creation.sh | 4+++-
Asrc/testing/test_merchant_templates.sh | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/util/contract_parse.c | 24++++++++++++++++--------
Msrc/util/template_parse.c | 46+++++++++++++++++++++++++++++++---------------
7 files changed, 305 insertions(+), 32 deletions(-)

diff --git a/src/backend/taler-merchant-httpd_private-get-orders-ID.c b/src/backend/taler-merchant-httpd_private-get-orders-ID.c @@ -904,6 +904,16 @@ phase_check_repurchase (struct GetOrderRequestContext *gorc) GNUNET_log (GNUNET_ERROR_TYPE_DEBUG, "Found already paid order %s\n", already_paid_order_id); + // FIXME (discuss with Florian): it is a bit wild that we use the session ID of the *client's request* \ + // (which we usually use to check that the right session got paid!) here to + // construct the payment URI when we actually _also_ have a session_id in the + // orders table! CG thinks we probably should change this and *ignore* the + // client's session ID and when building the taler://pay/ URI only use the + // session ID of the order's table (is that one even used right now?). + // Right now, test_merchant_templates.sh has to pass the PAIVANA_ID when + // calling this endpoint, otherwise the taler://pay/-URI would have no + // session ID, despite the templating mechanism having set one explicitly + // when instantiating the order. taler_pay_uri = TMH_make_taler_pay_uri (gorc->sc.con, hc->infix, gorc->session_id, diff --git a/src/include/taler_merchant_util.h b/src/include/taler_merchant_util.h @@ -271,10 +271,6 @@ struct TALER_MERCHANT_TemplateContractInventory */ bool choose_one; - /** - * Template allows tips. - */ - bool request_tip; }; /** @@ -333,6 +329,11 @@ struct TALER_MERCHANT_TemplateContract bool no_amount; /** + * Template allows tips. + */ + bool request_tip; + + /** * Minimum age required by the template. */ uint32_t minimum_age; diff --git a/src/testing/Makefile.am b/src/testing/Makefile.am @@ -12,14 +12,15 @@ check_SCRIPTS = \ test_merchant_instance_creation.sh \ test_merchant_instance_response.sh \ test_merchant_instance_purge.sh \ - test_merchant_product_creation.sh \ - test_merchant_order_creation.sh \ test_merchant_kyc.sh \ + test_merchant_order_creation.sh \ test_merchant_order_autocleanup.sh \ + test_merchant_product_creation.sh \ test_merchant_statistics.sh \ - test_merchant_wirewatch.sh \ + test_merchant_templates.sh \ + test_merchant_transfer_tracking.sh \ test-merchant-walletharness.sh \ - test_merchant_transfer_tracking.sh + test_merchant_wirewatch.sh lib_LTLIBRARIES = \ libtalermerchanttesting.la diff --git a/src/testing/test_merchant_order_creation.sh b/src/testing/test_merchant_order_creation.sh @@ -374,12 +374,14 @@ fi QUANTITY=$(jq -r .contract_terms.products[0].quantity < "$LAST_RESPONSE") if [ "$QUANTITY" != "1" ] then + cat < "$LAST_RESPONSE" >&2 exit_fail "Expected quantity 1. got: $QUANTITY" fi IMAGE=$(jq -r .contract_terms.products[0].image < "$LAST_RESPONSE") if [ "$IMAGE" != "$RANDOM_IMG" ] then + cat "$LAST_RESPONSE" >&2 exit_fail "Expected $RANDOM_IMG but got something else: $IMAGE" fi echo "OK" @@ -472,7 +474,7 @@ STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}" \ if [ "$STATUS" != "200" ] then jq . < "$LAST_RESPONSE" - exit_fail "Expected 200, getting order info before claming it. got: $STATUS" + exit_fail "Expected 200, getting order info. got: $STATUS" fi PAY_URL=$(jq -e -r .taler_pay_uri < "$LAST_RESPONSE") diff --git a/src/testing/test_merchant_templates.sh b/src/testing/test_merchant_templates.sh @@ -0,0 +1,235 @@ +#!/bin/bash +# This file is in the public domain. + +set -eu + +function clean_wallet() { + rm -f "${WALLET_DB}" + exit_cleanup +} + + +# Replace with 0 for nexus... +USE_FAKEBANK=1 +if [ 1 = "$USE_FAKEBANK" ] +then + ACCOUNT="exchange-account-2" + BANK_FLAGS="-f -d x-taler-bank -u $ACCOUNT" + BANK_URL="http://localhost:8082/" +else + ACCOUNT="exchange-account-1" + BANK_FLAGS="-ns -d iban -u $ACCOUNT" + BANK_URL="http://localhost:18082/" + echo -n "Testing for libeufin-bank" + libeufin-bank --help >/dev/null </dev/null || exit_skip " MISSING" + echo " FOUND" + +fi + +. setup.sh + +echo -n "Testing for taler-harness" +taler-harness --help >/dev/null </dev/null || exit_skip " MISSING" +echo " FOUND" + +# Launch exchange, merchant and bank. +setup -c "test_template.conf" \ + -r "merchant-exchange-default" \ + -em \ + $BANK_FLAGS +LAST_RESPONSE=$(mktemp -p "${TMPDIR:-/tmp}" test_response.conf-XXXXXX) +CONF="test_template.conf.edited" +WALLET_DB=$(mktemp -p "${TMPDIR:-/tmp}" test_wallet.json-XXXXXX) +EXCHANGE_URL="http://localhost:8081/" + +# Install cleanup handler (except for kill -9) +trap clean_wallet EXIT + +echo -n "First prepare wallet with coins ..." +rm -f "$WALLET_DB" +taler-wallet-cli \ + --no-throttle \ + --wallet-db="$WALLET_DB" \ + api \ + --expect-success 'withdrawTestBalance' \ + "$(jq -n ' + { + amount: "TESTKUDOS:99", + corebankApiBaseUrl: $BANK_URL, + exchangeBaseUrl: $EXCHANGE_URL + }' \ + --arg BANK_URL "${BANK_URL}" \ + --arg EXCHANGE_URL "$EXCHANGE_URL" + )" 2>wallet-withdraw-1.err >wallet-withdraw-1.out +echo -n "." +# FIXME-MS: add logic to have nexus check immediately here. +# sleep 10 +echo -n "." +# NOTE: once libeufin can do long-polling, we should +# be able to reduce the delay here and run wirewatch +# always in the background via setup +taler-exchange-wirewatch \ + -a "$ACCOUNT" \ + -L "INFO" \ + -c "$CONF" \ + -t &> taler-exchange-wirewatch.out +echo -n "." +taler-wallet-cli \ + --wallet-db="$WALLET_DB" \ + run-until-done \ + 2>wallet-withdraw-finish-1.err \ + >wallet-withdraw-finish-1.out +echo " OK" + +CURRENCY_COUNT=$(taler-wallet-cli --wallet-db="$WALLET_DB" balance | jq '.balances|length') +if [ "$CURRENCY_COUNT" = "0" ] +then + exit_fail "Expected least one currency, withdrawal failed. check log." +fi + +# +# CREATE INSTANCE FOR TESTING +# + +echo -n "Configuring merchant instance ..." + +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + -H 'Authorization: Bearer secret-token:super_secret' \ + http://localhost:9966/management/instances \ + -d '{"auth":{"method":"external"},"id":"admin","name":"default","user_type":"business","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us" : 50000000000},"default_pay_delay":{"d_us": 60000000000}}' \ + -w "%{http_code}" -s -o /dev/null) + +if [ "$STATUS" != "204" ] +then + exit_fail "Expected '204 No content' response. Got instead $STATUS" +fi +echo "Ok" + +echo -n "Configuring merchant bank account ..." + +if [ 1 = "$USE_FAKEBANK" ] +then + FORTYTHREE="payto://x-taler-bank/localhost/fortythree?receiver-name=fortythree" +else + FORTYTHREE=$(get_payto_uri fortythree x) +fi +# add bank account address +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + -H 'Authorization: Bearer secret-token:super_secret' \ + http://localhost:9966/private/accounts \ + -d '{"payto_uri":"'"$FORTYTHREE"'"}' \ + -w "%{http_code}" -s -o /dev/null) + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected '200 OK' response. Got instead $STATUS" +fi +echo "Ok" + + +echo -n "Creating Paivana template..." +TID="paivana" +STATUS=$(curl 'http://localhost:9966/private/templates' \ + -d '{"template_id":"paivana","template_description":"A Paivana template","template_contract":{"template_type":"paivana","summary":"The summary","choices":[{"amount":"TESTKUDOS:1"}]}}' \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + +if [ "$STATUS" != "204" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 204, template created. got: $STATUS" +fi + +echo "Checking template data ..." +STATUS=$(curl http://localhost:9966/templates/"$TID" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + +AMOUNT=$(jq -r .template_contract.choices[0].amount < "$LAST_RESPONSE") +if [ "$AMOUNT" != "TESTKUDOS:1" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected TESTKUDOS:1. Got: $AMOUNT" +fi +echo " OK" + +echo -n "Creating order using template..." +PAIVANA_ID="4321-4ZAXW7M5WW09QNZDF4MRAXBKS1321QQEBFVSWD68FCXHXS1G04A4ZAXW7M5WW09QNZDF4MRAXBKS1321QQEBFVSWD68FCXHXS1G04A0" +STATUS=$(curl 'http://localhost:9966/templates/'"$TID" \ + -d '{"template_type":"paivana","tip":"TESTKUDOS:0.1","website":"https://example.com/","paivana_id":"'"${PAIVANA_ID}"'"}' \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 200, order created. got: $STATUS" +fi + +ORDER_ID=$(jq -r .order_id < "$LAST_RESPONSE") +TOKEN=$(jq -r .token < "$LAST_RESPONSE") + +if [ "$TOKEN" == "null" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "token should not be null, got: $TOKEN" +fi + +echo "OK" + +echo -n "Fetching order details ..." +STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}?session_id=${PAIVANA_ID}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 200, getting order info. got: $STATUS" +fi + +PRICE=$(jq -r .total_amount < "$LAST_RESPONSE") +if [ "$PRICE" != "TESTKUDOS:1.1" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected TESTKUDOS:1.1 (with tip) but got: $PRICE" +fi + +PAY_URL=$(jq -e -r .taler_pay_uri < "$LAST_RESPONSE") +echo "OK: $PAY_URL" + + +NOW=$(date +%s) + +echo -n "Pay first order ${PAY_URL} ..." +# echo "0" to tell wallet to use choice #0 +echo "0" | \ + taler-wallet-cli \ + --no-throttle \ + --wallet-db="$WALLET_DB" \ + handle-uri "${PAY_URL}" \ + -y 2> wallet-pay1.err > wallet-pay1.log + +taler-wallet-cli \ + --no-throttle \ + --wallet-db="$WALLET_DB" \ + run-until-done 2> wallet-finish-pay1.err > wallet-finish-pay1.log +NOW2=$(date +%s) +echo " OK (took $(( NOW2 - NOW )) secs )" + +# Check payment status AND binding to session ID. +STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}?session_id=${PAIVANA_ID}" \ + -w "%{http_code}" -s -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 200, after pay. got: $STATUS" +fi + +ORDER_STATUS=$(jq -r .order_status < "$LAST_RESPONSE") + +if [ "$ORDER_STATUS" != "paid" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Order status should be 'paid'. got: $ORDER_STATUS" +fi + +echo "TEST PASSED" +exit 0 diff --git a/src/util/contract_parse.c b/src/util/contract_parse.c @@ -375,8 +375,8 @@ parse_choices ( for (unsigned int i = 0; i < *choices_len; i++) { struct TALER_MERCHANT_ContractChoice *choice = &(*choices)[i]; - const json_t *jinputs; - const json_t *joutputs; + const json_t *jinputs = NULL; + const json_t *joutputs = NULL; struct GNUNET_JSON_Specification spec[] = { TALER_JSON_spec_amount_any ("amount", &choice->amount), @@ -388,12 +388,18 @@ parse_choices ( GNUNET_JSON_spec_object_copy ("description_i18n", &choice->description_i18n), NULL), - TALER_JSON_spec_amount_any ("max_fee", - &choice->max_fee), - GNUNET_JSON_spec_array_const ("inputs", - &jinputs), - GNUNET_JSON_spec_array_const ("outputs", - &joutputs), + GNUNET_JSON_spec_mark_optional ( + TALER_JSON_spec_amount_any ("max_fee", + &choice->max_fee), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_array_const ("inputs", + &jinputs), + NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_array_const ("outputs", + &joutputs), + NULL), GNUNET_JSON_spec_end () }; const char *ename; @@ -414,6 +420,7 @@ parse_choices ( return GNUNET_SYSERR; } + if (NULL != jinputs) { const json_t *jinput; size_t idx; @@ -450,6 +457,7 @@ parse_choices ( } } + if (NULL != joutputs) { const json_t *joutput; size_t idx; diff --git a/src/util/template_parse.c b/src/util/template_parse.c @@ -100,10 +100,6 @@ parse_template_inventory (const json_t *template_contract, { struct GNUNET_JSON_Specification spec[] = { GNUNET_JSON_spec_mark_optional ( - GNUNET_JSON_spec_bool ("request_tip", - &out->details.inventory.request_tip), - NULL), - GNUNET_JSON_spec_mark_optional ( GNUNET_JSON_spec_bool ("selected_all", &out->details.inventory.selected_all), NULL), @@ -122,16 +118,19 @@ parse_template_inventory (const json_t *template_contract, NULL), GNUNET_JSON_spec_end () }; + const char *en; if (GNUNET_OK != GNUNET_JSON_parse ((json_t *) template_contract, spec, - error_name, + &en, NULL)) { GNUNET_log (GNUNET_ERROR_TYPE_WARNING, "Invalid inventory template_contract for field %s\n", - *error_name); + en); + if (NULL != error_name) + *error_name = en; return GNUNET_SYSERR; } @@ -148,7 +147,8 @@ parse_template_inventory (const json_t *template_contract, (0 > json_integer_value (entry)) ) { GNUNET_break_op (0); - *error_name = "selected_categories"; + if (NULL != error_name) + *error_name = "selected_categories"; return GNUNET_SYSERR; } } @@ -166,7 +166,8 @@ parse_template_inventory (const json_t *template_contract, if (! json_is_string (entry)) { GNUNET_break_op (0); - *error_name = "selected_products"; + if (NULL != error_name) + *error_name = "selected_products"; return GNUNET_SYSERR; } } @@ -198,16 +199,19 @@ parse_template_paivana (const json_t *template_contract, &out->details.paivana.choices_len), GNUNET_JSON_spec_end () }; + const char *en; if (GNUNET_OK != GNUNET_JSON_parse ((json_t *) template_contract, spec, - error_name, + &en, NULL)) { GNUNET_log (GNUNET_ERROR_TYPE_WARNING, "Invalid paivana template_contract for field %s\n", - *error_name); + en); + if (NULL != error_name) + *error_name = en; return GNUNET_SYSERR; } if (NULL != out->details.paivana.website_regex) @@ -221,6 +225,8 @@ parse_template_paivana (const json_t *template_contract, GNUNET_break_op (0); GNUNET_log (GNUNET_ERROR_TYPE_WARNING, "Invalid paivana website_regex given\n"); + if (NULL != error_name) + *error_name = "Invalid website_regex given"; return GNUNET_SYSERR; } regfree (&ex); @@ -261,24 +267,32 @@ TALER_MERCHANT_template_contract_parse ( GNUNET_JSON_spec_relative_time ("pay_duration", &out->pay_duration), NULL), + GNUNET_JSON_spec_mark_optional ( + GNUNET_JSON_spec_bool ("request_tip", + &out->request_tip), + NULL), GNUNET_JSON_spec_end () }; + const char *en; if (NULL == template_contract) { - *error_name = "template_contract is NULL"; + if (NULL != error_name) + *error_name = "template_contract is NULL"; return GNUNET_SYSERR; } if (GNUNET_OK != GNUNET_JSON_parse ((json_t *) template_contract, spec, - error_name, + &en, NULL)) { GNUNET_log (GNUNET_ERROR_TYPE_WARNING, "Invalid input for field %s\n", - *error_name); + en); + if (NULL != error_name) + *error_name = en; return GNUNET_SYSERR; } @@ -288,7 +302,8 @@ TALER_MERCHANT_template_contract_parse ( GNUNET_log (GNUNET_ERROR_TYPE_WARNING, "Invalid template_type used '%s'\n", template_type_str); - *error_name = "Invalid template_type used"; + if (NULL != error_name) + *error_name = "Invalid template_type used"; return GNUNET_SYSERR; } @@ -311,7 +326,8 @@ TALER_MERCHANT_template_contract_parse ( /* I think we are never supposed to reach it */ GNUNET_break_op (0); - *error_name = "template_type"; + if (NULL != error_name) + *error_name = "template_type"; return GNUNET_SYSERR; }