commit ecc5e07182e3f964492c60dfeb5c4723d8bcc6d5
parent 1a3ebf8f8aeb3f6a941197fe3bfc85360bbc228f
Author: Antoine A <>
Date: Sat, 13 Jan 2024 14:40:39 +0000
Merge nexus-integration-test into master
Diffstat:
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
}
}