merchant

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

commit 6a134493fe4b5f86aa0119580953a6565673a437
parent 007ac6f559d566b906a1d092a0bba3bbc7cfccf9
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Sun, 25 Oct 2026 16:39:04 -0300

fix forever pending wiretransfer

transfer stay unconfirmed if the expected amount is not present on
INSERT

Diffstat:
Msrc/backenddb/pg_triggers.sql | 26++++++++++++++++++++++++++
Msrc/backenddb/sql-schema/merchant-0036-init.sql.fragment | 2+-
Asrc/backenddb/sql-schema/merchant-0050.sql | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/sql-schema/meson.build | 1+
Msrc/testing/meson.build | 1+
Asrc/testing/test_merchant_depositcheck_confirmation.sh | 358+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 466 insertions(+), 1 deletion(-)

diff --git a/src/backenddb/pg_triggers.sql b/src/backenddb/pg_triggers.sql @@ -223,6 +223,32 @@ BEGIN END $$; +-- Re-check confirmation when taler-merchant-reconciliation fills in the +-- expected_credit_amount that taler-merchant-depositcheck could not set. +CREATE OR REPLACE FUNCTION merchant_expected_transfers_update_trigger() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF ( (OLD.expected_credit_amount IS DISTINCT FROM NEW.expected_credit_amount) + AND (NEW.expected_credit_amount IS NOT NULL) + AND (NOT NEW.confirmed) ) + THEN + UPDATE merchant_transfers + SET expected = TRUE + WHERE wtid = NEW.wtid + AND exchange_url = NEW.exchange_url + AND credit_amount = NEW.expected_credit_amount; + IF FOUND THEN + NEW.confirmed = TRUE; + END IF; + END IF; + RETURN NEW; +END $$; +COMMENT ON FUNCTION merchant_expected_transfers_update_trigger + IS 'Sets "confirmed" to TRUE for the updated record if a matching merchant_transfers row appears once the expected_credit_amount becomes known.'; + + CREATE OR REPLACE FUNCTION merchant_kyc_insert_trigger() RETURNS TRIGGER LANGUAGE plpgsql diff --git a/src/backenddb/sql-schema/merchant-0036-init.sql.fragment b/src/backenddb/sql-schema/merchant-0036-init.sql.fragment @@ -535,7 +535,7 @@ BEGIN COMMENT ON COLUMN merchant_expected_transfers.signkey_serial IS 'Identifies the online signing key of the exchange used to make the exchange_sig'; COMMENT ON COLUMN merchant_expected_transfers.exchange_sig IS 'Signature over the aggregation response from the exchange, or NULL on error or if we did not yet make that request'; COMMENT ON COLUMN merchant_expected_transfers.h_details IS 'Hash over the aggregation details returned by the exchange, provided here for fast exchange_sig validation'; - COMMENT ON COLUMN merchant_expected_transfers.confirmed IS 'true once the merchant confirmed that this transfer was received and a matching transfer exists in the merchant_transfers table; set automatically via INSERT TRIGGER merchant_expected_transfers_insert_trigger'; + COMMENT ON COLUMN merchant_expected_transfers.confirmed IS 'true once the merchant confirmed that this transfer was received and a matching transfer exists in the merchant_transfers table; set automatically via INSERT TRIGGER merchant_expected_transfers_insert_trigger or UPDATE TRIGGER merchant_expected_transfers_update_trigger when the amount becomes known later'; CREATE INDEX merchant_expected_transfers_by_open ON merchant_expected_transfers (retry_time) WHERE ((NOT confirmed) OR retry_needed); diff --git a/src/backenddb/sql-schema/merchant-0050.sql b/src/backenddb/sql-schema/merchant-0050.sql @@ -0,0 +1,79 @@ +-- +-- 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-0050.sql +-- @brief Re-check confirmation of expected transfers when expected_credit_amount is filled in +-- @author Kimi Code + +-- taler-merchant-depositcheck inserts merchant_expected_transfers with +-- expected_credit_amount = NULL because /deposits only returns the coin's +-- contribution. taler-merchant-reconciliation later fills in the actual +-- total amount via /transfers/$WTID. The INSERT triggers only fire when the +-- expected-transfer row is first created, so transfers that already existed +-- in merchant_transfers were never linked. This migration installs the body +-- of the UPDATE trigger that merchant-0037 already created as a placeholder. + +BEGIN; + +SELECT _v.register_patch('merchant-0050', NULL, NULL); + +SET search_path TO merchant; + +CREATE PROCEDURE merchant.merchant_0050_init(s TEXT) + LANGUAGE plpgsql + AS $OUTER$ +BEGIN + + EXECUTE format('SET LOCAL search_path TO %I', s); + + CREATE OR REPLACE FUNCTION merchant_expected_transfers_update_trigger() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF ( (OLD.expected_credit_amount IS DISTINCT FROM NEW.expected_credit_amount) + AND (NEW.expected_credit_amount IS NOT NULL) + AND (NOT NEW.confirmed) ) + THEN + UPDATE merchant_transfers + SET expected = TRUE + WHERE wtid = NEW.wtid + AND exchange_url = NEW.exchange_url + AND credit_amount = NEW.expected_credit_amount; + IF FOUND THEN + NEW.confirmed = TRUE; + END IF; + END IF; + RETURN NEW; + END $$; + + COMMENT ON FUNCTION merchant_expected_transfers_update_trigger IS + 'Sets "confirmed" to TRUE for the updated record if a matching merchant_transfers row appears once the expected_credit_amount becomes known.'; + + SET LOCAL search_path TO merchant; + +END +$OUTER$; + +INSERT INTO merchant.instance_fixups + (migration_name + ,version) + VALUES + ('merchant_0050_init' + ,50); +-- Apply new fix-up to existing instances +CALL merchant.fixup_instance_schema (50::INT8); + +COMMIT; diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build @@ -149,6 +149,7 @@ generated_sql = [ ['merchant-0047.sql'], ['merchant-0048.sql'], ['merchant-0049.sql'], + ['merchant-0050.sql'], ] migration_sql = [] diff --git a/src/testing/meson.build b/src/testing/meson.build @@ -17,6 +17,7 @@ check_SCRIPTS = [ 'test_merchant_signup_email', 'test_merchant_statistics', 'test_merchant_templates', + 'test_merchant_depositcheck_confirmation', 'test_merchant_transfer_tracking', 'test_merchant_units', 'test-merchant-walletharness', diff --git a/src/testing/test_merchant_depositcheck_confirmation.sh b/src/testing/test_merchant_depositcheck_confirmation.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# 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/> +# + + + + +# Testcase for the depositcheck/reconciliation confirmation race: +# taler-merchant-depositcheck creates merchant_expected_transfers with +# expected_credit_amount = NULL because /deposits only knows the coin's +# contribution. taler-merchant-reconciliation later fills in the total +# amount via /transfers/$WTID. The confirmation linking must also happen +# on that UPDATE, not only on INSERT. + +# FIXME: we need to make this tests simpler +# +# goto REPRODUCE THE RACE +# I was unable to test by endpoint because of src/backend/taler-merchant-httpd_get-private-transfers.c:84 is hardcoded with true + +set -eu + +. setup.sh + +# Exit, with status code "skip" (no 'real' failure) +function exit_skip() { + echo "SKIPPING: $1" + exit 77 +} + +# Use fakebank (instant) for this test. +USE_FAKEBANK=1 +ACCOUNT="exchange-account-2" +WIRE_METHOD="x-taler-bank" +BANK_FLAGS="-f -d $WIRE_METHOD -u $ACCOUNT" +BANK_URL="http://localhost:8082/" + + +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" \ + -em \ + -r "merchant-exchange-default" \ + $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/" + +FACADE_URL="http://localhost:8082/accounts/gnunet/taler-revenue/" +FACADE_USERNAME="gnunet" +FACADE_PASSWORD="password" + +# Extract merchant DB connection for direct DB assertions. +echo -n "Checking for psql ..." +psql --version >/dev/null 2>&1 || exit_skip " no psql" +echo " OK" + +DB_URI=$(sed -n '/^\[merchantdb-postgres\]/,/^\[/p' "$CONF" \ + | awk -F= '/^CONFIG[[:space:]]*=/ { gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2; exit }') +if [ -z "$DB_URI" ] +then + exit_fail "Could not extract merchant DB URI from $CONF" +fi + +# Helper to run a query against a merchant instance schema. +function merchant_psql() { + psql -At -q -d "$DB_URI" -v ON_ERROR_STOP=1 \ + -c "SET search_path TO ${MERCHANT_SCHEMA}; $1" +} + +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 "." +taler-exchange-wirewatch \ + -L "INFO" \ + -c "$CONF" \ + -t \ + &> taler-exchange-wirewatch.out +echo -n "." +timeout 60 taler-wallet-cli \ + --wallet-db="$WALLET_DB" \ + run-until-done \ + 2>wallet-withdraw-finish-1.err \ + >wallet-withdraw-finish-1.out +echo " OK" + +# +# CREATE INSTANCE FOR TESTING +# + +echo -n "Configuring merchant admin instance ..." +GNUNET_PAYTO="payto://x-taler-bank/localhost/gnunet?receiver-name=gnunet" +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + http://localhost:9966/management/instances \ + -d '{"auth":{"method":"token","password":"super_secret"},"id":"admin","name":"default","user_type":"business","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us" : 50000000},"default_pay_delay":{"d_us": 60000000}}' \ + -w "%{http_code}" -s -o /dev/null) + +if [ "$STATUS" != "204" ] +then + exit_fail "Expected 204 no content. Got: $STATUS" +fi +ADMIN_TOKEN=$(merchant_login admin super_secret) +echo "OK" + +# Resolve the per-instance schema name for direct DB inspection. +MERCHANT_SERIAL=$(psql -At -q -d "$DB_URI" -v ON_ERROR_STOP=1 \ + -c "SELECT merchant_serial FROM merchant.merchant_instances WHERE merchant_id='admin';") +MERCHANT_SCHEMA="merchant_instance_${MERCHANT_SERIAL}" + +echo -n "Configuring bank account..." +STATUS=$(curl -H "Content-Type: application/json" -X POST \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + http://localhost:9966/private/accounts \ + -d '{"payto_uri":"'"$GNUNET_PAYTO"'","credit_facade_url":"'"${FACADE_URL}"'","credit_facade_credentials":{"type":"basic","username":"'"$FACADE_USERNAME"'","password":"'"$FACADE_PASSWORD"'"}}' \ + -w "%{http_code}" -s -o /dev/null) + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK. Got: $STATUS" +fi +echo "OK" + +# CREATE ORDER AND SELL IT +echo -n "Creating order to be paid..." +STATUS=$(curl 'http://localhost:9966/private/orders' \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -d '{"refund_delay":{"d_us":0},"order":{"amount":"TESTKUDOS:1","summary":"payme"}}' \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 OK. Got: $STATUS " "$(cat "$LAST_RESPONSE")" +fi + +ORDER_ID=$(jq -e -r .order_id < "$LAST_RESPONSE") +STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 ok. Got: $STATUS" "$(cat "$LAST_RESPONSE")" +fi +PAY_URL=$(jq -e -r .taler_pay_uri < "$LAST_RESPONSE") +echo OK + +NOW=$(date +%s) +echo -n "Pay order ..." +taler-wallet-cli \ + --no-throttle \ + --wallet-db="$WALLET_DB" \ + handle-uri "${PAY_URL}" \ + -y 2> wallet-pay1.err > wallet-pay1.log +NOW2=$(date +%s) +echo "OK. Took $(( NOW2 - NOW))s." + +STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + exit_fail "Expected 200 Ok. Got: $STATUS" "$(cat "$LAST_RESPONSE")" +fi + +ORDER_STATUS=$(jq -r .order_status < "$LAST_RESPONSE") +if [ "$ORDER_STATUS" != "paid" ] +then + exit_fail "Expected order status 'paid'. Got: $ORDER_STATUS" "$(cat "$LAST_RESPONSE")" +fi + +# +# WIRE TRANSFER TO MERCHANT +# + +WIRE_DEADLINE=$(jq -r .contract_terms.wire_transfer_deadline.t_s < "$LAST_RESPONSE") +NOW=$(date +%s) + +TO_SLEEP="$(( 3600 + WIRE_DEADLINE - NOW ))" +echo -n "Perform wire transfers (with ${TO_SLEEP}s timeshift) ..." +taler-exchange-aggregator \ + -y \ + -c "$CONF" \ + -T "${TO_SLEEP}000000" \ + -t \ + -L INFO &> aggregator.log +taler-exchange-transfer \ + -c "$CONF" \ + -t \ + -L INFO &> transfer.log +echo " DONE" + +# +# REPRODUCE THE RACE: import the bank transfer first, then run depositcheck, +# then reconciliation. With the bug, the transfer stays unconfirmed because +# the expected_credit_amount only becomes known on UPDATE. +# + +echo -n "Running taler-merchant-wirewatch to import transfer ..." +taler-merchant-wirewatch \ + -c "$CONF" \ + -t \ + -L INFO &> merchant-wirewatch.log +echo " OK" + +# DB sanity check: we have the bank-side row but no expected transfer yet. +echo -n "Checking DB state after wirewatch ..." +DB_STATE=$(merchant_psql "SELECT (SELECT COUNT(*) FROM merchant_transfers), (SELECT COUNT(*) FROM merchant_expected_transfers);") +IFS='|' read -r TRANSFER_COUNT EXPECTED_COUNT <<< "$DB_STATE" +if [ "$TRANSFER_COUNT" != "1" ] +then + exit_fail "Expected 1 merchant_transfers row, got $TRANSFER_COUNT" +fi +if [ "$EXPECTED_COUNT" != "0" ] +then + exit_fail "Expected 0 merchant_expected_transfers rows before depositcheck, got $EXPECTED_COUNT" +fi +echo " OK" + +echo -n "Running taler-merchant-depositcheck to create expected transfer ..." +taler-merchant-depositcheck \ + -c "$CONF" \ + -e "http://localhost:8081/" \ + -T "${TO_SLEEP}000000" \ + -t \ + -L INFO &> merchant-depositcheck.log +echo " OK" + +# DB sanity check: depositcheck created an unconfirmed expected transfer +# without knowing the final credit amount. +echo -n "Checking DB state after depositcheck ..." +DB_STATE=$(merchant_psql "SELECT expected_credit_amount IS NULL, confirmed, retry_needed FROM merchant_expected_transfers;") +IFS='|' read -r AMOUNT_NULL CONFIRMED RETRY <<< "$DB_STATE" +if [ "$AMOUNT_NULL" != "t" ] +then + exit_fail "Expected expected_credit_amount to be NULL after depositcheck" +fi +if [ "$CONFIRMED" != "f" ] +then + exit_fail "Expected confirmed=false after depositcheck, got $CONFIRMED" +fi +if [ "$RETRY" != "t" ] +then + exit_fail "Expected retry_needed=true after depositcheck, got $RETRY" +fi +echo " OK" + +echo -n "Running taler-merchant-reconciliation to finalize transfer amount ..." +taler-merchant-reconciliation \ + -c "$CONF" \ + -T "${TO_SLEEP}000000" \ + -t \ + -L INFO &> merchant-reconciliation.log +echo " OK" + +# Primary regression check: reconciliation must have linked the expected +# transfer to the imported bank transfer now that the amount is known. +echo -n "Checking DB state after reconciliation ..." +DB_STATE=$(merchant_psql "SELECT expected_credit_amount IS NULL, confirmed FROM merchant_expected_transfers;") +IFS='|' read -r AMOUNT_NULL CONFIRMED <<< "$DB_STATE" +if [ "$AMOUNT_NULL" != "f" ] +then + exit_fail "Expected expected_credit_amount to be set after reconciliation" +fi +if [ "$CONFIRMED" != "t" ] +then + exit_fail "Expected confirmed=true after reconciliation, got $CONFIRMED (UPDATE trigger likely missing)" +fi +echo " OK" + +echo -n "Checking incoming transfer is confirmed via API ..." +STATUS=$(curl 'http://localhost:9966/private/incoming' \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") +if [ "$STATUS" != "200" ] +then + exit_fail "Expected response 200 Ok. Got: $STATUS" "$(jq . < "$LAST_RESPONSE")" +fi + +INCOMING_LIST_SIZE=$(jq -r '.incoming | length' < "$LAST_RESPONSE") +if [ "$INCOMING_LIST_SIZE" != "1" ] +then + exit_fail "Expected one incoming transfer. Got: $INCOMING_LIST_SIZE" "$(jq . < "$LAST_RESPONSE")" +fi + +CONFIRMED=$(jq -r '.incoming[0].confirmed' < "$LAST_RESPONSE") +if [ "$CONFIRMED" != "true" ] +then + jq . < "$LAST_RESPONSE" + exit_fail "Expected incoming[0].confirmed 'true'. Got: $CONFIRMED" +fi +echo " OK" + +# Also verify the order is wired. +echo -n "Checking order status ..." +STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}?transfer=YES" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + jq . < "$LAST_RESPONSE" + exit_fail "Expected 200 ok. got: $STATUS" "$(cat "$LAST_RESPONSE")" +fi + +WAS_WIRED=$(jq -r .wired < "$LAST_RESPONSE") +if [ "$WAS_WIRED" != "true" ] +then + jq . < "$LAST_RESPONSE" + exit_fail "Got .wired 'false', expected 'true'" +fi +echo " OK" + +echo "TEST PASSED" + +exit 0