-- -- This file is part of TALER -- Copyright (C) 2025 Taler Systems SA -- -- TALER is free software; you can redistribute it and/or modify it under the -- terms of the GNU General Public License as published by the Free Software -- Foundation; either version 3, or (at your option) any later version. -- -- TALER is distributed in the hope that it will be useful, but WITHOUT ANY -- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -- A PARTICULAR PURPOSE. See the GNU General Public License for more details. -- -- You should have received a copy of the GNU General Public License along with -- TALER; see the file COPYING. If not, see -- -- @file tops-0001.sql -- @brief special TOPS-specific (AML) rules to inject into an exchange -- @author Christian Grothoff -- Everything in one big transaction BEGIN; -- Check patch versioning is in place. SELECT _v.register_patch('tops-0001', NULL, NULL); -- Note: this NOT an accident: the schema MUST be named -- using the filename prefix (and the name under --enable-custom of taler-exchange-dbinit). CREATE SCHEMA IF NOT EXISTS tops; SET search_path TO tops,exchange; INSERT INTO exchange_statistic_interval_meta (origin ,slug ,description ,stype ,ranges ,precisions) VALUES -- this first one is just for testing right now ('tops' -- must match schema! ,'deposit-transactions' ,'number of (batch) deposits performed by this merchant, used to detect sudden increase in number of transactions' ,'number' ,ARRAY(SELECT generate_series (60*60*24*7, 60*60*24*7*52, 60*60*24*7)) -- weekly volume over the last year ,array_fill (60*60*24, ARRAY[52]) -- precision is per day ), ('tops' -- must match schema! ,'deposit-volume' ,'total amount deposited by this merchant in (batch) deposits including deposit fees, used to detect sudden increase in transaction volume' ,'amount' ,ARRAY(SELECT generate_series (60*60*24*7, 60*60*24*7*52, 60*60*24*7)) -- weekly volume over the last year ,array_fill (60*60*24, ARRAY[52]) -- precision is per day ) ON CONFLICT DO NOTHING; INSERT INTO exchange_statistic_bucket_meta (origin ,slug ,description ,stype ,ranges ,ages) VALUES -- this first one is just for testing right now ('tops' -- must match schema! ,'deposit-transactions' ,'number of (batch) deposits performed by this merchant, used to detect sudden increase in number of transactions' ,'number' ,ARRAY['day'::statistic_range,'week'] ,ARRAY[5,5] ), ('tops' -- must match schema! ,'deposit-volume' ,'total amount deposited by this merchant in (batch) deposits including deposit fees, used to detect sudden increase in transaction volume' ,'amount' ,ARRAY['day'::statistic_range,'week'] ,ARRAY[5,5] ) ON CONFLICT DO NOTHING; DROP FUNCTION IF EXISTS tops_deposit_statistics_trigger CASCADE; CREATE FUNCTION tops_deposit_statistics_trigger() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE my_h_payto BYTEA; -- normalized h_payto of target account my_rec RECORD; my_last_year taler_amount; -- sum of deposits this year my_last_month taler_amount; -- sum of deposits this month my_old_rules RECORD; my_properties TEXT; my_investigate_property JSONB; my_measure_name TEXT; my_rules TEXT; BEGIN SELECT wt.h_normalized_payto INTO my_h_payto FROM wire_targets wt WHERE wire_target_h_payto = NEW.wire_target_h_payto; CALL exchange_do_bump_amount_stat ('deposit-volume' ,my_h_payto ,CURRENT_TIMESTAMP(0)::TIMESTAMP ,NEW.total_amount); -- FIXME: this is just for testing, I want to also check -- the 'counter'-based functions. CALL exchange_do_bump_number_stat ('deposit-transactions' ,my_h_payto ,CURRENT_TIMESTAMP(0)::TIMESTAMP ,1); -- Get historical deposit volumes and extract the yearly and monthly -- interval statistic values from the result for the AML trigger check. FOR my_rec IN SELECT * FROM exchange_statistic_interval_amount_get( 'deposit-volume' ,my_h_payto ) LOOP IF (my_rec.range = 60*60*24*7*52) THEN my_last_year = my_rec.rvalue; END IF; IF (my_rec.range = 60*60*24*7*4) THEN my_last_month = my_rec.rvalue; END IF; END LOOP; -- Note: it is OK to ignore '.frac', as that cannot be significant. -- Also, we effectively exclude the current month's revenue from -- "last year" as otherwise the rule makes no sense. -- Finally, we define the "current month" always as the last 4 weeks, -- just like the "last year" is the last 52 weeks. IF (my_last_year.val < my_last_month.val * 2) THEN -- This is suspicious. => Flag account for AML review! -- -- FIXME: we probably want to factor the code from -- this branch out into a generic -- function to trigger investigations at some point! -- -- First, get existing rules and clear an 'is_active' -- flag, but ONLY if we are not _already_ investigating -- the account (as in the latter case, we'll do no INSERT). UPDATE legitimization_outcomes SET is_active=NOT to_investigate WHERE h_payto = my_h_payto AND is_active RETURNING jproperties ,new_measure_name ,jnew_rules ,to_investigate INTO my_old_rules; -- Note that if we have no active legitimization_outcome -- that means we are on default rules and the account -- did not cross KYC thresholds and thus we have no -- established business relationship. In this case, we -- do not care as the overall volume is insignificant. -- This also takes care of the case where a customer -- is new (and obviously the first few months are -- basically always above the inherently zero or near-zero -- transactions from the previous year). -- Thus, we only proceed IF FOUND. IF FOUND THEN my_properties = my_old_rules.jproperties; my_measure_name = my_old_rules.new_measure_name; my_rules = my_old_rules.jnew_rules; my_investigate_property = json_object(ARRAY['AML_INVESTIGATION_STATE', 'AML_INVESTIGATION_TRIGGER'], ARRAY['INVESTIATION_PENDING', 'DEPOSIT_ANOMALY']); IF my_properties IS NULL THEN my_properties = my_investigate_property::TEXT; ELSE my_properties = (my_properties::JSONB || my_investigate_property)::TEXT; END IF; -- Note: here we could in theory manipulate my_properties, -- say to set a note as to why the investigation was started. IF NOT my_old_rules.to_investigate THEN -- Only insert if 'to_investigate' was not already set. INSERT INTO legitimization_outcomes ( h_payto ,decision_time ,expiration_time ,jproperties ,new_measure_name ,to_investigate ,is_active ,jnew_rules ) VALUES ( my_h_payto ,my_now ,my_now + 366*24*60*60 ,my_properties ,my_measure_name ,TRUE ,TRUE ,my_rules); END IF; END IF; END IF; RETURN NEW; END $$; COMMENT ON FUNCTION tops_deposit_statistics_trigger IS 'creates deposit statistics'; -- Whenever a deposit is made, call our trigger to bump statistics CREATE TRIGGER tops_batch_deposits_on_insert AFTER INSERT ON batch_deposits FOR EACH ROW EXECUTE FUNCTION tops_deposit_statistics_trigger(); COMMIT;