merchant

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

commit 7d22e626f9e67a4f6ca0102f45d0b0c01a907c60
parent 324ca09045c85b352f1b441f31d5e53e9113a6f4
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sun, 22 Feb 2026 19:40:09 +0100

fix #10176 and #11121

Diffstat:
Msrc/backend/taler-merchant-httpd_private-post-transfers.c | 132+++++++++++++++++++++++++++++++++++--------------------------------------------
Msrc/backend/taler-merchant-wirewatch.c | 45+++++++++++++++++++++++++++++++++------------
Msrc/backenddb/pg_insert_transfer.c | 51++++++++++++++++++++++++---------------------------
Msrc/backenddb/pg_insert_transfer.h | 11++++++++++-
Asrc/backenddb/pg_insert_transfer.sql | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/procedures.sql.in | 1+
Msrc/include/taler_merchantdb_plugin.h | 11++++++++++-
7 files changed, 259 insertions(+), 114 deletions(-)

diff --git a/src/backend/taler-merchant-httpd_private-post-transfers.c b/src/backend/taler-merchant-httpd_private-post-transfers.c @@ -1,6 +1,6 @@ /* This file is part of TALER - (C) 2014-2023, 2025 Taler Systems SA + (C) 2014-2023, 2025, 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 @@ -57,6 +57,9 @@ TMH_private_post_transfers (const struct TMH_RequestHandler *rh, }; enum GNUNET_GenericReturnValue res; enum GNUNET_DB_QueryStatus qs; + bool no_instance; + bool no_account; + bool conflict; res = TALER_MHD_parse_json_data (connection, hc->request_body, @@ -72,81 +75,64 @@ TMH_private_post_transfers (const struct TMH_RequestHandler *rh, exchange_url); /* Check if transfer data is in database, if not, add it. */ - for (unsigned int retry = 0; retry<MAX_RETRIES; retry++) + qs = TMH_db->insert_transfer (TMH_db->cls, + hc->instance->settings.id, + exchange_url, + &wtid, + &amount, + payto_uri, + 0 /* no bank serial known! */, + &no_instance, + &no_account, + &conflict); + switch (qs) { - TMH_db->preflight (TMH_db->cls); - if (GNUNET_OK != - TMH_db->start (TMH_db->cls, - "post-transfers")) - { - GNUNET_break (0); - return TALER_MHD_reply_with_error (connection, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_START_FAILED, - "transfer"); - } - qs = TMH_db->insert_transfer (TMH_db->cls, - hc->instance->settings.id, - exchange_url, - &wtid, - &amount, - payto_uri, - 0 /* no bank serial known! */); - switch (qs) - { - case GNUNET_DB_STATUS_HARD_ERROR: - GNUNET_break (0); - TMH_db->rollback (TMH_db->cls); - return TALER_MHD_reply_with_error (connection, - MHD_HTTP_INTERNAL_SERVER_ERROR, - TALER_EC_GENERIC_DB_STORE_FAILED, - "insert_transfer"); - case GNUNET_DB_STATUS_SOFT_ERROR: - TMH_db->rollback (TMH_db->cls); - continue; - case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: - /* Must mean the bank account is unknown! */ - TMH_db->rollback (TMH_db->cls); - return TALER_MHD_reply_with_error ( - connection, - MHD_HTTP_CONFLICT, - TALER_EC_MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_SUBMISSION, - payto_uri.full_payto); - case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: - break; - } - { - struct GNUNET_DB_EventHeaderP es = { - .size = htons (sizeof (es)), - .type = htons (TALER_DBEVENT_MERCHANT_WIRE_TRANSFER_CONFIRMED) - }; - - TMH_db->event_notify (TMH_db->cls, - &es, - NULL, - 0); - } - qs = TMH_db->commit (TMH_db->cls); - switch (qs) - { - case GNUNET_DB_STATUS_HARD_ERROR: - GNUNET_break (0); - TMH_db->rollback (TMH_db->cls); - 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: - TMH_db->rollback (TMH_db->cls); - continue; - case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: - case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: - GNUNET_log (GNUNET_ERROR_TYPE_INFO, - "post-transfer committed successfully\n"); - break; - } + case GNUNET_DB_STATUS_HARD_ERROR: + GNUNET_break (0); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "insert_transfer"); + case GNUNET_DB_STATUS_SOFT_ERROR: + GNUNET_break (0); + return TALER_MHD_reply_with_error (connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + TALER_EC_GENERIC_DB_STORE_FAILED, + "insert_transfer"); + case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: + GNUNET_assert (0); /* should be impossible */ + case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: break; } + if (no_instance) + { + /* should be only possible if instance was concurrently deleted, + that's so theoretical we rather log as error... */ + GNUNET_break (0); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_NOT_FOUND, + TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN, + hc->instance->settings.id); + } + if (no_account) + { + GNUNET_break_op (0); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_GENERIC_ACCOUNT_UNKNOWN, + payto_uri.full_payto); + } + if (conflict) + { + GNUNET_break_op (0); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_CONFLICT, + TALER_EC_MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_SUBMISSION, + NULL); + } return TALER_MHD_reply_static (connection, MHD_HTTP_NO_CONTENT, NULL, diff --git a/src/backend/taler-merchant-wirewatch.c b/src/backend/taler-merchant-wirewatch.c @@ -29,6 +29,7 @@ #include "taler_merchantdb_lib.h" #include "taler_merchantdb_plugin.h" + /** * Timeout for the bank interaction. Rather long as we should do long-polling * and do not want to wake up too often. @@ -353,6 +354,9 @@ credit_cb ( enum GNUNET_DB_QueryStatus qs; char *exchange_url; struct TALER_WireTransferIdentifierRawP wtid; + bool no_instance; + bool no_account; + bool conflict; GNUNET_log (GNUNET_ERROR_TYPE_INFO, "Received wire transfer `%s' over %s\n", @@ -379,7 +383,11 @@ credit_cb ( &wtid, &details->amount, details->credit_account_uri, - serial_id); + serial_id, + &no_instance, + &no_account, + &conflict); + GNUNET_free (exchange_url); if (qs < 0) { @@ -388,19 +396,32 @@ credit_cb ( w->hh = NULL; return GNUNET_SYSERR; } - /* Success => reset back-off timer! */ - w->delay = GNUNET_TIME_UNIT_ZERO; + if (no_instance) + { + GNUNET_break (0); + GNUNET_SCHEDULER_shutdown (); + w->hh = NULL; + return GNUNET_SYSERR; + } + if (no_account) + { + GNUNET_break (0); + GNUNET_SCHEDULER_shutdown (); + w->hh = NULL; + return GNUNET_SYSERR; + } + if (conflict) { - struct GNUNET_DB_EventHeaderP es = { - .size = htons (sizeof (es)), - .type = htons (TALER_DBEVENT_MERCHANT_WIRE_TRANSFER_CONFIRMED) - }; - - db_plugin->event_notify (db_plugin->cls, - &es, - NULL, - 0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Fatal: new wire transfer #%llu has same WTID but different amount %s compared to a previous transfer\n", + (unsigned long long) serial_id, + TALER_amount2s (&details->amount)); + GNUNET_SCHEDULER_shutdown (); + w->hh = NULL; + return GNUNET_SYSERR; } + /* Success => reset back-off timer! */ + w->delay = GNUNET_TIME_UNIT_ZERO; } w->start_row = serial_id; return GNUNET_OK; diff --git a/src/backenddb/pg_insert_transfer.c b/src/backenddb/pg_insert_transfer.c @@ -34,50 +34,47 @@ TMH_PG_insert_transfer ( const struct TALER_WireTransferIdentifierRawP *wtid, const struct TALER_Amount *credit_amount, struct TALER_FullPayto payto_uri, - uint64_t bank_serial_id) + uint64_t bank_serial_id, + bool *no_instance, + bool *no_account, + bool *conflict) { struct PostgresClosure *pg = cls; struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get (); struct GNUNET_PQ_QueryParam params[] = { + GNUNET_PQ_query_param_string (instance_id), GNUNET_PQ_query_param_string (exchange_url), GNUNET_PQ_query_param_auto_from_type (wtid), TALER_PQ_query_param_amount_with_currency (pg->conn, credit_amount), GNUNET_PQ_query_param_string (payto_uri.full_payto), - GNUNET_PQ_query_param_string (instance_id), 0 == bank_serial_id ? GNUNET_PQ_query_param_null () : GNUNET_PQ_query_param_uint64 (&bank_serial_id), GNUNET_PQ_query_param_absolute_time (&now), GNUNET_PQ_query_param_end }; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_bool ("no_instance", + no_instance), + GNUNET_PQ_result_spec_bool ("no_account", + no_account), + GNUNET_PQ_result_spec_bool ("conflict", + conflict), + GNUNET_PQ_result_spec_end + }; check_connection (pg); - // FIXME-#10176: if transfer with matching exchange_url, wtid and credit_amount - // and account_serial exists already AND where bank_serial_id is NULL - // and if our bank_serial_id is NOT NULL, then maybe UPDATE instead? - // (user may have switched from manual import to automatic import, - // and now we may be duplicating all the records, which would be bad). PREPARE (pg, "insert_transfer", - "INSERT INTO merchant_transfers" - "(exchange_url" - ",wtid" - ",credit_amount" - ",account_serial" - ",bank_serial_id" - ",execution_time)" - "SELECT" - " $1, $2, $3, account_serial, $6, $7" - " FROM merchant_accounts" - " WHERE REGEXP_REPLACE(payto_uri,'\\?.*','')" - " =REGEXP_REPLACE($4,'\\?.*','')" - " AND merchant_serial=" - " (SELECT merchant_serial" - " FROM merchant_instances" - " WHERE merchant_id=$5)" - " ON CONFLICT DO NOTHING;"); - return GNUNET_PQ_eval_prepared_non_select (pg->conn, - "insert_transfer", - params); + "SELECT " + " out_no_instance AS no_instance" + " ,out_no_account AS no_account" + " ,out_conflict AS conflict" + " FROM merchant_do_insert_transfer" + " ($1, $2, $3, $4, $5, $6, $7);"); + return GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "insert_transfer", + params, + rs); } diff --git a/src/backenddb/pg_insert_transfer.h b/src/backenddb/pg_insert_transfer.h @@ -27,6 +27,9 @@ /** * Insert information about a wire transfer the merchant has received. + * Idempotent, will do nothing if the same wire transfer is already known. + * Updates the @a bank_serial_id if an equivalent transfer exists where + * the @a bank_serial_id was set to 0 (unknown). * * @param cls closure * @param instance_id the instance that received the transfer @@ -36,6 +39,9 @@ * @param payto_uri what is the merchant's bank account that received the transfer * @param bank_serial_id unique ID for the wire transfer at the bank, * 0 for "NULL" if none is known due to manual import + * @param[out] no_instance instance unknown to backend + * @param[out] no_account bank account with @a payto_uri unknown to backend + * @param[out] conflict transfer with same @a wtid but different @a credit_amount exists * @return transaction status */ enum GNUNET_DB_QueryStatus @@ -46,7 +52,10 @@ TMH_PG_insert_transfer ( const struct TALER_WireTransferIdentifierRawP *wtid, const struct TALER_Amount *credit_amount, struct TALER_FullPayto payto_uri, - uint64_t bank_serial_id); + uint64_t bank_serial_id, + bool *no_instance, + bool *no_account, + bool *conflict); #endif diff --git a/src/backenddb/pg_insert_transfer.sql b/src/backenddb/pg_insert_transfer.sql @@ -0,0 +1,122 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2026 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +-- + +DROP FUNCTION IF EXISTS merchant_do_insert_transfer; +CREATE FUNCTION merchant_do_insert_transfer ( + IN in_instance_id TEXT, + IN in_exchange_url TEXT, + IN in_wtid BYTEA, + IN in_credit_amount taler_amount_currency, + IN in_credited_account_payto TEXT, + IN in_bank_serial_id INT8, -- can be NULL if unknown + IN in_execution_time INT8, + OUT out_no_instance BOOL, + OUT out_no_account BOOL, + OUT out_conflict BOOL) +LANGUAGE plpgsql +AS $$ +DECLARE + my_merchant_id INT8; + my_account_serial INT8; + my_record RECORD; + my_bank_serial_id INT8; + my_credit_amount taler_amount_currency; +BEGIN + +out_conflict=FALSE; + +-- Which instance are we using? +SELECT merchant_serial + INTO my_merchant_id + FROM merchant_instances + WHERE merchant_id=in_instance_id; +IF NOT FOUND +THEN + out_no_instance=TRUE; + out_no_account=TRUE; -- also true... + RETURN; +END IF; +out_no_instance=FALSE; + +SELECT account_serial + INTO my_account_serial + FROM merchant_accounts + WHERE REGEXP_REPLACE(payto_uri, + '\\?.*','') + =REGEXP_REPLACE(in_credited_account_payto, + '\\?.*','') + AND merchant_serial=my_merchant_id; +IF NOT FOUND +THEN + out_no_account=TRUE; + RETURN; +END IF; +out_no_account=FALSE; + +SELECT bank_serial_id + ,credit_amount + INTO my_record + FROM merchant_transfers + WHERE wtid=in_wtid + AND account_serial=my_account_serial + AND exchange_url=in_exchange_url; +IF NOT FOUND +THEN + INSERT INTO merchant_transfers + (exchange_url + ,wtid + ,credit_amount + ,account_serial + ,bank_serial_id + ,execution_time + ) VALUES + (in_exchange_url + ,in_wtid + ,in_credit_amount + ,my_account_serial + ,in_bank_serial_id + ,in_execution_time); + -- Do notify on TALER_DBEVENT_MERCHANT_WIRE_TRANSFER_CONFIRMED + NOTIFY XJ5N652FA4TBS2WXGY3S1FMPMQYTD8KAZA9B7HW9JWJ4PZ2DB852G; + RETURN; +END IF; + +my_bank_serial_id = my_record.bank_serial_id; +my_credit_amount = my_record.credit_amount; + +IF ( (in_credit_amount.val != my_credit_amount.val) OR + (in_credit_amount.frac != my_credit_amount.frac) OR + (in_credit_amount.curr != my_credit_amount.curr) ) +THEN + out_conflict = TRUE; -- amounts differ, not OK! + RETURN; +END IF; + +IF ( (my_bank_serial_id IS NULL) AND + (in_bank_serial_id IS NOT NULL) ) +THEN + -- We learned the bank_bank_serial_id, update that + UPDATE merchant_transfers + SET bank_serial_id=in_bank_serial_id + WHERE wtid=in_wtid + AND account_serial=my_account_serial + AND exchange_url=in_exchange_url; + RETURN; +END IF; + +-- idempotent request, success. + +END $$; diff --git a/src/backenddb/procedures.sql.in b/src/backenddb/procedures.sql.in @@ -34,6 +34,7 @@ SET search_path TO merchant; #include "pg_update_money_pot.sql" #include "pg_increment_money_pots.sql" #include "pg_account_kyc_get_status.sql" +#include "pg_insert_transfer.sql" DROP PROCEDURE IF EXISTS merchant_do_gc; CREATE PROCEDURE merchant_do_gc(in_now INT8) diff --git a/src/include/taler_merchantdb_plugin.h b/src/include/taler_merchantdb_plugin.h @@ -3757,6 +3757,9 @@ struct TALER_MERCHANTDB_Plugin /** * Insert information about a wire transfer the merchant has received. + * Idempotent, will do nothing if the same wire transfer is already known. + * Updates the @a bank_serial_id if an equivalent transfer exists where + * the @a bank_serial_id was set to 0 (unknown). * * @param cls closure * @param instance_id instance to lookup the order from @@ -3765,6 +3768,9 @@ struct TALER_MERCHANTDB_Plugin * @param credit_amount how much did we receive * @param payto_uri what is the merchant's bank account that received the transfer * @param bank_serial_id bank serial transfer ID, 0 for none (use NULL in DB!) + * @param[out] no_instance instance unknown to backend + * @param[out] no_account bank account with @a payto_uri unknown to backend + * @param[out] conflict transfer with same @a wtid but different @a credit_amount exists * @return transaction status */ enum GNUNET_DB_QueryStatus @@ -3775,7 +3781,10 @@ struct TALER_MERCHANTDB_Plugin const struct TALER_WireTransferIdentifierRawP *wtid, const struct TALER_Amount *credit_amount, struct TALER_FullPayto payto_uri, - uint64_t bank_serial_id); + uint64_t bank_serial_id, + bool *no_instance, + bool *no_account, + bool *conflict); /**