libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit ecc5e07182e3f964492c60dfeb5c4723d8bcc6d5
parent 1a3ebf8f8aeb3f6a941197fe3bfc85360bbc228f
Author: Antoine A <>
Date:   Sat, 13 Jan 2024 14:40:39 +0000

Merge nexus-integration-test into master

Diffstat:
M.gitignore | 4++++
MMakefile | 23++++++++++++++++++++---
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 257+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mbank/src/main/resources/logback.xml | 3+++
Mdatabase-versioning/libeufin-conversion-setup.sql | 7++++++-
Mdatabase-versioning/libeufin-nexus-0001.sql | 8++++----
Mdatabase-versioning/libeufin-nexus-procedures.sql | 304+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mintegration/build.gradle | 30+++++++++++++++++++++---------
Mintegration/conf/integration.conf | 1-
Aintegration/conf/netzbon.conf | 30++++++++++++++++++++++++++++++
Aintegration/conf/postfinance.conf | 29+++++++++++++++++++++++++++++
Aintegration/src/main/kotlin/Main.kt | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aintegration/src/test/kotlin/IntegrationTest.kt | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dintegration/test/IntegrationTest.kt | 181-------------------------------------------------------------------------------
Mnexus/build.gradle | 5-----
Anexus/conf/test.conf | 14++++++++++++++
Mnexus/src/main/kotlin/tech/libeufin/nexus/Database.kt | 345++++++++++++++++++++++++++++++-------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt | 13+++++--------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 324+++++++++++++++++++++++++++++--------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt | 293++++++++++++++++++++++++++++---------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 114+++++++++++++++++++++++++++++++++----------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 197+++++++++++++++++++++++++++++++++++--------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 102+++++++++++++++++++++++++++++++++++--------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt | 6+-----
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt | 20++++++++++----------
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 14+++++---------
Anexus/src/test/kotlin/CliTest.kt | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnexus/src/test/kotlin/Common.kt | 6+++---
Mnexus/src/test/kotlin/DatabaseTest.kt | 164++++++++++++++++++++++++++++++++-----------------------------------------------
Mnexus/src/test/kotlin/Keys.kt | 16++++++----------
Dnexus/src/test/kotlin/PostFinance.kt | 220-------------------------------------------------------------------------------
Mutil/src/main/kotlin/Cli.kt | 23+++++++++++++----------
Mutil/src/main/kotlin/DB.kt | 2+-
Mutil/src/main/kotlin/ebics_h005/Ebics3Request.kt | 22++++++++++++++++------
34 files changed, 1701 insertions(+), 1687 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -4,6 +4,9 @@ /sandbox/bin/ /util/bin/ nexus/libeufin-nexus-dev +nexus/test +integration/test +integration/config.json sandbox/libeufin-sandbox-dev configure build/ @@ -23,6 +26,7 @@ __pycache__ *.log .DS_Store *.mk +*.xsd util/src/main/resources/version.txt debian/usr/share/libeufin/demobank-ui/index.js debian/usr/share/libeufin/demobank-ui/*.html diff --git a/Makefile b/Makefile @@ -63,6 +63,11 @@ install-nobuild-bank-files: install -m 644 -D -t $(sql_dir) database-versioning/libeufin-bank*.sql install -m 644 -D -t $(sql_dir) database-versioning/libeufin-conversion*.sql +.PHONY: install-nobuild-nexus-files +install-nobuild-nexus-files: + install -m 644 -D -t $(config_dir) contrib/nexus.conf + install -m 644 -D -t $(sql_dir) database-versioning/libeufin-nexus*.sql + .PHONY: install-nobuild-bank install-nobuild-bank: install-nobuild-common install-nobuild-bank-files install -d $(spa_dir) @@ -76,9 +81,8 @@ install-nobuild-bank: install-nobuild-common install-nobuild-bank-files install -m=644 -D -t $(lib_dir) bank/build/install/bank-shadow/lib/bank-*.jar .PHONY: install-nobuild-nexus -install-nobuild-nexus: install-nobuild-common +install-nobuild-nexus: install-nobuild-common install-nobuild-nexus-files install -m 644 -D -t $(config_dir) contrib/nexus.conf - install -m 644 -D -t $(sql_dir) database-versioning/libeufin-nexus*.sql install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/libeufin-nexus.1 install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/libeufin-nexus.conf.5 install -D -t $(bin_dir) contrib/libeufin-nexus-dbinit @@ -107,6 +111,19 @@ check: install-nobuild-bank-files test: install-nobuild-bank-files ./gradlew test --tests $(test) -i +.PHONY: nexus-test +nexus-test: install-nobuild-nexus-files + ./gradlew :nexus:test --tests $(test) -i + .PHONY: integration-test -integration-test: install-nobuild-bank-files +integration-test: install-nobuild-bank-files install-nobuild-nexus-files ./gradlew :integration:test --tests $(test) -i + +.PHONY: integration +integration: install-nobuild-bank-files install-nobuild-nexus-files + ./gradlew :integration:run --console=plain --args="$(test)" + +.PHONY: doc +doc: + ./gradlew dokkaHtmlMultiModule + open build/dokka/htmlMultiModule/index.html diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -59,6 +59,8 @@ import tech.libeufin.bank.db.* import tech.libeufin.util.* private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main") +// Dirty local variable to stop the server in test TODO remove this ugly hack +var engine: ApplicationEngine? = null /** * This plugin check for body lenght limit and inflates the requests that have "Content-Encoding: deflate" @@ -238,23 +240,24 @@ class BankDbInit : CliktCommand("Initialize the libeufin-bank database", name = val config = talerConfig(common.config) val cfg = config.loadDbConfig() val ctx = config.loadBankConfig(); - val db = Database(cfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - db.conn { conn -> - if (requestReset) { - resetDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank") + Database(cfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + db.conn { conn -> + if (requestReset) { + resetDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank") + } + initializeDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank") + } + // Create admin account if missing + val res = maybeCreateAdminAccount(db, ctx) // logs provided by the helper + when (res) { + AccountCreationResult.BonusBalanceInsufficient -> {} + AccountCreationResult.LoginReuse -> {} + AccountCreationResult.PayToReuse -> + throw Exception("Failed to create admin's account") + AccountCreationResult.Success -> + logger.info("Admin's account created") } - initializeDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank") - } - // Create admin account if missing - val res = maybeCreateAdminAccount(db, ctx) // logs provided by the helper - when (res) { - AccountCreationResult.BonusBalanceInsufficient -> {} - AccountCreationResult.LoginReuse -> {} - AccountCreationResult.PayToReuse -> - throw Exception("Failed to create admin's account") - AccountCreationResult.Success -> - logger.info("Admin's account created") } } } @@ -268,54 +271,56 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() val serverCfg = cfg.loadServerConfig() - val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - if (ctx.allowConversion) { - logger.info("Ensure exchange account exists") - val info = db.account.bankInfo("exchange") - if (info == null) { - throw Exception("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled") - } else if (!info.isTalerExchange) { - throw Exception("Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled") - } - logger.info("Ensure conversion is enabled") - val sqlProcedures = File("${dbCfg.sqlDir}/libeufin-conversion-setup.sql") - if (!sqlProcedures.exists()) { - throw Exception("Missing libeufin-conversion-setup.sql file") - } - db.conn { it.execSQLUpdate(sqlProcedures.readText()) } - } else { - logger.info("Ensure conversion is disabled") - val sqlProcedures = File("${dbCfg.sqlDir}/libeufin-conversion-drop.sql") - if (!sqlProcedures.exists()) { - throw Exception("Missing libeufin-conversion-drop.sql file") + Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + if (ctx.allowConversion) { + logger.info("Ensure exchange account exists") + val info = db.account.bankInfo("exchange") + if (info == null) { + throw Exception("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled") + } else if (!info.isTalerExchange) { + throw Exception("Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled") + } + logger.info("Ensure conversion is enabled") + val sqlProcedures = File("${dbCfg.sqlDir}/libeufin-conversion-setup.sql") + if (!sqlProcedures.exists()) { + throw Exception("Missing libeufin-conversion-setup.sql file") + } + db.conn { it.execSQLUpdate(sqlProcedures.readText()) } + } else { + logger.info("Ensure conversion is disabled") + val sqlProcedures = File("${dbCfg.sqlDir}/libeufin-conversion-drop.sql") + if (!sqlProcedures.exists()) { + throw Exception("Missing libeufin-conversion-drop.sql file") + } + db.conn { it.execSQLUpdate(sqlProcedures.readText()) } + // Remove conversion info from the database ? } - db.conn { it.execSQLUpdate(sqlProcedures.readText()) } - // Remove conversion info from the database ? } - } - - val env = applicationEngineEnvironment { - connector { - when (serverCfg) { - is ServerConfig.Tcp -> { - port = serverCfg.port + + val env = applicationEngineEnvironment { + connector { + when (serverCfg) { + is ServerConfig.Tcp -> { + port = serverCfg.port + } + is ServerConfig.Unix -> + throw Exception("Can only serve libeufin-bank via TCP") } - is ServerConfig.Unix -> - throw Exception("Can only serve libeufin-bank via TCP") } + module { corebankWebApp(db, ctx) } } - module { corebankWebApp(db, ctx) } - } - val engine = embeddedServer(Netty, env) - when (serverCfg) { - is ServerConfig.Tcp -> { - logger.info("Server listening on http://localhost:${serverCfg.port}") + val local = embeddedServer(Netty, env) + engine = local + when (serverCfg) { + is ServerConfig.Tcp -> { + logger.info("Server listening on http://localhost:${serverCfg.port}") + } + is ServerConfig.Unix -> + throw Exception("Can only serve libeufin-bank via TCP") } - is ServerConfig.Unix -> - throw Exception("Can only serve libeufin-bank via TCP") + local.start(wait = true) } - engine.start(wait = true) } } @@ -331,16 +336,17 @@ class ChangePw : CliktCommand("Change account password", name = "passwd") { val cfg = talerConfig(common.config) val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() - val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - val res = db.account.reconfigPassword(username, password, null, true) - when (res) { - AccountPatchAuthResult.UnknownAccount -> - throw Exception("Password change for '$username' account failed: unknown account") - AccountPatchAuthResult.OldPasswordMismatch, - AccountPatchAuthResult.TanRequired -> { /* Can never happen */ } - AccountPatchAuthResult.Success -> - logger.info("Password change for '$username' account succeeded") + Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + val res = db.account.reconfigPassword(username, password, null, true) + when (res) { + AccountPatchAuthResult.UnknownAccount -> + throw Exception("Password change for '$username' account failed: unknown account") + AccountPatchAuthResult.OldPasswordMismatch, + AccountPatchAuthResult.TanRequired -> { /* Can never happen */ } + AccountPatchAuthResult.Success -> + logger.info("Password change for '$username' account succeeded") + } } } } @@ -376,33 +382,34 @@ class EditAccount : CliktCommand( val cfg = talerConfig(common.config) val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() - val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - val req = AccountReconfiguration( - name = name, - is_taler_exchange = exchange, - is_public = is_public, - contact_data = ChallengeContactData( - // PATCH semantic, if not given do not change, if empty remove - email = if (email == null) Option.None else Option.Some(if (email != "") email else null), - phone = if (phone == null) Option.None else Option.Some(if (phone != "") phone else null), - ), - cashout_payto_uri = Option.Some(cashout_payto_uri), - debit_threshold = debit_threshold - ) - when (patchAccount(db, ctx, req, username, true, false)) { - AccountPatchResult.Success -> - logger.info("Account '$username' edited") - AccountPatchResult.UnknownAccount -> - throw Exception("Account '$username' not found") - AccountPatchResult.MissingTanInfo -> - throw Exception("missing info for tan channel ${req.tan_channel.get()}") - AccountPatchResult.NonAdminName, - AccountPatchResult.NonAdminCashout, - AccountPatchResult.NonAdminDebtLimit, - is AccountPatchResult.TanRequired -> { - // Unreachable as we edit account as admin - } + Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + val req = AccountReconfiguration( + name = name, + is_taler_exchange = exchange, + is_public = is_public, + contact_data = ChallengeContactData( + // PATCH semantic, if not given do not change, if empty remove + email = if (email == null) Option.None else Option.Some(if (email != "") email else null), + phone = if (phone == null) Option.None else Option.Some(if (phone != "") phone else null), + ), + cashout_payto_uri = Option.Some(cashout_payto_uri), + debit_threshold = debit_threshold + ) + when (patchAccount(db, ctx, req, username, true, true)) { + AccountPatchResult.Success -> + logger.info("Account '$username' edited") + AccountPatchResult.UnknownAccount -> + throw Exception("Account '$username' not found") + AccountPatchResult.MissingTanInfo -> + throw Exception("missing info for tan channel ${req.tan_channel.get()}") + AccountPatchResult.NonAdminName, + AccountPatchResult.NonAdminCashout, + AccountPatchResult.NonAdminDebtLimit, + is AccountPatchResult.TanRequired -> { + // Unreachable as we edit account as admin + } + } } } } @@ -454,37 +461,39 @@ class CreateAccount : CliktCommand( val cfg = talerConfig(common.config) val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() - val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - val req = json ?: options?.run { - RegisterAccountRequest( - username = username, - password = password, - name = name, - is_public = is_public, - is_taler_exchange = exchange, - contact_data = ChallengeContactData( - email = Option.Some(email), - phone = Option.Some(phone), - ), - cashout_payto_uri = cashout_payto_uri, - payto_uri = payto_uri, - debit_threshold = debit_threshold - ) - } - req?.let { - val (result, internalPayto) = createAccount(db, ctx, req, true); - when (result) { - AccountCreationResult.BonusBalanceInsufficient -> - throw Exception("Insufficient admin funds to grant bonus") - AccountCreationResult.LoginReuse -> - throw Exception("Account username reuse '${req.username}'") - AccountCreationResult.PayToReuse -> - throw Exception("Bank internalPayToUri reuse '${internalPayto.canonical}'") - AccountCreationResult.Success -> - logger.info("Account '${req.username}' created") + + Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + val req = json ?: options?.run { + RegisterAccountRequest( + username = username, + password = password, + name = name, + is_public = is_public, + is_taler_exchange = exchange, + contact_data = ChallengeContactData( + email = Option.Some(email), + phone = Option.Some(phone), + ), + cashout_payto_uri = cashout_payto_uri, + payto_uri = payto_uri, + debit_threshold = debit_threshold + ) + } + req?.let { + val (result, internalPayto) = createAccount(db, ctx, req, true); + when (result) { + AccountCreationResult.BonusBalanceInsufficient -> + throw Exception("Insufficient admin funds to grant bonus") + AccountCreationResult.LoginReuse -> + throw Exception("Account username reuse '${req.username}'") + AccountCreationResult.PayToReuse -> + throw Exception("Bank internalPayToUri reuse '${internalPayto.canonical}'") + AccountCreationResult.Success -> + logger.info("Account '${req.username}' created") + } + println(internalPayto) } - println(internalPayto) } } } diff --git a/bank/src/main/resources/logback.xml b/bank/src/main/resources/logback.xml @@ -12,6 +12,9 @@ <logger name="tech.libeufin.util" level="ALL" additivity="false"> <appender-ref ref="STDERR" /> </logger> + <logger name="tech.libeufin.nexus" level="ALL" additivity="false"> + <appender-ref ref="STDERR" /> + </logger> <logger name="io.netty" level="INFO" /> <logger name="ktor" level="TRACE" /> diff --git a/database-versioning/libeufin-conversion-setup.sql b/database-versioning/libeufin-conversion-setup.sql @@ -60,9 +60,14 @@ LANGUAGE plpgsql AS $$ FROM libeufin_bank.cashin(now_date, NEW.reserve_public_key, local_amount, subject); SET search_path TO libeufin_nexus; + -- Bounce on soft failures IF too_small THEN - RAISE EXCEPTION 'cashin currency conversion failed: too small amount'; + -- TODO bounce fees ? + PERFORM bounce_incoming(NEW.incoming_transaction_id, ((local_amount).val, (local_amount).frac)::taler_amount, now_date); + RETURN NULL; END IF; + + -- Error on hard failures IF no_config THEN RAISE EXCEPTION 'cashin currency conversion failed: missing conversion rates'; END IF; diff --git a/database-versioning/libeufin-nexus-0001.sql b/database-versioning/libeufin-nexus-0001.sql @@ -50,13 +50,13 @@ CREATE TABLE IF NOT EXISTS incoming_transactions ,wire_transfer_subject TEXT NOT NULL ,execution_time INT8 NOT NULL ,debit_payto_uri TEXT NOT NULL - ,bank_transfer_id TEXT NOT NULL -- EBICS or Depolymerizer (generic) + ,bank_transfer_id TEXT NOT NULL UNIQUE -- EBICS or Depolymerizer (generic) ); -- only active in exchange mode. Note: duplicate keys are another reason to bounce. CREATE TABLE IF NOT EXISTS talerable_incoming_transactions (incoming_transaction_id INT8 NOT NULL UNIQUE REFERENCES incoming_transactions(incoming_transaction_id) ON DELETE CASCADE - ,reserve_public_key BYTEA NOT NULL CHECK (LENGTH(reserve_public_key)=32) UNIQUE + ,reserve_public_key BYTEA NOT NULL UNIQUE CHECK (LENGTH(reserve_public_key)=32) ); CREATE TABLE IF NOT EXISTS outgoing_transactions @@ -65,7 +65,7 @@ CREATE TABLE IF NOT EXISTS outgoing_transactions ,wire_transfer_subject TEXT ,execution_time INT8 NOT NULL ,credit_payto_uri TEXT - ,bank_transfer_id TEXT NOT NULL + ,bank_transfer_id TEXT NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS initiated_outgoing_transactions @@ -76,7 +76,7 @@ CREATE TABLE IF NOT EXISTS initiated_outgoing_transactions ,last_submission_time INT8 ,submission_counter INT NOT NULL DEFAULT 0 ,credit_payto_uri TEXT NOT NULL - ,outgoing_transaction_id INT8 REFERENCES outgoing_transactions (outgoing_transaction_id) + ,outgoing_transaction_id INT8 UNIQUE REFERENCES outgoing_transactions (outgoing_transaction_id) ,submitted submission_state DEFAULT 'unsubmitted' ,hidden BOOL DEFAULT FALSE -- FIXME: explain this. ,request_uid TEXT NOT NULL UNIQUE CHECK (char_length(request_uid) <= 35) diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -1,197 +1,227 @@ BEGIN; +SET search_path TO public; +CREATE EXTENSION IF NOT EXISTS pgcrypto; + SET search_path TO libeufin_nexus; -CREATE OR REPLACE FUNCTION create_incoming_and_bounce( +-- Remove all existing functions +DO +$do$ +DECLARE + _sql text; +BEGIN + SELECT INTO _sql + string_agg(format('DROP %s %s CASCADE;' + , CASE prokind + WHEN 'f' THEN 'FUNCTION' + WHEN 'p' THEN 'PROCEDURE' + END + , oid::regprocedure) + , E'\n') + FROM pg_proc + WHERE pronamespace = 'libeufin_nexus'::regnamespace; + + IF _sql IS NOT NULL THEN + EXECUTE _sql; + END IF; +END +$do$; + +CREATE FUNCTION register_outgoing( IN in_amount taler_amount ,IN in_wire_transfer_subject TEXT ,IN in_execution_time BIGINT - ,IN in_debit_payto_uri TEXT + ,IN in_credit_payto_uri TEXT ,IN in_bank_transfer_id TEXT - ,IN in_timestamp BIGINT - ,IN in_request_uid TEXT - ,IN in_refund_amount taler_amount - ,OUT out_ok BOOLEAN -) RETURNS BOOLEAN + ,OUT out_tx_id BIGINT + ,OUT out_found BOOLEAN + ,OUT out_initiated BOOLEAN +) LANGUAGE plpgsql AS $$ DECLARE -new_tx_id INT8; -new_init_id INT8; +init_id BIGINT; BEGIN --- creating the bounced incoming transaction. -INSERT INTO incoming_transactions ( - amount - ,wire_transfer_subject - ,execution_time - ,debit_payto_uri - ,bank_transfer_id +-- Check if already registered +SELECT outgoing_transaction_id INTO out_tx_id + FROM outgoing_transactions + WHERE bank_transfer_id = in_bank_transfer_id; +IF FOUND THEN + out_found = true; + -- TODO Should we update the subject and credit payto if it's finally found + -- TODO Should we check that amount and other info match ? + SELECT true INTO out_initiated + FROM initiated_outgoing_transactions + WHERE outgoing_transaction_id = out_tx_id; +ELSE + -- Store the transaction in the database + INSERT INTO outgoing_transactions ( + amount + ,wire_transfer_subject + ,execution_time + ,credit_payto_uri + ,bank_transfer_id ) VALUES ( in_amount ,in_wire_transfer_subject ,in_execution_time - ,in_debit_payto_uri + ,in_credit_payto_uri ,in_bank_transfer_id - ) RETURNING incoming_transaction_id INTO new_tx_id; - --- creating its reimbursement. -INSERT INTO initiated_outgoing_transactions ( - amount - ,wire_transfer_subject - ,credit_payto_uri - ,initiation_time - ,request_uid - ) VALUES ( - in_refund_amount - ,'refund: ' || in_wire_transfer_subject - ,in_debit_payto_uri - ,in_timestamp - ,in_request_uid - ) RETURNING initiated_outgoing_transaction_id INTO new_init_id; + ) + RETURNING outgoing_transaction_id + INTO out_tx_id; -INSERT INTO bounced_transactions ( - incoming_transaction_id - ,initiated_outgoing_transaction_id -) VALUES ( - new_tx_id - ,new_init_id -); -out_ok = TRUE; + -- Reconciles the related initiated transaction + UPDATE initiated_outgoing_transactions + SET outgoing_transaction_id = out_tx_id + WHERE request_uid = in_bank_transfer_id + RETURNING true INTO out_initiated; +END IF; END $$; +COMMENT ON FUNCTION register_outgoing + IS 'Register an outgoing transaction and optionally reconciles the related initiated transaction with it'; -COMMENT ON FUNCTION create_incoming_and_bounce(taler_amount, TEXT, BIGINT, TEXT, TEXT, BIGINT, TEXT, taler_amount) - IS 'creates one incoming transaction with a bounced state and initiates its related refund.'; - -CREATE OR REPLACE FUNCTION create_outgoing_payment( +CREATE FUNCTION register_incoming( IN in_amount taler_amount ,IN in_wire_transfer_subject TEXT ,IN in_execution_time BIGINT - ,IN in_credit_payto_uri TEXT + ,IN in_debit_payto_uri TEXT ,IN in_bank_transfer_id TEXT - ,IN in_initiated_id BIGINT - ,OUT out_nx_initiated BOOLEAN + ,OUT out_found BOOLEAN + ,OUT out_tx_id BIGINT ) LANGUAGE plpgsql AS $$ -DECLARE -new_outgoing_transaction_id BIGINT; BEGIN - -IF in_initiated_id IS NULL THEN - out_nx_initiated = FALSE; +-- Check if already registered +SELECT incoming_transaction_id INTO out_tx_id + FROM incoming_transactions + WHERE bank_transfer_id = in_bank_transfer_id; +IF FOUND THEN + out_found = true; + -- TODO Should we check that amount and other info match ? ELSE - PERFORM 1 - FROM initiated_outgoing_transactions - WHERE initiated_outgoing_transaction_id = in_initiated_id; - IF NOT FOUND THEN - out_nx_initiated = TRUE; - RETURN; - END IF; -END IF; - -INSERT INTO outgoing_transactions ( - amount - ,wire_transfer_subject - ,execution_time - ,credit_payto_uri - ,bank_transfer_id -) VALUES ( - in_amount - ,in_wire_transfer_subject - ,in_execution_time - ,in_credit_payto_uri - ,in_bank_transfer_id -) - RETURNING outgoing_transaction_id - INTO new_outgoing_transaction_id; - -IF in_initiated_id IS NOT NULL -THEN - UPDATE initiated_outgoing_transactions - SET outgoing_transaction_id = new_outgoing_transaction_id - WHERE initiated_outgoing_transaction_id = in_initiated_id; + -- Store the transaction in the database + INSERT INTO incoming_transactions ( + amount + ,wire_transfer_subject + ,execution_time + ,debit_payto_uri + ,bank_transfer_id + ) VALUES ( + in_amount + ,in_wire_transfer_subject + ,in_execution_time + ,in_debit_payto_uri + ,in_bank_transfer_id + ) RETURNING incoming_transaction_id INTO out_tx_id; END IF; END $$; - -COMMENT ON FUNCTION create_outgoing_payment(taler_amount, TEXT, BIGINT, TEXT, TEXT, BIGINT) - IS 'Creates a new outgoing payment and optionally reconciles the related initiated payment with it. If the initiated payment to reconcile is not found, it inserts NOTHING.'; - -CREATE OR REPLACE FUNCTION bounce_payment( - IN in_incoming_transaction_id BIGINT - ,IN in_initiation_time BIGINT - ,IN in_request_uid TEXT - ,OUT out_nx_incoming_payment BOOLEAN +COMMENT ON FUNCTION register_incoming + IS 'Register an incoming transaction'; + +CREATE FUNCTION bounce_incoming( + IN tx_id BIGINT + ,IN in_bounce_amount taler_amount + ,IN in_now_date BIGINT + ,OUT out_bounce_id TEXT ) LANGUAGE plpgsql AS $$ +DECLARE +bank_id TEXT; +payto_uri TEXT; +init_id BIGINT; BEGIN - +-- Get incoming transaction bank ID and creditor +SELECT bank_transfer_id, debit_payto_uri + INTO bank_id, payto_uri + FROM incoming_transactions + WHERE incoming_transaction_id = tx_id; +-- Generate a bounce ID deterministically from the bank ID +-- We hash the bank ID with SHA-256 then we encode the hash using base64 +-- As bank id can be at most 35 characters long we truncate the encoded hash +-- We are not sure whether this field is case-insensitive in all banks as the standard +-- does not clearly specify this, so we have chosen to capitalise it +SELECT upper(substr(encode(public.digest(bank_id, 'sha256'), 'base64'), 0, 35)) INTO out_bounce_id; + +-- Initiate the bounce transaction INSERT INTO initiated_outgoing_transactions ( amount ,wire_transfer_subject ,credit_payto_uri ,initiation_time ,request_uid + ) VALUES ( + in_bounce_amount + ,'bounce: ' || bank_id + ,payto_uri + ,in_now_date + ,out_bounce_id ) - SELECT - amount - ,'refund: ' || wire_transfer_subject - ,debit_payto_uri - ,in_initiation_time - ,in_request_uid - FROM incoming_transactions - WHERE incoming_transaction_id = in_incoming_transaction_id; - -IF NOT FOUND THEN - out_nx_incoming_payment=TRUE; - RETURN; + ON CONFLICT (request_uid) DO NOTHING -- idempotent + RETURNING initiated_outgoing_transaction_id INTO init_id; +IF FOUND THEN + -- Register the bounce + INSERT INTO bounced_transactions ( + incoming_transaction_id ,initiated_outgoing_transaction_id + ) VALUES (tx_id, init_id); END IF; -out_nx_incoming_payment=FALSE; +END$$; +COMMENT ON FUNCTION bounce_incoming + IS 'Bounce an incoming transaction, initiate a bouce outgoing transaction with a deterministic ID'; --- finally setting the payment as bounced. Not checking --- the update outcome since the row existence was checked --- just above. +CREATE FUNCTION register_incoming_and_bounce( + IN in_amount taler_amount + ,IN in_wire_transfer_subject TEXT + ,IN in_execution_time BIGINT + ,IN in_debit_payto_uri TEXT + ,IN in_bank_transfer_id TEXT + ,IN in_bounce_amount taler_amount + ,IN in_now_date BIGINT + ,OUT out_found BOOLEAN + ,OUT out_tx_id BIGINT + ,OUT out_bounce_id TEXT +) +LANGUAGE plpgsql AS $$ +DECLARE +init_id BIGINT; +BEGIN +-- Register the incoming transaction +SELECT reg.out_found, reg.out_tx_id + FROM register_incoming(in_amount, in_wire_transfer_subject, in_execution_time, in_debit_payto_uri, in_bank_transfer_id) as reg + INTO out_found, out_tx_id; -UPDATE incoming_transactions - SET bounced = true - WHERE incoming_transaction_id = in_incoming_transaction_id; +-- Bounce the incoming transaction +SELECT b.out_bounce_id INTO out_bounce_id FROM bounce_incoming(out_tx_id, in_bounce_amount, in_now_date) as b; END $$; +COMMENT ON FUNCTION register_incoming_and_bounce + IS 'Register an incoming transaction and bounce it'; -COMMENT ON FUNCTION bounce_payment(BIGINT, BIGINT, TEXT) IS 'Marks an incoming payment as bounced and initiates its refunding payment'; - -CREATE OR REPLACE FUNCTION create_incoming_talerable( +CREATE FUNCTION register_incoming_and_talerable( IN in_amount taler_amount ,IN in_wire_transfer_subject TEXT ,IN in_execution_time BIGINT ,IN in_debit_payto_uri TEXT ,IN in_bank_transfer_id TEXT ,IN in_reserve_public_key BYTEA - ,OUT out_ok BOOLEAN -) RETURNS BOOLEAN + ,OUT out_found BOOLEAN + ,OUT out_tx_id BIGINT +) LANGUAGE plpgsql AS $$ -DECLARE -new_tx_id INT8; BEGIN -INSERT INTO incoming_transactions ( - amount - ,wire_transfer_subject - ,execution_time - ,debit_payto_uri - ,bank_transfer_id - ) VALUES ( - in_amount - ,in_wire_transfer_subject - ,in_execution_time - ,in_debit_payto_uri - ,in_bank_transfer_id - ) RETURNING incoming_transaction_id INTO new_tx_id; +-- Register the incoming transaction +SELECT reg.out_found, reg.out_tx_id + FROM register_incoming(in_amount, in_wire_transfer_subject, in_execution_time, in_debit_payto_uri, in_bank_transfer_id) as reg + INTO out_found, out_tx_id; + +-- Register as talerable bounce INSERT INTO talerable_incoming_transactions ( incoming_transaction_id ,reserve_public_key ) VALUES ( - new_tx_id + out_tx_id ,in_reserve_public_key -); -out_ok = TRUE; +) ON CONFLICT (incoming_transaction_id) DO NOTHING; END $$; - -COMMENT ON FUNCTION create_incoming_talerable(taler_amount, TEXT, BIGINT, TEXT, TEXT, BYTEA) IS ' +COMMENT ON FUNCTION register_incoming_and_talerable IS ' Creates one row in the incoming transactions table and one row in the talerable transactions table. The talerable row links the incoming one.'; \ No newline at end of file diff --git a/integration/build.gradle b/integration/build.gradle @@ -1,5 +1,6 @@ plugins { id("kotlin") + id("application") } java { @@ -10,18 +11,29 @@ java { compileKotlin.kotlinOptions.jvmTarget = "17" compileTestKotlin.kotlinOptions.jvmTarget = "17" -sourceSets.test.java.srcDirs = ["test"] +sourceSets.main.java.srcDirs = ["src/main/kotlin"] dependencies { - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") - testImplementation(project(":util")) - testImplementation(project(":bank")) - testImplementation(project(":nexus")) + implementation(project(":util")) + implementation(project(":bank")) + implementation(project(":nexus")) - testImplementation("com.github.ajalt.clikt:clikt:$clikt_version") + implementation("com.github.ajalt.clikt:clikt:$clikt_version") - testImplementation("io.ktor:ktor-server-test-host:$ktor_version") - testImplementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") - testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + implementation("org.postgresql:postgresql:$postgres_version") + + implementation("io.ktor:ktor-server-test-host:$ktor_version") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") +} + +application { + mainClass = "tech.libeufin.integration.MainKt" + applicationName = "libeufin-integration-test" +} + +run { + standardInput = System.in } \ No newline at end of file diff --git a/integration/conf/integration.conf b/integration/conf/integration.conf @@ -6,7 +6,6 @@ allow_conversion = YES FIAT_CURRENCY = EUR tan_sms = libeufin-tan-file.sh tan_email = libeufin-tan-fail.sh -PORT = 8090 [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck diff --git a/integration/conf/netzbon.conf b/integration/conf/netzbon.conf @@ -0,0 +1,30 @@ +[nexus-ebics] +CURRENCY = CHF + +# Bank +HOST_BASE_URL = https://ebics.postfinance.ch/ebics/ebics.aspx +BANK_DIALECT = postfinance + +# EBICS IDs +HOST_ID = PFEBICS +USER_ID = 5183101 +PARTNER_ID = 51831 + + +BANK_PUBLIC_KEYS_FILE = test/netzbon/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/netzbon/client-keys.json + +IBAN = CH4009000000160948810 +BIC = POFICHBEXXX +NAME = Genossenschaft Netz Soziale Oekonomie + +[nexus-fetch] +FREQUENCY = 5s +STATEMENT_LOG_DIRECTORY = test/netzbon/fetch + +[nexus-submit] +FREQUENCY = 5s +SUBMISSIONS_LOG_DIRECTORY = test/netzbon/submit + +[nexus-postgres] +CONFIG = postgres:///libeufincheck diff --git a/integration/conf/postfinance.conf b/integration/conf/postfinance.conf @@ -0,0 +1,29 @@ +[nexus-ebics] +currency = CHF + +# Bank +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_DIALECT = postfinance + +# EBICS IDs +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 + +# Key files +BANK_PUBLIC_KEYS_FILE = test/postfinance/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/postfinance/client-keys.json + +#IBAN = CH2989144971918294289 +IBAN = CH7789144474425692816 + +[nexus-fetch] +FREQUENCY = 5s +STATEMENT_LOG_DIRECTORY = test/postfinance/fetch + +[nexus-submit] +FREQUENCY = 5s +SUBMISSIONS_LOG_DIRECTORY = test/postfinance/submit + +[nexus-postgres] +CONFIG = postgres:///libeufincheck diff --git a/integration/src/main/kotlin/Main.kt b/integration/src/main/kotlin/Main.kt @@ -0,0 +1,184 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 Taler Systems S.A. + * + * LibEuFin 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. + * + * LibEuFin 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 Affero General + * Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.integration + +import tech.libeufin.nexus.Database as NexusDb +import tech.libeufin.nexus.TalerAmount as NexusAmount +import tech.libeufin.nexus.* +import tech.libeufin.bank.* +import tech.libeufin.util.* +import com.github.ajalt.clikt.core.* +import com.github.ajalt.clikt.parameters.arguments.* +import com.github.ajalt.clikt.parameters.types.* +import com.github.ajalt.clikt.testing.* +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import kotlin.test.* +import java.io.File +import java.nio.file.* +import java.time.Instant +import kotlinx.coroutines.runBlocking +import io.ktor.client.request.* +import net.taler.wallet.crypto.Base32Crockford +import kotlin.io.path.* + +fun randBytes(lenght: Int): ByteArray { + val bytes = ByteArray(lenght) + kotlin.random.Random.nextBytes(bytes) + return bytes +} + +val nexusCmd = LibeufinNexusCommand() +val client = HttpClient(CIO) + +fun step(name: String) { + println("\u001b[35m$name\u001b[0m") +} + +fun ask(question: String): String? { + print("\u001b[;1m$question\u001b[0m") + System.out.flush() + return readlnOrNull() +} + +fun CliktCommandTestResult.assertOk(msg: String? = null) { + assertEquals(0, statusCode, msg) +} + +fun CliktCommandTestResult.assertErr(msg: String? = null) { + assertEquals(1, statusCode, msg) +} + +enum class Kind { + postfinance, + netzbon +} + +class Cli : CliktCommand("Run integration tests on banks provider") { + val kind: Kind by argument().enum<Kind>() + override fun run() { + val name = kind.name + step("Test init $name") + + runBlocking { + Path("test/$name").createDirectories() + val conf = "conf/$name.conf" + val cfg = loadConfig(conf) + + val clientKeysPath = Path(cfg.requireString("nexus-ebics", "client_private_keys_file")) + val bankKeysPath = Path(cfg.requireString("nexus-ebics", "bank_public_keys_file")) + + var hasClientKeys = clientKeysPath.exists() + var hasBankKeys = bankKeysPath.exists() + + if (ask("Reset DB ? y/n>") == "y") nexusCmd.test("dbinit -r -c $conf").assertOk() + else nexusCmd.test("dbinit -c $conf").assertOk() + val nexusDb = NexusDb("postgresql:///libeufincheck") + + when (kind) { + Kind.postfinance -> { + if (hasClientKeys || hasBankKeys) { + if (ask("Reset keys ? y/n>") == "y") { + if (hasClientKeys) clientKeysPath.deleteIfExists() + if (hasBankKeys) bankKeysPath.deleteIfExists() + hasClientKeys = false + hasBankKeys = false + } + } + + if (!hasClientKeys) { + step("Test INI order") + ask("Got to https://isotest.postfinance.ch/corporates/user/settings/ebics and click on 'Reset EBICS user'.\nPress Enter when done>") + nexusCmd.test("ebics-setup -c $conf") + .assertErr("ebics-setup should failed the first time") + } + + if (!hasBankKeys) { + step("Test HIA order") + ask("Got to https://isotest.postfinance.ch/corporates/user/settings/ebics and click on 'Activate EBICS user'.\nPress Enter when done>") + nexusCmd.test("ebics-setup --auto-accept-keys -c $conf") + .assertOk("ebics-setup should succeed the second time") + } + + if (ask("Submit transactions ? y/n>") == "y") { + val payto = "payto://iban/CH2989144971918294289?receiver-name=Test" + + step("Test submit one transaction") + nexusDb.initiatedPaymentCreate(InitiatedPayment( + amount = NexusAmount(42L, 0, "CFH"), + creditPaytoUri = payto, + wireTransferSubject = "single transaction test", + initiationTime = Instant.now(), + requestUid = Base32Crockford.encode(randBytes(16)) + )) + nexusCmd.test("ebics-submit --transient -c $conf").assertOk() + + step("Test submit many transaction") + repeat(4) { + nexusDb.initiatedPaymentCreate(InitiatedPayment( + amount = NexusAmount(100L + it, 0, "CFH"), + creditPaytoUri = payto, + wireTransferSubject = "multi transaction test $it", + initiationTime = Instant.now(), + requestUid = Base32Crockford.encode(randBytes(16)) + )) + } + nexusCmd.test("ebics-submit --transient -c $conf").assertOk() + } + + step("Test fetch transactions") + nexusCmd.test("ebics-fetch --transient -c $conf --pinned-start 2022-01-01").assertOk() + } + Kind.netzbon -> { + if (!hasClientKeys) + throw Exception("Clients keys are required to run netzbon tests") + + if (!hasBankKeys) { + step("Test HIA order") + nexusCmd.test("ebics-setup --auto-accept-keys -c $conf").assertOk("ebics-setup should succeed the second time") + } + + step("Test fetch transactions") + nexusCmd.test("ebics-fetch --transient -c $conf --pinned-start 2022-01-01").assertOk() + + while (true) { + when (ask("Run 'fetch', 'submit' or 'exit'>")) { + "fetch" -> { + step("Fetch new transactions") + nexusCmd.test("ebics-fetch --transient -c $conf").assertOk() + } + "submit" -> { + step("Submit pending transactions") + nexusCmd.test("ebics-submit --transient -c $conf").assertOk() + } + "exit" -> break + } + } + } + } + } + + step("Test succeed") + } +} + +fun main(args: Array<String>) { + Cli().main(args) +} diff --git a/integration/src/test/kotlin/IntegrationTest.kt b/integration/src/test/kotlin/IntegrationTest.kt @@ -0,0 +1,328 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 Taler Systems S.A. + * + * LibEuFin 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. + * + * LibEuFin 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 Affero General + * Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import org.junit.Test +import net.taler.wallet.crypto.Base32Crockford +import tech.libeufin.bank.* +import tech.libeufin.bank.TalerAmount as BankAmount +import tech.libeufin.nexus.* +import tech.libeufin.nexus.Database as NexusDb +import tech.libeufin.nexus.TalerAmount as NexusAmount +import tech.libeufin.bank.db.AccountDAO.* +import tech.libeufin.util.* +import java.io.File +import java.time.Instant +import java.util.Arrays +import java.sql.SQLException +import kotlinx.coroutines.runBlocking +import com.github.ajalt.clikt.testing.test +import com.github.ajalt.clikt.core.CliktCommand +import org.postgresql.jdbc.PgConnection +import kotlin.test.* +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.HttpStatusCode + +fun CliktCommand.run(cmd: String) { + val result = test(cmd) + if (result.statusCode != 0) + throw Exception(result.output) + println(result.output) +} + +fun HttpResponse.assertNoContent() { + assertEquals(HttpStatusCode.NoContent, this.status) +} + +fun randBytes(lenght: Int): ByteArray { + val bytes = ByteArray(lenght) + kotlin.random.Random.nextBytes(bytes) + return bytes +} + +fun server(lambda: () -> Unit) { + // Start the HTTP server in another thread + kotlin.concurrent.thread(isDaemon = true) { + lambda() + } + // Wait for the HTTP server to be up + runBlocking { + HttpClient(CIO) { + install(HttpRequestRetry) { + maxRetries = 10 + constantDelay(200, 100) + } + }.get("http://0.0.0.0:8080/config") + } + +} + +fun setup(lambda: suspend (NexusDb) -> Unit) { + try { + runBlocking { + NexusDb("postgresql:///libeufincheck").use { + lambda(it) + } + } + } finally { + engine?.stop(0, 0) // Stop http server if started + } +} + +inline fun assertException(msg: String, lambda: () -> Unit) { + try { + lambda() + throw Exception("Expected failure: $msg") + } catch (e: Exception) { + assert(e.message!!.startsWith(msg)) { "${e.message}" } + } +} + +class IntegrationTest { + val nexusCmd = LibeufinNexusCommand() + val bankCmd = LibeufinBankCommand(); + val client = HttpClient(CIO) + + @Test + fun mini() { + bankCmd.run("dbinit -c conf/mini.conf -r") + bankCmd.run("passwd admin password -c conf/mini.conf") + bankCmd.run("dbinit -c conf/mini.conf") // Indempotent + + server { + bankCmd.run("serve -c conf/mini.conf") + } + + setup { _ -> + // Check bank is running + client.get("http://0.0.0.0:8080/public-accounts").assertNoContent() + } + } + + @Test + fun errors() { + nexusCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("passwd admin password -c conf/integration.conf") + + suspend fun checkCount(db: NexusDb, nbIncoming: Int, nbBounce: Int, nbTalerable: Int) { + db.runConn { conn -> + conn.prepareStatement("SELECT count(*) FROM incoming_transactions").oneOrNull { + assertEquals(nbIncoming, it.getInt(1)) + } + conn.prepareStatement("SELECT count(*) FROM bounced_transactions").oneOrNull { + assertEquals(nbBounce, it.getInt(1)) + } + conn.prepareStatement("SELECT count(*) FROM talerable_incoming_transactions").oneOrNull { + assertEquals(nbTalerable, it.getInt(1)) + } + } + } + + setup { db -> + val userPayTo = IbanPayTo(genIbanPaytoUri()) + val fiatPayTo = IbanPayTo(genIbanPaytoUri()) + + // Load conversion setup manually as the server would refuse to start without an exchange account + val sqlProcedures = File("../database-versioning/libeufin-conversion-setup.sql") + db.runConn { + it.execSQLUpdate(sqlProcedures.readText()) + it.execSQLUpdate("SET search_path TO libeufin_nexus;") + } + + val reservePub = randBytes(32) + val payment = IncomingPayment( + amount = NexusAmount(10, 0, "EUR"), + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = "Error test ${Base32Crockford.encode(reservePub)}", + executionTime = Instant.now(), + bankTransferId = "error" + ) + + assertException("ERROR: cashin failed: missing exchange account") { + ingestIncomingPayment(db, payment) + } + + // Create exchange account + bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") + + assertException("ERROR: cashin currency conversion failed: missing conversion rates") { + ingestIncomingPayment(db, payment) + } + + // Start server + server { + bankCmd.run("serve -c conf/integration.conf") + } + + // Set conversion rates + client.post("http://0.0.0.0:8080/conversion-info/conversion-rate") { + basicAuth("admin", "password") + json { + "cashin_ratio" to "0.8" + "cashin_fee" to "KUDOS:0.02" + "cashin_tiny_amount" to "KUDOS:0.01" + "cashin_rounding_mode" to "nearest" + "cashin_min_amount" to "EUR:0" + "cashout_ratio" to "1.25" + "cashout_fee" to "EUR:0.003" + "cashout_tiny_amount" to "EUR:0.00000001" + "cashout_rounding_mode" to "zero" + "cashout_min_amount" to "KUDOS:0.1" + } + }.assertNoContent() + + assertException("ERROR: cashin failed: admin balance insufficient") { + db.registerTalerableIncoming(payment, reservePub) + } + + // Allow admin debt + bankCmd.run("edit-account admin --debit_threshold KUDOS:100 -c conf/integration.conf") + + // Too small amount + checkCount(db, 0, 0, 0) + ingestIncomingPayment(db, payment.copy( + amount = NexusAmount(0, 10, "EUR"), + )) + checkCount(db, 1, 1, 0) + client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { + basicAuth("exchange", "password") + }.assertNoContent() + + // Check success + ingestIncomingPayment(db, IncomingPayment( + amount = NexusAmount(10, 0, "EUR"), + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = "Success ${Base32Crockford.encode(randBytes(32))}", + executionTime = Instant.now(), + bankTransferId = "success" + )) + checkCount(db, 2, 1, 1) + client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { + basicAuth("exchange", "password") + }.assertOkJson<BankAccountTransactionsResponse>() + + // TODO check double insert cashin with different subject + } + } + + @Test + fun conversion() { + nexusCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("passwd admin password -c conf/integration.conf") + bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 -c conf/integration.conf") + bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") + nexusCmd.run("dbinit -c conf/integration.conf") // Idempotent + bankCmd.run("dbinit -c conf/integration.conf") // Idempotent + + server { + bankCmd.run("serve -c conf/integration.conf") + } + + setup { db -> + val userPayTo = IbanPayTo(genIbanPaytoUri()) + val fiatPayTo = IbanPayTo(genIbanPaytoUri()) + + // Create user + client.post("http://0.0.0.0:8080/accounts") { + basicAuth("admin", "password") + json { + "username" to "customer" + "password" to "password" + "name" to "JohnSmith" + "internal_payto_uri" to userPayTo + "cashout_payto_uri" to fiatPayTo + "debit_threshold" to "KUDOS:100" + "contact_data" to obj { + "phone" to "+99" + } + } + }.assertOkJson<RegisterAccountResponse>() + + // Set conversion rates + client.post("http://0.0.0.0:8080/conversion-info/conversion-rate") { + basicAuth("admin", "password") + json { + "cashin_ratio" to "0.8" + "cashin_fee" to "KUDOS:0.02" + "cashin_tiny_amount" to "KUDOS:0.01" + "cashin_rounding_mode" to "nearest" + "cashin_min_amount" to "EUR:0" + "cashout_ratio" to "1.25" + "cashout_fee" to "EUR:0.003" + "cashout_tiny_amount" to "EUR:0.00000001" + "cashout_rounding_mode" to "zero" + "cashout_min_amount" to "KUDOS:0.1" + } + }.assertNoContent() + + // Cashin + repeat(3) { i -> + val reservePub = randBytes(32); + val amount = NexusAmount(20L + i, 0, "EUR") + val subject = "cashin test $i: ${Base32Crockford.encode(reservePub)}" + ingestIncomingPayment(db, + IncomingPayment( + amount = amount, + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = subject, + executionTime = Instant.now(), + bankTransferId = Base32Crockford.encode(reservePub) + ) + ) + val converted = client.get("http://0.0.0.0:8080/conversion-info/cashin-rate?amount_debit=EUR:${20 + i}") + .assertOkJson<ConversionResponse>().amount_credit + client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { + basicAuth("exchange", "password") + }.assertOkJson<BankAccountTransactionsResponse> { + val tx = it.transactions.first() + assertEquals(subject, tx.subject) + assertEquals(converted, tx.amount) + } + client.get("http://0.0.0.0:8080/accounts/exchange/taler-wire-gateway/history/incoming") { + basicAuth("exchange", "password") + }.assertOkJson<IncomingHistory> { + val tx = it.incoming_transactions.first() + assertEquals(converted, tx.amount) + assert(Arrays.equals(reservePub, tx.reserve_pub.raw)) + } + } + + // Cashout + repeat(3) { i -> + val requestUid = randBytes(32); + val amount = BankAmount("KUDOS:${10+i}") + val convert = client.get("http://0.0.0.0:8080/conversion-info/cashout-rate?amount_debit=$amount") + .assertOkJson<ConversionResponse>().amount_credit; + client.post("http://0.0.0.0:8080/accounts/customer/cashouts") { + basicAuth("customer", "password") + json { + "request_uid" to ShortHashCode(requestUid) + "amount_debit" to amount + "amount_credit" to convert + } + }.assertOkJson<CashoutResponse>() + } + } + } +} diff --git a/integration/test/IntegrationTest.kt b/integration/test/IntegrationTest.kt @@ -1,181 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023 Taler Systems S.A. - * - * LibEuFin 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. - * - * LibEuFin 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 Affero General - * Public License for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -import org.junit.Test -import tech.libeufin.bank.* -import tech.libeufin.bank.TalerAmount as BankAmount -import tech.libeufin.nexus.* -import tech.libeufin.nexus.Database as NexusDb -import tech.libeufin.nexus.TalerAmount as NexusAmount -import tech.libeufin.bank.db.AccountDAO.* -import tech.libeufin.util.* -import java.io.File -import java.time.Instant -import java.util.Arrays -import kotlinx.coroutines.runBlocking -import com.github.ajalt.clikt.testing.test -import com.github.ajalt.clikt.core.CliktCommand -import kotlin.test.* -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.HttpStatusCode - -fun CliktCommand.run(cmd: String) { - val result = test(cmd) - if (result.statusCode != 0) - throw Exception(result.output) - println(result.output) -} - -fun HttpResponse.assertNoContent() { - assertEquals(HttpStatusCode.NoContent, this.status) -} - -fun randBytes(lenght: Int): ByteArray { - val bytes = ByteArray(lenght) - kotlin.random.Random.nextBytes(bytes) - return bytes -} - -class IntegrationTest { - val nexusCmd = LibeufinNexusCommand() - val bankCmd = LibeufinBankCommand(); - val client = HttpClient(CIO) { - install(HttpRequestRetry) { - maxRetries = 10 - constantDelay(200, 100) - } - } - - @Test - fun mini() { - bankCmd.run("dbinit -c conf/mini.conf -r") - bankCmd.run("passwd admin password -c conf/mini.conf") - bankCmd.run("dbinit -c conf/mini.conf") // Indempotent - kotlin.concurrent.thread(isDaemon = true) { - bankCmd.run("serve -c conf/mini.conf") - } - - runBlocking { - // Check bank is running - client.get("http://0.0.0.0:8080/public-accounts").assertNoContent() - } - } - - @Test - fun conversion() { - nexusCmd.run("dbinit -c conf/integration.conf -r") - bankCmd.run("dbinit -c conf/integration.conf -r") - bankCmd.run("passwd admin password -c conf/integration.conf") - bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 -c conf/integration.conf") - bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") - nexusCmd.run("dbinit -c conf/integration.conf") // Idempotent - bankCmd.run("dbinit -c conf/integration.conf") // Idempotent - kotlin.concurrent.thread(isDaemon = true) { - bankCmd.run("serve -c conf/integration.conf") - } - - runBlocking { - val nexusDb = NexusDb("postgresql:///libeufincheck") - val userPayTo = IbanPayTo(genIbanPaytoUri()) - val fiatPayTo = IbanPayTo(genIbanPaytoUri()) - - // Create user - client.post("http://0.0.0.0:8090/accounts") { - basicAuth("admin", "password") - json { - "username" to "customer" - "password" to "password" - "name" to "JohnSmith" - "internal_payto_uri" to userPayTo - "cashout_payto_uri" to fiatPayTo - "debit_threshold" to "KUDOS:100" - "contact_data" to obj { - "phone" to "+99" - } - } - }.assertOkJson<RegisterAccountResponse>() - - // Set conversion rates - client.post("http://0.0.0.0:8090/conversion-info/conversion-rate") { - basicAuth("admin", "password") - json { - "cashin_ratio" to "0.8" - "cashin_fee" to "KUDOS:0.02" - "cashin_tiny_amount" to "KUDOS:0.01" - "cashin_rounding_mode" to "nearest" - "cashin_min_amount" to "EUR:0" - "cashout_ratio" to "1.25" - "cashout_fee" to "EUR:0.003" - "cashout_tiny_amount" to "EUR:0.00000001" - "cashout_rounding_mode" to "zero" - "cashout_min_amount" to "KUDOS:0.1" - } - }.assertNoContent() - - // Cashin - repeat(3) { i -> - val reservePub = randBytes(32); - val amount = NexusAmount(20L + i, 0, "EUR") - nexusDb.incomingTalerablePaymentCreate(IncomingPayment( - amount = amount, - debitPaytoUri = userPayTo.canonical, - wireTransferSubject = "cashin test $i", - executionTime = Instant.now(), - bankTransferId = "entropic"), - reservePub) - val converted = client.get("http://0.0.0.0:8090/conversion-info/cashin-rate?amount_debit=EUR:${20 + i}") - .assertOkJson<ConversionResponse>().amount_credit - client.get("http://0.0.0.0:8090/accounts/exchange/transactions") { - basicAuth("exchange", "password") - }.assertOkJson<BankAccountTransactionsResponse> { - val tx = it.transactions.first() - assertEquals("cashin test $i", tx.subject) - assertEquals(converted, tx.amount) - } - client.get("http://0.0.0.0:8090/accounts/exchange/taler-wire-gateway/history/incoming") { - basicAuth("exchange", "password") - }.assertOkJson<IncomingHistory> { - val tx = it.incoming_transactions.first() - assertEquals(converted, tx.amount) - assert(Arrays.equals(reservePub, tx.reserve_pub.raw)) - } - } - - // Cashout - repeat(3) { i -> - val requestUid = randBytes(32); - val amount = BankAmount("KUDOS:${10+i}") - val convert = client.get("http://0.0.0.0:8090/conversion-info/cashout-rate?amount_debit=$amount") - .assertOkJson<ConversionResponse>().amount_credit; - client.post("http://0.0.0.0:8090/accounts/customer/cashouts") { - basicAuth("customer", "password") - json { - "request_uid" to ShortHashCode(requestUid) - "amount_debit" to amount - "amount_credit" to convert - } - }.assertOkJson<CashoutResponse>() - } - } - } -} diff --git a/nexus/build.gradle b/nexus/build.gradle @@ -57,11 +57,6 @@ dependencies { testImplementation("io.ktor:ktor-client-mock:$ktor_version") } -test { - failFast = true - testLogging.showStandardStreams = false -} - application { mainClassName = "tech.libeufin.nexus.MainKt" applicationName = "libeufin-nexus" diff --git a/nexus/conf/test.conf b/nexus/conf/test.conf @@ -0,0 +1,13 @@ +[nexus-ebics] +currency = CHF +BANK_DIALECT = postfinance +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_PUBLIC_KEYS_FILE = test/tmp/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/tmp/client-keys.json +IBAN = CH7789144474425692816 +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 + +[nexus-postgres] +CONFIG = postgres:///libeufincheck +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt @@ -9,6 +9,14 @@ import tech.libeufin.util.* import java.sql.PreparedStatement import java.sql.SQLException import java.time.Instant +import java.util.Date +import java.text.SimpleDateFormat + +fun Instant.fmtDate(): String { + val formatter = SimpleDateFormat("yyyy-MM-dd") + return formatter.format(Date.from(this)) +} + // Remove this once TalerAmount from the bank // module gets moved to the 'util' module (#7987). @@ -16,7 +24,16 @@ data class TalerAmount( val value: Long, val fraction: Int, // has at most 8 digits. val currency: String -) +) { + override fun toString(): String { + if (fraction == 0) { + return "$currency:$value" + } else { + return "$currency:$value.${fraction.toString().padStart(8, '0')}" + .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 + } + } +} // INCOMING PAYMENTS STRUCTS @@ -29,7 +46,12 @@ data class IncomingPayment( val debitPaytoUri: String, val executionTime: Instant, val bankTransferId: String -) +) { + override fun toString(): String { + return "IN ${executionTime.fmtDate()} '$amount $bankTransferId' debitor=$debitPaytoUri subject=$wireTransferSubject" + } +} + // INITIATED PAYMENTS STRUCTS @@ -104,27 +126,31 @@ data class OutgoingPayment( val bankTransferId: String, val creditPaytoUri: String? = null, // not showing in camt.054 val wireTransferSubject: String? = null // not showing in camt.054 +) { + override fun toString(): String { + return "OUT ${executionTime.fmtDate()} $amount '$bankTransferId' creditor=$creditPaytoUri subject=$wireTransferSubject" + } +} + +/** Outgoing payments registration result */ +data class OutgoingRegistrationResult( + val id: Long, + val initiated: Boolean, + val new: Boolean ) -/** - * Witnesses the outcome of inserting an outgoing - * payment into the database. - */ -enum class OutgoingPaymentOutcome { - /** - * The caller wanted to link a previously initiated payment - * to this outgoing one, but the row ID passed to the inserting - * function could not be found in the payment initiations table. - * Note: NO insertion takes place in this case. - */ - INITIATED_COUNTERPART_NOT_FOUND, - /** - * The outgoing payment got inserted and _in case_ the caller - * wanted to link a previously initiated payment to this one, that - * succeeded too. - */ - SUCCESS -} +/** Incoming payments registration result */ +data class IncomingRegistrationResult( + val id: Long, + val new: Boolean +) + +/** Incoming payments bounce registration result */ +data class IncomingBounceRegistrationResult( + val id: Long, + val bounceId: String, + val new: Boolean +) /** * Performs a INSERT, UPDATE, or DELETE operation. @@ -184,28 +210,21 @@ class Database(dbConfig: String): java.io.Closeable { // OUTGOING PAYMENTS METHODS /** - * Creates one outgoing payment OPTIONALLY reconciling it with its + * Register an outgoing payment OPTIONALLY reconciling it with its * initiated payment counterpart. * * @param paymentData information about the outgoing payment. - * @param reconcileId optional row ID of the initiated payment - * that will reference this one. If null, then only the - * outgoing payment record gets inserted. * @return operation outcome enum. */ - suspend fun outgoingPaymentCreate( - paymentData: OutgoingPayment, - reconcileId: Long? = null - ): OutgoingPaymentOutcome = runConn { + suspend fun registerOutgoing(paymentData: OutgoingPayment): OutgoingRegistrationResult = runConn { val stmt = it.prepareStatement(""" - SELECT out_nx_initiated - FROM create_outgoing_payment( + SELECT out_tx_id, out_initiated, out_found + FROM register_outgoing( (?,?)::taler_amount ,? ,? ,? ,? - ,? )""" ) val executionTime = paymentData.executionTime.toDbMicros() @@ -216,116 +235,112 @@ class Database(dbConfig: String): java.io.Closeable { stmt.setLong(4, executionTime) stmt.setString(5, paymentData.creditPaytoUri) stmt.setString(6, paymentData.bankTransferId) - if (reconcileId == null) - stmt.setNull(7, java.sql.Types.BIGINT) - else - stmt.setLong(7, reconcileId) stmt.executeQuery().use { - if (!it.next()) throw Exception("Inserting outgoing payment gave no outcome.") - if (it.getBoolean("out_nx_initiated")) - return@runConn OutgoingPaymentOutcome.INITIATED_COUNTERPART_NOT_FOUND - } - return@runConn OutgoingPaymentOutcome.SUCCESS - } - - /** - * Checks if the outgoing payment was already processed by Nexus. - * - * @param bankUid unique identifier assigned by the bank to the payment. - * Normally, that's the <UETR> value found in camt.05x records. Outgoing - * payment have been observed to _lack_ the <AcctSvcrRef> element. - * @return true if found, false otherwise - */ - suspend fun isOutgoingPaymentSeen(bankUid: String): Boolean = runConn { conn -> - val stmt = conn.prepareStatement(""" - SELECT 1 - FROM outgoing_transactions - WHERE bank_transfer_id = ?; - """) - stmt.setString(1, bankUid) - val res = stmt.executeQuery() - res.use { - return@runConn it.next() + when { + !it.next() -> throw Exception("Inserting outgoing payment gave no outcome.") + else -> OutgoingRegistrationResult( + it.getLong("out_tx_id"), + it.getBoolean("out_initiated"), + !it.getBoolean("out_found") + ) + } } } // INCOMING PAYMENTS METHODS /** - * Flags an incoming payment as bounced. NOTE: the flag merely means - * that the payment had an invalid subject for a Taler withdrawal _and_ - * it got initiated as an outgoing payments. In NO way this flag - * means that the actual value was returned to the initial debtor. + * Register an incoming payment and bounce it * - * @param rowId row ID of the payment to flag as bounced. - * @param initiatedRequestUid unique identifier for the outgoing payment to - * initiate for this bouncing. - * @return true if the payment could be set as bounced, false otherwise. + * @param paymentData information about the incoming payment + * @param requestUid unique identifier of the bounce outgoing payment to + * initiate + * @param bounceAmount amount to send back to the original debtor + * @param bounceSubject subject of the bounce outhoing payment + * @return true if new */ - suspend fun incomingPaymentSetAsBounced(rowId: Long, initiatedRequestUid: String): Boolean = runConn { conn -> - val timestamp = Instant.now().toDbMicros() - ?: throw Exception("Could not convert Instant.now() to microseconds, won't bounce this payment.") - val stmt = conn.prepareStatement(""" - SELECT out_nx_incoming_payment - FROM bounce_payment(?,?,?) - """ + suspend fun registerMalformedIncoming( + paymentData: IncomingPayment, + bounceAmount: TalerAmount, + now: Instant + ): IncomingBounceRegistrationResult = runConn { + val stmt = it.prepareStatement(""" + SELECT out_found, out_tx_id, out_bounce_id + FROM register_incoming_and_bounce( + (?,?)::taler_amount + ,? + ,? + ,? + ,? + ,(?,?)::taler_amount + ,? + )""" ) - stmt.setLong(1, rowId) - stmt.setLong(2, timestamp) - stmt.setString(3, initiatedRequestUid) - stmt.executeQuery().use { maybeResult -> - if (!maybeResult.next()) throw Exception("Expected outcome from the SQL bounce_payment function") - return@runConn !maybeResult.getBoolean("out_nx_incoming_payment") + val refundTimestamp = now.toDbMicros() + ?: throw Exception("Could not convert refund execution time from Instant.now() to microsends.") + val executionTime = paymentData.executionTime.toDbMicros() + ?: throw Exception("Could not convert payment execution time from Instant to microseconds.") + stmt.setLong(1, paymentData.amount.value) + stmt.setInt(2, paymentData.amount.fraction) + stmt.setString(3, paymentData.wireTransferSubject) + stmt.setLong(4, executionTime) + stmt.setString(5, paymentData.debitPaytoUri) + stmt.setString(6, paymentData.bankTransferId) + stmt.setLong(7, bounceAmount.value) + stmt.setInt(8, bounceAmount.fraction) + stmt.setLong(9, refundTimestamp) + stmt.executeQuery().use { + when { + !it.next() -> throw Exception("Inserting malformed incoming payment gave no outcome") + else -> IncomingBounceRegistrationResult( + it.getLong("out_tx_id"), + it.getString("out_bounce_id"), + !it.getBoolean("out_found") + ) + } } } /** - * Creates an incoming payment as bounced _and_ initiates its - * reimbursement. + * Register an talerable incoming payment * - * @param paymentData information related to the incoming payment. - * @param requestUid unique identifier of the outgoing payment to - * initiate, in order to reimburse the bounced tx. - * @param refundAmount amount to send back to the original debtor. If - * null, it defaults to the amount of the bounced - * incoming payment. + * @param paymentData incoming talerable payment. + * @param reservePub reserve public key. The caller is + * responsible to check it. */ - suspend fun incomingPaymentCreateBounced( + suspend fun registerTalerableIncoming( paymentData: IncomingPayment, - requestUid: String, - refundAmount: TalerAmount? = null - ): Boolean = runConn { conn -> - val refundTimestamp = Instant.now().toDbMicros() - ?: throw Exception("Could not convert refund execution time from Instant.now() to microsends.") + reservePub: ByteArray + ): IncomingRegistrationResult = runConn { conn -> + val stmt = conn.prepareStatement(""" + SELECT out_found, out_tx_id + FROM register_incoming_and_talerable( + (?,?)::taler_amount + ,? + ,? + ,? + ,? + ,? + )""" + ) val executionTime = paymentData.executionTime.toDbMicros() ?: throw Exception("Could not convert payment execution time from Instant to microseconds.") - val stmt = conn.prepareStatement(""" - SELECT out_ok FROM create_incoming_and_bounce ( - (?,?)::taler_amount - ,? - ,? - ,? - ,? - ,? - ,? - ,(?,?)::taler_amount - )""") stmt.setLong(1, paymentData.amount.value) stmt.setInt(2, paymentData.amount.fraction) stmt.setString(3, paymentData.wireTransferSubject) stmt.setLong(4, executionTime) stmt.setString(5, paymentData.debitPaytoUri) stmt.setString(6, paymentData.bankTransferId) - stmt.setLong(7, refundTimestamp) - stmt.setString(8, requestUid) - val finalRefundAmount: TalerAmount = refundAmount ?: paymentData.amount - stmt.setLong(9, finalRefundAmount.value) - stmt.setInt(10, finalRefundAmount.fraction) - val res = stmt.executeQuery() - res.use { - if (!it.next()) return@runConn false - return@runConn it.getBoolean("out_ok") + stmt.setBytes(7, reservePub) + stmt.executeQuery().use { + when { + !it.next() -> throw Exception("Inserting talerable incoming payment gave no outcome") + else -> IncomingRegistrationResult( + it.getLong("out_tx_id"), + !it.getBoolean("out_found") + ) + } } } @@ -366,26 +381,6 @@ class Database(dbConfig: String): java.io.Closeable { } /** - * Checks if the incoming payment was already processed by Nexus. - * - * @param bankUid unique identifier assigned by the bank to the payment. - * Normally, that's the <AcctSvcrRef> value found in camt.05x records. - * @return true if found, false otherwise - */ - suspend fun isIncomingPaymentSeen(bankUid: String): Boolean = runConn { conn -> - val stmt = conn.prepareStatement(""" - SELECT 1 - FROM incoming_transactions - WHERE bank_transfer_id = ?; - """) - stmt.setString(1, bankUid) - val res = stmt.executeQuery() - res.use { - return@runConn it.next() - } - } - - /** * Checks if the reserve public key already exists. * * @param maybeReservePub reserve public key to look up @@ -404,84 +399,6 @@ class Database(dbConfig: String): java.io.Closeable { } } - /** - * Creates an incoming transaction row and links a new talerable - * row to it. - * - * @param paymentData incoming talerable payment. - * @param reservePub reserve public key. The caller is - * responsible to check it. - */ - suspend fun incomingTalerablePaymentCreate( - paymentData: IncomingPayment, - reservePub: ByteArray - ): Boolean = runConn { conn -> - val stmt = conn.prepareStatement(""" - SELECT out_ok FROM create_incoming_talerable( - (?,?)::taler_amount - ,? - ,? - ,? - ,? - ,? - )""") - bindIncomingPayment(paymentData, stmt) - stmt.setBytes(7, reservePub) - stmt.executeQuery().use { - if (!it.next()) return@runConn false - return@runConn it.getBoolean("out_ok") - } - } - - /** - * Binds the values of an incoming payment to the prepared - * statement's placeholders. Warn: may easily break in case - * the placeholders get their positions changed! - * - * @param data incoming payment to bind to the placeholders - * @param stmt statement to receive the values in its placeholders - */ - private fun bindIncomingPayment( - data: IncomingPayment, - stmt: PreparedStatement - ) { - stmt.setLong(1, data.amount.value) - stmt.setInt(2, data.amount.fraction) - stmt.setString(3, data.wireTransferSubject) - val executionTime = data.executionTime.toDbMicros() ?: run { - throw Exception("Execution time could not be converted to microseconds for the database.") - } - stmt.setLong(4, executionTime) - stmt.setString(5, data.debitPaytoUri) - stmt.setString(6, data.bankTransferId) - } - /** - * Creates a new incoming payment record in the database. It does NOT - * update the "talerable" table. - * - * @param paymentData information related to the incoming payment. - * @return true on success, false otherwise. - */ - suspend fun incomingPaymentCreate(paymentData: IncomingPayment): Boolean = runConn { conn -> - val stmt = conn.prepareStatement(""" - INSERT INTO incoming_transactions ( - amount - ,wire_transfer_subject - ,execution_time - ,debit_payto_uri - ,bank_transfer_id - ) VALUES ( - (?,?)::taler_amount - ,? - ,? - ,? - ,? - ) - """) - bindIncomingPayment(paymentData, stmt) - return@runConn stmt.maybeUpdate() - } - // INITIATED PAYMENTS METHODS /** diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/DbInit.kt @@ -4,7 +4,6 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.groups.* import tech.libeufin.util.* -import kotlin.system.exitProcess /** * This subcommand tries to load the SQL files that define @@ -19,14 +18,12 @@ class DbInit : CliktCommand("Initialize the libeufin-nexus database", name = "db ).flag() override fun run() { - val cfg = loadConfigOrFail(common.config).extractDbConfigOrFail() - doOrFail { - pgDataSource(cfg.dbConnStr).pgConnection().use { conn -> - if (requestReset) { - resetDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-nexus") - } - initializeDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-nexus") + val cfg = loadConfig(common.config).dbConfig() + pgDataSource(cfg.dbConnStr).pgConnection().use { conn -> + if (requestReset) { + resetDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-nexus") } + initializeDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-nexus") } } } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -4,7 +4,7 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.groups.* import io.ktor.client.* -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* import net.taler.wallet.crypto.Base32Crockford import net.taler.wallet.crypto.EncodingException import tech.libeufin.nexus.ebics.* @@ -17,9 +17,7 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.util.UUID -import kotlin.concurrent.fixedRateTimer -import kotlin.io.path.createDirectories -import kotlin.system.exitProcess +import kotlin.io.path.* /** * Necessary data to perform a download. @@ -75,7 +73,7 @@ data class FetchContext( private suspend inline fun downloadHelper( ctx: FetchContext, lastExecutionTime: Instant? = null -): ByteArray? { +): ByteArray { val initXml = if (ctx.ebicsVersion == EbicsVersion.three) { createEbics3DownloadInitialization( ctx.cfg, @@ -112,7 +110,7 @@ private suspend inline fun downloadHelper( * bank side. A client with an unreliable bank is not useful, hence * failing here. */ - exitProcess(1) + throw e } } @@ -141,26 +139,20 @@ fun maybeLogFile( val asUtcDate = LocalDate.ofInstant(now, ZoneId.of("UTC")) val subDir = "${asUtcDate.year}-${asUtcDate.monthValue}-${asUtcDate.dayOfMonth}" // Creating the combined dir. - val dirs = Path.of(maybeLogDir, subDir) - doOrFail { dirs.createDirectories() } - fun maybeWrite(f: File, xml: String) { - if (f.exists()) { - logger.error("Log file exists already at: ${f.path}") - exitProcess(1) - } - doOrFail { f.writeText(xml) } - } + val dirs = Path(maybeLogDir, subDir) + dirs.createDirectories() if (nonZip) { - val f = File(dirs.toString(), "${now.toDbMicros()}_HAC_response.pain.002.xml") - maybeWrite(f, content.toString(Charsets.UTF_8)) - return - } - // Write each ZIP entry in the combined dir. - content.unzipForEach { fileName, xmlContent -> - val f = File(dirs.toString(), "${now.toDbMicros()}_$fileName") - // Rare: cannot download the same file twice in the same microsecond. - maybeWrite(f, xmlContent) + val f = Path(dirs.toString(), "${now.toDbMicros()}_HAC_response.pain.002.xml") + f.writeBytes(content) + } else { + // Write each ZIP entry in the combined dir. + content.unzipForEach { fileName, xmlContent -> + val f = Path(dirs.toString(), "${now.toDbMicros()}_$fileName") + // Rare: cannot download the same file twice in the same microsecond. + f.writeText(xmlContent) + } } + } /** @@ -254,25 +246,17 @@ fun removeSubjectNoise(subject: String): String? { * Checks the two conditions that may invalidate one incoming * payment: subject validity and availability. * - * @param db database connection. * @param payment incoming payment whose subject is to be checked. * @return [ByteArray] as the reserve public key, or null if the * payment cannot lead to a Taler withdrawal. */ private suspend fun getTalerReservePub( - db: Database, payment: IncomingPayment ): ByteArray? { // Removing noise around the potential reserve public key. val maybeReservePub = removeSubjectNoise(payment.wireTransferSubject) ?: return null // Checking validity first. val dec = isReservePub(maybeReservePub) ?: return null - // Now checking availability. - val maybeUnavailable = db.isReservePubFound(dec) - if (maybeUnavailable) { - logger.error("Incoming payment with subject '${payment.wireTransferSubject}' exists already") - return null - } return dec } @@ -284,31 +268,18 @@ private suspend fun getTalerReservePub( * @param db database handle. * @param payment payment to (maybe) ingest. */ -private suspend fun ingestOutgoingPayment( +suspend fun ingestOutgoingPayment( db: Database, payment: OutgoingPayment ) { - logger.debug("Ingesting outgoing payment UID ${payment.bankTransferId}, subject ${payment.wireTransferSubject}") - // Check if the payment was ingested already. - if (db.isOutgoingPaymentSeen(payment.bankTransferId)) { - logger.debug("Outgoing payment with UID '${payment.bankTransferId}' already seen.") - return - } - /** - * Getting the initiate payment to link to this. A missing initiated - * payment could mean that a third party is downloading the bank account - * history (to conduct an audit, for example) - */ - val initId: Long? = db.initiatedPaymentGetFromUid(payment.bankTransferId); - if (initId == null) - logger.info("Outgoing payment lacks initiated counterpart with UID ${payment.bankTransferId}") - // store the payment and its (maybe null) linked init - val insertionResult = db.outgoingPaymentCreate(payment, initId) - if (insertionResult != OutgoingPaymentOutcome.SUCCESS) { - throw Exception("Could not store outgoing payment with UID " + - "'${payment.bankTransferId}' and update its related initiation." + - " DB result: $insertionResult" - ) + val result = db.registerOutgoing(payment) + if (result.new) { + if (result.initiated) + logger.debug("$payment") + else + logger.debug("$payment recovered") + } else { + logger.debug("OUT '${payment.bankTransferId}' already seen") } } @@ -319,29 +290,35 @@ private suspend fun ingestOutgoingPayment( * * @param db database handle. * @param currency fiat currency of the watched bank account. - * @param incomingPayment payment to (maybe) ingest. + * @param payment payment to (maybe) ingest. */ -private suspend fun ingestIncomingPayment( +suspend fun ingestIncomingPayment( db: Database, - incomingPayment: IncomingPayment + payment: IncomingPayment ) { - logger.debug("Ingesting incoming payment UID: ${incomingPayment.bankTransferId}, subject: ${incomingPayment.wireTransferSubject}") - if (db.isIncomingPaymentSeen(incomingPayment.bankTransferId)) { - logger.debug("Incoming payment with UID '${incomingPayment.bankTransferId}' already seen.") - return - } - val reservePub = getTalerReservePub(db, incomingPayment) + val reservePub = getTalerReservePub(payment) if (reservePub == null) { - logger.debug("Incoming payment with UID '${incomingPayment.bankTransferId}'" + - " has invalid subject: ${incomingPayment.wireTransferSubject}." + logger.debug("Incoming payment with UID '${payment.bankTransferId}'" + + " has invalid subject: ${payment.wireTransferSubject}." ) - db.incomingPaymentCreateBounced( - incomingPayment, - UUID.randomUUID().toString().take(35) + val result = db.registerMalformedIncoming( + payment, + payment.amount, + Instant.now() ) - return + if (result.new) { + logger.debug("$payment bounced in '${result.bounceId}'") + } else { + logger.debug("IN '${payment.bankTransferId}' already seen and bounced in '${result.bounceId}'") + } + } else { + val result = db.registerTalerableIncoming(payment, reservePub) + if (result.new) { + logger.debug("$payment") + } else { + logger.debug("IN '${payment.bankTransferId}' already seen") + } } - db.incomingTalerablePaymentCreate(incomingPayment, reservePub) } /** @@ -373,62 +350,34 @@ fun firstLessThanSecond( * @param db database connection. * @param content the ZIP file that contains the EBICS * notification as camt.054 records. - * @return true if the ingestion succeeded, false otherwise. - * False should fail the process, since it means that - * the notification could not be parsed. */ private fun ingestNotification( db: Database, ctx: FetchContext, content: ByteArray -): Boolean { +) { val incomingPayments = mutableListOf<IncomingPayment>() val outgoingPayments = mutableListOf<OutgoingPayment>() - val filenamePrefixForIncoming = "camt.054_P_${ctx.cfg.myIbanAccount.iban}" - val filenamePrefixForOutgoing = "camt.054-Debit_P_${ctx.cfg.myIbanAccount.iban}" + try { content.unzipForEach { fileName, xmlContent -> if (!fileName.contains("camt.054", ignoreCase = true)) throw Exception("Asked for notification but did NOT get a camt.054") - /** - * We ignore any camt.054 that does not bring Taler-relevant information, - * like camt.054-Credit, for example. - */ - if (!fileName.startsWith(filenamePrefixForIncoming) && - !fileName.startsWith(filenamePrefixForOutgoing)) { - logger.debug("Ignoring camt.054: $fileName") - return@unzipForEach - } - - if (fileName.startsWith(filenamePrefixForIncoming)) - incomingPayments += parseIncomingTxNotif(xmlContent, ctx.cfg.currency) - else outgoingPayments += parseOutgoingTxNotif(xmlContent, ctx.cfg.currency) + logger.debug("parse $fileName") + parseTxNotif(xmlContent, ctx.cfg.currency, incomingPayments, outgoingPayments) } } catch (e: IOException) { - logger.error("Could not open any ZIP archive") - return false - } catch (e: Exception) { - logger.error(e.message) - return false + throw Exception("Could not open any ZIP archive", e) } - try { - runBlocking { - incomingPayments.forEach { - ingestIncomingPayment( - db, - it - ) - } - outgoingPayments.forEach { - ingestOutgoingPayment(db, it) - } + runBlocking { + incomingPayments.forEach { + ingestIncomingPayment(db, it) + } + outgoingPayments.forEach { + ingestOutgoingPayment(db, it) } - } catch (e: Exception) { - logger.error(e.message) - return false } - return true } /** @@ -470,7 +419,7 @@ private suspend fun fetchDocuments( val lastExecutionTime: Instant? = ctx.pinnedStart ?: requestFrom logger.debug("Fetching ${ctx.whichDocument} from timestamp: $lastExecutionTime") // downloading the content - val maybeContent = downloadHelper(ctx, lastExecutionTime) ?: exitProcess(1) // client is wrong, failing. + val maybeContent = downloadHelper(ctx, lastExecutionTime) if (maybeContent.isEmpty()) return // logging, if the configuration wants. maybeLogFile( @@ -483,9 +432,10 @@ private suspend fun fetchDocuments( logger.warn("Not ingesting ${ctx.whichDocument}. Only camt.054 notifications supported.") return } - if (!ingestNotification(db, ctx, maybeContent)) { - logger.error("Ingesting notifications failed") - exitProcess(1) + try { + ingestNotification(db, ctx, maybeContent) + } catch (e: Exception) { + throw Exception("Ingesting notifications failed", e) } } @@ -541,13 +491,9 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti * mode when no flags are passed to the invocation. * FIXME: reduce code duplication with the submit subcommand. */ - override fun run() { - val cfg: EbicsSetupConfig = doOrFail { - extractEbicsConfig(common.config) - } - - val dbCfg = cfg.config.extractDbConfigOrFail() - val db = Database(dbCfg.dbConnStr) + override fun run() = cliCmd(logger) { + val cfg: EbicsSetupConfig = extractEbicsConfig(common.config) + val dbCfg = cfg.config.dbConfig() // Deciding what to download. var whichDoc = SupportedDocument.CAMT_054 @@ -555,113 +501,77 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti if (onlyReports) whichDoc = SupportedDocument.CAMT_052 if (onlyStatements) whichDoc = SupportedDocument.CAMT_053 if (onlyLogs) whichDoc = SupportedDocument.PAIN_002_LOGS - if (parse || import) { - logger.debug("Reading from STDIN, running in debug mode. Not involving the database.") - val maybeStdin = generateSequence(::readLine).joinToString("\n") - when(whichDoc) { - SupportedDocument.CAMT_054 -> { - try { - val incomingTxs = parseIncomingTxNotif(maybeStdin, cfg.currency) + + Database(dbCfg.dbConnStr).use { db -> + if (parse || import) { + logger.debug("Reading from STDIN, running in debug mode. Not involving the database.") + val maybeStdin = generateSequence(::readLine).joinToString("\n") + when(whichDoc) { + SupportedDocument.CAMT_054 -> { + val incomingTxs = mutableListOf<IncomingPayment>() + val outgoingTxs = mutableListOf<OutgoingPayment>() + parseTxNotif(maybeStdin, cfg.currency, incomingTxs, outgoingTxs) println(incomingTxs) + println(outgoingTxs) if (import) { runBlocking { incomingTxs.forEach { ingestIncomingPayment(db, it) } - } - } - } catch (e: WrongPaymentDirection) { - logger.info("Input doesn't contain incoming payments") - } catch (e: Exception) { - logger.error(e.message) - exitProcess(1) - } - try { - val outgoingTxs = parseOutgoingTxNotif(maybeStdin, cfg.currency) - println(outgoingTxs) - if (import) { - runBlocking { outgoingTxs.forEach { ingestOutgoingPayment(db, it) } } } - } catch (e: WrongPaymentDirection) { - logger.debug("Input doesn't contain outgoing payments") - } catch (e: Exception) { - logger.error(e.message) - exitProcess(1) } + else -> throw Exception("Parsing $whichDoc not supported") } - else -> { - logger.error("Parsing $whichDoc not supported") - exitProcess(1) - } + return@cliCmd } - return - } - - // Fail now if keying is incomplete. - if (!isKeyingComplete(cfg)) exitProcess(1) - val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) ?: exitProcess(1) - if (!bankKeys.accepted && !import && !parse) { - logger.error("Bank keys are not accepted, yet. Won't fetch any records.") - exitProcess(1) - } - val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - if (clientKeys == null) { - logger.error("Client private keys not found at: ${cfg.clientPrivateKeysFilename}") - exitProcess(1) - } - val ctx = FetchContext( - cfg, - HttpClient(), - clientKeys, - bankKeys, - whichDoc, - EbicsVersion.three, - ebicsExtraLog - ) - if (transient) { - logger.info("Transient mode: fetching once and returning.") - val pinnedStartVal = pinnedStart - val pinnedStartArg = if (pinnedStartVal != null) { - logger.debug("Pinning start date to: $pinnedStartVal") - doOrFail { + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val ctx = FetchContext( + cfg, + HttpClient(), + clientKeys, + bankKeys, + whichDoc, + EbicsVersion.three, + ebicsExtraLog + ) + if (transient) { + logger.info("Transient mode: fetching once and returning.") + val pinnedStartVal = pinnedStart + val pinnedStartArg = if (pinnedStartVal != null) { + logger.debug("Pinning start date to: $pinnedStartVal") // Converting YYYY-MM-DD to Instant. LocalDate.parse(pinnedStartVal).atStartOfDay(ZoneId.of("UTC")).toInstant() - } - } else null - ctx.pinnedStart = pinnedStartArg - if (whichDoc == SupportedDocument.PAIN_002_LOGS) - ctx.ebicsVersion = EbicsVersion.two - runBlocking { - fetchDocuments(db, ctx) - } - return - } - val frequency: NexusFrequency = doOrFail { - val configValue = cfg.config.requireString("nexus-fetch", "frequency") - val frequencySeconds = checkFrequency(configValue) - return@doOrFail NexusFrequency(frequencySeconds, configValue) - } - logger.debug("Running with a frequency of ${frequency.fromConfig}") - if (frequency.inSeconds == 0) { - logger.warn("Long-polling not implemented, running therefore in transient mode") - runBlocking { - fetchDocuments(db, ctx) - } - return - } - fixedRateTimer( - name = "ebics submit period", - period = (frequency.inSeconds * 1000).toLong(), - action = { + } else null + ctx.pinnedStart = pinnedStartArg + if (whichDoc == SupportedDocument.PAIN_002_LOGS) + ctx.ebicsVersion = EbicsVersion.two runBlocking { fetchDocuments(db, ctx) } + } else { + val configValue = cfg.config.requireString("nexus-fetch", "frequency") + val frequencySeconds = checkFrequency(configValue) + val cfgFrequency: NexusFrequency = NexusFrequency(frequencySeconds, configValue) + logger.debug("Running with a frequency of ${cfgFrequency.fromConfig}") + val frequency: NexusFrequency? = if (cfgFrequency.inSeconds == 0) { + logger.warn("Long-polling not implemented, running therefore in transient mode") + null + } else { + cfgFrequency + } + runBlocking { + do { + // TODO error handling + fetchDocuments(db, ctx) + delay(((frequency?.inSeconds ?: 0) * 1000).toLong()) + } while (frequency != null) + } } - ) + } } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt @@ -26,7 +26,6 @@ import io.ktor.client.* import kotlinx.coroutines.runBlocking import tech.libeufin.util.ebics_h004.EbicsTypes import java.io.File -import kotlin.system.exitProcess import TalerConfigError import kotlinx.serialization.encodeToString import tech.libeufin.nexus.ebics.* @@ -34,32 +33,9 @@ import tech.libeufin.util.* import tech.libeufin.util.ebics_h004.HTDResponseOrderData import java.time.Instant import kotlin.reflect.typeOf - -/** - * Checks the configuration to secure that the key exchange between - * the bank and the subscriber took place. Helps to fail before starting - * to talk EBICS to the bank. - * - * @param cfg configuration handle. - * @return true if the keying was made before, false otherwise. - */ -fun isKeyingComplete(cfg: EbicsSetupConfig): Boolean { - val maybeClientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - if (maybeClientKeys == null || - (!maybeClientKeys.submitted_ini) || - (!maybeClientKeys.submitted_hia)) { - logger.error("Cannot operate without or with unsubmitted subscriber keys." + - " Run 'libeufin-nexus ebics-setup' first.") - return false - } - val maybeBankKeys = loadBankKeys(cfg.bankPublicKeysFilename) - if (maybeBankKeys == null || (!maybeBankKeys.accepted)) { - logger.error("Cannot operate without or with unaccepted bank keys." + - " Run 'libeufin-nexus ebics-setup' until accepting the bank keys.") - return false - } - return true -} +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import kotlin.io.path.* /** * Writes the JSON content to disk. Used when we create or update @@ -67,23 +43,23 @@ fun isKeyingComplete(cfg: EbicsSetupConfig): Boolean { * silently what's found under the given location! * * @param obj the class representing the JSON content to store to disk. - * @param location where to store `obj` - * @return true in case of success, false otherwise. + * @param path where to store `obj` */ -inline fun <reified T> syncJsonToDisk(obj: T, location: String): Boolean { - val fileContent = try { +inline fun <reified T> syncJsonToDisk(obj: T, path: String) { + val content = try { myJson.encodeToString(obj) } catch (e: Exception) { - logger.error("Could not encode the input '${typeOf<T>()}' to JSON, detail: ${e.message}") - return false + throw Exception("Could not encode the input '${typeOf<T>()}' to JSON", e) } try { - File(location).writeText(fileContent) + // Write to temp file then rename to enable atomicity when possible + val path = Path(path).absolute() + val tmp = Files.createTempFile(path.parent, "tmp_", "_${path.fileName}") + tmp.writeText(content) + tmp.moveTo(path, StandardCopyOption.REPLACE_EXISTING); } catch (e: Exception) { - logger.error("Could not write JSON content at $location, detail: ${e.message}") - return false + throw Exception("Could not write JSON content at $path", e) } - return true } /** @@ -99,43 +75,28 @@ fun generateNewKeys(): ClientPrivateKeysFile = submitted_hia = false, submitted_ini = false ) -/** - * Conditionally generates the client private keys and stores them - * to disk, if the file does not exist already. Does nothing if the - * file exists. - * - * @param filename keys file location - * @return true if the keys file existed already or its creation - * went through, false for any error. - */ -fun maybeCreatePrivateKeysFile(filename: String): Boolean { - val f = File(filename) - // NOT overriding any file at the wanted location. - if (f.exists()) { - logger.debug("Private key file found at: $filename.") - return true - } - val newKeys = generateNewKeys() - if (!syncJsonToDisk(newKeys, filename)) - return false - logger.info("New client keys created at: $filename") - return true -} /** * Obtains the client private keys, regardless of them being * created for the first time, or read from an existing file * on disk. * - * @param location path to the file that contains the keys. - * @return true if the operation succeeds, false otherwise. + * @param path path to the file that contains the keys. + * @return current or new client keys */ -private fun preparePrivateKeys(location: String): ClientPrivateKeysFile? { - if (!maybeCreatePrivateKeysFile(location)) { - logger.error("Could not create client keys at $location") - exitProcess(1) +private fun preparePrivateKeys(path: String): ClientPrivateKeysFile { + // If exists load from disk + val current = loadPrivateKeysFromDisk(path) + if (current != null) return current + // Else create new keys + try { + val newKeys = generateNewKeys() + syncJsonToDisk(newKeys, path) + logger.info("New client keys created at: $path") + return newKeys + } catch (e: Exception) { + throw Exception("Could not create client keys at $path", e) } - return loadPrivateKeysFromDisk(location) // loads what found at location. } /** @@ -185,47 +146,40 @@ private fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean { * * @param cfg used to get the location of the bank keys file. * @param bankKeys bank response to the HPB message. - * @return true if the keys were stored to disk (as "not accepted"), - * false if the storage failed or the content was invalid. */ private fun handleHpbResponse( cfg: EbicsSetupConfig, bankKeys: EbicsKeyManagementResponseContent -): Boolean { +) { val hpbBytes = bankKeys.orderData // silences compiler. if (hpbBytes == null) { - logger.error("HPB content not found in a EBICS response with successful return codes.") - return false + throw Exception("HPB content not found in a EBICS response with successful return codes.") } val hpbObj = try { parseEbicsHpbOrder(hpbBytes) - } - catch (e: Exception) { - logger.error("HPB response content seems invalid.") - return false + } catch (e: Exception) { + throw Exception("HPB response content seems invalid: e") } val encPub = try { CryptoUtil.loadRsaPublicKey(hpbObj.encryptionPubKey.encoded) } catch (e: Exception) { - logger.error("Could not import bank encryption key from HPB response, detail: ${e.message}") - return false + throw Exception("Could not import bank encryption key from HPB response", e) } val authPub = try { CryptoUtil.loadRsaPublicKey(hpbObj.authenticationPubKey.encoded) } catch (e: Exception) { - logger.error("Could not import bank authentication key from HPB response, detail: ${e.message}") - return false + throw Exception("Could not import bank authentication key from HPB response", e) } val json = BankPublicKeysFile( bank_authentication_public_key = authPub, bank_encryption_public_key = encPub, accepted = false ) - if (!syncJsonToDisk(json, cfg.bankPublicKeysFilename)) { - logger.error("Failed to persist the bank keys to disk at: ${cfg.bankPublicKeysFilename}") - return false + try { + syncJsonToDisk(json, cfg.bankPublicKeysFilename) + } catch (e: Exception) { + throw Exception("Failed to persist the bank keys to disk", e) } - return true } /** @@ -239,15 +193,13 @@ private fun handleHpbResponse( * @param orderType INI or HIA. * @param autoAcceptBankKeys only given in case of HPB. Expresses * the --auto-accept-key CLI flag. - * @return true if the message fulfilled its purpose AND the state - * on disk was accordingly updated, or false otherwise. */ suspend fun doKeysRequestAndUpdateState( cfg: EbicsSetupConfig, privs: ClientPrivateKeysFile, client: HttpClient, orderType: KeysOrderType -): Boolean { +) { logger.debug("Doing key request ${orderType.name}") val req = when(orderType) { KeysOrderType.INI -> generateIniMessage(cfg, privs) @@ -256,33 +208,29 @@ suspend fun doKeysRequestAndUpdateState( } val xml = client.postToBank(cfg.hostBaseUrl, req) if (xml == null) { - logger.error("Could not POST the ${orderType.name} message to the bank") - return false + throw Exception("Could not POST the ${orderType.name} message to the bank") } val ebics = parseKeysMgmtResponse(privs.encryption_private_key, xml) if (ebics == null) { - logger.error("Could not get any EBICS from the bank ${orderType.name} response ($xml).") - return false + throw Exception("Could not get any EBICS from the bank ${orderType.name} response ($xml).") } if (ebics.technicalReturnCode != EbicsReturnCode.EBICS_OK) { - logger.error("EBICS ${orderType.name} failed with code: ${ebics.technicalReturnCode}") - return false + throw Exception("EBICS ${orderType.name} failed with code: ${ebics.technicalReturnCode}") } if (ebics.bankReturnCode != EbicsReturnCode.EBICS_OK) { - logger.error("EBICS ${orderType.name} reached the bank, but could not be fulfilled, error code: ${ebics.bankReturnCode}") - return false + throw Exception("EBICS ${orderType.name} reached the bank, but could not be fulfilled, error code: ${ebics.bankReturnCode}") } - when(orderType) { + when (orderType) { KeysOrderType.INI -> privs.submitted_ini = true KeysOrderType.HIA -> privs.submitted_hia = true KeysOrderType.HPB -> return handleHpbResponse(cfg, ebics) } - if (!syncJsonToDisk(privs, cfg.clientPrivateKeysFilename)) { - logger.error("Could not update the ${orderType.name} state on disk") - return false + try { + syncJsonToDisk(privs, cfg.clientPrivateKeysFilename) + } catch (e: Exception) { + throw Exception("Could not update the ${orderType.name} state on disk", e) } - return true } /** @@ -292,15 +240,8 @@ suspend fun doKeysRequestAndUpdateState( * @return internal representation of the configuration. */ fun extractEbicsConfig(configFile: String?): EbicsSetupConfig { - val config = loadConfigOrFail(configFile) - // Checking the config. - val cfg = try { - EbicsSetupConfig(config) - } catch (e: TalerConfigError) { - logger.error(e.message) - exitProcess(1) - } - return cfg + val config = loadConfig(configFile) + return EbicsSetupConfig(config) } /** @@ -314,14 +255,12 @@ private fun makePdf(privs: ClientPrivateKeysFile, cfg: EbicsSetupConfig) { val pdf = generateKeysPdf(privs, cfg) val pdfFile = File("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf") if (pdfFile.exists()) { - logger.error("PDF file exists already at: ${pdfFile.path}, not overriding it") - exitProcess(1) + throw Exception("PDF file exists already at: ${pdfFile.path}, not overriding it") } try { pdfFile.writeBytes(pdf) } catch (e: Exception) { - logger.error("Could not write PDF to ${pdfFile}, detail: ${e.message}") - exitProcess(1) + throw Exception("Could not write PDF to ${pdfFile}, detail: ${e.message}") } println("PDF file with keys hex encoding created at: $pdfFile") } @@ -346,104 +285,80 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { /** * This function collects the main steps of setting up an EBICS access. */ - override fun run() { - val cfg = doOrFail { extractEbicsConfig(common.config) } + override fun run() = cliCmd(logger) { + val cfg = extractEbicsConfig(common.config) if (checkFullConfig) { - doOrFail { - cfg.config.requireString("nexus-submit", "frequency").apply { - if (getFrequencyInSeconds(this) == null) - throw Exception("frequency value of nexus-submit section is not valid: $this") - } - cfg.config.requireString("nexus-fetch", "frequency").apply { - if (getFrequencyInSeconds(this) == null) - throw Exception("frequency value of nexus-fetch section is not valid: $this") - } - cfg.config.requirePath("nexus-fetch", "statement_log_directory") - cfg.config.requireNumber("nexus-httpd", "port") - cfg.config.requirePath("nexus-httpd", "unixpath") - cfg.config.requireString("nexus-httpd", "serve") - cfg.config.requireString("nexus-httpd-wire-gateway-facade", "enabled") - cfg.config.requireString("nexus-httpd-wire-gateway-facade", "auth_method") - cfg.config.requireString("nexus-httpd-wire-gateway-facade", "auth_token") - cfg.config.requireString("nexus-httpd-revenue-facade", "enabled") - cfg.config.requireString("nexus-httpd-revenue-facade", "auth_method") - cfg.config.requireString("nexus-httpd-revenue-facade", "auth_token") + cfg.config.requireString("nexus-submit", "frequency").apply { + if (getFrequencyInSeconds(this) == null) + throw Exception("frequency value of nexus-submit section is not valid: $this") + } + cfg.config.requireString("nexus-fetch", "frequency").apply { + if (getFrequencyInSeconds(this) == null) + throw Exception("frequency value of nexus-fetch section is not valid: $this") } - return + cfg.config.requirePath("nexus-fetch", "statement_log_directory") + cfg.config.requireNumber("nexus-httpd", "port") + cfg.config.requirePath("nexus-httpd", "unixpath") + cfg.config.requireString("nexus-httpd", "serve") + cfg.config.requireString("nexus-httpd-wire-gateway-facade", "enabled") + cfg.config.requireString("nexus-httpd-wire-gateway-facade", "auth_method") + cfg.config.requireString("nexus-httpd-wire-gateway-facade", "auth_token") + cfg.config.requireString("nexus-httpd-revenue-facade", "enabled") + cfg.config.requireString("nexus-httpd-revenue-facade", "auth_method") + cfg.config.requireString("nexus-httpd-revenue-facade", "auth_token") + return@cliCmd } // Config is sane. Go (maybe) making the private keys. - val privsMaybe = preparePrivateKeys(cfg.clientPrivateKeysFilename) - if (privsMaybe == null) { - logger.error("Private keys preparation failed.") - exitProcess(1) - } + val clientKeys = preparePrivateKeys(cfg.clientPrivateKeysFilename) val httpClient = HttpClient() // Privs exist. Upload their pubs - val keysNotSub = !privsMaybe.submitted_ini || !privsMaybe.submitted_hia + val keysNotSub = !clientKeys.submitted_ini || !clientKeys.submitted_hia runBlocking { - if ((!privsMaybe.submitted_ini) || forceKeysResubmission) - doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, KeysOrderType.INI).apply { if (!this) exitProcess(1) } - if ((!privsMaybe.submitted_hia) || forceKeysResubmission) - doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, KeysOrderType.HIA).apply { if (!this) exitProcess(1) } - } - // Reloading new state from disk if any upload (and therefore a disk write) actually took place - val haveSubmitted = forceKeysResubmission || keysNotSub - val privs = if (haveSubmitted) { - logger.info("Keys submitted to the bank, at ${cfg.hostBaseUrl}") - loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - } else privsMaybe - if (privs == null) { - logger.error("Could not reload private keys from disk after submission") - exitProcess(1) - } - // Really both must be submitted here. - if ((!privs.submitted_hia) || (!privs.submitted_ini)) { - logger.error("Cannot continue with non-submitted client keys.") - exitProcess(1) + if ((!clientKeys.submitted_ini) || forceKeysResubmission) + doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.INI) + if ((!clientKeys.submitted_hia) || forceKeysResubmission) + doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.HIA) } // Eject PDF if the keys were submitted for the first time, or the user asked. - if (keysNotSub || generateRegistrationPdf) makePdf(privs, cfg) + if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, cfg) // Checking if the bank keys exist on disk. val bankKeysFile = File(cfg.bankPublicKeysFilename) if (!bankKeysFile.exists()) { - val areKeysOnDisk = runBlocking { - doKeysRequestAndUpdateState( - cfg, - privs, - httpClient, - KeysOrderType.HPB - ) - } - if (!areKeysOnDisk) { - logger.error("Could not download bank keys. Send client keys (and/or related PDF document with --generate-registration-pdf) to the bank.") - exitProcess(1) + runBlocking { + try { + doKeysRequestAndUpdateState( + cfg, + clientKeys, + httpClient, + KeysOrderType.HPB + ) + } catch (e: Exception) { + throw Exception("Could not download bank keys. Send client keys (and/or related PDF document with --generate-registration-pdf) to the bank", e) + } } logger.info("Bank keys stored at ${cfg.bankPublicKeysFilename}") } // bank keys made it to the disk, check if they're accepted. val bankKeysMaybe = loadBankKeys(cfg.bankPublicKeysFilename) if (bankKeysMaybe == null) { - logger.error("Although previous checks, could not load the bank keys file from: ${cfg.bankPublicKeysFilename}") - exitProcess(1) - } - val printOk = { println("setup ready") } - - if (bankKeysMaybe.accepted) { - printOk() - return + throw Exception("Although previous checks, could not load the bank keys file from: ${cfg.bankPublicKeysFilename}") } - // Finishing the setup by accepting the bank keys. - if (autoAcceptKeys) bankKeysMaybe.accepted = true - else bankKeysMaybe.accepted = askUserToAcceptKeys(bankKeysMaybe) if (!bankKeysMaybe.accepted) { - logger.error("Cannot successfully finish the setup without accepting the bank keys.") - exitProcess(1) - } - if (!syncJsonToDisk(bankKeysMaybe, cfg.bankPublicKeysFilename)) { - logger.error("Could not set bank keys as accepted on disk.") - exitProcess(1) + // Finishing the setup by accepting the bank keys. + if (autoAcceptKeys) bankKeysMaybe.accepted = true + else bankKeysMaybe.accepted = askUserToAcceptKeys(bankKeysMaybe) + + if (!bankKeysMaybe.accepted) { + throw Exception("Cannot successfully finish the setup without accepting the bank keys.") + } + try { + syncJsonToDisk(bankKeysMaybe, cfg.bankPublicKeysFilename) + } catch (e: Exception) { + throw Exception("Could not set bank keys as accepted on disk.", e) + } } - printOk() + + println("setup ready") } } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -23,7 +23,7 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.groups.* import io.ktor.client.* -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* import tech.libeufin.nexus.ebics.EbicsSideError import tech.libeufin.nexus.ebics.EbicsSideException import tech.libeufin.nexus.ebics.EbicsUploadException @@ -35,9 +35,8 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.util.* -import kotlin.concurrent.fixedRateTimer import kotlin.io.path.createDirectories -import kotlin.system.exitProcess +import kotlin.io.path.* /** * Possible stages when an error may occur. These stages @@ -105,8 +104,7 @@ class NexusSubmitException( */ private fun maybeLog( maybeLogDir: String?, - xml: String, - requestUid: String + xml: String ) { if (maybeLogDir == null) { logger.info("Logging pain.001 to files is disabled") @@ -116,18 +114,17 @@ private fun maybeLog( val now = Instant.now() val asUtcDate = LocalDate.ofInstant(now, ZoneId.of("UTC")) val subDir = "${asUtcDate.year}-${asUtcDate.monthValue}-${asUtcDate.dayOfMonth}" - val dirs = Path.of(maybeLogDir, subDir) - doOrFail { dirs.createDirectories() } - val f = File( + val dirs = Path(maybeLogDir, subDir) + dirs.createDirectories() + val f = Path( dirs.toString(), - "${now.toDbMicros()}_requestUid_${requestUid}_pain.001.xml" + "${now.toDbMicros()}_pain.001.xml" ) // Very rare: same pain.001 should not be submitted twice in the same microsecond. if (f.exists()) { - logger.error("pain.001 log file exists already at: $f") - exitProcess(1) + throw Exception("pain.001 log file exists already at: $f") } - doOrFail { f.writeText(xml) } + f.writeText(xml) } /** @@ -163,8 +160,7 @@ private suspend fun submitInitiatedPayment( ) maybeLog( maybeLogDir, - xml, - initiatedPayment.requestUid + xml ) try { submitPain001( @@ -270,24 +266,10 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat * or long-polls (currently not implemented) for new payments. * FIXME: reduce code duplication with the fetch subcommand. */ - override fun run() { - val cfg: EbicsSetupConfig = doOrFail { - extractEbicsConfig(common.config) - } - // Fail now if keying is incomplete. - if (!isKeyingComplete(cfg)) exitProcess(1) - val dbCfg = cfg.config.extractDbConfigOrFail() - val db = Database(dbCfg.dbConnStr) - val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) ?: exitProcess(1) - if (!bankKeys.accepted) { - logger.error("Bank keys are not accepted, yet. Won't submit any payment.") - exitProcess(1) - } - val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - if (clientKeys == null) { - logger.error("Client private keys not found at: ${cfg.clientPrivateKeysFilename}") - exitProcess(1) - } + override fun run() = cliCmd(logger) { + val cfg: EbicsSetupConfig = extractEbicsConfig(common.config) + val dbCfg = cfg.config.dbConfig() + val (clientKeys, bankKeys) = expectFullKeys(cfg) val ctx = SubmissionContext( cfg = cfg, bankPublicKeysFile = bankKeys, @@ -298,42 +280,42 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat if (debug) { logger.info("Running in debug mode, submitting STDIN to the bank") val maybeStdin = generateSequence(::readLine).joinToString("\n") - doOrFail { - runBlocking { - submitPain001( - maybeStdin, - ctx.cfg, - ctx.clientPrivateKeysFile, - ctx.bankPublicKeysFile, - ctx.httpClient, - ctx.ebicsExtraLog - ) - } + runBlocking { + submitPain001( + maybeStdin, + ctx.cfg, + ctx.clientPrivateKeysFile, + ctx.bankPublicKeysFile, + ctx.httpClient, + ctx.ebicsExtraLog + ) } - return - } - if (transient) { - logger.info("Transient mode: submitting what found and returning.") - submitBatch(ctx, db) - return - } - val frequency: NexusFrequency = doOrFail { - val configValue = cfg.config.requireString("nexus-submit", "frequency") - val frequencySeconds = checkFrequency(configValue) - return@doOrFail NexusFrequency(frequencySeconds, configValue) + return@cliCmd } - logger.debug("Running with a frequency of ${frequency.fromConfig}") - if (frequency.inSeconds == 0) { - logger.warn("Long-polling not implemented, running therefore in transient mode") - submitBatch(ctx, db) - return - } - fixedRateTimer( - name = "ebics submit period", - period = (frequency.inSeconds * 1000).toLong(), - action = { - submitBatch(ctx, db) + Database(dbCfg.dbConnStr).use { db -> + val frequency = if (transient) { + logger.info("Transient mode: submitting what found and returning.") + null + } else { + val configValue = cfg.config.requireString("nexus-submit", "frequency") + val frequencySeconds = checkFrequency(configValue) + val frequency: NexusFrequency = NexusFrequency(frequencySeconds, configValue) + logger.debug("Running with a frequency of ${frequency.fromConfig}") + if (frequency.inSeconds == 0) { + logger.warn("Long-polling not implemented, running therefore in transient mode") + null + } else { + frequency + } } - ) + runBlocking { + do { + // TODO error handling + submitBatch(ctx, db) + // TODO take submitBatch taken time in the delay + delay(((frequency?.inSeconds ?: 0) * 1000).toLong()) + } while (frequency != null) + } + } } } \ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -156,78 +156,22 @@ fun createPain001( } /** - * Thrown if the parser expects DBIT but the transaction - * is CRDT, and vice-versa. - */ -class WrongPaymentDirection(val msg: String) : Exception(msg) - -/** - * Parses a camt.054 document looking for outgoing payments. + * Searches payments in a camt.054 (Detailavisierung) document. * - * @param notifXml input document. + * @param notifXml camt.054 input document * @param acceptedCurrency currency accepted by Nexus - * @return the list of outgoing payments. + * @param incoming list of incoming payments + * @param outgoing list of outgoing payments */ -fun parseOutgoingTxNotif( +fun parseTxNotif( notifXml: String, acceptedCurrency: String, -): List<OutgoingPayment> { - val ret = mutableListOf<OutgoingPayment>() - notificationForEachTx(notifXml) { bookDate -> - requireUniqueChildNamed("CdtDbtInd") { - if (focusElement.textContent != "DBIT") - throw WrongPaymentDirection("The payment is not outgoing, won't parse it") - } - // Obtaining the amount. - val amount: TalerAmount = requireUniqueChildNamed("Amt") { - val currency = focusElement.getAttribute("Ccy") - if (currency != acceptedCurrency) throw Exception("Currency $currency not supported") - getTalerAmount(focusElement.textContent, currency) - } - - /** - * The MsgId extracted in the block below matches the one that - * was specified as the MsgId element in the pain.001 that originated - * this outgoing payment. MsgId is considered unique because the - * bank enforces its uniqueness. Associating MsgId to this outgoing - * payment is also convenient to match its initiated outgoing payment - * in the database for reconciliation. - */ - val uidFromBank = StringBuilder() - requireUniqueChildNamed("Refs") { - requireUniqueChildNamed("MsgId") { - uidFromBank.append(focusElement.textContent) - } - } - - ret.add( - OutgoingPayment( - amount = amount, - bankTransferId = uidFromBank.toString(), - executionTime = bookDate - ) - ) - } - return ret -} - -/** - * Searches incoming payments in a camt.054 (Detailavisierung) document. - * - * @param notifXml camt.054 input document - * @param acceptedCurrency currency accepted by Nexus. - * @return the list of incoming payments to ingest in the database. - */ -fun parseIncomingTxNotif( - notifXml: String, - acceptedCurrency: String -): List<IncomingPayment> { - val ret = mutableListOf<IncomingPayment>() + incoming: MutableList<IncomingPayment>, + outgoing: MutableList<OutgoingPayment> +) { notificationForEachTx(notifXml) { bookDate -> - // Check the direction first. - requireUniqueChildNamed("CdtDbtInd") { - if (focusElement.textContent != "CRDT") - throw WrongPaymentDirection("The payment is not incoming, won't parse it") + val kind = requireUniqueChildNamed("CdtDbtInd") { + focusElement.textContent } val amount: TalerAmount = requireUniqueChildNamed("Amt") { val currency = focusElement.getAttribute("Ccy") @@ -237,52 +181,85 @@ fun parseIncomingTxNotif( if (currency != acceptedCurrency) throw Exception("Currency $currency not supported") getTalerAmount(focusElement.textContent, currency) } - // Obtaining payment UID. - val uidFromBank: String = requireUniqueChildNamed("Refs") { - requireUniqueChildNamed("AcctSvcrRef") { - focusElement.textContent - } - } - // Obtaining payment subject. - val subject = StringBuilder() - requireUniqueChildNamed("RmtInf") { - this.mapEachChildNamed("Ustrd") { - val piece = this.focusElement.textContent - subject.append(piece) - } - } + when (kind) { + "CRDT" -> { + // Obtaining payment UID. + val uidFromBank: String = requireUniqueChildNamed("Refs") { + requireUniqueChildNamed("AcctSvcrRef") { + focusElement.textContent + } + } + // Obtaining payment subject. + val subject = maybeUniqueChildNamed("RmtInf") { + val subject = StringBuilder() + mapEachChildNamed("Ustrd") { + val piece = focusElement.textContent + subject.append(piece) + } + subject + } + if (subject == null) { + logger.debug("Skip notification $uidFromBank, missing subject") + return@notificationForEachTx + } - // Obtaining the payer's details - val debtorPayto = StringBuilder("payto://iban/") - requireUniqueChildNamed("RltdPties") { - requireUniqueChildNamed("DbtrAcct") { - requireUniqueChildNamed("Id") { - requireUniqueChildNamed("IBAN") { - debtorPayto.append(focusElement.textContent) + // Obtaining the payer's details + val debtorPayto = StringBuilder("payto://iban/") + requireUniqueChildNamed("RltdPties") { + requireUniqueChildNamed("DbtrAcct") { + requireUniqueChildNamed("Id") { + requireUniqueChildNamed("IBAN") { + debtorPayto.append(focusElement.textContent) + } + } + } + // warn: it might need the postal address too.. + requireUniqueChildNamed("Dbtr") { + maybeUniqueChildNamed("Pty") { + requireUniqueChildNamed("Nm") { + val urlEncName = URLEncoder.encode(focusElement.textContent, "utf-8") + debtorPayto.append("?receiver-name=$urlEncName") + } + } } } + incoming.add( + IncomingPayment( + amount = amount, + bankTransferId = uidFromBank, + debitPaytoUri = debtorPayto.toString(), + executionTime = bookDate, + wireTransferSubject = subject.toString() + ) + ) } - // warn: it might need the postal address too.. - requireUniqueChildNamed("Dbtr") { - requireUniqueChildNamed("Pty") { - requireUniqueChildNamed("Nm") { - val urlEncName = URLEncoder.encode(focusElement.textContent, "utf-8") - debtorPayto.append("?receiver-name=$urlEncName") + "DBIT" -> { + /** + * The MsgId extracted in the block below matches the one that + * was specified as the MsgId element in the pain.001 that originated + * this outgoing payment. MsgId is considered unique because the + * bank enforces its uniqueness. Associating MsgId to this outgoing + * payment is also convenient to match its initiated outgoing payment + * in the database for reconciliation. + */ + val uidFromBank = StringBuilder() + requireUniqueChildNamed("Refs") { + requireUniqueChildNamed("MsgId") { + uidFromBank.append(focusElement.textContent) } } + + outgoing.add( + OutgoingPayment( + amount = amount, + bankTransferId = uidFromBank.toString(), + executionTime = bookDate + ) + ) } - } - ret.add( - IncomingPayment( - amount = amount, - bankTransferId = uidFromBank, - debitPaytoUri = debtorPayto.toString(), - executionTime = bookDate, - wireTransferSubject = subject.toString() - ) - ) + else -> throw Exception("Unknown transaction notification kind '$kind'") + } } - return ret } /** @@ -303,11 +280,13 @@ private fun notificationForEachTx( mapEachChildNamed("Ntfctn") { mapEachChildNamed("Ntry") { requireUniqueChildNamed("Sts") { - requireUniqueChildNamed("Cd") { - if (focusElement.textContent != "BOOK") - throw Exception("Found non booked transaction, " + - "stop parsing. Status was: ${focusElement.textContent}" - ) + if (focusElement.textContent != "BOOK") { + requireUniqueChildNamed("Cd") { + if (focusElement.textContent != "BOOK") + throw Exception("Found non booked transaction, " + + "stop parsing. Status was: ${focusElement.textContent}" + ) + } } } val bookDate: Instant = requireUniqueChildNamed("BookgDt") { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -35,7 +35,6 @@ import kotlinx.serialization.KSerializer import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File -import kotlin.system.exitProcess import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -49,6 +48,7 @@ import tech.libeufin.nexus.ebics.* import tech.libeufin.util.* import java.security.interfaces.RSAPrivateCrtKey import java.security.interfaces.RSAPublicKey +import java.io.FileNotFoundException val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-nexus", "libeufin-nexus") val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus") @@ -269,46 +269,51 @@ data class BankPublicKeysFile( ) /** - * Runs the argument and fails the process, if that throws - * an exception. + * Load client and bank keys from disk. + * Checks that the keying process has been fully completed. + * + * Helps to fail before starting to talk EBICS to the bank. * - * @param getLambda function that might return a value. - * @return the value from getLambda. + * @param cfg configuration handle. + * @return both client and bank keys */ -fun <T>doOrFail(getLambda: () -> T): T = - try { - getLambda() - } catch (e: Exception) { - logger.error(e.message) - exitProcess(1) +fun expectFullKeys( + cfg: EbicsSetupConfig +): Pair<ClientPrivateKeysFile, BankPublicKeysFile> { + val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) + if (clientKeys == null) { + throw Exception("Cannot operate without client keys. Missing '${cfg.clientPrivateKeysFilename}' file. Run 'libeufin-nexus ebics-setup' first") + } else if (!clientKeys.submitted_ini || !clientKeys.submitted_hia) { + throw Exception("Cannot operate with unsubmitted client keys, run 'libeufin-nexus ebics-setup' first") + } + val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) + if (bankKeys == null) { + throw Exception("Cannot operate without bank keys. Missing '${cfg.bankPublicKeysFilename}' file. run 'libeufin-nexus ebics-setup' first") + } else if (!bankKeys.accepted) { + throw Exception("Cannot operate with unaccepted bank keys, run 'libeufin-nexus ebics-setup' until accepting the bank keys") } + return Pair(clientKeys, bankKeys) +} /** * Load the bank keys file from disk. * * @param location the keys file location. * @return the internal JSON representation of the keys file, - * or null on failures. + * or null if the file does not exist */ fun loadBankKeys(location: String): BankPublicKeysFile? { - val f = File(location) - if (!f.exists()) { - logger.error("Could not find the bank keys file at: $location") + val content = try { + File(location).readText() + } catch (e: FileNotFoundException) { return null - } - val fileContent = try { - f.readText() // read from disk. } catch (e: Exception) { - logger.error("Could not read the bank keys file from disk, detail: ${e.message}") - return null + throw Exception("Could not read the bank keys file from disk", e) } return try { - myJson.decodeFromString(fileContent) // Parse into JSON. + myJson.decodeFromString(content) } catch (e: Exception) { - logger.error(e.message) - @OptIn(InternalAPI::class) // enables message below. - logger.error(e.rootCause?.message) // actual useful message mentioning failing fields - return null + throw Exception("Could not decode bank keys", e) } } @@ -317,60 +322,43 @@ fun loadBankKeys(location: String): BankPublicKeysFile? { * * @param location the keys file location. * @return the internal JSON representation of the keys file, - * or null on failures. + * or null if the file does not exist */ fun loadPrivateKeysFromDisk(location: String): ClientPrivateKeysFile? { - val f = File(location) - if (!f.exists()) { - logger.error("Could not find the private keys file at: $location") + val content = try { + File(location).readText() + } catch (e: FileNotFoundException) { return null - } - val fileContent = try { - f.readText() // read from disk. } catch (e: Exception) { - logger.error("Could not read private keys from disk, detail: ${e.message}") - return null + throw Exception("Could not read private keys from disk", e) } return try { - myJson.decodeFromString(fileContent) // Parse into JSON. + myJson.decodeFromString(content) } catch (e: Exception) { - logger.error(e.message) - @OptIn(InternalAPI::class) // enables message below. - logger.error(e.rootCause?.message) // actual useful message mentioning failing fields - return null + throw Exception("Could not decode private keys", e) } } /** - * Abstracts the config loading and exception handling. + * Abstracts the config loading * * @param configFile potentially NULL configuration file location. * @return the configuration handle. */ -fun loadConfigOrFail(configFile: String?): TalerConfig { +fun loadConfig(configFile: String?): TalerConfig { val config = TalerConfig(NEXUS_CONFIG_SOURCE) - try { - config.load(configFile) - } catch (e: Exception) { - logger.error("Could not load configuration from ${configFile}, detail: ${e.message}") - exitProcess(1) - } + config.load(configFile) return config } /** * Abstracts fetching the DB config values to set up Nexus. */ -fun TalerConfig.extractDbConfigOrFail(): DatabaseConfig = - try { - DatabaseConfig( - dbConnStr = requireString("nexus-postgres", "config"), - sqlDir = requirePath("libeufin-nexusdb-postgres", "sql_dir") - ) - } catch (e: Exception) { - logger.error("Could not load config options for Nexus DB, detail: ${e.message}.") - exitProcess(1) - } +fun TalerConfig.dbConfig(): DatabaseConfig = + DatabaseConfig( + dbConnStr = requireString("nexus-postgres", "config"), + sqlDir = requirePath("libeufin-nexusdb-postgres", "sql_dir") + ) /** * Main CLI class that collects all the subcommands. diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics2.kt @@ -60,7 +60,7 @@ suspend fun doEbicsCustomDownload( clientKeys: ClientPrivateKeysFile, bankKeys: BankPublicKeysFile, client: HttpClient -): ByteArray? { +): ByteArray { val xmlReq = createEbics25DownloadInit(cfg, clientKeys, bankKeys, messageType) return doEbicsDownload(client, cfg, clientKeys, bankKeys, xmlReq, false) } @@ -85,10 +85,6 @@ suspend fun fetchBankAccounts( ): HTDResponseOrderData? { val xmlReq = createEbics25DownloadInit(cfg, clientKeys, bankKeys, "HTD") val bytesResp = doEbicsDownload(client, cfg, clientKeys, bankKeys, xmlReq, false) - if (bytesResp == null) { - logger.error("EBICS HTD transaction failed.") - return null - } val xmlResp = bytesResp.toString(Charsets.UTF_8) return try { logger.debug("Fetched accounts: $bytesResp") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/Ebics3.kt @@ -89,7 +89,7 @@ fun createEbics3DownloadInitialization( cfg: EbicsSetupConfig, bankkeys: BankPublicKeysFile, clientKeys: ClientPrivateKeysFile, - orderParams: Ebics3Request.OrderDetails.BTOrderParams + orderParams: Ebics3Request.OrderDetails.BTDOrderParams ): String { val nonce = getNonce(128) val req = Ebics3Request.createForDownloadInitializationPhase( @@ -278,7 +278,7 @@ fun prepNotificationRequest3( startDate: Instant? = null, endDate: Instant? = null, isAppendix: Boolean -): Ebics3Request.OrderDetails.BTOrderParams { +): Ebics3Request.OrderDetails.BTDOrderParams { val service = Ebics3Request.OrderDetails.Service().apply { serviceName = "REP" scope = "CH" @@ -292,7 +292,7 @@ fun prepNotificationRequest3( if (!isAppendix) serviceOption = "XDCI" } - return Ebics3Request.OrderDetails.BTOrderParams().apply { + return Ebics3Request.OrderDetails.BTDOrderParams().apply { this.service = service this.dateRange = if (startDate != null) getEbics3DateRange(startDate, endDate ?: Instant.now()) @@ -314,7 +314,7 @@ fun prepNotificationRequest3( fun prepAckRequest3( startDate: Instant? = null, endDate: Instant? = null -): Ebics3Request.OrderDetails.BTOrderParams { +): Ebics3Request.OrderDetails.BTDOrderParams { val service = Ebics3Request.OrderDetails.Service().apply { serviceName = "PSR" scope = "CH" @@ -326,7 +326,7 @@ fun prepAckRequest3( version = "10" } } - return Ebics3Request.OrderDetails.BTOrderParams().apply { + return Ebics3Request.OrderDetails.BTDOrderParams().apply { this.service = service this.dateRange = if (startDate != null) getEbics3DateRange(startDate, endDate ?: Instant.now()) @@ -347,7 +347,7 @@ fun prepAckRequest3( fun prepStatementRequest3( startDate: Instant? = null, endDate: Instant? = null -): Ebics3Request.OrderDetails.BTOrderParams { +): Ebics3Request.OrderDetails.BTDOrderParams { val service = Ebics3Request.OrderDetails.Service().apply { serviceName = "EOP" scope = "CH" @@ -359,7 +359,7 @@ fun prepStatementRequest3( version = "08" } } - return Ebics3Request.OrderDetails.BTOrderParams().apply { + return Ebics3Request.OrderDetails.BTDOrderParams().apply { this.service = service this.dateRange = if (startDate != null) getEbics3DateRange(startDate, endDate ?: Instant.now()) @@ -380,7 +380,7 @@ fun prepStatementRequest3( fun prepReportRequest3( startDate: Instant? = null, endDate: Instant? = null -): Ebics3Request.OrderDetails.BTOrderParams { +): Ebics3Request.OrderDetails.BTDOrderParams { val service = Ebics3Request.OrderDetails.Service().apply { serviceName = "STM" scope = "CH" @@ -392,7 +392,7 @@ fun prepReportRequest3( version = "08" } } - return Ebics3Request.OrderDetails.BTOrderParams().apply { + return Ebics3Request.OrderDetails.BTDOrderParams().apply { this.service = service this.dateRange = if (startDate != null) getEbics3DateRange(startDate, endDate ?: Instant.now()) @@ -413,7 +413,7 @@ fun prepReportRequest3( fun prepEbics3Document( whichDoc: SupportedDocument, startDate: Instant? = null -): Ebics3Request.OrderDetails.BTOrderParams = +): Ebics3Request.OrderDetails.BTDOrderParams = when(whichDoc) { SupportedDocument.PAIN_002 -> prepAckRequest3(startDate) SupportedDocument.CAMT_052 -> prepReportRequest3(startDate) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -332,20 +332,18 @@ suspend fun doEbicsDownload( reqXml: String, isEbics3: Boolean, tolerateEmptyResult: Boolean = false -): ByteArray? { +): ByteArray { val initResp = postEbics(client, cfg, bankKeys, reqXml, isEbics3) logger.debug("Download init phase done. EBICS- and bank-technical codes are: ${initResp.technicalReturnCode}, ${initResp.bankReturnCode}") if (initResp.technicalReturnCode != EbicsReturnCode.EBICS_OK) { - logger.error("Download init phase has EBICS-technical error: ${initResp.technicalReturnCode}") - return null + throw Exception("Download init phase has EBICS-technical error: ${initResp.technicalReturnCode}") } if (initResp.bankReturnCode == EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE && tolerateEmptyResult) { logger.info("Download content is empty") return ByteArray(0) } if (initResp.bankReturnCode != EbicsReturnCode.EBICS_OK) { - logger.error("Download init phase has bank-technical error: ${initResp.bankReturnCode}") - return null + throw Exception("Download init phase has bank-technical error: ${initResp.bankReturnCode}") } val tId = initResp.transactionID ?: throw EbicsSideException( @@ -355,8 +353,7 @@ suspend fun doEbicsDownload( logger.debug("EBICS download transaction passed the init phase, got ID: $tId") val howManySegments = initResp.numSegments if (howManySegments == null) { - tech.libeufin.nexus.logger.error("Init response lacks the quantity of segments, failing.") - return null + throw Exception("Init response lacks the quantity of segments, failing.") } val ebicsChunks = mutableListOf<String>() // Getting the chunk(s) @@ -388,8 +385,7 @@ suspend fun doEbicsDownload( } val chunk = transResp.orderDataEncChunk if (chunk == null) { - tech.libeufin.nexus.logger.error("EBICS transfer phase lacks chunk #$x, failing.") - return null + throw Exception("EBICS transfer phase lacks chunk #$x, failing.") } ebicsChunks.add(chunk) } diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt @@ -0,0 +1,98 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 Stanisci and Dold. + + * LibEuFin 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. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import tech.libeufin.nexus.* +import com.github.ajalt.clikt.core.* +import com.github.ajalt.clikt.testing.* +import kotlin.test.* +import java.io.* +import java.nio.file.* +import kotlin.io.path.* +import tech.libeufin.util.* + +val nexusCmd = LibeufinNexusCommand() + +fun CliktCommand.testErr(cmd: String, msg: String) { + val prevOut = System.err + val tmpOut = ByteArrayOutputStream() + System.setErr(PrintStream(tmpOut)) + val result = test(cmd) + System.setErr(prevOut) + val tmpStr = tmpOut.toString(Charsets.UTF_8) + println(tmpStr) + assertEquals(1, result.statusCode, "'$cmd' should have failed") + val line = tmpStr.substringAfterLast(" - ").trimEnd('\n') + println(line) + assertEquals(msg, line) +} + +class CliTest { + /** Test error format related to the keying process */ + @Test + fun keys() { + val cmds = listOf("ebics-submit", "ebics-fetch") + val allCmds = listOf("ebics-submit", "ebics-fetch", "ebics-setup") + val conf = "conf/test.conf" + val cfg = loadConfig(conf) + val clientKeysPath = Path(cfg.requireString("nexus-ebics", "client_private_keys_file")) + val bankKeysPath = Path(cfg.requireString("nexus-ebics", "bank_public_keys_file")) + clientKeysPath.parent?.createDirectories() + bankKeysPath.parent?.createDirectories() + + // Missing client keys + clientKeysPath.deleteIfExists() + for (cmd in cmds) { + nexusCmd.testErr("$cmd -c $conf", "Cannot operate without client keys. Missing '$clientKeysPath' file. Run 'libeufin-nexus ebics-setup' first") + } + // Bad client json + clientKeysPath.writeText("CORRUPTION", Charsets.UTF_8) + for (cmd in allCmds) { + nexusCmd.testErr("$cmd -c $conf", "Could not decode private keys: Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: CORRUPTION") + } + // Unfinished client + syncJsonToDisk(generateNewKeys(), clientKeysPath.toString()) + for (cmd in cmds) { + nexusCmd.testErr("$cmd -c $conf", "Cannot operate with unsubmitted client keys, run 'libeufin-nexus ebics-setup' first") + } + + // Missing bank keys + syncJsonToDisk(generateNewKeys().apply { + submitted_hia = true + submitted_ini = true + }, clientKeysPath.toString()) + bankKeysPath.deleteIfExists() + for (cmd in cmds) { + nexusCmd.testErr("$cmd -c $conf", "Cannot operate without bank keys. Missing '$bankKeysPath' file. run 'libeufin-nexus ebics-setup' first") + } + // Bad bank json + bankKeysPath.writeText("CORRUPTION", Charsets.UTF_8) + for (cmd in allCmds) { + nexusCmd.testErr("$cmd -c $conf", "Could not decode bank keys: Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: CORRUPTION") + } + // Unfinished bank + syncJsonToDisk(BankPublicKeysFile( + bank_authentication_public_key = CryptoUtil.generateRsaKeyPair(2048).public, + bank_encryption_public_key = CryptoUtil.generateRsaKeyPair(2048).public, + accepted = false + ), bankKeysPath.toString()) + for (cmd in cmds) { + nexusCmd.testErr("$cmd -c $conf", "Cannot operate with unaccepted bank keys, run 'libeufin-nexus ebics-setup' until accepting the bank keys") + } + } +} +\ No newline at end of file diff --git a/nexus/src/test/kotlin/Common.kt b/nexus/src/test/kotlin/Common.kt @@ -91,7 +91,7 @@ fun genInitPay( ) // Generates an incoming payment, given its subject. -fun genIncPay(subject: String = "test wire transfer") = +fun genInPay(subject: String) = IncomingPayment( amount = TalerAmount(44, 0, "KUDOS"), debitPaytoUri = "payto://iban/not-used", @@ -101,11 +101,11 @@ fun genIncPay(subject: String = "test wire transfer") = ) // Generates an outgoing payment, given its subject. -fun genOutPay(subject: String = "outgoing payment") = +fun genOutPay(subject: String, bankTransferId: String) = OutgoingPayment( amount = TalerAmount(44, 0, "KUDOS"), creditPaytoUri = "payto://iban/TEST-IBAN?receiver-name=Test", wireTransferSubject = subject, executionTime = Instant.now(), - bankTransferId = "entropic" + bankTransferId = bankTransferId ) \ No newline at end of file diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/nexus/src/test/kotlin/DatabaseTest.kt @@ -8,145 +8,113 @@ import kotlin.test.assertEquals class OutgoingPaymentsTest { - - /** - * Tests the insertion of outgoing payments, including - * the case where we reconcile with an initiated payment. - */ - @Test - fun outgoingPaymentCreation() { - val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE)) - runBlocking { - // inserting without reconciling - assertFalse(db.isOutgoingPaymentSeen("entropic")) - assertEquals( - OutgoingPaymentOutcome.SUCCESS, - db.outgoingPaymentCreate(genOutPay("paid by nexus")) - ) - assertTrue(db.isOutgoingPaymentSeen("entropic")) - // inserting trying to reconcile with a non-existing initiated payment. - assertEquals( - OutgoingPaymentOutcome.INITIATED_COUNTERPART_NOT_FOUND, - db.outgoingPaymentCreate(genOutPay(), 5) - ) - // initiating a payment to reconcile later. Takes row ID == 1 - assertEquals( - PaymentInitiationOutcome.SUCCESS, - db.initiatedPaymentCreate(genInitPay("waiting for reconciliation")) - ) - // Creating an outgoing payment, reconciling it with the one above. - assertEquals( - OutgoingPaymentOutcome.SUCCESS, - db.outgoingPaymentCreate(genOutPay(), 1) - ) - } - } -} - -// @Ignore // enable after having modified the bouncing logic in Kotlin -class IncomingPaymentsTest { @Test - fun bounceWithCustomRefund() { + fun register() { val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE)) runBlocking { - // creating and bouncing one incoming transaction. - assertTrue( - db.incomingPaymentCreateBounced( - genIncPay("incoming and bounced"), - "UID", - TalerAmount(2, 53000000, "KUDOS") - ) - ) - db.runConn { - // check incoming shows up. - val checkIncoming = it.prepareStatement(""" - SELECT - (amount).val as amount_value - ,(amount).frac as amount_frac - FROM incoming_transactions - WHERE incoming_transaction_id = 1; - """).executeQuery() - assertTrue(checkIncoming.next()) - assertEquals(44, checkIncoming.getLong("amount_value")) - assertEquals(0, checkIncoming.getLong("amount_frac")) - // check bounced has the custom value - val findBounced = it.prepareStatement(""" - SELECT - initiated_outgoing_transaction_id - FROM bounced_transactions - WHERE incoming_transaction_id = 1; - """).executeQuery() - assertTrue(findBounced.next()) - val initiatedId = findBounced.getLong("initiated_outgoing_transaction_id") - assertEquals(1, initiatedId) - val findInitiatedAmount = it.prepareStatement(""" - SELECT - (amount).val as amount_value - ,(amount).frac as amount_frac - FROM initiated_outgoing_transactions - WHERE initiated_outgoing_transaction_id = 1; - """).executeQuery() - assertTrue(findInitiatedAmount.next()) + // With reconciling + genOutPay("paid by nexus", "first").run { assertEquals( - 53000000, - findInitiatedAmount.getInt("amount_frac") - ) - assertEquals( - 2, - findInitiatedAmount.getInt("amount_value") + PaymentInitiationOutcome.SUCCESS, + db.initiatedPaymentCreate(genInitPay("waiting for reconciliation", "first")) ) + db.registerOutgoing(this).run { + assertTrue(new,) + assertTrue(initiated) + } + db.registerOutgoing(this).run { + assertFalse(new) + assertTrue(initiated) + } + } + // Without reconciling + genOutPay("not paid by nexus", "second").run { + db.registerOutgoing(this).run { + assertTrue(new) + assertFalse(initiated) + } + db.registerOutgoing(this).run { + assertFalse(new) + assertFalse(initiated) + } } } } +} + +class IncomingPaymentsTest { // Tests creating and bouncing incoming payments in one DB transaction. @Test - fun incomingAndBounce() { + fun bounce() { val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE)) runBlocking { // creating and bouncing one incoming transaction. - assertTrue(db.incomingPaymentCreateBounced( - genIncPay("incoming and bounced"), - "UID" - )) + val payment = genInPay("incoming and bounced") + db.registerMalformedIncoming( + payment, + TalerAmount(2, 53000000, "KUDOS"), + Instant.now() + ).run { + assertTrue(new) + } + db.registerMalformedIncoming( + payment, + TalerAmount(2, 53000000, "KUDOS"), + Instant.now() + ).run { + assertFalse(new) + } db.runConn { // Checking one incoming got created val checkIncoming = it.prepareStatement(""" - SELECT 1 FROM incoming_transactions WHERE incoming_transaction_id = 1; + SELECT (amount).val as amount_value, (amount).frac as amount_frac + FROM incoming_transactions WHERE incoming_transaction_id = 1 """).executeQuery() assertTrue(checkIncoming.next()) + assertEquals(payment.amount.value, checkIncoming.getLong("amount_value")) + assertEquals(payment.amount.fraction, checkIncoming.getInt("amount_frac")) // Checking the bounced table got its row. val checkBounced = it.prepareStatement(""" - SELECT 1 FROM bounced_transactions WHERE incoming_transaction_id = 1; + SELECT 1 FROM bounced_transactions + WHERE incoming_transaction_id = 1 AND initiated_outgoing_transaction_id = 1 """).executeQuery() assertTrue(checkBounced.next()) // check the related initiated payment exists. val checkInitiated = it.prepareStatement(""" - SELECT - COUNT(initiated_outgoing_transaction_id) AS how_many - FROM initiated_outgoing_transactions + SELECT + (amount).val as amount_value + ,(amount).frac as amount_frac + FROM initiated_outgoing_transactions + WHERE initiated_outgoing_transaction_id = 1 """).executeQuery() assertTrue(checkInitiated.next()) - assertEquals(1, checkInitiated.getInt("how_many")) + assertEquals( + 53000000, + checkInitiated.getInt("amount_frac") + ) + assertEquals( + 2, + checkInitiated.getInt("amount_value") + ) } } } // Tests the creation of a talerable incoming payment. @Test - fun incomingTalerableCreation() { + fun talerable() { val db = prepDb(TalerConfig(NEXUS_CONFIG_SOURCE)) val reservePub = ByteArray(32) Random.nextBytes(reservePub) runBlocking { - val inc = genIncPay("reserve-pub") + val inc = genInPay("reserve-pub") // Checking the reserve is not found. assertFalse(db.isReservePubFound(reservePub)) - assertFalse(db.isIncomingPaymentSeen(inc.bankTransferId)) - assertTrue(db.incomingTalerablePaymentCreate(inc, reservePub)) + assertTrue(db.registerTalerableIncoming(inc, reservePub).new) // Checking the reserve is not found. assertTrue(db.isReservePubFound(reservePub)) - assertTrue(db.isIncomingPaymentSeen(inc.bankTransferId)) + assertFalse(db.registerTalerableIncoming(inc, reservePub).new) } } } diff --git a/nexus/src/test/kotlin/Keys.kt b/nexus/src/test/kotlin/Keys.kt @@ -23,7 +23,7 @@ class PublicKeys { bank_encryption_public_key = CryptoUtil.generateRsaKeyPair(2028).public ) // storing them on disk. - assertTrue(syncJsonToDisk(fileContent, "/tmp/nexus-tests-bank-keys.json")) + syncJsonToDisk(fileContent, "/tmp/nexus-tests-bank-keys.json") // loading them and check that values are the same. val fromDisk = loadBankKeys("/tmp/nexus-tests-bank-keys.json") assertNotNull(fromDisk) @@ -50,16 +50,12 @@ class PrivateKeys { fun createWrongPermissions() { f.writeText("won't be overridden") f.setReadOnly() - assertFalse(syncJsonToDisk(clientKeys, f.path)) + try { + syncJsonToDisk(clientKeys, f.path) + throw Exception("Should have failed") + } catch (e: Exception) { } } - // Testing keys file creation. - @Test - fun creation() { - assertFalse(f.exists()) - maybeCreatePrivateKeysFile(f.path) // file doesn't exist, this must create. - j.decodeFromString<ClientPrivateKeysFile>(f.readText()) // reading and validating disk content. - } /** * Tests whether loading keys from disk yields the same * values that were stored to the file. @@ -67,7 +63,7 @@ class PrivateKeys { @Test fun load() { assertFalse(f.exists()) - assertTrue(syncJsonToDisk(clientKeys, f.path)) // Artificially storing this to the file. + syncJsonToDisk(clientKeys, f.path) // Artificially storing this to the file. val fromDisk = loadPrivateKeysFromDisk(f.path) // loading it via the tested routine. assertNotNull(fromDisk) // Checking the values from disk match the initial object. diff --git a/nexus/src/test/kotlin/PostFinance.kt b/nexus/src/test/kotlin/PostFinance.kt @@ -1,219 +0,0 @@ -import io.ktor.client.* -import kotlinx.coroutines.runBlocking -import org.junit.Ignore -import org.junit.Test -import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* -import tech.libeufin.util.ebics_h005.Ebics3Request -import tech.libeufin.util.parsePayto -import java.io.File -import java.time.Instant -import java.time.temporal.ChronoUnit -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -// Tests only manual, that's why they are @Ignore - -private fun prep(): EbicsSetupConfig { - val handle = TalerConfig(NEXUS_CONFIG_SOURCE) - val ebicsUserId = File("/tmp/pofi-ebics-user-id.txt").readText() - val ebicsPartnerId = File("/tmp/pofi-ebics-partner-id.txt").readText() - handle.loadFromString(getPofiConfig(ebicsUserId, ebicsPartnerId)) - return EbicsSetupConfig(handle) -} - -@Ignore -class Iso20022 { - - private val yesterday: Instant = Instant.now().minus(1, ChronoUnit.DAYS) - - @Test // asks a pain.002, links with pain.001's MsgId - fun getAck() { - download(prepAckRequest3(startDate = yesterday) - )?.unzipForEach { name, content -> - println(name) - println(content) - } - } - - /** - * With the "mit Detailavisierung" option, each entry has an - * AcctSvcrRef & wire transfer subject. - */ - @Test - fun getStatement() { - val inflatedBytes = download(prepStatementRequest3()) - inflatedBytes?.unzipForEach { name, content -> - println(name) - println(content) - } - } - - @Test - fun getNotification() { - val inflatedBytes = download( - prepNotificationRequest3( - // startDate = yesterday, - isAppendix = true - ) - ) - inflatedBytes?.unzipForEach { name, content -> - println(name) - println(content) - } - } - - /** - * Never shows the subject. - */ - @Test - fun getReport() { - download(prepReportRequest3(yesterday))?.unzipForEach { name, content -> - println(name) - println(content) - } - } - - @Test - fun simulateIncoming() { - val cfg = prep() - val orderService: Ebics3Request.OrderDetails.Service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "OTH" - scope = "BIL" - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "csv" - } - serviceOption = "CH002LMF" - } - val instruction = """ - Product;Channel;Account;Currency;Amount;Reference;Name;Street;Number;Postcode;City;Country;DebtorAddressLine;DebtorAddressLine;DebtorAccount;ReferenceType;UltimateDebtorName;UltimateDebtorStreet;UltimateDebtorNumber;UltimateDebtorPostcode;UltimateDebtorTownName;UltimateDebtorCountry;UltimateDebtorAddressLine;UltimateDebtorAddressLine;RemittanceInformationText - QRR;PO;CH9789144829733648596;CHF;1;;D009;Musterstrasse;1;1111;Musterstadt;CH;;;;NON;D009;Musterstrasse;1;1111;Musterstadt;CH;;;Taler-Demo - """.trimIndent() - - runBlocking { - try { - doEbicsUpload( - HttpClient(), - cfg, - loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!!, - loadBankKeys(cfg.bankPublicKeysFilename)!!, - orderService, - instruction.toByteArray(Charsets.UTF_8) - ) - } - catch (e: EbicsUploadException) { - logger.error(e.message) - logger.error("bank EC: ${e.bankErrorCode}, EBICS EC: ${e.ebicsErrorCode}") - } - } - } - - fun download(req: Ebics3Request.OrderDetails.BTOrderParams): ByteArray? { - val cfg = prep() - val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename)!! - val myKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!! - val initXml = createEbics3DownloadInitialization( - cfg, - bankKeys, - myKeys, - orderParams = req - ) - return runBlocking { - doEbicsDownload( - HttpClient(), - cfg, - myKeys, - bankKeys, - initXml, - isEbics3 = true, - tolerateEmptyResult = true - ) - } - } - - @Test - fun sendPayment() { - val cfg = prep() - val xml = createPain001( - "random", - Instant.now(), - cfg.myIbanAccount, - TalerAmount(4, 0, "CHF"), - "Test reimbursement, part 2", - parsePayto("payto://iban/CH9300762011623852957?receiver-name=NotGiven")!! - ) - runBlocking { - // Not asserting, as it throws in case of errors. - submitPain001( - xml, - cfg, - loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!!, - loadBankKeys(cfg.bankPublicKeysFilename)!!, - HttpClient() - ) - } - } -} - -@Ignore -class PostFinance { - // Tests sending client keys to the PostFinance test platform. - @Test - fun postClientKeys() { - val cfg = prep() - runBlocking { - val httpClient = HttpClient() - assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.INI)) - assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.HIA)) - } - } - - // Tests getting the PostFinance keys from their test platform. - @Test - fun getBankKeys() { - val cfg = prep() - val keys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - assertNotNull(keys) - assertTrue(keys.submitted_ini) - assertTrue(keys.submitted_hia) - runBlocking { - assertTrue( - doKeysRequestAndUpdateState( - cfg, - keys, - HttpClient(), - KeysOrderType.HPB - )) - } - } - - // Arbitrary download request for manual tests. - @Test - fun customDownload() { - val cfg = prep() - val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) - runBlocking { - val bytes = doEbicsCustomDownload( - messageType = "HTD", - cfg = cfg, - bankKeys = bankKeys!!, - clientKeys = clientKeys!!, - client = HttpClient() - ) - println(bytes.toString()) - } - } - - // Tests the HTD message type. - @Test - fun fetchAccounts() { - val cfg = prep() - val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - assertNotNull(clientKeys) - val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) - assertNotNull(bankKeys) - val htd = runBlocking { fetchBankAccounts(cfg, clientKeys, bankKeys, HttpClient()) } - println(htd) - } -} -\ No newline at end of file diff --git a/util/src/main/kotlin/Cli.kt b/util/src/main/kotlin/Cli.kt @@ -22,24 +22,29 @@ package tech.libeufin.util import ConfigSource import TalerConfig import TalerConfigError -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.core.* import com.github.ajalt.clikt.parameters.types.* import com.github.ajalt.clikt.parameters.arguments.* import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.groups.* import org.slf4j.Logger import org.slf4j.LoggerFactory -import kotlin.system.exitProcess private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.util.ConfigCli") fun cliCmd(logger: Logger, lambda: () -> Unit) { try { lambda() - } catch (e: Exception) { - logger.error(e.message) - exitProcess(1) + } catch (e: Throwable) { + var msg = StringBuilder(e.message) + var cause = e.cause; + while (cause != null) { + msg.append(": ") + msg.append(cause.message) + cause = cause.cause + } + logger.error(msg.toString()) + throw ProgramResult(1) } } @@ -83,15 +88,13 @@ private class CliConfigGet(private val configSource: ConfigSource) : CliktComman if (isPath) { val res = config.lookupPath(sectionName, optionName) if (res == null) { - logger.error("value not found in config") - exitProcess(2) + throw Exception("value not found in config") } println(res) } else { val res = config.lookupString(sectionName, optionName) if (res == null) { - logger.error("value not found in config") - exitProcess(2) + throw Exception("value not found in config") } println(res) } diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt @@ -235,7 +235,7 @@ fun initializeDatabaseTables(conn: PgConnection, cfg: DatabaseConfig, sqlFilePre val patchName = "$sqlFilePrefix-$numStr" checkStmt.setString(1, patchName) - val patchCount = checkStmt.oneOrNull { it.getInt(1) } ?: throw Error("unable to query patches"); + val patchCount = checkStmt.oneOrNull { it.getInt(1) } ?: throw Exception("unable to query patches"); if (patchCount >= 1) { logger.info("patch $patchName already applied") continue diff --git a/util/src/main/kotlin/ebics_h005/Ebics3Request.kt b/util/src/main/kotlin/ebics_h005/Ebics3Request.kt @@ -198,7 +198,8 @@ class Ebics3Request { } @XmlAccessorType(XmlAccessType.NONE) - class BTOrderParams { + @XmlType(propOrder = ["service", "signatureFlag", "dateRange"]) + class BTUOrderParams { @get:XmlElement(name = "Service", required = true) lateinit var service: Service @@ -214,11 +215,21 @@ class Ebics3Request { var dateRange: DateRange? = null } + @XmlAccessorType(XmlAccessType.NONE) + @XmlType(propOrder = ["service", "dateRange"]) + class BTDOrderParams { + @get:XmlElement(name = "Service", required = true) + lateinit var service: Service + + @get:XmlElement(name = "DateRange", required = true) + var dateRange: DateRange? = null + } + @get:XmlElement(name = "BTUOrderParams", required = true) - var btuOrderParams: BTOrderParams? = null + var btuOrderParams: BTUOrderParams? = null @get:XmlElement(name = "BTDOrderParams", required = true) - var btdOrderParams: BTOrderParams? = null + var btdOrderParams: BTDOrderParams? = null /** * Only present if this ebicsRequest is an upload order @@ -359,7 +370,6 @@ class Ebics3Request { fun createForDownloadReceiptPhase( transactionId: String?, hostId: String - ): Ebics3Request { return Ebics3Request().apply { header = Header().apply { @@ -393,7 +403,7 @@ class Ebics3Request { date: XMLGregorianCalendar, bankEncPub: RSAPublicKey, bankAuthPub: RSAPublicKey, - myOrderParams: OrderDetails.BTOrderParams + myOrderParams: OrderDetails.BTDOrderParams ): Ebics3Request { return Ebics3Request().apply { version = "H005" @@ -463,7 +473,7 @@ class Ebics3Request { userID = userId orderDetails = OrderDetails().apply { this.adminOrderType = "BTU" - this.btuOrderParams = OrderDetails.BTOrderParams().apply { + this.btuOrderParams = OrderDetails.BTUOrderParams().apply { service = aOrderService } }