libeufin

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

commit 6b9c4878539ead5fc0eb334811d0eddc7e74b71e
parent eb392e409d94e5776b2ae4fc9bf9fa73a30c1873
Author: Antoine A <>
Date:   Tue,  2 Dec 2025 17:43:23 +0100

common: rename and clean modules

Diffstat:
M.gitignore | 2+-
MMakefile | 20++++++++++----------
Dbank/build.gradle | 64----------------------------------------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 54------------------------------------------------------
Mbuild.gradle | 8++++----
Mcontrib/ci/jobs/0-codespell/job.sh | 6+++---
Mdebian/libeufin-bank.install | 4++--
Mdebian/libeufin-nexus.install | 4++--
Adocker-compose.yml | 13+++++++++++++
Rbank/README -> libeufin-bank/README | 0
Alibeufin-bank/build.gradle | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rbank/conf/test.conf -> libeufin-bank/conf/test.conf | 0
Rbank/conf/test_bonus.conf -> libeufin-bank/conf/test_bonus.conf | 0
Rbank/conf/test_no_conversion.conf -> libeufin-bank/conf/test_no_conversion.conf | 0
Rbank/conf/test_no_password_check.conf -> libeufin-bank/conf/test_no_password_check.conf | 0
Rbank/conf/test_restrict.conf -> libeufin-bank/conf/test_restrict.conf | 0
Rbank/conf/test_tan_err.conf -> libeufin-bank/conf/test_tan_err.conf | 0
Rbank/conf/test_with_fees.conf -> libeufin-bank/conf/test_with_fees.conf | 0
Rbank/conf/test_x_taler_bank.conf -> libeufin-bank/conf/test_x_taler_bank.conf | 0
Rbank/src/main/kotlin/tech/libeufin/bank/Config.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/Config.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/Constants.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/Constants.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/Error.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/Error.kt | 0
Alibeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/api/ObservabilityApi.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ObservabilityApi.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/auth/mfa.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/auth/mfa.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/cli/BenchPwh.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/BenchPwh.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/cli/ChangePw.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/ChangePw.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/cli/DbInit.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/DbInit.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/cli/Gc.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/Gc.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/cli/Serve.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/Serve.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/Database.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/helpers.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 0
Rbank/src/main/kotlin/tech/libeufin/bank/params.kt -> libeufin-bank/src/main/kotlin/tech/libeufin/bank/params.kt | 0
Rbank/src/test/kotlin/AmountTest.kt -> libeufin-bank/src/test/kotlin/AmountTest.kt | 0
Rbank/src/test/kotlin/BankIntegrationApiTest.kt -> libeufin-bank/src/test/kotlin/BankIntegrationApiTest.kt | 0
Rbank/src/test/kotlin/CommonApiTest.kt -> libeufin-bank/src/test/kotlin/CommonApiTest.kt | 0
Rbank/src/test/kotlin/ConversionApiTest.kt -> libeufin-bank/src/test/kotlin/ConversionApiTest.kt | 0
Rbank/src/test/kotlin/CoreBankApiTest.kt -> libeufin-bank/src/test/kotlin/CoreBankApiTest.kt | 0
Rbank/src/test/kotlin/DatabaseTest.kt -> libeufin-bank/src/test/kotlin/DatabaseTest.kt | 0
Rbank/src/test/kotlin/GcTest.kt -> libeufin-bank/src/test/kotlin/GcTest.kt | 0
Rbank/src/test/kotlin/JsonTest.kt -> libeufin-bank/src/test/kotlin/JsonTest.kt | 0
Rbank/src/test/kotlin/ObservabilityTest.kt -> libeufin-bank/src/test/kotlin/ObservabilityTest.kt | 0
Rbank/src/test/kotlin/PaytoTest.kt -> libeufin-bank/src/test/kotlin/PaytoTest.kt | 0
Rbank/src/test/kotlin/RevenueApiTest.kt -> libeufin-bank/src/test/kotlin/RevenueApiTest.kt | 0
Rbank/src/test/kotlin/SecurityTest.kt -> libeufin-bank/src/test/kotlin/SecurityTest.kt | 0
Rbank/src/test/kotlin/StatsTest.kt -> libeufin-bank/src/test/kotlin/StatsTest.kt | 0
Rbank/src/test/kotlin/WireGatewayApiTest.kt -> libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt | 0
Rbank/src/test/kotlin/bench.kt -> libeufin-bank/src/test/kotlin/bench.kt | 0
Rbank/src/test/kotlin/helpers.kt -> libeufin-bank/src/test/kotlin/helpers.kt | 0
Rbank/src/test/kotlin/routines.kt -> libeufin-bank/src/test/kotlin/routines.kt | 0
Rcommon/build.gradle -> libeufin-common/build.gradle | 0
Rcommon/src/main/kotlin/AnsiColor.kt -> libeufin-common/src/main/kotlin/AnsiColor.kt | 0
Rcommon/src/main/kotlin/ApiError.kt -> libeufin-common/src/main/kotlin/ApiError.kt | 0
Rcommon/src/main/kotlin/Backoff.kt -> libeufin-common/src/main/kotlin/Backoff.kt | 0
Rcommon/src/main/kotlin/Cli.kt -> libeufin-common/src/main/kotlin/Cli.kt | 0
Rcommon/src/main/kotlin/Config.kt -> libeufin-common/src/main/kotlin/Config.kt | 0
Rcommon/src/main/kotlin/Constants.kt -> libeufin-common/src/main/kotlin/Constants.kt | 0
Rcommon/src/main/kotlin/Encoding.kt -> libeufin-common/src/main/kotlin/Encoding.kt | 0
Rcommon/src/main/kotlin/Subject.kt -> libeufin-common/src/main/kotlin/Subject.kt | 0
Rcommon/src/main/kotlin/Table.kt -> libeufin-common/src/main/kotlin/Table.kt | 0
Rcommon/src/main/kotlin/TalerCommon.kt -> libeufin-common/src/main/kotlin/TalerCommon.kt | 0
Rcommon/src/main/kotlin/TalerConfig.kt -> libeufin-common/src/main/kotlin/TalerConfig.kt | 0
Rcommon/src/main/kotlin/TalerErrorCode.kt -> libeufin-common/src/main/kotlin/TalerErrorCode.kt | 0
Rcommon/src/main/kotlin/TalerMessage.kt -> libeufin-common/src/main/kotlin/TalerMessage.kt | 0
Rcommon/src/main/kotlin/api/route.kt -> libeufin-common/src/main/kotlin/api/route.kt | 0
Rcommon/src/main/kotlin/api/server.kt -> libeufin-common/src/main/kotlin/api/server.kt | 0
Rcommon/src/main/kotlin/client.kt -> libeufin-common/src/main/kotlin/client.kt | 0
Rcommon/src/main/kotlin/crypto/CryptoUtil.kt -> libeufin-common/src/main/kotlin/crypto/CryptoUtil.kt | 0
Rcommon/src/main/kotlin/crypto/PwCrypto.kt -> libeufin-common/src/main/kotlin/crypto/PwCrypto.kt | 0
Rcommon/src/main/kotlin/db/DbPool.kt -> libeufin-common/src/main/kotlin/db/DbPool.kt | 0
Rcommon/src/main/kotlin/db/config.kt -> libeufin-common/src/main/kotlin/db/config.kt | 0
Rcommon/src/main/kotlin/db/helpers.kt -> libeufin-common/src/main/kotlin/db/helpers.kt | 0
Rcommon/src/main/kotlin/db/notifications.kt -> libeufin-common/src/main/kotlin/db/notifications.kt | 0
Rcommon/src/main/kotlin/db/schema.kt -> libeufin-common/src/main/kotlin/db/schema.kt | 0
Rcommon/src/main/kotlin/db/statement.kt -> libeufin-common/src/main/kotlin/db/statement.kt | 0
Rcommon/src/main/kotlin/db/transaction.kt -> libeufin-common/src/main/kotlin/db/transaction.kt | 0
Rcommon/src/main/kotlin/db/types.kt -> libeufin-common/src/main/kotlin/db/types.kt | 0
Rcommon/src/main/kotlin/helpers.kt -> libeufin-common/src/main/kotlin/helpers.kt | 0
Rcommon/src/main/kotlin/iban.kt -> libeufin-common/src/main/kotlin/iban.kt | 0
Rcommon/src/main/kotlin/log.kt -> libeufin-common/src/main/kotlin/log.kt | 0
Rcommon/src/main/kotlin/params.kt -> libeufin-common/src/main/kotlin/params.kt | 0
Rcommon/src/main/kotlin/registry.kt -> libeufin-common/src/main/kotlin/registry.kt | 0
Rcommon/src/main/kotlin/security.kt -> libeufin-common/src/main/kotlin/security.kt | 0
Rcommon/src/main/kotlin/test/bench.kt -> libeufin-common/src/main/kotlin/test/bench.kt | 0
Rcommon/src/main/kotlin/test/helpers.kt -> libeufin-common/src/main/kotlin/test/helpers.kt | 0
Rcommon/src/main/kotlin/test/routines.kt -> libeufin-common/src/main/kotlin/test/routines.kt | 0
Rcommon/src/main/kotlin/time.kt -> libeufin-common/src/main/kotlin/time.kt | 0
Rcommon/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider -> libeufin-common/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider | 0
Alibeufin-common/src/main/resources/version.txt | 2++
Rcommon/src/main/resources/xsd/camt.052.001.02.xsd -> libeufin-common/src/main/resources/xsd/camt.052.001.02.xsd | 0
Rcommon/src/main/resources/xsd/camt.053.001.02.xsd -> libeufin-common/src/main/resources/xsd/camt.053.001.02.xsd | 0
Rcommon/src/main/resources/xsd/camt.054.001.02.xsd -> libeufin-common/src/main/resources/xsd/camt.054.001.02.xsd | 0
Rcommon/src/main/resources/xsd/ebics_H004.xsd -> libeufin-common/src/main/resources/xsd/ebics_H004.xsd | 0
Rcommon/src/main/resources/xsd/ebics_H005.xsd -> libeufin-common/src/main/resources/xsd/ebics_H005.xsd | 0
Rcommon/src/main/resources/xsd/ebics_hev.xsd -> libeufin-common/src/main/resources/xsd/ebics_hev.xsd | 0
Rcommon/src/main/resources/xsd/ebics_keymgmt_request_H004.xsd -> libeufin-common/src/main/resources/xsd/ebics_keymgmt_request_H004.xsd | 0
Rcommon/src/main/resources/xsd/ebics_keymgmt_request_H005.xsd -> libeufin-common/src/main/resources/xsd/ebics_keymgmt_request_H005.xsd | 0
Rcommon/src/main/resources/xsd/ebics_keymgmt_response_H004.xsd -> libeufin-common/src/main/resources/xsd/ebics_keymgmt_response_H004.xsd | 0
Rcommon/src/main/resources/xsd/ebics_keymgmt_response_H005.xsd -> libeufin-common/src/main/resources/xsd/ebics_keymgmt_response_H005.xsd | 0
Rcommon/src/main/resources/xsd/ebics_orders_H004.xsd -> libeufin-common/src/main/resources/xsd/ebics_orders_H004.xsd | 0
Rcommon/src/main/resources/xsd/ebics_orders_H005.xsd -> libeufin-common/src/main/resources/xsd/ebics_orders_H005.xsd | 0
Rcommon/src/main/resources/xsd/ebics_request_H004.xsd -> libeufin-common/src/main/resources/xsd/ebics_request_H004.xsd | 0
Rcommon/src/main/resources/xsd/ebics_request_H005.xsd -> libeufin-common/src/main/resources/xsd/ebics_request_H005.xsd | 0
Rcommon/src/main/resources/xsd/ebics_response_H004.xsd -> libeufin-common/src/main/resources/xsd/ebics_response_H004.xsd | 0
Rcommon/src/main/resources/xsd/ebics_response_H005.xsd -> libeufin-common/src/main/resources/xsd/ebics_response_H005.xsd | 0
Rcommon/src/main/resources/xsd/ebics_signature_S002.xsd -> libeufin-common/src/main/resources/xsd/ebics_signature_S002.xsd | 0
Rcommon/src/main/resources/xsd/ebics_signatures.xsd -> libeufin-common/src/main/resources/xsd/ebics_signatures.xsd | 0
Rcommon/src/main/resources/xsd/ebics_types_H004.xsd -> libeufin-common/src/main/resources/xsd/ebics_types_H004.xsd | 0
Rcommon/src/main/resources/xsd/ebics_types_H005.xsd -> libeufin-common/src/main/resources/xsd/ebics_types_H005.xsd | 0
Rcommon/src/main/resources/xsd/pain.001.001.03.ch.02.xsd -> libeufin-common/src/main/resources/xsd/pain.001.001.03.ch.02.xsd | 0
Rcommon/src/main/resources/xsd/pain.001.001.03.xsd -> libeufin-common/src/main/resources/xsd/pain.001.001.03.xsd | 0
Rcommon/src/main/resources/xsd/pain.001.001.09.ch.03.xsd -> libeufin-common/src/main/resources/xsd/pain.001.001.09.ch.03.xsd | 0
Rcommon/src/main/resources/xsd/pain.002.001.13.xsd -> libeufin-common/src/main/resources/xsd/pain.002.001.13.xsd | 0
Rcommon/src/main/resources/xsd/xmldsig-core-schema.xsd -> libeufin-common/src/main/resources/xsd/xmldsig-core-schema.xsd | 0
Rcommon/src/test/kotlin/AmountTest.kt -> libeufin-common/src/test/kotlin/AmountTest.kt | 0
Rcommon/src/test/kotlin/BaseUrlTest.kt -> libeufin-common/src/test/kotlin/BaseUrlTest.kt | 0
Rcommon/src/test/kotlin/ConfigTest.kt -> libeufin-common/src/test/kotlin/ConfigTest.kt | 0
Rcommon/src/test/kotlin/CryptoUtilTest.kt -> libeufin-common/src/test/kotlin/CryptoUtilTest.kt | 0
Rcommon/src/test/kotlin/EncodingTest.kt -> libeufin-common/src/test/kotlin/EncodingTest.kt | 0
Rcommon/src/test/kotlin/IbanTest.kt -> libeufin-common/src/test/kotlin/IbanTest.kt | 0
Rcommon/src/test/kotlin/ParamsTest.kt -> libeufin-common/src/test/kotlin/ParamsTest.kt | 0
Rcommon/src/test/kotlin/PaytoTest.kt -> libeufin-common/src/test/kotlin/PaytoTest.kt | 0
Rcommon/src/test/kotlin/SubjectTest.kt -> libeufin-common/src/test/kotlin/SubjectTest.kt | 0
Rcommon/src/test/kotlin/TlsTest.kt -> libeufin-common/src/test/kotlin/TlsTest.kt | 0
Rcommon/src/test/resources/ebics_hev.xml -> libeufin-common/src/test/resources/ebics_hev.xml | 0
Rcommon/src/test/resources/ebics_ini_inner_key.xml -> libeufin-common/src/test/resources/ebics_ini_inner_key.xml | 0
Rcommon/src/test/resources/ebics_ini_request_sample.xml -> libeufin-common/src/test/resources/ebics_ini_request_sample.xml | 0
Rcommon/src/test/resources/hia_request.xml -> libeufin-common/src/test/resources/hia_request.xml | 0
Rcommon/src/test/resources/hia_request_order_data.xml -> libeufin-common/src/test/resources/hia_request_order_data.xml | 0
Rcommon/src/test/resources/hpb_request.xml -> libeufin-common/src/test/resources/hpb_request.xml | 0
Rcommon/src/test/resources/signature1/doc.xml -> libeufin-common/src/test/resources/signature1/doc.xml | 0
Rcommon/src/test/resources/signature1/public_key.txt -> libeufin-common/src/test/resources/signature1/public_key.txt | 0
Alibeufin-common/tmp/test-conf.conf | 4++++
Alibeufin-common/tmp/test-second-conf.conf | 0
Alibeufin-nexus/build.gradle | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/codegen.py -> libeufin-nexus/codegen.py | 0
Rnexus/conf/auth.conf -> libeufin-nexus/conf/auth.conf | 0
Rnexus/conf/fetch.conf -> libeufin-nexus/conf/fetch.conf | 0
Rnexus/conf/gls.conf -> libeufin-nexus/conf/gls.conf | 0
Rnexus/conf/maerki_baumann.conf -> libeufin-nexus/conf/maerki_baumann.conf | 0
Rnexus/conf/mini.conf -> libeufin-nexus/conf/mini.conf | 0
Rnexus/conf/skip.conf -> libeufin-nexus/conf/skip.conf | 0
Rnexus/conf/test.conf -> libeufin-nexus/conf/test.conf | 0
Rnexus/conf/valiant.conf -> libeufin-nexus/conf/valiant.conf | 0
Rnexus/sample/platform/gls_camt052.xml -> libeufin-nexus/sample/platform/gls_camt052.xml | 0
Rnexus/sample/platform/gls_camt053.xml -> libeufin-nexus/sample/platform/gls_camt053.xml | 0
Rnexus/sample/platform/gls_camt054.xml -> libeufin-nexus/sample/platform/gls_camt054.xml | 0
Rnexus/sample/platform/gls_pain001.xml -> libeufin-nexus/sample/platform/gls_pain001.xml | 0
Rnexus/sample/platform/hac.xml -> libeufin-nexus/sample/platform/hac.xml | 0
Rnexus/sample/platform/maerki_baumann_camt053.xml -> libeufin-nexus/sample/platform/maerki_baumann_camt053.xml | 0
Rnexus/sample/platform/maerki_baumann_pain001.xml -> libeufin-nexus/sample/platform/maerki_baumann_pain001.xml | 0
Rnexus/sample/platform/pain002_accp.xml -> libeufin-nexus/sample/platform/pain002_accp.xml | 0
Rnexus/sample/platform/pain002_part.xml -> libeufin-nexus/sample/platform/pain002_part.xml | 0
Rnexus/sample/platform/postfinance_camt053.xml -> libeufin-nexus/sample/platform/postfinance_camt053.xml | 0
Rnexus/sample/platform/postfinance_camt054.xml -> libeufin-nexus/sample/platform/postfinance_camt054.xml | 0
Rnexus/sample/platform/postfinance_pain001.xml -> libeufin-nexus/sample/platform/postfinance_pain001.xml | 0
Rnexus/sample/platform/valiant_camt052.xml -> libeufin-nexus/sample/platform/valiant_camt052.xml | 0
Rnexus/sample/platform/valiant_pain001.xml -> libeufin-nexus/sample/platform/valiant_pain001.xml | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/main/kotlin/tech/libeufin/nexus/Constants.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Constants.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/DbInit.kt | 44++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 556+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/main/kotlin/tech/libeufin/nexus/cli/List.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/List.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/cli/Manual.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Manual.kt | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Serve.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/db/EbicsDAO.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/EbicsDAO.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/db/KvDAO.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/KvDAO.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 442+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsConstants.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsConstants.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsLogger.kt | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/BankTransactionCode.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/BankTransactionCode.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Constants.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Constants.kt | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt | 478+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt | 712+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/hac.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/hac.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain001.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain001.kt | 0
Rnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain002.kt -> libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain002.kt | 0
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/xml.kt | 369+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnexus/src/test/kotlin/CliTest.kt -> libeufin-nexus/src/test/kotlin/CliTest.kt | 0
Rnexus/src/test/kotlin/DatabaseTest.kt -> libeufin-nexus/src/test/kotlin/DatabaseTest.kt | 0
Rnexus/src/test/kotlin/EbicsTest.kt -> libeufin-nexus/src/test/kotlin/EbicsTest.kt | 0
Rnexus/src/test/kotlin/Iso20022Test.kt -> libeufin-nexus/src/test/kotlin/Iso20022Test.kt | 0
Rnexus/src/test/kotlin/Keys.kt -> libeufin-nexus/src/test/kotlin/Keys.kt | 0
Rnexus/src/test/kotlin/MySerializers.kt -> libeufin-nexus/src/test/kotlin/MySerializers.kt | 0
Rnexus/src/test/kotlin/ObservabilityTest.kt -> libeufin-nexus/src/test/kotlin/ObservabilityTest.kt | 0
Rnexus/src/test/kotlin/RegistrationTest.kt -> libeufin-nexus/src/test/kotlin/RegistrationTest.kt | 0
Rnexus/src/test/kotlin/RevenueApiTest.kt -> libeufin-nexus/src/test/kotlin/RevenueApiTest.kt | 0
Rnexus/src/test/kotlin/WireGatewayApiTest.kt -> libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt | 0
Rnexus/src/test/kotlin/WsTest.kt -> libeufin-nexus/src/test/kotlin/WsTest.kt | 0
Rnexus/src/test/kotlin/XmlCombinatorsTest.kt -> libeufin-nexus/src/test/kotlin/XmlCombinatorsTest.kt | 0
Rnexus/src/test/kotlin/XmlUtilTest.kt -> libeufin-nexus/src/test/kotlin/XmlUtilTest.kt | 0
Rnexus/src/test/kotlin/bench.kt -> libeufin-nexus/src/test/kotlin/bench.kt | 0
Rnexus/src/test/kotlin/helpers.kt -> libeufin-nexus/src/test/kotlin/helpers.kt | 0
Rnexus/src/test/kotlin/routines.kt -> libeufin-nexus/src/test/kotlin/routines.kt | 0
Rnexus/src/test/resources/signature1/doc.xml -> libeufin-nexus/src/test/resources/signature1/doc.xml | 0
Rnexus/src/test/resources/signature1/public_key.txt -> libeufin-nexus/src/test/resources/signature1/public_key.txt | 0
Dnexus/build.gradle | 76----------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/Config.kt | 217-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/EbicsLogger.kt | 146-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 69---------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/XMLUtil.kt | 168-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt | 226-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/cli/DbInit.kt | 45---------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 554-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt | 79-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt | 55-------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/cli/Serve.kt | 74--------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 438-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt | 207-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt | 481-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt | 709-------------------------------------------------------------------------------
Dnexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt | 91-------------------------------------------------------------------------------
Msettings.gradle | 6+++---
Mtestbench/build.gradle | 6+++---
258 files changed, 3789 insertions(+), 3781 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,6 +1,6 @@ .idea/* .vscode -nexus/test +libeufin-nexus/test common/tmp testbench/test testbench/config.json diff --git a/Makefile b/Makefile @@ -30,7 +30,7 @@ lib_dir=$(abs_destdir)$(prefix)/lib # it's like a destdir install that only touches the source tree. .PHONY: build build: - ./gradlew bank:installShadowDist nexus:installShadowDist + ./gradlew libeufin-bank:installShadowDist libeufin-nexus:installShadowDist .PHONY: dist @@ -71,18 +71,18 @@ install-nobuild-bank: install-nobuild-files install -d $(bin_dir) install -d $(lib_dir) install -D -t $(bin_dir) contrib/libeufin-bank-dbinit - install -D -t $(bin_dir) bank/build/install/libeufin-bank-shadow/bin/libeufin-bank + install -D -t $(bin_dir) libeufin-bank/build/install/libeufin-bank-shadow/bin/libeufin-bank install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/libeufin-bank.1 install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/libeufin-bank.conf.5 - install -m 644 -D -t $(lib_dir) bank/build/install/libeufin-bank-shadow/lib/bank-*.jar + install -m 644 -D -t $(lib_dir) libeufin-bank/build/install/libeufin-bank-shadow/lib/libeufin-bank-all.jar .PHONY: install-nobuild-nexus install-nobuild-nexus: install-nobuild-files install -D -t $(bin_dir) contrib/libeufin-nexus-dbinit - install -D -t $(bin_dir) nexus/build/install/libeufin-nexus-shadow/bin/libeufin-nexus + install -D -t $(bin_dir) libeufin-nexus/build/install/libeufin-nexus-shadow/bin/libeufin-nexus 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 -m 644 -D -t $(lib_dir) nexus/build/install/libeufin-nexus-shadow/lib/nexus-*.jar + install -m 644 -D -t $(lib_dir) libeufin-nexus/build/install/libeufin-nexus-shadow/lib/libeufin-nexus-all.jar .PHONY: install install: @@ -99,15 +99,15 @@ check: install-nobuild-files .PHONY: bank-test bank-test: install-nobuild-files - ./gradlew :bank:test --tests $(test) -i + ./gradlew :libeufin-bank:test --tests $(test) -i .PHONY: nexus-test nexus-test: install-nobuild-files - ./gradlew :nexus:test --tests $(test) -i + ./gradlew :libeufin-nexus:test --tests $(test) -i .PHONY: common-test common-test: install-nobuild-files - ./gradlew :common:test --tests $(test) -i + ./gradlew :libeufin-common:test --tests $(test) -i .PHONY: testbench-test testbench-test: install-nobuild-files @@ -130,9 +130,9 @@ ci: .PHONY: bank-bench-db bank-bench-db: install-nobuild-files - ./gradlew cleanTest :bank:test --tests Bench.benchDb -i --no-build-cache + ./gradlew cleanTest :libeufin-bank:test --tests Bench.benchDb -i --no-build-cache .PHONY: nexus-bench-db nexus-bench-db: install-nobuild-files - ./gradlew cleanTest :nexus:test --tests Bench.benchDb -i --no-build-cache + ./gradlew cleanTest :libeufin-nexus:test --tests Bench.benchDb -i --no-build-cache diff --git a/bank/build.gradle b/bank/build.gradle @@ -1,63 +0,0 @@ -plugins { - id("kotlin") - id("application") - id("com.gradleup.shadow") version "$shadow_version" - id("org.jetbrains.kotlin.plugin.serialization") version "$kotlin_version" -} - -version = rootProject.version - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -compileKotlin.kotlinOptions.jvmTarget = "17" -compileTestKotlin.kotlinOptions.jvmTarget = "17" - -sourceSets.main.java.srcDirs = ["src/main/kotlin"] - -dependencies { - // Core language libraries - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") - - implementation(project(":common")) - - implementation("org.postgresql:postgresql:$postgres_version") - implementation("com.github.ajalt.clikt:clikt:$clikt_version") - implementation("com.github.ajalt.mordant:mordant:3.0.2") - - // Metrics - implementation("io.prometheus:prometheus-metrics-core:$prometheus_version") - implementation("io.prometheus:prometheus-metrics-instrumentation-jvm:$prometheus_version") - implementation("io.prometheus:prometheus-metrics-exposition-formats:$prometheus_version") - - implementation("io.ktor:ktor-server-core:$ktor_version") - implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") - - // UNIX domain sockets support (used to connect to PostgreSQL) - implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version") - - testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") - testImplementation("io.ktor:ktor-server-test-host:$ktor_version") - - testImplementation(project(":common")) -} - -application { - mainClass = "tech.libeufin.bank.MainKt" - applicationName = "libeufin-bank" -} - -shadowJar { - minimize { - // Kotlin serialization - exclude(dependency("io.ktor:ktor-serialization-kotlinx-json:.*")) - // Postgres unix socket driver - exclude(dependency("com.kohlschutter.junixsocket:junixsocket-core:.*")) - // CLI - exclude(dependency("com.github.ajalt.mordant:mordant:.*")) - // Crypto - exclude(dependency("org.bouncycastle:.*")) - } -} -\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -1,54 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023-2025 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.bank - -import io.ktor.server.application.* -import io.ktor.server.http.content.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.bank.api.* -import tech.libeufin.bank.cli.LibeufinBank -import tech.libeufin.bank.db.Database -import tech.libeufin.common.api.talerApi -import com.github.ajalt.clikt.core.main - -val logger: Logger = LoggerFactory.getLogger("libeufin-bank") - -/** Set up web server handlers for the Taler corebank API */ -fun Application.corebankWebApp(db: Database, cfg: BankConfig) = talerApi(LoggerFactory.getLogger("libeufin-bank-api")) { - coreBankApi(db, cfg) - conversionApi(db, cfg) - bankIntegrationApi(db, cfg) - wireGatewayApi(db, cfg) - revenueApi(db, cfg) - observabilityApi(db, cfg) - cfg.spaPath?.let { - get("/") { - call.respondRedirect("/webui/") - } - staticFiles("/webui/", it.toFile()) - } -} - -fun main(args: Array<String>) { - LibeufinBank().main(args) -} diff --git a/build.gradle b/build.gradle @@ -40,7 +40,7 @@ subprojects { // Invalidate tests cache when editing SQL logic inputs.dir("$rootDir/database-versioning").withPathSensitivity(PathSensitivity.RELATIVE) // Or when editing ISO20022 test samples - inputs.dir("$rootDir/nexus/sample").withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir("$rootDir/libeufin-nexus/sample").withPathSensitivity(PathSensitivity.RELATIVE) inputs.dir("$rootDir/testbench/sample").withPathSensitivity(PathSensitivity.RELATIVE) testLogging { exceptionFormat = 'full' @@ -60,7 +60,7 @@ task libeufinVersion { } dependencies { - dokka(project(":common:")) - dokka(project(":bank:")) - dokka(project(":nexus:")) + dokka(project(":libeufin-common:")) + dokka(project(":libeufin-bank:")) + dokka(project(":libeufin-nexus:")) } \ No newline at end of file diff --git a/contrib/ci/jobs/0-codespell/job.sh b/contrib/ci/jobs/0-codespell/job.sh @@ -19,9 +19,9 @@ configure~ */build/* */*.xsd */*.xml -*/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt -*/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/BankTransactionCode.kt -*/common/src/main/kotlin/registry.kt +*/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt +*/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/BankTransactionCode.kt +*/libeufin-common/src/main/kotlin/registry.kt */testbench/test/* EOF ); diff --git a/debian/libeufin-bank.install b/debian/libeufin-bank.install @@ -3,7 +3,7 @@ debian/etc/nginx/sites-available/libeufin-bank etc/nginx/sites-available/ debian/etc/apache2/sites-available/libeufin-bank.conf etc/apache2/sites-available/ debian/etc/libeufin/settings.json etc/libeufin/ -bank/build/install/libeufin-bank-shadow/bin/libeufin-bank usr/bin/ +libeufin-bank/build/install/libeufin-bank-shadow/bin/libeufin-bank usr/bin/ contrib/libeufin-bank-dbinit usr/bin/ contrib/libeufin-tan-*.sh usr/bin/ @@ -14,7 +14,7 @@ contrib/wallet-core/bank/* usr/share/libeufin/spa contrib/bank.conf usr/share/libeufin/config.d/ # FIXME: This name should be prefixed! -bank/build/install/libeufin-bank-shadow/lib/bank-*.jar usr/lib/ +libeufin-bank/build/install/libeufin-bank-shadow/lib/libeufin-bank-all.jar usr/lib/ doc/prebuilt/man/libeufin-bank.1 usr/share/man/man1 doc/prebuilt/man/libeufin-bank.conf.5 usr/share/man/man5 diff --git a/debian/libeufin-nexus.install b/debian/libeufin-nexus.install @@ -1,6 +1,6 @@ debian/etc/libeufin/libeufin-nexus.conf etc/libeufin/ -nexus/build/install/libeufin-nexus-shadow/bin/libeufin-nexus usr/bin/ +libeufin-nexus/build/install/libeufin-nexus-shadow/bin/libeufin-nexus usr/bin/ contrib/libeufin-nexus-dbinit usr/bin/ database-versioning/libeufin-nexus*.sql usr/share/libeufin/sql/ @@ -8,7 +8,7 @@ database-versioning/libeufin-nexus*.sql usr/share/libeufin/sql/ contrib/nexus.conf usr/share/libeufin/config.d/ # FIXME: This name should be prefixed! -nexus/build/install/libeufin-nexus-shadow/lib/nexus-*.jar usr/lib/ +libeufin-nexus/build/install/libeufin-nexus-shadow/lib/libeufin-nexus-all.jar usr/lib/ doc/prebuilt/man/libeufin-nexus.1 usr/share/man/man1 doc/prebuilt/man/libeufin-nexus.conf.5 usr/share/man/man5 diff --git a/docker-compose.yml b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + azurite: + image: mcr.microsoft.com/azure-storage/azurite + container_name: azurite + ports: + - 10000:10000 + volumes: + - azurite-data:/data + restart: unless-stopped + +volumes: + azurite-data: +\ No newline at end of file diff --git a/bank/README b/libeufin-bank/README diff --git a/libeufin-bank/build.gradle b/libeufin-bank/build.gradle @@ -0,0 +1,63 @@ +plugins { + id("kotlin") + id("application") + id("com.gradleup.shadow") version "$shadow_version" + id("org.jetbrains.kotlin.plugin.serialization") version "$kotlin_version" +} + +version = rootProject.version + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +compileKotlin.kotlinOptions.jvmTarget = "17" +compileTestKotlin.kotlinOptions.jvmTarget = "17" + +sourceSets.main.java.srcDirs = ["src/main/kotlin"] + +dependencies { + // Core language libraries + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") + + implementation(project(":libeufin-common")) + + implementation("org.postgresql:postgresql:$postgres_version") + implementation("com.github.ajalt.clikt:clikt:$clikt_version") + implementation("com.github.ajalt.mordant:mordant:3.0.2") + + // Metrics + implementation("io.prometheus:prometheus-metrics-core:$prometheus_version") + implementation("io.prometheus:prometheus-metrics-instrumentation-jvm:$prometheus_version") + implementation("io.prometheus:prometheus-metrics-exposition-formats:$prometheus_version") + + implementation("io.ktor:ktor-server-core:$ktor_version") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + + // UNIX domain sockets support (used to connect to PostgreSQL) + implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version") + + testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + testImplementation("io.ktor:ktor-server-test-host:$ktor_version") + + testImplementation(project(":libeufin-common")) +} + +application { + mainClass = "tech.libeufin.bank.MainKt" +} + +shadowJar { + version = "" + minimize { + // Kotlin serialization + exclude(dependency("io.ktor:ktor-serialization-kotlinx-json:.*")) + // Postgres unix socket driver + exclude(dependency("com.kohlschutter.junixsocket:junixsocket-core:.*")) + // CLI + exclude(dependency("com.github.ajalt.mordant:mordant:.*")) + // Crypto + exclude(dependency("org.bouncycastle:.*")) + } +} +\ No newline at end of file diff --git a/bank/conf/test.conf b/libeufin-bank/conf/test.conf diff --git a/bank/conf/test_bonus.conf b/libeufin-bank/conf/test_bonus.conf diff --git a/bank/conf/test_no_conversion.conf b/libeufin-bank/conf/test_no_conversion.conf diff --git a/bank/conf/test_no_password_check.conf b/libeufin-bank/conf/test_no_password_check.conf diff --git a/bank/conf/test_restrict.conf b/libeufin-bank/conf/test_restrict.conf diff --git a/bank/conf/test_tan_err.conf b/libeufin-bank/conf/test_tan_err.conf diff --git a/bank/conf/test_with_fees.conf b/libeufin-bank/conf/test_with_fees.conf diff --git a/bank/conf/test_x_taler_bank.conf b/libeufin-bank/conf/test_x_taler_bank.conf diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Config.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Config.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Constants.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Error.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Error.kt diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -0,0 +1,55 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023-2025 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.bank + +import io.ktor.server.application.* +import io.ktor.server.http.content.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.bank.api.* +import tech.libeufin.bank.cli.LibeufinBank +import tech.libeufin.bank.db.Database +import tech.libeufin.common.api.talerApi +import com.github.ajalt.clikt.core.main + +val logger: Logger = LoggerFactory.getLogger("libeufin-bank") + +/** Set up web server handlers for the Taler corebank API */ +fun Application.corebankWebApp(db: Database, cfg: BankConfig) = talerApi(LoggerFactory.getLogger("libeufin-bank-api")) { + coreBankApi(db, cfg) + conversionApi(db, cfg) + bankIntegrationApi(db, cfg) + wireGatewayApi(db, cfg) + revenueApi(db, cfg) + observabilityApi(db, cfg) + cfg.spaPath?.let { + get("/") { + call.respondRedirect("/webui/") + } + staticFiles("/webui/", it.toFile()) + } +} + +fun main(args: Array<String>) { + LibeufinBank().main(args) +} + diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/BankIntegrationApi.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ConversionApi.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/ObservabilityApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/ObservabilityApi.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/RevenueApi.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/auth/auth.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/auth/mfa.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/auth/mfa.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/BenchPwh.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/BenchPwh.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/ChangePw.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/ChangePw.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/CreateAccount.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/CreateToken.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/DbInit.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/DbInit.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/EditAccount.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/Gc.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/Gc.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/LibeufinBank.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/cli/Serve.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/Serve.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/CashoutDAO.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ConversionDAO.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/Database.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/helpers.kt diff --git a/bank/src/main/kotlin/tech/libeufin/bank/params.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/params.kt diff --git a/bank/src/test/kotlin/AmountTest.kt b/libeufin-bank/src/test/kotlin/AmountTest.kt diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/libeufin-bank/src/test/kotlin/BankIntegrationApiTest.kt diff --git a/bank/src/test/kotlin/CommonApiTest.kt b/libeufin-bank/src/test/kotlin/CommonApiTest.kt diff --git a/bank/src/test/kotlin/ConversionApiTest.kt b/libeufin-bank/src/test/kotlin/ConversionApiTest.kt diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/libeufin-bank/src/test/kotlin/DatabaseTest.kt diff --git a/bank/src/test/kotlin/GcTest.kt b/libeufin-bank/src/test/kotlin/GcTest.kt diff --git a/bank/src/test/kotlin/JsonTest.kt b/libeufin-bank/src/test/kotlin/JsonTest.kt diff --git a/bank/src/test/kotlin/ObservabilityTest.kt b/libeufin-bank/src/test/kotlin/ObservabilityTest.kt diff --git a/bank/src/test/kotlin/PaytoTest.kt b/libeufin-bank/src/test/kotlin/PaytoTest.kt diff --git a/bank/src/test/kotlin/RevenueApiTest.kt b/libeufin-bank/src/test/kotlin/RevenueApiTest.kt diff --git a/bank/src/test/kotlin/SecurityTest.kt b/libeufin-bank/src/test/kotlin/SecurityTest.kt diff --git a/bank/src/test/kotlin/StatsTest.kt b/libeufin-bank/src/test/kotlin/StatsTest.kt diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt diff --git a/bank/src/test/kotlin/bench.kt b/libeufin-bank/src/test/kotlin/bench.kt diff --git a/bank/src/test/kotlin/helpers.kt b/libeufin-bank/src/test/kotlin/helpers.kt diff --git a/bank/src/test/kotlin/routines.kt b/libeufin-bank/src/test/kotlin/routines.kt diff --git a/common/build.gradle b/libeufin-common/build.gradle diff --git a/common/src/main/kotlin/AnsiColor.kt b/libeufin-common/src/main/kotlin/AnsiColor.kt diff --git a/common/src/main/kotlin/ApiError.kt b/libeufin-common/src/main/kotlin/ApiError.kt diff --git a/common/src/main/kotlin/Backoff.kt b/libeufin-common/src/main/kotlin/Backoff.kt diff --git a/common/src/main/kotlin/Cli.kt b/libeufin-common/src/main/kotlin/Cli.kt diff --git a/common/src/main/kotlin/Config.kt b/libeufin-common/src/main/kotlin/Config.kt diff --git a/common/src/main/kotlin/Constants.kt b/libeufin-common/src/main/kotlin/Constants.kt diff --git a/common/src/main/kotlin/Encoding.kt b/libeufin-common/src/main/kotlin/Encoding.kt diff --git a/common/src/main/kotlin/Subject.kt b/libeufin-common/src/main/kotlin/Subject.kt diff --git a/common/src/main/kotlin/Table.kt b/libeufin-common/src/main/kotlin/Table.kt diff --git a/common/src/main/kotlin/TalerCommon.kt b/libeufin-common/src/main/kotlin/TalerCommon.kt diff --git a/common/src/main/kotlin/TalerConfig.kt b/libeufin-common/src/main/kotlin/TalerConfig.kt diff --git a/common/src/main/kotlin/TalerErrorCode.kt b/libeufin-common/src/main/kotlin/TalerErrorCode.kt diff --git a/common/src/main/kotlin/TalerMessage.kt b/libeufin-common/src/main/kotlin/TalerMessage.kt diff --git a/common/src/main/kotlin/api/route.kt b/libeufin-common/src/main/kotlin/api/route.kt diff --git a/common/src/main/kotlin/api/server.kt b/libeufin-common/src/main/kotlin/api/server.kt diff --git a/common/src/main/kotlin/client.kt b/libeufin-common/src/main/kotlin/client.kt diff --git a/common/src/main/kotlin/crypto/CryptoUtil.kt b/libeufin-common/src/main/kotlin/crypto/CryptoUtil.kt diff --git a/common/src/main/kotlin/crypto/PwCrypto.kt b/libeufin-common/src/main/kotlin/crypto/PwCrypto.kt diff --git a/common/src/main/kotlin/db/DbPool.kt b/libeufin-common/src/main/kotlin/db/DbPool.kt diff --git a/common/src/main/kotlin/db/config.kt b/libeufin-common/src/main/kotlin/db/config.kt diff --git a/common/src/main/kotlin/db/helpers.kt b/libeufin-common/src/main/kotlin/db/helpers.kt diff --git a/common/src/main/kotlin/db/notifications.kt b/libeufin-common/src/main/kotlin/db/notifications.kt diff --git a/common/src/main/kotlin/db/schema.kt b/libeufin-common/src/main/kotlin/db/schema.kt diff --git a/common/src/main/kotlin/db/statement.kt b/libeufin-common/src/main/kotlin/db/statement.kt diff --git a/common/src/main/kotlin/db/transaction.kt b/libeufin-common/src/main/kotlin/db/transaction.kt diff --git a/common/src/main/kotlin/db/types.kt b/libeufin-common/src/main/kotlin/db/types.kt diff --git a/common/src/main/kotlin/helpers.kt b/libeufin-common/src/main/kotlin/helpers.kt diff --git a/common/src/main/kotlin/iban.kt b/libeufin-common/src/main/kotlin/iban.kt diff --git a/common/src/main/kotlin/log.kt b/libeufin-common/src/main/kotlin/log.kt diff --git a/common/src/main/kotlin/params.kt b/libeufin-common/src/main/kotlin/params.kt diff --git a/common/src/main/kotlin/registry.kt b/libeufin-common/src/main/kotlin/registry.kt diff --git a/common/src/main/kotlin/security.kt b/libeufin-common/src/main/kotlin/security.kt diff --git a/common/src/main/kotlin/test/bench.kt b/libeufin-common/src/main/kotlin/test/bench.kt diff --git a/common/src/main/kotlin/test/helpers.kt b/libeufin-common/src/main/kotlin/test/helpers.kt diff --git a/common/src/main/kotlin/test/routines.kt b/libeufin-common/src/main/kotlin/test/routines.kt diff --git a/common/src/main/kotlin/time.kt b/libeufin-common/src/main/kotlin/time.kt diff --git a/common/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider b/libeufin-common/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider diff --git a/libeufin-common/src/main/resources/version.txt b/libeufin-common/src/main/resources/version.txt @@ -0,0 +1 @@ +v1.0.6-git-942f58a3 +\ No newline at end of file diff --git a/common/src/main/resources/xsd/camt.052.001.02.xsd b/libeufin-common/src/main/resources/xsd/camt.052.001.02.xsd diff --git a/common/src/main/resources/xsd/camt.053.001.02.xsd b/libeufin-common/src/main/resources/xsd/camt.053.001.02.xsd diff --git a/common/src/main/resources/xsd/camt.054.001.02.xsd b/libeufin-common/src/main/resources/xsd/camt.054.001.02.xsd diff --git a/common/src/main/resources/xsd/ebics_H004.xsd b/libeufin-common/src/main/resources/xsd/ebics_H004.xsd diff --git a/common/src/main/resources/xsd/ebics_H005.xsd b/libeufin-common/src/main/resources/xsd/ebics_H005.xsd diff --git a/common/src/main/resources/xsd/ebics_hev.xsd b/libeufin-common/src/main/resources/xsd/ebics_hev.xsd diff --git a/common/src/main/resources/xsd/ebics_keymgmt_request_H004.xsd b/libeufin-common/src/main/resources/xsd/ebics_keymgmt_request_H004.xsd diff --git a/common/src/main/resources/xsd/ebics_keymgmt_request_H005.xsd b/libeufin-common/src/main/resources/xsd/ebics_keymgmt_request_H005.xsd diff --git a/common/src/main/resources/xsd/ebics_keymgmt_response_H004.xsd b/libeufin-common/src/main/resources/xsd/ebics_keymgmt_response_H004.xsd diff --git a/common/src/main/resources/xsd/ebics_keymgmt_response_H005.xsd b/libeufin-common/src/main/resources/xsd/ebics_keymgmt_response_H005.xsd diff --git a/common/src/main/resources/xsd/ebics_orders_H004.xsd b/libeufin-common/src/main/resources/xsd/ebics_orders_H004.xsd diff --git a/common/src/main/resources/xsd/ebics_orders_H005.xsd b/libeufin-common/src/main/resources/xsd/ebics_orders_H005.xsd diff --git a/common/src/main/resources/xsd/ebics_request_H004.xsd b/libeufin-common/src/main/resources/xsd/ebics_request_H004.xsd diff --git a/common/src/main/resources/xsd/ebics_request_H005.xsd b/libeufin-common/src/main/resources/xsd/ebics_request_H005.xsd diff --git a/common/src/main/resources/xsd/ebics_response_H004.xsd b/libeufin-common/src/main/resources/xsd/ebics_response_H004.xsd diff --git a/common/src/main/resources/xsd/ebics_response_H005.xsd b/libeufin-common/src/main/resources/xsd/ebics_response_H005.xsd diff --git a/common/src/main/resources/xsd/ebics_signature_S002.xsd b/libeufin-common/src/main/resources/xsd/ebics_signature_S002.xsd diff --git a/common/src/main/resources/xsd/ebics_signatures.xsd b/libeufin-common/src/main/resources/xsd/ebics_signatures.xsd diff --git a/common/src/main/resources/xsd/ebics_types_H004.xsd b/libeufin-common/src/main/resources/xsd/ebics_types_H004.xsd diff --git a/common/src/main/resources/xsd/ebics_types_H005.xsd b/libeufin-common/src/main/resources/xsd/ebics_types_H005.xsd diff --git a/common/src/main/resources/xsd/pain.001.001.03.ch.02.xsd b/libeufin-common/src/main/resources/xsd/pain.001.001.03.ch.02.xsd diff --git a/common/src/main/resources/xsd/pain.001.001.03.xsd b/libeufin-common/src/main/resources/xsd/pain.001.001.03.xsd diff --git a/common/src/main/resources/xsd/pain.001.001.09.ch.03.xsd b/libeufin-common/src/main/resources/xsd/pain.001.001.09.ch.03.xsd diff --git a/common/src/main/resources/xsd/pain.002.001.13.xsd b/libeufin-common/src/main/resources/xsd/pain.002.001.13.xsd diff --git a/common/src/main/resources/xsd/xmldsig-core-schema.xsd b/libeufin-common/src/main/resources/xsd/xmldsig-core-schema.xsd diff --git a/common/src/test/kotlin/AmountTest.kt b/libeufin-common/src/test/kotlin/AmountTest.kt diff --git a/common/src/test/kotlin/BaseUrlTest.kt b/libeufin-common/src/test/kotlin/BaseUrlTest.kt diff --git a/common/src/test/kotlin/ConfigTest.kt b/libeufin-common/src/test/kotlin/ConfigTest.kt diff --git a/common/src/test/kotlin/CryptoUtilTest.kt b/libeufin-common/src/test/kotlin/CryptoUtilTest.kt diff --git a/common/src/test/kotlin/EncodingTest.kt b/libeufin-common/src/test/kotlin/EncodingTest.kt diff --git a/common/src/test/kotlin/IbanTest.kt b/libeufin-common/src/test/kotlin/IbanTest.kt diff --git a/common/src/test/kotlin/ParamsTest.kt b/libeufin-common/src/test/kotlin/ParamsTest.kt diff --git a/common/src/test/kotlin/PaytoTest.kt b/libeufin-common/src/test/kotlin/PaytoTest.kt diff --git a/common/src/test/kotlin/SubjectTest.kt b/libeufin-common/src/test/kotlin/SubjectTest.kt diff --git a/common/src/test/kotlin/TlsTest.kt b/libeufin-common/src/test/kotlin/TlsTest.kt diff --git a/common/src/test/resources/ebics_hev.xml b/libeufin-common/src/test/resources/ebics_hev.xml diff --git a/common/src/test/resources/ebics_ini_inner_key.xml b/libeufin-common/src/test/resources/ebics_ini_inner_key.xml diff --git a/common/src/test/resources/ebics_ini_request_sample.xml b/libeufin-common/src/test/resources/ebics_ini_request_sample.xml diff --git a/common/src/test/resources/hia_request.xml b/libeufin-common/src/test/resources/hia_request.xml diff --git a/common/src/test/resources/hia_request_order_data.xml b/libeufin-common/src/test/resources/hia_request_order_data.xml diff --git a/common/src/test/resources/hpb_request.xml b/libeufin-common/src/test/resources/hpb_request.xml diff --git a/common/src/test/resources/signature1/doc.xml b/libeufin-common/src/test/resources/signature1/doc.xml diff --git a/common/src/test/resources/signature1/public_key.txt b/libeufin-common/src/test/resources/signature1/public_key.txt diff --git a/libeufin-common/tmp/test-conf.conf b/libeufin-common/tmp/test-conf.conf @@ -0,0 +1,3 @@ + + +@inline@test-conf.conf +\ No newline at end of file diff --git a/libeufin-common/tmp/test-second-conf.conf b/libeufin-common/tmp/test-second-conf.conf diff --git a/libeufin-nexus/build.gradle b/libeufin-nexus/build.gradle @@ -0,0 +1,75 @@ +plugins { + id("kotlin") + id("application") + id("com.gradleup.shadow") version "$shadow_version" + id("org.jetbrains.kotlin.plugin.serialization") version "$kotlin_version" +} + +version = rootProject.version + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +compileKotlin.kotlinOptions.jvmTarget = "17" +compileTestKotlin.kotlinOptions.jvmTarget = "17" + +sourceSets.main.java.srcDirs = ["src/main/kotlin"] + +dependencies { + // Core language libraries + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") + + implementation(project(":libeufin-common")) + + // Metrics + implementation("io.prometheus:prometheus-metrics-core:$prometheus_version") + implementation("io.prometheus:prometheus-metrics-instrumentation-jvm:$prometheus_version") + implementation("io.prometheus:prometheus-metrics-exposition-formats:$prometheus_version") + + // Command line parsing + implementation("com.github.ajalt.clikt:clikt:$clikt_version") + implementation("org.postgresql:postgresql:$postgres_version") + // Ktor client library + implementation("io.ktor:ktor-server-core:$ktor_version") + implementation("io.ktor:ktor-client-cio:$ktor_version") + implementation("io.ktor:ktor-client-mock:$ktor_version") + implementation("io.ktor:ktor-client-websockets:$ktor_version") + + // PDF generation + implementation("com.itextpdf:itext-core:9.3.0") + + // UNIX domain sockets support (used to connect to PostgreSQL) + implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version") + + // Serialization + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + + // Unit testing + testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + testImplementation("io.ktor:ktor-server-test-host:$ktor_version") + testImplementation("io.ktor:ktor-server-cio:$ktor_version") +} + +application { + mainClass = "tech.libeufin.nexus.MainKt" +} + +shadowJar { + version = "" + minimize { + // Kotlin serialization + exclude(dependency("io.ktor:ktor-serialization-kotlinx-json:.*")) + // Postgres unix socket driver + exclude(dependency("com.kohlschutter.junixsocket:junixsocket-core:.*")) + // CIO engine + exclude(dependency("io.ktor:ktor-client-cio:.*")) + // Crypto + exclude(dependency("org.bouncycastle:.*")) + // CLI + exclude(dependency("com.github.ajalt.mordant:mordant:.*")) + // PDF + exclude(dependency("com.itextpdf:itext-core:.*")) + } +} +\ No newline at end of file diff --git a/nexus/codegen.py b/libeufin-nexus/codegen.py diff --git a/nexus/conf/auth.conf b/libeufin-nexus/conf/auth.conf diff --git a/nexus/conf/fetch.conf b/libeufin-nexus/conf/fetch.conf diff --git a/nexus/conf/gls.conf b/libeufin-nexus/conf/gls.conf diff --git a/nexus/conf/maerki_baumann.conf b/libeufin-nexus/conf/maerki_baumann.conf diff --git a/nexus/conf/mini.conf b/libeufin-nexus/conf/mini.conf diff --git a/nexus/conf/skip.conf b/libeufin-nexus/conf/skip.conf diff --git a/nexus/conf/test.conf b/libeufin-nexus/conf/test.conf diff --git a/nexus/conf/valiant.conf b/libeufin-nexus/conf/valiant.conf diff --git a/nexus/sample/platform/gls_camt052.xml b/libeufin-nexus/sample/platform/gls_camt052.xml diff --git a/nexus/sample/platform/gls_camt053.xml b/libeufin-nexus/sample/platform/gls_camt053.xml diff --git a/nexus/sample/platform/gls_camt054.xml b/libeufin-nexus/sample/platform/gls_camt054.xml diff --git a/nexus/sample/platform/gls_pain001.xml b/libeufin-nexus/sample/platform/gls_pain001.xml diff --git a/nexus/sample/platform/hac.xml b/libeufin-nexus/sample/platform/hac.xml diff --git a/nexus/sample/platform/maerki_baumann_camt053.xml b/libeufin-nexus/sample/platform/maerki_baumann_camt053.xml diff --git a/nexus/sample/platform/maerki_baumann_pain001.xml b/libeufin-nexus/sample/platform/maerki_baumann_pain001.xml diff --git a/nexus/sample/platform/pain002_accp.xml b/libeufin-nexus/sample/platform/pain002_accp.xml diff --git a/nexus/sample/platform/pain002_part.xml b/libeufin-nexus/sample/platform/pain002_part.xml diff --git a/nexus/sample/platform/postfinance_camt053.xml b/libeufin-nexus/sample/platform/postfinance_camt053.xml diff --git a/nexus/sample/platform/postfinance_camt054.xml b/libeufin-nexus/sample/platform/postfinance_camt054.xml diff --git a/nexus/sample/platform/postfinance_pain001.xml b/libeufin-nexus/sample/platform/postfinance_pain001.xml diff --git a/nexus/sample/platform/valiant_camt052.xml b/libeufin-nexus/sample/platform/valiant_camt052.xml diff --git a/nexus/sample/platform/valiant_pain001.xml b/libeufin-nexus/sample/platform/valiant_pain001.xml diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -0,0 +1,220 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus + +import tech.libeufin.common.* +import tech.libeufin.common.db.DatabaseConfig +import tech.libeufin.nexus.db.Database +import tech.libeufin.nexus.ebics.Dialect +import java.nio.file.Path +import java.time.Instant +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +private val logger: Logger = LoggerFactory.getLogger("libeufin-config") + +val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-nexus", "libeufin-nexus") + +data class NexusIngestConfig( + val accountType: AccountType, + val ignoreTransactionsBefore: Instant, + val ignoreBouncesBefore: Instant, + val restrictionPaytoRegex: Regex?, + val bounceDeduceFee: Boolean, + val bounceFee: TalerAmount +) { + companion object { + fun default(accountType: AccountType, currency: String = "KUDOS") + = NexusIngestConfig(accountType, Instant.MIN, Instant.MIN, null, false, TalerAmount.zero(currency)) + } +} + +class NexusFetchConfig(config: TalerConfig, currency: String) { + private val section = config.section("nexus-fetch") + val frequency = section.duration("frequency").require() + val frequencyRaw = section.string("frequency").require() + val checkpointTime = section.time("checkpoint_time_of_day").require() + val ignoreTransactionsBefore = section.date("ignore_transactions_before").default(Instant.MIN) + val ignoreBouncesBefore = section.date("ignore_bounces_before").default(Instant.MIN) + val restrictionPaytoRegex = section.regex("restriction_payto_regex").orNull() + val bounceDeduceFee = section.boolean("bounce_deduce_fee").default(false) + val bounceFee = section.amount("bounce_fee", currency).default(TalerAmount.zero(currency)) +} + +class NexusSubmitConfig(config: TalerConfig) { + private val section = config.section("nexus-submit") + val frequency = section.duration("frequency").require() + val frequencyRaw = section.string("frequency").require() + val requireAck = section.boolean("manual_ack").default(false) +} + +class NexusSetupConfig(config: TalerConfig) { + private val section = config.section("nexus-setup") + val bankAuthPubKey = section.hex("bank_authentication_pub_key_hash").orNull() + val bankEncPubKey = section.hex("bank_encryption_pub_key_hash").orNull() +} + +class NexusHostConfig(sect: TalerConfigSection) { + /** The bank base URL */ + val baseUrl = sect.string("host_base_url").require() + /** The bank EBICS host ID */ + val ebicsHostId = sect.string("host_id").require() + /** EBICS user ID */ + val ebicsUserId = sect.string("user_id").require() + /** EBICS partner ID */ + val ebicsPartnerId = sect.string("partner_id").require() +} + +class NexusEbicsConfig( + sect: TalerConfigSection, +) { + val host by lazy { NexusHostConfig(sect) } + /** Bank account metadata */ + val account = IbanAccountMetadata( + iban = sect.string("iban").require(), + bic = sect.string("bic").require(), + name = sect.string("name").require() + ) + /** Bank account payto */ + val payto = IbanPayto.build(account.iban, account.bic, account.name) + + val dialect = sect.map("bank_dialect", "bank dialect", mapOf( + "postfinance" to Dialect.postfinance, + "gls" to Dialect.gls, + "maerki_baumann" to Dialect.maerki_baumann, + "valiant" to Dialect.valiant, + )).require() + + /** Path where we store the bank public keys */ + val bankPublicKeysPath = sect.path("bank_public_keys_file").require() + /** Path where we store our private keys */ + val clientPrivateKeysPath = sect.path("client_private_keys_file").require() +} + +class ApiConfig(section: TalerConfigSection) { + val authMethod = section.requireAuthMethod() +} + +/** Configuration for libeufin-nexus */ +class NexusConfig internal constructor (val cfg: TalerConfig) { + private val sect = cfg.section("nexus-ebics") + + val dbCfg by lazy { cfg.dbConfig() } + val serverCfg by lazy { + cfg.loadServerConfig("nexus-httpd") + } + + /** The bank's currency */ + val currency = sect.string("currency").require() + + val accountType = sect.map("account_type", "account type", mapOf( + "normal" to AccountType.normal, + "exchange" to AccountType.exchange + )).require() + + val fetch by lazy { NexusFetchConfig(cfg, currency) } + val submit by lazy { NexusSubmitConfig(cfg) } + val ebics by lazy { NexusEbicsConfig(sect) } + val setup by lazy { NexusSetupConfig(cfg) } + + val ingest get() = NexusIngestConfig( + accountType, + fetch.ignoreTransactionsBefore, + fetch.ignoreBouncesBefore, + fetch.restrictionPaytoRegex, + fetch.bounceDeduceFee, + fetch.bounceFee + ) + + val wireGatewayApiCfg = cfg.section("nexus-httpd-wire-gateway-api").apiConf() + val revenueApiCfg = cfg.section("nexus-httpd-revenue-api").apiConf() + val observabilityApiCfg = cfg.section("nexus-httpd-observability-api").apiConf() +} + +fun NexusConfig.checkCurrency(amount: TalerAmount) { + if (amount.currency != currency) throw badRequest( + "Wrong currency: expected $currency got ${amount.currency}", + TalerErrorCode.GENERIC_CURRENCY_MISMATCH + ) +} + +private fun TalerConfigSection.requireAuthMethod(): AuthMethod { + return mapLambda("auth_method", "auth method", mapOf( + "none" to { AuthMethod.None }, + "bearer-token" to { + logger.warn("Deprecated auth method option 'auth_method' used deprecated value 'bearer-token'") + val token = string("auth_bearer_token").require() + AuthMethod.Bearer(token) + }, + "bearer" to { + val token = string("token").require() + AuthMethod.Bearer(token) + }, + "basic" to { + val username = string("username").require() + val password = string("password").require() + AuthMethod.Basic("$username:$password".encodeBase64()) + } + )).require() +} + +private fun TalerConfigSection.apiConf(): ApiConfig? { + val enabled = boolean("enabled").require() + return if (enabled) { + return ApiConfig(this) + } else { + null + } +} + +sealed interface AuthMethod { + data object None: AuthMethod + data class Bearer(val token: String): AuthMethod + data class Basic(val token: String): AuthMethod +} + +enum class AccountType { + normal, + exchange +} + +private fun TalerConfig.dbConfig(): DatabaseConfig { + val sect = section("libeufin-nexusdb-postgres") + val configOption = sect.string("config") + return DatabaseConfig( + dbConnStr = configOption.orNull() ?: section("nexus-postgres").string("config").orNull() ?: configOption.require(), + sqlDir = sect.path("sql_dir").require() + ) +} + +/** Load nexus config at [configPath] */ +fun nexusConfig(configPath: Path?): NexusConfig { + val config = NEXUS_CONFIG_SOURCE.fromFile(configPath) + return NexusConfig(config) +} + +/** Load nexus db config at [configPath] */ +fun dbConfig(configPath: Path?): DatabaseConfig = + NEXUS_CONFIG_SOURCE.fromFile(configPath).dbConfig() + +/** Run [lambda] with access to a database conn pool */ +suspend fun NexusConfig.withDb(lambda: suspend (Database, NexusConfig) -> Unit) { + Database(dbCfg, currency).use { lambda(it, this) } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Constants.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Constants.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/KeyFiles.kt diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -0,0 +1,66 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023-2025 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/> + */ + +/** + * This file collects all the CLI subcommands and runs + * them. The actual implementation of each subcommand is + * kept in their respective files. + */ +package tech.libeufin.nexus + +import io.ktor.server.application.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import kotlinx.serialization.Serializable +import kotlinx.serialization.Contextual +import tech.libeufin.common.api.talerApi +import tech.libeufin.common.setupSecurityProperties +import tech.libeufin.nexus.api.revenueApi +import tech.libeufin.nexus.api.wireGatewayApi +import tech.libeufin.nexus.api.observabilityApi +import tech.libeufin.nexus.cli.LibeufinNexus +import tech.libeufin.nexus.db.Database +import com.github.ajalt.clikt.core.main +import java.time.Instant + +/** Triple identifying one IBAN bank account */ +data class IbanAccountMetadata( + val iban: String, + val bic: String?, + val name: String +) + +fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(LoggerFactory.getLogger("libeufin-nexus-api")) { + wireGatewayApi(db, cfg) + revenueApi(db, cfg) + observabilityApi(db, cfg) +} + +fun main(args: Array<String>) { + setupSecurityProperties() + LibeufinNexus().main(args) +} + +@Serializable +data class TaskStatus( + @Contextual + val last_successfull: Instant? = null, + @Contextual + val last_trial: Instant? = null +) +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/PDF.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/ObservabilityApi.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/RevenueApi.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/helpers.kt diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/DbInit.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/DbInit.kt @@ -0,0 +1,43 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import tech.libeufin.common.TalerCmd +import tech.libeufin.common.db.dbInit +import tech.libeufin.common.db.pgDataSource +import tech.libeufin.nexus.dbConfig + +class DbInit : TalerCmd("dbinit") { + override fun help(context: Context) = "Initialize the libeufin-nexus database" + + private val reset by option( + "--reset", "-r", + help = "Reset database (DANGEROUS: All existing data is lost)" + ).flag() + + override fun run() = cliCmd(logger) { + val cfg = dbConfig(config) + pgDataSource(cfg.dbConnStr).dbInit(cfg, "libeufin-nexus", reset) + } +} +\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -0,0 +1,556 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.parameters.arguments.* +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.enum +import kotlin.math.min +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.Contextual +import tech.libeufin.common.* +import tech.libeufin.nexus.* +import tech.libeufin.nexus.db.* +import tech.libeufin.nexus.db.PaymentDAO.* +import tech.libeufin.nexus.ebics.* +import tech.libeufin.nexus.iso20022.* +import java.io.IOException +import java.io.InputStream +import java.time.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.time.temporal.* + +/** Register an outgoing [payment] into [db] */ +suspend fun registerOutgoingPayment( + db: Database, + payment: OutgoingPayment +): OutgoingRegistrationResult { + val metadata: Pair<ShortHashCode, BaseURL>? = payment.subject?.let { + runCatching { parseOutgoingSubject(it) }.getOrNull() + } + val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second) + if (result.new) { + if (result.initiated) + logger.info("$payment") + else + logger.warn("$payment recovered") + } else { + logger.debug("{} already seen", payment) + } + return result +} + +/** Register an outgoing [payment] into [db] */ +suspend fun registerOutgoingBatch( + db: Database, + batch: OutgoingBatch +) { + logger.info("BATCH ${batch.executionTime.fmtDate()} ${batch.msgId}") + for (it in db.initiated.unsettledTxInBatch(batch.msgId, batch.executionTime)) { + registerOutgoingPayment(db, it) + } +} + +/** + * Register an incoming [payment] into [db] + * Stores the payment into valid talerable ones or bounces it + */ +suspend fun registerIncomingPayment( + db: Database, + cfg: NexusIngestConfig, + payment: IncomingPayment, +) { + fun logRes(res: InResult, kind: String = "", suffix: String = "") { + val fmt = buildString { + append(payment) + if (kind != "") { + append(" ") + append(kind) + } + if (res.new) { + if (res.bounceId != null) { + append(" bounced in ${res.bounceId}") + } + } else { + if (res.completed) { + append(" completed") + if (res.bounceId != null) { + append(" bounced in ${res.bounceId}") + } + } else { + if (res.bounceId != null) { + append(" already bounced in ${res.bounceId}") + } + } + } + if (suffix != "") { + append(" ") + append(suffix) + } + } + if (res.completed || res.new) { + logger.info(fmt) + } else { + logger.debug(fmt) + } + } + suspend fun bounce(cause: String) { + if (payment.id == null) { + logger.debug("{} ignored: missing bank ID", payment) + return + } + when (cfg.accountType) { + AccountType.exchange -> { + if (payment.executionTime < cfg.ignoreBouncesBefore) { + val res = db.payment.registerIncoming(payment) + logRes(res, suffix = "ignored bounce: $cause") + } else { + var bounceAmount = payment.amount + if (payment.creditFee != null && cfg.bounceDeduceFee) { + if (payment.creditFee > bounceAmount) { + val res = db.payment.registerIncoming(payment) + logRes(res, suffix = "skip bounce (transfer fee higher than amount): $cause") + return + } + bounceAmount -= payment.creditFee + } + if (cfg.bounceFee > bounceAmount) { + val res = db.payment.registerIncoming(payment) + logRes(res, suffix = "skip bounce (bounce fee higher than amount): $cause") + return + } + bounceAmount -= cfg.bounceFee + val res = db.payment.registerMalformedIncoming( + payment, + bounceAmount, + randEbicsId(), + Instant.now(), + cause + ) + when (res) { + IncomingBounceRegistrationResult.Talerable -> + logger.warn("{} tried to bounce a talerable transaction", payment) + is IncomingBounceRegistrationResult.Success -> + logRes(res, suffix=": $cause") + } + } + } + AccountType.normal -> { + val res = db.payment.registerIncoming(payment) + logRes(res) + } + } + } + // Check we have enough info to handle this transaction + if (payment.debtor == null) { + val res = db.payment.registerIncoming(payment) + logRes(res, kind = "incomplete") + return + } + if (cfg.restrictionPaytoRegex != null) { + if (!cfg.restrictionPaytoRegex.matches(payment.debtor.toString())) { + bounce("restricted account") + return + } + } + // Else we try to parse the incoming subject + runCatching { parseIncomingSubject(payment.subject) }.fold( + onSuccess = { metadata -> + if (metadata is IncomingSubject.AdminBalanceAdjust) { + val res = db.payment.registerIncoming(payment) + logRes(res, kind = "admin balance adjust") + } else { + when (val res = db.payment.registerTalerableIncoming(payment, metadata)) { + IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") + is IncomingRegistrationResult.Success -> logRes(res) + } + } + }, + onFailure = { e -> bounce(e.fmt())} + ) +} + +/** Register a [tx] notification into [db] */ +suspend fun registerTransaction( + db: Database, + cfg: NexusIngestConfig, + tx: TxNotification, +) { + if (tx.executionTime < cfg.ignoreTransactionsBefore) { + logger.debug("IGNORE {}", tx) + } else { + when (tx) { + is IncomingPayment -> registerIncomingPayment(db, cfg, tx) + is OutgoingPayment -> registerOutgoingPayment(db, tx) + is OutgoingBatch -> registerOutgoingBatch(db, tx) + is OutgoingReversal -> { + logger.error("{}", tx) + db.initiated.txStatusUpdate(tx.endToEndId, tx.msgId, StatusUpdate.permanent_failure, "Payment bounced: ${tx.reason}") + } + } + } +} + +/** Register a single EBICS [xml] txs [document] into [db] */ +suspend fun registerTxs( + db: Database, + cfg: NexusConfig, + xml: InputStream +): Int { + var nbTx: Int = 0 + parseTx(xml).forEach { accountTx -> + if (accountTx.iban == cfg.ebics.account.iban) { + require(accountTx.currency == null || accountTx.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${accountTx.currency}" } + accountTx.txs.forEach { tx -> + when (tx) { + is IncomingPayment -> + require(tx.amount.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${tx.amount.currency}" } + is OutgoingPayment -> + require(tx.amount.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${tx.amount.currency}" } + is OutgoingBatch, is OutgoingReversal -> {} + } + registerTransaction(db, cfg.ingest, tx) + nbTx += 1 + } + } else { + logger.debug("Skip transaction for unknown account ${accountTx.iban}") + } + } + return nbTx +} + +/** Register a single EBICS [xml] [document] into [db] */ +suspend fun registerFile( + db: Database, + cfg: NexusConfig, + xml: InputStream, + doc: OrderDoc +) { + when (doc) { + OrderDoc.report, OrderDoc.statement, OrderDoc.notification -> { + try { + registerTxs(db, cfg, xml) + } catch (e: Exception) { + throw Exception("Ingesting transactions files failed", e) + } + } + OrderDoc.acknowledgement -> { + val acks = parseCustomerAck(xml) + for (ack in acks) { + when (ack.actionType) { + HacAction.ORDER_HAC_FINAL_POS -> { + logger.debug("{}", ack) + db.initiated.orderSuccess(ack.orderId!!)?.let { messageId -> + logger.info("Batch $messageId order ${ack.orderId} accepted at ${ack.timestamp.fmtDateTime()}") + } + } + HacAction.ORDER_HAC_FINAL_NEG -> { + logger.debug("{}", ack) + db.initiated.orderFailure(ack.orderId!!)?.let { (messageId, msg) -> + logger.error("Batch $messageId order ${ack.orderId} refused at ${ack.timestamp.fmtDateTime()}${if (msg != null) ": $msg" else ""}") + } + } + else -> { + logger.debug("{}", ack) + if (ack.orderId != null) { + db.initiated.orderStep(ack.orderId, ack.msg()) + } + } + } + } + } + OrderDoc.status -> { + val msgStatus = parseCustomerPaymentStatusReport(xml) + logger.debug("{}", msgStatus) + if (msgStatus.code != null) { + val msg = msgStatus.msg() + val state = when (msgStatus.code) { + ExternalPaymentGroupStatusCode.ACSC -> StatusUpdate.success + ExternalPaymentGroupStatusCode.RJCT -> { + logger.error("Batch ${msgStatus.id} failed: $msg") + StatusUpdate.permanent_failure + } + else -> StatusUpdate.pending + } + db.initiated.batchStatusUpdate(msgStatus.id, state, msg) + } + for (pmtStatus in msgStatus.payments) { + if (pmtStatus.id != "NOTPROVIDED") { + logger.warn("Unexpected payment status for ${msgStatus.id}.${pmtStatus.id}") + } else if (pmtStatus.code != null) { + val msg = pmtStatus.msg() + val state = when (pmtStatus.code) { + ExternalPaymentGroupStatusCode.ACSC -> StatusUpdate.success + ExternalPaymentGroupStatusCode.RJCT -> { + logger.error("Batch ${msgStatus.id} failed: $msg") + StatusUpdate.permanent_failure + } + else -> StatusUpdate.pending + } + db.initiated.batchStatusUpdate(msgStatus.id, state, msg) + } + for (txStatus in pmtStatus.transactions) { + val msg = txStatus.msg() + val state = when (txStatus.code) { + ExternalPaymentTransactionStatusCode.RJCT, + ExternalPaymentTransactionStatusCode.BLCK -> { + logger.error("Transaction ${txStatus.endToEndId} failed: $msg") + StatusUpdate.permanent_failure + } + else -> StatusUpdate.pending + } + db.initiated.txStatusUpdate(txStatus.endToEndId, null, state, msg) + } + } + } + } +} + +/** Register an EBICS [payload] of [doc] into [db] */ +private suspend fun registerPayload( + db: Database, + cfg: NexusConfig, + payload: InputStream, + doc: OrderDoc +) { + // Unzip payload if necessary + when (doc) { + OrderDoc.status, + OrderDoc.report, + OrderDoc.statement, + OrderDoc.notification -> { + try { + payload.unzipEach { fileName, xml -> + logger.trace("parse $fileName") + registerFile(db, cfg, xml, doc) + } + } catch (e: IOException) { + throw Exception("Could not open any ZIP archive", e) + } + } + OrderDoc.acknowledgement -> registerFile(db, cfg, payload, doc) + } +} + +/** + * Fetch and register banking records from [orders] using EBICS [client] starting from [pinnedStart] + * + * If [pinnedStart] is null fetch new records. + */ +private suspend fun fetchEbicsDocuments( + client: EbicsClient, + orders: Collection<EbicsOrder>, + pinnedStart: Instant?, + peek: Boolean +): Boolean { + val lastExecutionTime: Instant? = pinnedStart + var success = true + for ((doc, orders) in orders.groupBy { it.doc() }) { + if (doc == null) { + logger.debug("Skip unsupported orders {}", orders) + } else { + if (lastExecutionTime == null) { + logger.info("Fetching new '${doc.fullDescription()}'") + } else { + logger.info("Fetching '${doc.fullDescription()}' from timestamp: $lastExecutionTime") + } + for (order in orders) { + try { + client.download( + order, + lastExecutionTime, + null, + peek + ) { payload -> + registerPayload(client.db, client.cfg, payload, doc) + } + } catch (e: EbicsError.Code) { + when (e.bankCode) { + EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE -> continue + EbicsReturnCode.EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED -> { + e.fmtLog(logger) + success = false + continue + } + else -> throw e + } + } + } + } + } + return success +} + +@Serializable +data class Checkpoint( + @Contextual + val last_successfull: Instant? = null, + @Contextual + val last_trial: Instant? = null +) + +class EbicsFetch: EbicsCmd() { + override fun help(context: Context) = "Downloads and parse EBICS files from the bank and register them into the database" + + private val documents: Set<OrderDoc> by argument( + help = "Which documents should be fetched? If none are specified, all supported documents will be fetched", + helpTags = OrderDoc.entries.associate { Pair(it.name, it.shortDescription()) }, + ).enum<OrderDoc>().multiple().unique() + private val pinnedStart by option( + help = "Only supported in --transient mode, this option lets specify the earliest timestamp of the downloaded documents", + metavar = "YYYY-MM-DD" + ).convert { dateToInstant(it) } + private val peek by option("--peek", + help = "Only supported in --transient mode, do not consume fetched documents" + ).flag() + private val transientCheckpoint by option("--checkpoint", + help = "Only supported in --transient mode, run a checkpoint" + ).flag() + + override fun run() = cliCmd(logger) { + nexusConfig(config).withDb { db, cfg -> + val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics) + val client = EbicsClient( + cfg, + httpClient(), + db, + EbicsLogger(ebicsLog), + clientKeys, + bankKeys + ) + val docs = if (documents.isEmpty()) OrderDoc.entries else documents.toList() + + // EBICS order than should be fetched + val selectedOrder = docs.flatMap { cfg.ebics.dialect.downloadDoc(it) } + + // Try to obtain real-time notification channel if not transient + val wssNotification = if (transient) { + logger.info("Transient mode: fetching once and returning") + null + } else { + val tmp = listenForNotification(client) + logger.info("Running with a frequency of ${cfg.fetch.frequencyRaw}") + tmp + } + + var lastFetch = Instant.EPOCH + + while (true) { + val checkpoint = db.kv.get<TaskStatus>(CHECKPOINT_KEY) ?: TaskStatus() + var nextFetch = lastFetch + cfg.fetch.frequency + var nextCheckpoint = run { + // We never ran, we must checkpoint now + if (checkpoint.last_trial == null) { + Instant.EPOCH + } else { + // We run today at checkpointTime + val checkpointDate = OffsetDateTime.now().with(cfg.fetch.checkpointTime) + val checkpointToday = checkpointDate.toInstant() + // If we already ran today we ruAn tomorrow + if (checkpoint.last_trial > checkpointToday) { + checkpointDate.plusDays(1).toInstant() + } else { + checkpointToday + } + } + } + + val now = Instant.now() + var success: Boolean = true + if ( + // Run transient checkpoint at request + (transient && transientCheckpoint) || + // Or run recurrent checkpoint + (!transient && now > nextCheckpoint) + ) { + logger.info("Running checkpoint") + + val since = if (transient && pinnedStart != null && (checkpoint.last_successfull == null || pinnedStart!!.isBefore(checkpoint.last_successfull))) { + pinnedStart + } else { + checkpoint.last_successfull + } + success = try { + /// We fetch HKD to only fetch supported EBICS orders and get the document versions + val orders = client.download(EbicsOrder.V3.HKD) { stream -> + val hkd = EbicsAdministrative.parseHKD(stream) + val supportedOrder = hkd.partner.orders.map { it.order } + logger.debug { + val fmt = supportedOrder.map(EbicsOrder::description).joinToString(" ") + "HKD: ${fmt}" + } + selectedOrder select supportedOrder + } + fetchEbicsDocuments(client, orders, since, transient && peek) + } catch (e: Exception) { + e.fmtLog(logger) + false + } + db.kv.updateTaskStatus(CHECKPOINT_KEY, now, success) + lastFetch = now + } else if (transient || now > nextFetch) { + if (!transient) logger.info("Running at frequency") + success = try { + /// We fetch HAA to only fetch pending & supported EBICS orders and get the document versions + val orders = client.download(EbicsOrder.V3.HAA) { stream -> + val haa = EbicsAdministrative.parseHAA(stream) + logger.debug { + val orders = haa.orders.map(EbicsOrder::description).joinToString(" ") + "HAA: ${orders}" + } + selectedOrder select haa.orders + } + fetchEbicsDocuments(client, orders, if (transient) pinnedStart else null, transient && peek) + } catch (e: Exception) { + e.fmtLog(logger) + false + } + lastFetch = now + } + db.kv.updateTaskStatus(SUBMIT_TASK_KEY, now, success) + + if (transient) throw ProgramResult(if (!success) 1 else 0) + + val delay = min(ChronoUnit.MILLIS.between(now, nextFetch), ChronoUnit.MILLIS.between(now, nextCheckpoint)) + if (wssNotification == null) { + delay(delay) + } else { + val notifications = withTimeoutOrNull(delay) { + wssNotification.receive() + } + if (notifications != null) { + // Only fetch requested and supported orders + val orders = selectedOrder select notifications + if (orders.isNotEmpty()) { + logger.info("Running at real-time notifications reception") + fetchEbicsDocuments(client, notifications, null, false) + } + } + } + } + } + } +} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSubmit.kt diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt @@ -0,0 +1,77 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.convert +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.convert +import com.github.ajalt.clikt.parameters.options.option +import tech.libeufin.common.* +import tech.libeufin.nexus.db.InitiatedPayment +import tech.libeufin.nexus.ebics.randEbicsId +import tech.libeufin.nexus.nexusConfig +import tech.libeufin.nexus.withDb +import java.time.Instant + +class InitiatePayment: TalerCmd() { + override fun help(context: Context) = "Initiate an outgoing payment" + + private val amount by option( + "--amount", + help = "The amount to transfer, payto 'amount' parameter takes the precedence" + ).convert { TalerAmount(it) } + private val subject by option( + "--subject", + help = "The payment subject, payto 'message' parameter takes the precedence" + ) + private val endToEndId by option( + "--end-to-end-id", + "--request-uid", + help = "The payment end-to-end UID" + ) + private val payto by argument( + help = "The credited account IBAN payto URI" + ).convert { Payto.parse(it).expectIban() } + + override fun run() = cliCmd(logger) { + nexusConfig(config).withDb { db, cfg -> + val subject = requireNotNull(payto.message ?: subject) { "Missing subject" } + val amount = requireNotNull(payto.amount ?: amount) { "Missing amount" } + + requireNotNull(payto.receiverName) { "Missing receiver name in creditor payto" } + require(amount.currency == cfg.currency) { + "Wrong currency: expected ${cfg.currency} got ${amount.currency}" + } + + db.initiated.create( + InitiatedPayment( + id = -1, + amount = amount, + subject = subject, + creditor = payto, + initiationTime = Instant.now(), + endToEndId = endToEndId ?: randEbicsId() + ) + ) + } + } +} +\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt @@ -0,0 +1,58 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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.nexus.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.versionOption +import com.github.ajalt.clikt.parameters.types.path +import tech.libeufin.common.* +import tech.libeufin.nexus.NEXUS_CONFIG_SOURCE +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +internal val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") + +fun CliktCommand.ebicsLogOption() = option( + "--debug-ebics", + help = "Log EBICS transactions steps and payload at log_dir", + metavar = "log_dir" +).path() + +fun CliktCommand.transientOption() = option( + "--transient", + help = "Execute once and return, ignoring the 'FREQUENCY' configuration value" +).flag(default = false) + +abstract class EbicsCmd(name: String? = null): TalerCmd(name) { + val ebicsLog by ebicsLogOption() + val transient by transientOption() +} + + +class LibeufinNexus : CliktCommand() { + init { + versionOption(VERSION) + subcommands(DbInit(), EbicsSetup(), EbicsSubmit(), EbicsFetch(), Serve(), InitiatePayment(), ManualCmd(), ListCmd(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd()) + } + override fun run() = Unit +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/List.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/List.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Manual.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Manual.kt diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Serve.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Serve.kt @@ -0,0 +1,72 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import tech.libeufin.common.TalerCmd +import tech.libeufin.common.api.serve +import tech.libeufin.nexus.nexusApi +import tech.libeufin.nexus.nexusConfig +import tech.libeufin.nexus.withDb + + +class Serve : TalerCmd("serve") { + override fun help(context: Context) = "Run libeufin-nexus HTTP server" + + private val check by option( + help = "Check whether an API is in use (if it's useful to start the HTTP server). Exit with 0 if at least one API is enabled, otherwise 1" + ).flag() + + override fun run() = cliCmd(logger) { + val cfg = nexusConfig(config) + + if (check) { + // Check if the server is to be started + val apis = listOf( + cfg.wireGatewayApiCfg to "Wire Gateway API", + cfg.revenueApiCfg to "Revenue API" + ) + var startServer = false + for ((api, name) in apis) { + if (api != null) { + startServer = true + logger.info("$name is enabled: starting the server") + } + } + if (!startServer) { + logger.info("All APIs are disabled: not starting the server") + throw ProgramResult(1) + } else { + throw ProgramResult(0) + } + } + + cfg.withDb { db, cfg -> + serve(cfg.serverCfg) { + nexusApi(db, cfg) + } + } + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/Database.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/EbicsDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/EbicsDAO.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/InitiatedDAO.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/KvDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/KvDAO.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsBTS.kt diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -0,0 +1,441 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus.ebics + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.utils.io.jvm.javaio.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import org.w3c.dom.Document +import org.xml.sax.SAXException +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.nexus.* +import tech.libeufin.nexus.db.Database +import java.io.InputStream +import java.io.SequenceInputStream +import java.security.interfaces.RSAPrivateCrtKey +import java.time.Instant +import java.util.* + +internal val logger: Logger = LoggerFactory.getLogger("libeufin-ebics") + +/** Supported documents that can be downloaded via EBICS */ +enum class SupportedDocument { + PAIN_002, + PAIN_002_LOGS, + CAMT_053, + CAMT_052, + CAMT_054 +} + +/** EBICS related errors */ +sealed class EbicsError(msg: String, cause: Throwable? = null): Exception(msg, cause) { + /** Network errors */ + class Network(msg: String, cause: Throwable): EbicsError(msg, cause) + /** Http errors */ + class HTTP(msg: String, val status: HttpStatusCode): EbicsError(msg) + /** EBICS protocol & XML format error */ + class Protocol(msg: String, cause: Throwable? = null): EbicsError(msg, cause) + /** EBICS protocol & XML format error */ + class Code(msg: String, val technicalCode: EbicsReturnCode, val bankCode: EbicsReturnCode): EbicsError(msg) +} + +/** POST an EBICS request [msg] to [bankUrl] returning a parsed XML response */ +suspend fun HttpClient.postToBank( + bankUrl: String, + msg: ByteArray, + phase: String, + stepLogger: StepLogger +): Document { + stepLogger.logRequest(msg) + val res = try { + post(urlString = bankUrl) { + contentType(ContentType.Text.Xml) + setBody(msg) + } + } catch (e: Exception) { + throw EbicsError.Network("$phase: failed to contact bank", e) + } + + if (res.status != HttpStatusCode.OK) { + stepLogger.logFailure(res) + throw EbicsError.HTTP("$phase: bank HTTP error: ${res.status}", res.status) + } + try { + val bodyStream = res.bodyAsChannel().toInputStream() + val loggedStream = stepLogger.logResponse(bodyStream) + return XMLUtil.parseIntoDom(loggedStream) + } catch (e: SAXException) { + throw EbicsError.Protocol("$phase: invalid XML bank response", e) + } catch (e: Exception) { + throw EbicsError.Network("$phase: failed read bank response", e) + } +} + +/** POST an EBICS BTS request [xmlReq] using [client] returning a validated and parsed XML response */ +suspend fun EbicsBTS.postBTS( + client: HttpClient, + xmlReq: ByteArray, + phase: String, + stepLogger: StepLogger +): BTSResponse { + val doc = client.postToBank(cfg.host.baseUrl, xmlReq, phase, stepLogger) + try { + XMLUtil.verifyEbicsDocument( + doc, + bankKeys.bank_authentication_public_key + ) + } catch (e: Exception) { + throw EbicsError.Protocol("$phase ${order.description()}: invalid signature", e) + } + val response = try { + EbicsBTS.parseResponse(doc) + } catch (e: Exception) { + throw EbicsError.Protocol("$phase ${order.description()}: invalid ebics response", e) + } + logger.debug { + buildString { + append(phase) + response.content.transactionID?.let { + append(" for ") + append(it) + } + append(": ") + append(response.technicalCode) + append(" & ") + append(response.bankCode) + } + } + return response.okOrFail(phase) +} + +/** High level EBICS client */ +class EbicsClient( + val cfg: NexusConfig, + val client: HttpClient, + val db: Database, + val ebicsLogger: EbicsLogger, + val clientKeys: ClientPrivateKeysFile, + val bankKeys: BankPublicKeysFile +) { + /** + * Performs an EBICS download transaction of [order] between [startDate] and [endDate]. + * Download content is passed to [processing] + * + * It conducts init -> transfer -> processing -> receipt phases. + * + * Cancellations and failures are handled. + */ + suspend fun <T> download( + order: EbicsOrder, + startDate: Instant? = null, + endDate: Instant? = null, + peek: Boolean = false, + processing: suspend (InputStream) -> T, + ): T { + val description = order.description() + logger.debug { + buildString { + append("Download order ") + append(description) + if (startDate != null) { + append(" from ") + append(startDate) + if (endDate != null) { + append(" to ") + append(endDate) + } + } + } + } + val impl = EbicsBTS(cfg.ebics, bankKeys, clientKeys, order) + + // Close interrupted + val interruptedLog = ebicsLogger.tx("INTD") + while (true) { + val tId = db.ebics.first() + if (tId == null) break + val xml = impl.downloadReceipt(tId, false) + try { + impl.postBTS(client, xml, "Closing interrupted transaction ${tId}", interruptedLog.step(tId)) + } catch (e: Exception) { + when (e) { + // Transaction already closed or expired - EBICS protocol error + is EbicsError.Code if e.technicalCode == EbicsReturnCode.EBICS_TX_UNKNOWN_TXID -> {} + // Transaction already closed or expired - HTTP protocol error for non compliant banks + is EbicsError.HTTP if e.status == HttpStatusCode.BadRequest -> {} + // Unexpected error + else -> throw e + } + logger.debug("${e.fmt()}") + } + db.ebics.remove(tId) + } + + val txLog = ebicsLogger.tx(order) + + // We need to run the logic in a non-cancelable context because we need to send + // a receipt for each open download transaction, otherwise we'll be stuck in an + // error loop until the pending transaction timeout. + val (tId, initContent) = withContext(NonCancellable) { + // Init phase + val initReq = impl.downloadInitialization(startDate, endDate) + val initContent = impl.postBTS(client, initReq, "Download init $description", txLog.step("init")) + val tId = requireNotNull(initContent.transactionID) { + "Download init $description: missing transaction ID" + } + db.ebics.register(tId) + Pair(tId, initContent) + } + val howManySegments = requireNotNull(initContent.numSegments) { + "Download init $description: missing num segments" + } + val firstSegment = requireNotNull(initContent.segment) { + "Download init $description: missing OrderData" + } + val dataEncryptionInfo = requireNotNull(initContent.dataEncryptionInfo) { + "Download init $description: missing EncryptionInfo" + } + + // Transfer phase + val segments = mutableListOf(firstSegment) + for (x in 2 .. howManySegments) { + val transReq = impl.downloadTransfer(x, howManySegments, tId) + val transResp = impl.postBTS(client, transReq, "Download transfer $description", txLog.step("transfer$x")) + val segment = requireNotNull(transResp.segment) { + "Download transfer: missing encrypted segment" + } + segments.add(segment) + } + + + // Decompress encrypted chunks + val payloadStream = try { + decryptAndDecompressPayload( + clientKeys.encryption_private_key, + dataEncryptionInfo, + segments + ) + } catch (e: Exception) { + throw EbicsError.Protocol("invalid chunks", e) + } + + val container = when (order) { + is EbicsOrder.V2_5 -> "rax" // TODO infer ? + is EbicsOrder.V3 -> order.container ?: "xml" + } + val loggedStream = txLog.payload(payloadStream, container) + + // Run business logic + val res = runCatching { + processing(loggedStream) + } + + // First send a proper EBICS transaction receipt + val xml = impl.downloadReceipt(tId, res.isSuccess && !peek) + impl.postBTS(client, xml, "Download receipt $description", txLog.step("receipt")) + runCatching { db.ebics.remove(tId) } + // Then throw business logic exception if any + return res.getOrThrow() + } + + /** + * Performs an EBICS upload transaction of [order] using [payload]. + * + * It conducts init -> upload phases. + * + * Returns upload orderID + */ + suspend fun upload( + order: EbicsOrder, + payload: ByteArray, + ): String { + val description = order.description(); + logger.debug { "Upload order $description" } + val txLog = ebicsLogger.tx(order) + val impl = EbicsBTS(cfg.ebics, bankKeys, clientKeys, order) + val preparedPayload = prepareUploadPayload(cfg.ebics, clientKeys, bankKeys, payload) + + // Init phase + val initXml = impl.uploadInitialization(preparedPayload) + val initResp = impl.postBTS(client, initXml, "Upload init $description", txLog.step("init")) + val tId = requireNotNull(initResp.transactionID) { + "Upload init $description: missing transaction ID" + } + val orderId = requireNotNull(initResp.orderID) { + "Upload init $description: missing order ID" + } + + txLog.payload(payload, "xml") + + // Transfer phase + for (i in 1..preparedPayload.segments.size) { + val transferXml = impl.uploadTransfer(tId, preparedPayload, i) + impl.postBTS(client, transferXml, "Upload transfer $description", txLog.step("transfer$i")) + } + return orderId + } +} + +suspend fun HEV( + client: HttpClient, + cfg: NexusEbicsConfig, + ebicsLogger: EbicsLogger +): List<VersionNumber> { + logger.info("Doing administrative request HEV") + val txLog = ebicsLogger.tx("HEV") + val req = EbicsAdministrative.HEV(cfg) + val xml = client.postToBank(cfg.host.baseUrl, req, "HEV", txLog.step()) + return EbicsAdministrative.parseHEV(xml).okOrFail("HEV") +} + +suspend fun keyManagement( + cfg: NexusEbicsConfig, + privs: ClientPrivateKeysFile, + client: HttpClient, + ebicsLogger: EbicsLogger, + order: EbicsKeyMng.Order +): EbicsResponse<InputStream?> { + logger.info("Doing key request $order") + val txLog = ebicsLogger.tx(order.name) + val ebics3 = when (cfg.dialect) { + // TODO GLS needs EBICS 2.5 for key management + Dialect.gls -> false + else -> true + } + val req = EbicsKeyMng(cfg, privs, ebics3).request(order) + val xml = client.postToBank(cfg.host.baseUrl, req, order.name, txLog.step()) + return EbicsKeyMng.parseResponse(xml, privs.encryption_private_key) +} + +class PreparedUploadData( + val transactionKey: ByteArray, + val userSignatureDataEncrypted: String, + val dataDigest: ByteArray, + val segments: List<String> +) + +/** Signs, encrypts and format EBICS BTS payload */ +fun prepareUploadPayload( + cfg: NexusEbicsConfig, + clientKeys: ClientPrivateKeysFile, + bankKeys: BankPublicKeysFile, + payload: ByteArray, +): PreparedUploadData { + val payloadDigest = CryptoUtil.digestEbicsOrderA006(payload) + val innerSignedEbicsXml = XmlBuilder.toBytes("UserSignatureData") { + attr("xmlns", "http://www.ebics.org/S002") + el("OrderSignatureData") { + el("SignatureVersion", "A006") + el("SignatureValue", CryptoUtil.signEbicsA006( + payloadDigest, + clientKeys.signature_private_key, + ).encodeBase64()) + el("PartnerID", cfg.host.ebicsPartnerId) + el("UserID", cfg.host.ebicsUserId) + } + } + // Generate ephemeral transaction key + val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(bankKeys.bank_encryption_public_key) + // Compress and encrypt order signature + val orderSignature = CryptoUtil.encryptEbicsE002( + transactionKey, + innerSignedEbicsXml.inputStream().deflate() + ).encodeBase64() + // Compress and encrypt payload + val encrypted = CryptoUtil.encryptEbicsE002( + transactionKey, + payload.inputStream().deflate() + ) + // Chunks of 1MB and encode segments + val segments = encrypted.encodeBase64().chunked(1000000) + + return PreparedUploadData( + encryptedTransactionKey, + orderSignature, + payloadDigest, + segments + ) +} + +/** Decrypts and decompresses EBICS BTS payload */ +fun decryptAndDecompressPayload( + clientEncryptionKey: RSAPrivateCrtKey, + encryptionInfo: DataEncryptionInfo, + segments: List<ByteArray> +): InputStream { + val transactionKey = CryptoUtil.decryptEbicsE002Key(clientEncryptionKey, encryptionInfo.transactionKey) + return SequenceInputStream(Collections.enumeration(segments.map { it.inputStream() })) // Aggregate + .run { + CryptoUtil.decryptEbicsE002( + transactionKey, + this + ) + }.inflate() +} + +/** Generate a secure random nonce of [size] bytes */ +fun getNonce(size: Int): ByteArray { + return ByteArray(size / 8).secureRand() +} + +private val EBICS_ID_ALPHABET = ('A'..'Z') + ('0'..'9') + +fun randEbicsId(): String { + return List(34) { EBICS_ID_ALPHABET.random() }.joinToString("") +} + +class DataEncryptionInfo( + val transactionKey: ByteArray, + val bankPubDigest: ByteArray +) + +class EbicsResponse<T>( + val technicalCode: EbicsReturnCode, + val bankCode: EbicsReturnCode, + internal val content: T +) { + /** Checks that return codes are both EBICS_OK */ + fun ok(): T? { + return if (technicalCode.kind() != EbicsReturnCode.Kind.Error && + bankCode.kind() != EbicsReturnCode.Kind.Error) { + content + } else { + null + } + } + + /** Checks that return codes are both EBICS_OK or throw an exception */ + fun okOrFail(phase: String): T { + if (technicalCode.kind() == EbicsReturnCode.Kind.Error) { + throw EbicsError.Code("$phase has technical error: $technicalCode", technicalCode, bankCode) + } else if (bankCode.kind() == EbicsReturnCode.Kind.Error) { + throw EbicsError.Code("$phase has bank error: $bankCode", technicalCode, bankCode) + } else { + return content + } + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsConstants.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsConstants.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsKeyMng.kt diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsLogger.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsLogger.kt @@ -0,0 +1,145 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus.ebics + +import tech.libeufin.common.* +import tech.libeufin.nexus.ebics.EbicsOrder +import java.io.* +import java.nio.file.* +import java.time.* +import java.time.format.DateTimeFormatter +import kotlin.io.* +import kotlin.io.path.* +import io.ktor.client.statement.* + +/** Log EBICS transactions steps and payload if [path] is not null */ +class EbicsLogger(private val dir: Path?) { + + init { + if (dir != null) { + try { + // Create logging directory if missing + dir.createDirectories() + } catch (e: Exception) { + throw Exception("Failed to init EBICS debug logging directory", e) + } + logger.info("Logging to '$dir'") + } + } + + /** Create a new [name] EBICS transaction logger */ + fun tx(name: String): TxLogger { + if (dir == null) return TxLogger(null) + val utcDateTime = Instant.now().atOffset(ZoneOffset.UTC) + val txDir = dir + // yyyy-MM-dd per day directory + .resolve(utcDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE)) + // HH:mm:ss.SSS-name per transaction directory + .resolve("${utcDateTime.format(TIME_WITH_MS)}-$name") + txDir.createDirectories() + return TxLogger(txDir) + } + + /** Create a new [order] EBICS transaction logger */ + fun tx(order: EbicsOrder): TxLogger { + if (dir == null) return TxLogger(null) + return tx(order.description()) + } + + companion object { + private val TIME_WITH_MS = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") + } +} + +/** Log EBICS transaction steps and payload */ +class TxLogger internal constructor( + private val dir: Path? +) { + /** Create a new [name] EBICS transaction step logger*/ + fun step(name: String? = null) = StepLogger(dir, name) + + /** Log a [stream] EBICS transaction payload of [type] */ + fun payload(stream: InputStream, type: String = "xml"): InputStream { + if (dir == null) return stream + return payload(stream.readBytes(), type).inputStream() + } + + /** Log a [content] EBICS transaction payload of [type] */ + fun payload(content: ByteArray, type: String = "xml"): ByteArray { + if (dir == null) return content + val type = type.lowercase() + if (type == "zip") { + val payloadDir = dir.resolve("payload") + payloadDir.createDirectory() + content.inputStream().unzipEach { fileName, xmlContent -> + xmlContent.use { + Files.copy(it, payloadDir.resolve(fileName)) + } + } + } else { + dir.resolve("payload.$type").writeBytes(content, StandardOpenOption.CREATE_NEW) + } + return content + } +} + +/** Log EBICS transaction protocol step */ +class StepLogger internal constructor( + private val dir: Path?, + private val name: String? +) { + private val prefix = if (name != null) "$name-" else "" + + /** Log a protocol step [request] */ + fun logRequest(request: ByteArray) { + if (dir != null) { + dir.resolve("${prefix}request.xml") + .writeBytes(request, StandardOpenOption.CREATE_NEW) + } + } + + /** Log a protocol step failure */ + suspend fun logFailure(res: HttpResponse) { + if (dir != null) { + // TODO reduce allocation + // TODO silent io error + val bytes = buildString { + append("${res.version} ${res.status}\n") + for ((k, vs) in res.headers.entries()) { + for (v in vs) { + append("${k}: ${v}\n") + } + } + append('\n') + }.toByteArray() + res.readRawBytes() + dir.resolve("${prefix}failure") + .writeBytes(bytes, StandardOpenOption.CREATE_NEW) + } + } + + /** Log a protocol step [response] */ + fun logResponse(response: InputStream): InputStream { + if (dir == null) return response + val bytes = response.readBytes() + dir.resolve("${prefix}response.xml") + .writeBytes(bytes, StandardOpenOption.CREATE_NEW) + return bytes.inputStream() + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt @@ -0,0 +1,206 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus.ebics + +import io.ktor.client.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.* +import io.ktor.websocket.* +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.common.* + +private val wsLog: Logger = LoggerFactory.getLogger("libeufin-ebics-ws") + +@Serializable +data class WssParams( + val URL: String, + val TOKEN: String, + val OTT: String, + val VALIDITY: String, + val PARTNERID: String, + val USERID: String? = null, +) + +@Serializable +data class WssNotificationClass( + val NAME: String, + val VERS: String, + val TIMESTAMP: String, +) + +@Serializable +data class WssNotificationBTF( + val SERVICE: String, + val SCOPE: String? = null, + val OPTION: String? = null, + val CONTTYPE: String? = null, + val MSGNAME: String, + val VARIANT: String? = null, + val VERSION: String? = null, + val FORMAT: String? = null, +) + +@Serializable +data class WssNewData( + val MCLASS: List<WssNotificationClass>, + val PARTNERID: String, + val USERID: String? = null, + val BTF: List<WssNotificationBTF>, + val ORDERTYPE: List<String>? = null +): WssNotification + +@Serializable +data class WssInfo( + val LANG: String, + val FREE: String +) + +@Serializable +data class WssGeneralInfo( + val MCLASS: List<WssNotificationClass>, + val INFO: List<WssInfo> +): WssNotification + +@Serializable(with = WssNotification.Serializer::class) +sealed interface WssNotification { + companion object Serializer : JsonContentPolymorphicSerializer<WssNotification>(WssNotification::class) { + override fun selectDeserializer(element: JsonElement) = when { + "INFO" in element.jsonObject -> WssGeneralInfo.serializer() + else -> WssNewData.serializer() + } + } +} + +/** Download EBICS real-time notifications websocket params */ +suspend fun EbicsClient.wssParams(): WssParams = + download(EbicsOrder.V3.WSS_PARAMS) { stream -> + Json.decodeFromStream(stream) + } + +/** Receive a JSON message from a websocket session */ +private suspend inline fun <reified T> DefaultClientWebSocketSession.receiveJson(): T { + val frame = incoming.receive() + val content = frame.readBytes() + val msg = Json.decodeFromStream(kotlinx.serialization.serializer<T>(), content.inputStream()) + return msg +} + +/** Connect to the EBICS real-time notifications websocket */ +suspend fun WssParams.connect(client: HttpClient, lambda: suspend (WssNotification) -> Unit) { + val client = client.config { + install(WebSockets) { + contentConverter = KotlinxWebsocketSerializationConverter(Json) + } + } + // TODO check PARTNERID and USERID match conf ? + val credentials = buildString { + // Username + append(PARTNERID) + if (USERID != null) { + append('_') + append(USERID) + } + // Password + append(':') + append(TOKEN) + }.encodeBase64() + + client.wss(URL.replace("https://", "wss://"), request = { + headers { + append(HttpHeaders.Authorization, "Basic $credentials") + } + }) { + while (true) { + wsLog.trace("wait for ws msg") + // TODO use receiveDeserialized from ktor when it works + val msg = receiveJson<WssNotification>() + wsLog.trace("received: {}", msg) + lambda(msg) + } + } +} + +suspend fun listenForNotification(client: EbicsClient): ReceiveChannel<List<EbicsOrder>>? { + val channel = Channel<List<EbicsOrder>>() + val backoff = ExpoBackoffDecorr( + 30 * 1000, // 30 seconds + 30 * 60 * 1000 // 30 min + ) + kotlin.concurrent.thread(isDaemon = true) { + runBlocking { + while (true) { + try { + // Try to get params + val params = try { + client.wssParams() + } catch (e: EbicsError) { + if ( + // Expected EBICS error + (e is EbicsError.Code && e.technicalCode == EbicsReturnCode.EBICS_INVALID_ORDER_IDENTIFIER) || + // Netzbon HTTP error + (e is EbicsError.HTTP && e.status == HttpStatusCode.BadRequest) + ) { + // Failure is expected if this wss is not supported + wsLog.info("Real-time EBICS notifications is not supported") + return@runBlocking + } else throw e + } + wsLog.info("Listening to real-time EBICS notifications") + wsLog.trace("{}", params) + params.connect(client.client) { msg -> + backoff.reset() + when (msg) { + is WssGeneralInfo -> { + for (info in msg.INFO) { + wsLog.info("info: {}", info.FREE) + } + } + is WssNewData -> { + val orders = msg.BTF.map { + EbicsOrder.V3( + type = "BTD", + service = it.SERVICE, + scope = it.SCOPE, + message = it.MSGNAME, + version = it.VERSION, + container = it.CONTTYPE, + option = it.OPTION + ) + } + channel.send(orders) + } + } + } + } catch (e: Exception) { + e.fmtLog(wsLog) + delay(backoff.next()) + } + } + } + } + return channel +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/helpers.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/BankTransactionCode.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/BankTransactionCode.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Constants.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/Constants.kt diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt @@ -0,0 +1,477 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024 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/> + */ + +// THIS FILE IS GENERATED, DO NOT EDIT + +package tech.libeufin.nexus.iso20022 + +enum class ExternalStatusReasonCode(val isoCode: String, val description: String) { + AB01("AbortedClearingTimeout", "Clearing process aborted due to timeout."), + AB02("AbortedClearingFatalError", "Clearing process aborted due to a fatal error."), + AB03("AbortedSettlementTimeout", "Settlement aborted due to timeout."), + AB04("AbortedSettlementFatalError", "Settlement process aborted due to a fatal error."), + AB05("TimeoutCreditorAgent", "Transaction stopped due to timeout at the Creditor Agent."), + AB06("TimeoutInstructedAgent", "Transaction stopped due to timeout at the Instructed Agent."), + AB07("OfflineAgent", "Agent of message is not online."), + AB08("OfflineCreditorAgent", "Creditor Agent is not online."), + AB09("ErrorCreditorAgent", "Transaction stopped due to error at the Creditor Agent."), + AB10("ErrorInstructedAgent", "Transaction stopped due to error at the Instructed Agent."), + AB11("TimeoutDebtorAgent", "Transaction stopped due to timeout at the Debtor Agent."), + AB12("InvalidConcurrentBatch", "Duplicate Concurrent Batch Sequence number– for Settlement Instructions."), + AB13("InvalidRoutingCodeUtilised", "Wrong Message Routing Type for Return-of-Funds."), + AB15("InvalidAccountNumberForSettlementType", "Instruction may not be placed on the Continuous Processing Line settlement processor."), + AB21("InvalidSettlementAgreementNumberSpecified", "Agreement number not valid (beneficiary)."), + AB26("InvalidBatchSettlementInstruction", "Settlement Instruction does not exist."), + AC01("IncorrectAccountNumber", "Account number is invalid or missing."), + AC02("InvalidDebtorAccountNumber", "Debtor account number invalid or missing"), + AC03("InvalidCreditorAccountNumber", "Creditor account number invalid or missing"), + AC04("ClosedAccountNumber", "Account number specified has been closed on the bank of account's books."), + AC05("ClosedDebtorAccountNumber", "Debtor account number closed"), + AC06("BlockedAccount", "Account specified is blocked, prohibiting posting of transactions against it."), + AC07("ClosedCreditorAccountNumber", "Creditor account number closed"), + AC08("InvalidBranchCode", "Branch code is invalid or missing"), + AC09("InvalidAccountCurrency", "Account currency is invalid or missing"), + AC10("InvalidDebtorAccountCurrency", "Debtor account currency is invalid or missing"), + AC11("InvalidCreditorAccountCurrency", "Creditor account currency is invalid or missing"), + AC12("InvalidAccountType", "Account type missing or invalid."), + AC13("InvalidDebtorAccountType", "Debtor account type missing or invalid"), + AC14("InvalidCreditorAccountType", "Creditor account type missing or invalid"), + AC15("AccountDetailsChanged", "The account details for the counterparty have changed."), + AC16("CardNumberInvalid", "Credit or debit card number is invalid."), + AEXR("AlreadyExpiredRTP", "Request-to-pay Expiry Date and Time has already passed."), + AG01("TransactionForbidden", "Transaction forbidden on this type of account (formerly NoAgreement)"), + AG02("InvalidBankOperationCode", "Bank Operation code specified in the message is not valid for receiver"), + AG03("TransactionNotSupported", "Transaction type not supported/authorized on this account"), + AG04("InvalidAgentCountry", "Agent country code is missing or invalid."), + AG05("InvalidDebtorAgentCountry", "Debtor agent country code is missing or invalid"), + AG06("InvalidCreditorAgentCountry", "Creditor agent country code is missing or invalid"), + AG07("UnsuccesfulDirectDebit", "Debtor account cannot be debited for a generic reason."), + AG08("InvalidAccessRights", "Transaction failed due to invalid or missing user or access right"), + AG09("PaymentNotReceived", "Original payment never received."), + AG10("AgentSuspended", "Agent of message is suspended from the Real Time Payment system."), + AG11("CreditorAgentSuspended", "Creditor Agent of message is suspended from the Real Time Payment system."), + AG12("NotAllowedBookTransfer", "Payment orders made by transferring funds from one account to another at the same financial institution (bank or payment institution) are not allowed."), + AG13("ForbiddenReturnPayment", "Returned payments derived from previously returned transactions are not allowed."), + AGNT("IncorrectAgent", "Agent in the payment workflow is incorrect"), + ALAC("AlreadyAcceptedRTP", "Request-to-pay has already been accepted by the Debtor."), + AM01("ZeroAmount", "Specified message amount is equal to zero"), + AM02("NotAllowedAmount", "Specific transaction/message amount is greater than allowed maximum"), + AM03("NotAllowedCurrency", "Specified message amount is an non processable currency outside of existing agreement"), + AM04("InsufficientFunds", "Amount of funds available to cover specified message amount is insufficient."), + AM05("Duplication", "Duplication"), + AM06("TooLowAmount", "Specified transaction amount is less than agreed minimum."), + AM07("BlockedAmount", "Amount specified in message has been blocked by regulatory authorities."), + AM09("WrongAmount", "Amount received is not the amount agreed or expected"), + AM10("InvalidControlSum", "Sum of instructed amounts does not equal the control sum."), + AM11("InvalidTransactionCurrency", "Transaction currency is invalid or missing"), + AM12("InvalidAmount", "Amount is invalid or missing"), + AM13("AmountExceedsClearingSystemLimit", "Transaction amount exceeds limits set by clearing system"), + AM14("AmountExceedsAgreedLimit", "Transaction amount exceeds limits agreed between bank and client"), + AM15("AmountBelowClearingSystemMinimum", "Transaction amount below minimum set by clearing system"), + AM16("InvalidGroupControlSum", "Control Sum at the Group level is invalid"), + AM17("InvalidPaymentInfoControlSum", "Control Sum at the Payment Information level is invalid"), + AM18("InvalidNumberOfTransactions", "Number of transactions is invalid or missing."), + AM19("InvalidGroupNumberOfTransactions", "Number of transactions at the Group level is invalid or missing"), + AM20("InvalidPaymentInfoNumberOfTransactions", "Number of transactions at the Payment Information level is invalid"), + AM21("LimitExceeded", "Transaction amount exceeds limits agreed between bank and client."), + AM22("ZeroAmountNotApplied", "Unable to apply zero amount to designated account. For example, where the rules of a service allow the use of zero amount payments, however the back-office system is unable to apply the funds to the account. If the rules of a service prohibit the use of zero amount payments, then code AM01 is used to report the error condition."), + AM23("AmountExceedsSettlementLimit", "Transaction amount exceeds settlement limit."), + AMSE("AttachmentMaximumSize", "Size of the attachment exceeds the allowed maximum."), + APAR("AlreadyPaidRTP", "Request To Pay has already been paid by the Debtor."), + ARFR("AlreadyRefusedRTP", "Request-to-pay has already been refused by the Debtor."), + ARJR("AlreadyRejectedRTP", "Request-to-pay has already been rejected."), + ATNS("AttachementsNotSupported", "Attachments to the request-to-pay are not supported."), + BDAY("NotBusinessDay", "Settlement Cycle Day and Calendar day should be the same."), + BE01("InconsistenWithEndCustomer", "Identification of end customer is not consistent with associated account number. (formerly CreditorConsistency)."), + BE04("MissingCreditorAddress", "Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress)."), + BE05("UnrecognisedInitiatingParty", "Party who initiated the message is not recognised by the end customer"), + BE06("UnknownEndCustomer", "End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books"), + BE07("MissingDebtorAddress", "Specification of debtor's address, which is required for payment, is missing/not correct."), + BE08("MissingDebtorName", "Debtor name is missing"), + BE09("InvalidCountry", "Country code is missing or Invalid."), + BE10("InvalidDebtorCountry", "Debtor country code is missing or invalid"), + BE11("InvalidCreditorCountry", "Creditor country code is missing or invalid"), + BE12("InvalidCountryOfResidence", "Country code of residence is missing or Invalid."), + BE13("InvalidDebtorCountryOfResidence", "Country code of debtor's residence is missing or Invalid"), + BE14("InvalidCreditorCountryOfResidence", "Country code of creditor's residence is missing or Invalid"), + BE15("InvalidIdentificationCode", "Identification code missing or invalid."), + BE16("InvalidDebtorIdentificationCode", "Debtor or Ultimate Debtor identification code missing or invalid"), + BE17("InvalidCreditorIdentificationCode", "Creditor or Ultimate Creditor identification code missing or invalid"), + BE18("InvalidContactDetails", "Contact details missing or invalid"), + BE19("InvalidChargeBearerCode", "Charge bearer code for transaction type is invalid"), + BE20("InvalidNameLength", "Name length exceeds local rules for payment type."), + BE21("MissingName", "Name missing or invalid. Generic usage if cannot specifically identify debtor or creditor."), + BE22("MissingCreditorName", "Creditor name is missing"), + BE23("AccountProxyInvalid", "Phone number or email address, or any other proxy, used as the account proxy is unknown or invalid."), + CERI("CheckERI", "Credit transfer is not tagged as an Extended Remittance Information (ERI) transaction but contains ERI."), + CH03("RequestedExecutionDateOrRequestedCollectionDateTooFarInFuture", "Value in Requested Execution Date or Requested Collection Date is too far in the future"), + CH04("RequestedExecutionDateOrRequestedCollectionDateTooFarInPast", "Value in Requested Execution Date or Requested Collection Date is too far in the past"), + CH07("ElementIsNotToBeUsedAtB-andC-Level", "Element is not to be used at B- and C-Level"), + CH09("MandateChangesNotAllowed", "Mandate changes are not allowed"), + CH10("InformationOnMandateChangesMissing", "Information on mandate changes are missing"), + CH11("CreditorIdentifierIncorrect", "Value in Creditor Identifier is incorrect"), + CH12("CreditorIdentifierNotUnambiguouslyAtTransaction-Level", "Creditor Identifier is ambiguous at Transaction Level"), + CH13("OriginalDebtorAccountIsNotToBeUsed", "Original Debtor Account is not to be used"), + CH14("OriginalDebtorAgentIsNotToBeUsed", "Original Debtor Agent is not to be used"), + CH15("ElementContentIncludesMoreThan140Characters", "Content Remittance Information/Structured includes more than 140 characters"), + CH16("ElementContentFormallyIncorrect", "Content is incorrect"), + CH17("ElementNotAdmitted", "Element is not allowed"), + CH19("ValuesWillBeSetToNextTARGETday", "Values in Interbank Settlement Date or Requested Collection Date will be set to the next TARGET day"), + CH20("DecimalPointsNotCompatibleWithCurrency", "Number of decimal points not compatible with the currency"), + CH21("RequiredCompulsoryElementMissing", "Mandatory element is missing"), + CH22("COREandB2BwithinOnemessage", "SDD CORE and B2B not permitted within one message"), + CHCO("UnacceptedChargeCodeType", "Related to a Charge message to convey that the code in Charge Breakdown / Type / Code is not accepted by the receiving party."), + CHQC("ChequeSettledOnCreditorAccount", "Cheque has been presented in cheque clearing and settled on the creditor’s account."), + CHRG("UnderlyingChargeBearerWasNotDebt", "Related to a Charge message to convey that the charge bearer code used in the corresponding Payment message was not debt."), + CN01("AuthorisationCancelled", "Authorisation is cancelled."), + CNNS("CreditNotesNotSupported", "Credit notes are not supported."), + CNOR("CreditorBankIsNotRegistered", "Creditor bank is not registered under this BIC in the CSM"), + CURR("IncorrectCurrency", "Currency of the payment is incorrect"), + CUST("RequestedByCustomer", "Cancellation requested by the Debtor"), + DC02("SettlementNotReceived", "Rejection of a payment due to covering FI settlement not being received."), + DNOR("DebtorBankIsNotRegistered", "Debtor bank is not registered under this BIC in the CSM"), + DS01("ElectronicSignaturesCorrect", "The electronic signature(s) is/are correct"), + DS02("OrderCancelled", "An authorized user has cancelled the order"), + DS03("OrderNotCancelled", "The user’s attempt to cancel the order was not successful"), + DS04("OrderRejected", "The order was rejected by the bank side (for reasons concerning content)"), + DS05("OrderForwardedForPostprocessing", "The order was correct and could be forwarded for postprocessing"), + DS06("TransferOrder", "The order was transferred to VEU"), + DS07("ProcessingOK", "All actions concerning the order could be done by the EBICS bank server"), + DS08("DecompressionError", "The decompression of the file was not successful"), + DS09("DecryptionError", "The decryption of the file was not successful"), + DS0A("DataSignRequested", "Data signature is required."), + DS0B("UnknownDataSignFormat", "Data signature for the format is not available or invalid."), + DS0C("SignerCertificateRevoked", "The signer certificate is revoked."), + DS0D("SignerCertificateNotValid", "The signer certificate is not valid (revoked or not active)."), + DS0E("IncorrectSignerCertificate", "The signer certificate is not present."), + DS0F("SignerCertificationAuthoritySignerNotValid", "The authority of the signer certification sending the certificate is unknown."), + DS0G("NotAllowedPayment", "Signer is not allowed to sign this operation type."), + DS0H("NotAllowedAccount", "Signer is not allowed to sign for this account."), + DS0K("NotAllowedNumberOfTransaction", "The number of transaction is over the number allowed for this signer."), + DS10("Signer1CertificateRevoked", "The certificate is revoked for the first signer."), + DS11("Signer1CertificateNotValid", "The certificate is not valid (revoked or not active) for the first signer."), + DS12("IncorrectSigner1Certificate", "The certificate is not present for the first signer."), + DS13("SignerCertificationAuthoritySigner1NotValid", "The authority of signer certification sending the certificate is unknown for the first signer."), + DS14("UserDoesNotExist", "The user is unknown on the server"), + DS15("IdenticalSignatureFound", "The same signature has already been sent to the bank"), + DS16("PublicKeyVersionIncorrect", "The public key version is not correct. This code is returned when a customer sends signature files to the financial institution after conversion from an older program version (old ES format) to a new program version (new ES format) without having carried out re-initialisation with regard to a public key change."), + DS17("DifferentOrderDataInSignatures", "Order data and signatures don’t match"), + DS18("RepeatOrder", "File cannot be tested, the complete order has to be repeated. This code is returned in the event of a malfunction during the signature check, e.g. not enough storage space."), + DS19("ElectronicSignatureRightsInsufficient", "The user’s rights (concerning his signature) are insufficient to execute the order"), + DS20("Signer2CertificateRevoked", "The certificate is revoked for the second signer."), + DS21("Signer2CertificateNotValid", "The certificate is not valid (revoked or not active) for the second signer."), + DS22("IncorrectSigner2Certificate", "The certificate is not present for the second signer."), + DS23("SignerCertificationAuthoritySigner2NotValid", "The authority of signer certification sending the certificate is unknown for the second signer."), + DS24("WaitingTimeExpired", "Waiting time expired due to incomplete order"), + DS25("OrderFileDeleted", "The order file was deleted by the bank server"), + DS26("UserSignedMultipleTimes", "The same user has signed multiple times"), + DS27("UserNotYetActivated", "The user is not yet activated (technically)"), + DS28("ReturnForTechnicalReason", "Message routed to the wrong environment."), + DT01("InvalidDate", "Invalid date (eg, wrong or missing settlement date)"), + DT02("InvalidCreationDate", "Invalid creation date and time in Group Header (eg, historic date)"), + DT03("InvalidNonProcessingDate", "Invalid non bank processing date (eg, weekend or local public holiday)"), + DT04("FutureDateNotSupported", "Future date not supported"), + DT05("InvalidCutOffDate", "Associated message, payment information block or transaction was received after agreed processing cut-off date, i.e., date in the past."), + DT06("ExecutionDateChanged", "Execution Date has been modified in order for transaction to be processed"), + DU01("DuplicateMessageID", "Message Identification is not unique."), + DU02("DuplicatePaymentInformationID", "Payment Information Block is not unique."), + DU03("DuplicateTransaction", "Transaction is not unique."), + DU04("DuplicateEndToEndID", "End To End ID is not unique."), + DU05("DuplicateInstructionID", "Instruction ID is not unique."), + DUPL("DuplicatePaymentOrCharge", "Payment or charge is a duplicate of another payment or charge."), + ED01("CorrespondentBankNotPossible", "Correspondent bank not possible."), + ED03("BalanceInfoRequest", "Balance of payments complementary info is requested"), + ED05("SettlementFailed", "Settlement of the transaction has failed."), + ED06("SettlementSystemNotAvailable", "Interbank settlement system not available."), + EDNA("ExecutionDateNotAccepted", "Requested execution date of the payment is not accepted."), + EDTL("ExpiryDateTooLong", "Expiry date time of the request-to-pay is too far in the future."), + EDTR("ExpiryDateTimeReached", "Expiry date time of the request-to-pay is already reached."), + EOL1("EndOfLife", "Expiration of the payment authorisation due to no use for too long."), + ERIN("ERIOptionNotSupported", "Extended Remittance Information (ERI) option is not supported."), + FF01("InvalidFileFormat", "File Format incomplete or invalid"), + FF02("SyntaxError", "Syntax error reason is provided as narrative information in the additional reason information."), + FF03("InvalidPaymentTypeInformation", "Payment Type Information is missing or invalid."), + FF04("InvalidServiceLevelCode", "Service Level code is missing or invalid"), + FF05("InvalidLocalInstrumentCode", "Local Instrument code is missing or invalid"), + FF06("InvalidCategoryPurposeCode", "Category Purpose code is missing or invalid"), + FF07("InvalidPurpose", "Purpose is missing or invalid"), + FF08("InvalidEndToEndId", "End to End Id missing or invalid"), + FF09("InvalidChequeNumber", "Cheque number missing or invalid"), + FF10("BankSystemProcessingError", "File or transaction cannot be processed due to technical issues at the bank side"), + FF11("ClearingRequestAborted", "Clearing request rejected due it being subject to an abort operation."), + FF12("OriginalTransactionNotEligibleForRequestedReturn", "Original payment is not eligible to be returned given its current status."), + FF13("RequestForCancellationNotFound", "No record of request for cancellation found."), + FOCR("FollowingCancellationRequest", "Return following a cancellation request."), + FR01("Fraud", "Returned as a result of fraud."), + FRAD("FraudulentOrigin", "Cancellation requested following a transaction that was originated fraudulently. The use of the FraudulentOrigin code should be governed by jurisdictions."), + G000("PaymentTransferredAndTracked", "In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure. The payment transfer is tracked. No further updates will follow from the Status Originator."), + G001("PaymentTransferredAndNotTracked", "In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure. The payment transfer is not tracked. No further updates will follow from the Status Originator."), + G002("CreditDebitNotConfirmed", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account may not be confirmed same day. Update will follow from the Status Originator."), + G003("CreditPendingDocuments", "In a FIToFI Customer Credit Transfer: Credit to creditor’s account is pending receipt of required documents. The Status Originator has requested creditor to provide additional documentation. Update will follow from the Status Originator."), + G004("CreditPendingFunds", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover. Update will follow from the Status Originator."), + G005("DeliveredWithServiceLevel", "Payment has been delivered to creditor agent with service level."), + G006("DeliveredWIthoutServiceLevel", "Payment has been delivered to creditor agent without service level."), + ID01("CorrespondingOriginalFileStillNotSent", "Signature file was sent to the bank but the corresponding original file has not been sent yet."), + IEDT("IncorrectExpiryDateTime", "Expiry date time of the request-to-pay is incorrect."), + INAR("InvalidActivationReference", "Payer’s activation reference is invalid."), + INDT("InvalidDetails", "Details not valid for this field."), + IPNS("InstalmentPaymentsNotSupported", "Payments in instalments are not supported."), + IRNR("InitialRTPNeverReceived", "No initial request-to-pay has been received."), + ISWS("InvalidSettlementWindow", "Cannot schedule instruction for Night Window."), + MD01("NoMandate", "No Mandate"), + MD02("MissingMandatoryInformationInMandate", "Mandate related information data required by the scheme is missing."), + MD05("CollectionNotDue", "Creditor or creditor's agent should not have collected the direct debit"), + MD06("RefundRequestByEndCustomer", "Return of funds requested by end customer"), + MD07("EndCustomerDeceased", "End customer is deceased."), + MINF("MissingInformation", "Information missing for the field or cannot be empty."), + MS02("NotSpecifiedReasonCustomerGenerated", "Reason has not been specified by end customer"), + MS03("NotSpecifiedReasonAgentGenerated", "Reason has not been specified by agent."), + NARR("Narrative", "Reason is provided as narrative information in the additional reason information."), + NERI("NoERI", "Credit transfer is tagged as an Extended Remittance Information (ERI) transaction but does not contain ERI."), + NOAR("NonAgreedRTP", "No existing agreement for receiving request-to-pay messages."), + NOAS("NoAnswerFromCustomer", "No response from Beneficiary."), + NOCM("NotCompliantGeneric", "Customer account is not compliant with regulatory requirements, for example FICA (in South Africa) or any other regulatory requirements which render an account inactive for certain processing."), + NOFR("OutstandingFundingForSettlement", "Continuous Processing Line on Hold Instruction."), + NOPG("NoPaymentGuarantee", "Requested payment guarantee (by Creditor) related to a request-to-pay cannot be provided."), + NRCH("PayerOrPayerRTPSPNotReachable", "Recipient side of the request-to-pay (payer or its request-to-pay service provider) is not reachable."), + OSNS("OptionalServiceNotSupported", "Requested optional service (for example instalment payments) is not supported."), + PINS("TypeOfPaymentInstrumentNotSupported", "Type of payment requested in the request-to-pay is not supported by the payer."), + RC01("BankIdentifierIncorrect", "Bank identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode)."), + RC02("InvalidBankIdentifier", "Bank identifier is invalid or missing."), + RC03("InvalidDebtorBankIdentifier", "Debtor bank identifier is invalid or missing"), + RC04("InvalidCreditorBankIdentifier", "Creditor bank identifier is invalid or missing"), + RC05("InvalidBICIdentifier", "BIC identifier is invalid or missing."), + RC06("InvalidDebtorBICIdentifier", "Debtor BIC identifier is invalid or missing"), + RC07("InvalidCreditorBICIdentifier", "Creditor BIC identifier is invalid or missing"), + RC08("InvalidClearingSystemMemberIdentifier", "ClearingSystemMemberidentifier is invalid or missing."), + RC09("InvalidDebtorClearingSystemMemberIdentifier", "Debtor ClearingSystemMember identifier is invalid or missing"), + RC10("InvalidCreditorClearingSystemMemberIdentifier", "Creditor ClearingSystemMember identifier is invalid or missing"), + RC11("InvalidIntermediaryAgent", "Intermediary Agent is invalid or missing"), + RC12("MissingCreditorSchemeId", "Creditor Scheme Id is invalid or missing"), + RC13("ParticipantNotAnActiveMemberofRTGS", "Originator not active any more."), + RC15("ParticipantNotActiveMemberSettlementType", "Settlement agreement required."), + RC16("ParticipantNotActiveMemberofSADCRTGS", "Participant blocked from SADC-RTGS."), + RCON("RMessageConflict", "Conflict with R-Message"), + RECI("ReceiverCustomerInformation", "Further information regarding the intended recipient."), + REPR("RTPReceivedCanBeProcessed", "Request-to-pay has been received and can be processed further."), + RF01("NotUniqueTransactionReference", "Transaction reference is not unique within the message."), + RQNR("RequestNotRecognized", "Payer did not recognize the request from Payee Participant,"), + RR01("MissingDebtorAccountOrIdentification", "Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing"), + RR02("MissingDebtorNameOrAddress", "Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing."), + RR03("MissingCreditorNameOrAddress", "Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing."), + RR04("RegulatoryReason", "Regulatory Reason"), + RR05("RegulatoryInformationInvalid", "Regulatory or Central Bank Reporting information missing, incomplete or invalid."), + RR06("TaxInformationInvalid", "Tax information missing, incomplete or invalid."), + RR07("RemittanceInformationInvalid", "Remittance information structure does not comply with rules for payment type."), + RR08("RemittanceInformationTruncated", "Remittance information truncated to comply with rules for payment type."), + RR09("InvalidStructuredCreditorReference", "Structured creditor reference invalid or missing."), + RR10("InvalidCharacterSet", "Character set supplied not valid for the country and payment type."), + RR11("InvalidDebtorAgentServiceID", "Invalid or missing identification of a bank proprietary service."), + RR12("InvalidPartyID", "Invalid or missing identification required within a particular country or payment type."), + RTNS("RTPNotSupportedForDebtor", "Debtor does not support request-to-pay transactions."), + RUTA("ReturnUponUnableToApply", "Return following investigation request and no remediation possible."), + S000("ValidRequestForCancellationAcknowledged", "Request for Cancellation is acknowledged following validation."), + S001("UETRFlaggedForCancellation", "Unique End-to-end Transaction Reference (UETR) relating to a payment has been identified as being associated with a Request for Cancellation."), + S002("NetworkStopOfUETR", "Unique End-to-end Transaction Reference (UETR) relating to a payment has been prevent from traveling across a messaging network."), + S003("RequestForCancellationForwarded", "Request for Cancellation has been forwarded to the payment processing/last payment processing agent."), + S004("RequestForCancellationDeliveryAcknowledgement", "Request for Cancellation has been acknowledged as delivered to payment processing/last payment processing agent."), + SBRN("SettlementBatchRemovalNotification", "Remove Concurrent Batch Processing Line on hold instruction."), + SL01("SpecificServiceOfferedByDebtorAgent", "Due to specific service offered by the Debtor Agent."), + SL02("SpecificServiceOfferedByCreditorAgent", "Due to specific service offered by the Creditor Agent."), + SL03("ServiceofClearingSystem", "Due to a specific service offered by the clearing system."), + SL11("CreditorNotOnWhitelistOfDebtor", "Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet). In the Whitelist the Debtor may list all allowed Creditors to debit Debtor bank account."), + SL12("CreditorOnBlacklistOfDebtor", "Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist”. In the Blacklist the Debtor may list all Creditors not allowed to debit Debtor bank account."), + SL13("MaximumNumberOfDirectDebitTransactionsExceeded", "Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent."), + SL14("MaximumDirectDebitTransactionAmountExceeded", "Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent."), + SL15("MaximumNumberOfCreditTransactionsExceeded", "Maximum number of credit transactions allowed by the account servicer per service period exceeded."), + SL16("MaximumCreditTransactionsAmountExceeded", "Maximum total credit amount allowed by the account servicer per service period exceeded."), + SL17("DebtorNotOnWhitelistOfCreditorSide", "Whitelisting service offered by payment system operator or financial institution. Debtor is not included on the Creditor side whitelist."), + SL18("DebtorOnBlacklistOfCreditorSide", "Blacklisting service offered by payment system operator or financial institution. Debtor included on the Creditor side blacklist."), + SNRD("ServiceNotRendered", "Services are not yet rendered by the Payee Participant (Creditor)."), + SPII("RTPServiceProviderIdentifierIncorrect", "Identifier of the request-to-pay service provider is incorrect."), + TA01("TransmissonAborted", "The transmission of the file was not successful – it had to be aborted (for technical reasons)"), + TD01("NoDataAvailable", "There is no data available (for download)"), + TD02("FileNonReadable", "The file cannot be read (e.g. unknown format)"), + TD03("IncorrectFileStructure", "The file format is incomplete or invalid"), + TK01("TokenInvalid", "Token is invalid."), + TK02("SenderTokenNotFound", "Token used for the sender does not exist."), + TK03("ReceiverTokenNotFound", "Token used for the receiver does not exist."), + TK09("TokenMissing", "Token required for request is missing."), + TKCM("TokenCounterpartyMismatch", "Token found with counterparty mismatch."), + TKSG("TokenSingleUse", "Single Use Token already used."), + TKSP("TokenSuspended", "Token found with suspended status."), + TKVE("TokenValueLimitExceeded", "Token found with value limit rule violation."), + TKXP("TokenExpired", "Token expired."), + TM01("InvalidCutOffTime", "Associated message, payment information block, or transaction was received after agreed processing cut-off time."), + TS01("TransmissionSuccessful", "The (technical) transmission of the file was successful."), + TS04("TransferToSignByHand", "The order was transferred to pass by accompanying note signed by hand"), + UCRD("UnknownCreditor", "Unknown Creditor."), + UPAY("UnduePayment", "Payment is not justified."), +} + +enum class ExternalPaymentGroupStatusCode(val isoCode: String, val description: String) { + ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the creditor's account has been completed."), + ACCP("AcceptedCustomerProfile", "Preceding check of technical validation was successful. Customer profile check was also successful."), + ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement on the debtor's account has been completed."), + ACSP("AcceptedSettlementInProcess", "All preceding checks such as technical validation and customer profile were successful and therefore the payment initiation has been accepted for execution."), + ACTC("AcceptedTechnicalValidation", "Authentication and syntactical and semantical validation are successful"), + ACWC("AcceptedWithChange", "Instruction is accepted but a change will be made, such as date or remittance not sent."), + PART("PartiallyAccepted", "A number of transactions have been accepted, whereas another number of transactions have not yet achieved"), + PDNG("Pending", "Payment initiation or individual transaction included in the payment initiation is pending. Further checks and status update will be performed."), + RCVC("ReceivedVerificationCompleted", "Verification of Payee check have been applied to received transactions stating to be complete without mismatching data."), + RCVD("Received", "Payment initiation has been received by the receiving agent"), + RJCT("Rejected", "Payment initiation or individual transaction included in the payment initiation has been rejected."), + RVCM("ReceivedVerificationCompletedWithMismatches", "Verification of Payee checks have been applied to received transactions stating to be complete containing mismatching data."), + RVNC("ReceivedVerificationNotCompleted", "Verification of party check on transactions received is not yet completed."), +} + +enum class ExternalPaymentTransactionStatusCode(val isoCode: String, val description: String) { + ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the creditor's account has been completed."), + ACCP("AcceptedCustomerProfile", "Preceding check of technical validation was successful. Customer profile check was also successful."), + ACFC("AcceptedFundsChecked", "Preceding check of technical validation and customer profile was successful and an automatic funds check was positive."), + ACFW("AcceptedFundsCheckedWaitingConfirmation", "Preceding check of technical validation and customer profile was successful, and an automatic funds check was positive, but an explicit confirmation by the initiating party is outstanding."), + ACIS("AcceptedandChequeIssued", "Payment instruction to issue a cheque has been accepted, and the cheque has been issued but not yet been deposited or cleared."), + ACPD("AcceptedClearingProcessed", "Status of transaction released from the Debtor Agent and accepted by the clearing."), + ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement completed."), + ACSP("AcceptedSettlementInProcess", "All preceding checks such as technical validation and customer profile were successful and therefore the payment instruction has been accepted for execution."), + ACTC("AcceptedTechnicalValidation", "Authentication and syntactical and semantical validation are successful"), + ACWC("AcceptedWithChange", "Instruction is accepted but a change will be made, such as date or remittance not sent."), + ACWP("AcceptedWithoutPosting", "Payment instruction included in the credit transfer is accepted without being posted to the creditor customer’s account."), + BLCK("Blocked", "Payment transaction previously reported with status 'ACWP' is blocked, for example, funds will neither be posted to the Creditor's account, nor be returned to the Debtor."), + CANC("Cancelled", "Payment initiation has been successfully cancelled after having received a request for cancellation."), + CPUC("CashPickedUpByCreditor", "Cash has been picked up by the Creditor."), + PATC("PartiallyAcceptedTechnicalCorrect", "Payment initiation needs multiple authentications, where some but not yet all have been performed. Syntactical and semantical validations are successful."), + PDNG("Pending", "Payment instruction is pending. Further checks and status update will be performed."), + PRES("Presented", "Request for Payment has been presented to the Debtor."), + RCVC("ReceivedVerificationCompleted", "Verification of Payee check has been applied to received transaction stating to be complete without mismatching data."), + RCVD("Received", "Payment instruction has been received."), + RJCT("Rejected", "Payment instruction has been rejected."), + RVCM("ReceivedVerificationCompletedWithMismatches", "Verification of Payee checks have been applied to received transaction stating to be completed containing mismatching data."), + RVMC("ReceivedVerificationCompletedMatchClosely", "Verification of Payee check has been applied to received transaction stating to be complete with data matching closely."), + RVNA("ReceivedVerificationCompletedNotApplicable", "Verification of Payee check has been applied to received transaction stating to be complete with not applicable data."), + RVNC("ReceivedVerificationNotCompleted", "Verification of party check on the transaction is not yet completed."), + RVNM("ReceivedVerificationCompletedNoMatch", "Verification of Payee check has been applied to received transaction stating to be complete with mismatching data."), +} + +enum class ExternalReturnReasonCode(val isoCode: String, val description: String) { + AC01("IncorrectAccountNumber", "Format of the account number specified is not correct"), + AC02("InvalidDebtorAccountNumber", "Debtor account number invalid or missing."), + AC03("InvalidCreditorAccountNumber", "Wrong IBAN in SCT"), + AC04("ClosedAccountNumber", "Account number specified has been closed on the bank of account's books"), + AC06("BlockedAccount", "Account specified is blocked, prohibiting posting of transactions against it."), + AC07("ClosedCreditorAccountNumber", "Creditor account number closed."), + AC13("InvalidDebtorAccountType", "Debtor account type is missing or invalid"), + AC14("InvalidAgent", "An agent in the payment chain is invalid."), + AC15("AccountDetailsChanged", "Account details have changed."), + AC16("AccountInSequestration", "Account is in sequestration."), + AC17("AccountInLiquidation", "Account is in liquidation."), + AG01("TransactionForbidden", "Transaction forbidden on this type of account (formerly NoAgreement)"), + AG02("InvalidBankOperationCode", "Bank Operation code specified in the message is not valid for receiver"), + AG07("UnsuccesfulDirectDebit", "Debtor account cannot be debited for a generic reason."), + AGNT("IncorrectAgent", "Agent in the payment workflow is incorrect."), + AM01("ZeroAmount", "Specified message amount is equal to zero"), + AM02("NotAllowedAmount", "Specific transaction/message amount is greater than allowed maximum"), + AM03("NotAllowedCurrency", "Specified message amount is an non processable currency outside of existing agreement"), + AM04("InsufficientFunds", "Amount of funds available to cover specified message amount is insufficient."), + AM05("Duplication", "Duplication"), + AM06("TooLowAmount", "Specified transaction amount is less than agreed minimum."), + AM07("BlockedAmount", "Amount specified in message has been blocked by regulatory authorities."), + AM09("WrongAmount", "Amount received is not the amount agreed or expected"), + AM10("InvalidControlSum", "Sum of instructed amounts does not equal the control sum."), + ARDT("AlreadyReturnedTransaction", "Already returned original SCT"), + BE01("InconsistenWithEndCustomer", "Identification of end customer is not consistent with associated account number, organisation ID or private ID."), + BE04("MissingCreditorAddress", "Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress)."), + BE05("UnrecognisedInitiatingParty", "Party who initiated the message is not recognised by the end customer"), + BE06("UnknownEndCustomer", "End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books"), + BE07("MissingDebtorAddress", "Specification of debtor's address, which is required for payment, is missing/not correct."), + BE08("BankError", "Returned as a result of a bank error."), + BE10("InvalidDebtorCountry", "Debtor country code is missing or invalid."), + BE11("InvalidCreditorCountry", "Creditor country code is missing or invalid."), + BE16("InvalidDebtorIdentificationCode", "Debtor or Ultimate Debtor identification code missing or invalid."), + BE17("InvalidCreditorIdentificationCode", "Creditor or Ultimate Creditor identification code missing or invalid."), + CN01("AuthorisationCancelled", "Authorisation is cancelled."), + CNOR("CreditorBankIsNotRegistered", "Creditor bank is not registered under this BIC in the CSM"), + CNPC("CashNotPickedUp", "Cash not picked up by Creditor or cash could not be delivered to Creditor"), + CURR("IncorrectCurrency", "Currency of the payment is incorrect"), + CUST("RequestedByCustomer", "Cancellation requested by the Debtor"), + DC04("NoCustomerCreditTransferReceived", "Return of Covering Settlement due to the underlying Credit Transfer details not being received."), + DNOR("DebtorBankIsNotRegistered", "Debtor bank is not registered under this BIC in the CSM"), + DS28("ReturnForTechnicalReason", "Return following technical problems resulting in erroneous transaction."), + DT01("InvalidDate", "Invalid date (eg, wrong settlement date)"), + DT02("ChequeExpired", "Cheque has been issued but not deposited and is considered expired."), + DT04("FutureDateNotSupported", "Future date not supported."), + DUPL("DuplicatePayment", "Payment is a duplicate of another payment."), + ED01("CorrespondentBankNotPossible", "Correspondent bank not possible."), + ED03("BalanceInfoRequest", "Balance of payments complementary info is requested"), + ED05("SettlementFailed", "Settlement of the transaction has failed."), + EMVL("EMVLiabilityShift", "The card payment is fraudulent and was not processed with EMV technology for an EMV card."), + ERIN("ERIOptionNotSupported", "The Extended Remittance Information (ERI) option is not supported."), + FF03("InvalidPaymentTypeInformation", "Payment Type Information is missing or invalid."), + FF04("InvalidServiceLevelCode", "Service Level code is missing or invalid."), + FF05("InvalidLocalInstrumentCode", "Local Instrument code is missing or invalid"), + FF06("InvalidCategoryPurposeCode", "Category Purpose code is missing or invalid."), + FF07("InvalidPurpose", "Purpose is missing or invalid."), + FOCR("FollowingCancellationRequest", "Return following a cancellation request"), + FR01("Fraud", "Returned as a result of fraud."), + FRTR("FinalResponseMandateCancelled", "Final response/tracking is recalled as mandate is cancelled."), + G004("CreditPendingFunds", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover. Update will follow from the Status Originator."), + MD01("NoMandate", "No Mandate"), + MD02("MissingMandatoryInformationInMandate", "Mandate related information data required by the scheme is missing."), + MD05("CollectionNotDue", "Creditor or creditor's agent should not have collected the direct debit."), + MD06("RefundRequestByEndCustomer", "Return of funds requested by end customer"), + MD07("EndCustomerDeceased", "End customer is deceased."), + MS02("NotSpecifiedReasonCustomerGenerated", "Reason has not been specified by end customer"), + MS03("NotSpecifiedReasonAgentGenerated", "Reason has not been specified by agent."), + NARR("Narrative", "Reason is provided as narrative information in the additional reason information."), + NOAS("NoAnswerFromCustomer", "No response from Beneficiary"), + NOCM("NotCompliant", "Customer account is not compliant with regulatory requirements, for example FICA (in South Africa) or any other regulatory requirements which render an account inactive for certain processing."), + NOOR("NoOriginalTransactionReceived", "Original SCT never received"), + PINL("PINLiabilityShift", "The card payment is fraudulent (lost and stolen fraud) and was processed as EMV transaction without PIN verification."), + RC01("BankIdentifierIncorrect", "Bank Identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode)."), + RC03("InvalidDebtorBankIdentifier", "Debtor bank identifier is invalid or missing."), + RC04("InvalidCreditorBankIdentifier", "Creditor bank identifier is invalid or missing."), + RC07("InvalidCreditorBICIdentifier", "Incorrrect BIC of the beneficiary Bank in the SCTR"), + RC08("InvalidClearingSystemMemberIdentifier", "ClearingSystemMemberidentifier is invalid or missing."), + RC11("InvalidIntermediaryAgent", "Intermediary Agent is invalid or missing."), + RF01("NotUniqueTransactionReference", "Transaction reference is not unique within the message."), + RR01("MissingDebtorAccountOrIdentification", "Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing"), + RR02("MissingDebtorNameOrAddress", "Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing."), + RR03("MissingCreditorNameOrAddress", "Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing."), + RR04("RegulatoryReason", "Regulatory Reason"), + RR05("RegulatoryInformationInvalid", "Regulatory or Central Bank Reporting information missing, incomplete or invalid."), + RR06("TaxInformationInvalid", "Tax information missing, incomplete or invalid."), + RR07("RemittanceInformationInvalid", "Remittance information structure does not comply with rules for payment type."), + RR08("RemittanceInformationTruncated", "Remittance information truncated to comply with rules for payment type."), + RR09("InvalidStructuredCreditorReference", "Structured creditor reference invalid or missing."), + RR11("InvalidDebtorAgentServiceIdentification", "Invalid or missing identification of a bank proprietary service."), + RR12("InvalidPartyIdentification", "Invalid or missing identification required within a particular country or payment type."), + RUTA("ReturnUponUnableToApply", "Return following investigation request and no remediation possible."), + SL01("SpecificServiceOfferedByDebtorAgent", "Due to specific service offered by the Debtor Agent"), + SL02("SpecificServiceOfferedByCreditorAgent", "Due to specific service offered by the Creditor Agent"), + SL11("CreditorNotOnWhitelistOfDebtor", "Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet). In the Whitelist the Debtor may list all allowed Creditors to debit Debtor bank account."), + SL12("CreditorOnBlacklistOfDebtor", "Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist”. In the Blacklist the Debtor may list all Creditors not allowed to debit Debtor bank account."), + SL13("MaximumNumberOfDirectDebitTransactionsExceeded", "Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent."), + SL14("MaximumDirectDebitTransactionAmountExceeded", "Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent."), + SP01("PaymentStopped", "Payment is stopped by account holder."), + SP02("PreviouslyStopped", "Previously stopped by means of a stop payment advise."), + SVNR("ServiceNotRendered", "The card payment is returned since a cash amount rendered was not correct or goods or a service was not rendered to the customer, e.g. in an e-commerce situation."), + TM01("CutOffTime", "Associated message was received after agreed processing cut-off time."), + TRAC("RemovedFromTracking", "Return following direct debit being removed from tracking process."), + UPAY("UnduePayment", "Payment is not justified."), +} +\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt @@ -0,0 +1,711 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus.iso20022 + +import tech.libeufin.common.* +import tech.libeufin.nexus.* +import java.io.InputStream +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +private val logger: Logger = LoggerFactory.getLogger("libeufin-iso20022") + +sealed interface TxNotification { + val executionTime: Instant +} + +/** ID for incoming transactions */ +data class IncomingId( + /** ISO20022 UETR */ + val uetr: UUID? = null, + /** ISO20022 TxID */ + val txId: String? = null, + /** ISO20022 AcctSvcrRef */ + val acctSvcrRef: String? = null, +) { + constructor(uetr: String, txId: String?, acctSvcrRef: String?) : this(UUID.fromString(uetr), txId, acctSvcrRef); + + fun ref(): String = uetr?.toString() ?: txId ?: acctSvcrRef!! + + override fun toString(): String = buildString { + append('(') + if (uetr != null) { + append("uetr=") + append(uetr.toString()) + } + if (txId != null) { + if (length != 1) append(" ") + append("tx=") + append(txId) + } + if (acctSvcrRef != null) { + if (length != 1) append(" ") + append("ref=") + append(acctSvcrRef) + } + append(')') + } +} + +sealed interface OutId {} + +/** ID for outgoing transactions */ +data class OutgoingId( + /** + * Unique msg ID generated by libeufin-nexus + * ISO20022 MessageId + **/ + val msgId: String? = null, + /** + * Unique end-to-end ID generated by libeufin-nexus + * ISO20022 EndToEndId or MessageId (retrocompatibility) + **/ + val endToEndId: String? = null, + /** + * Unique end-to-end ID generated by the bank + * ISO20022 AcctSvcrRef + **/ + val acctSvcrRef: String? = null, +): OutId { + fun ref(): String = endToEndId ?: acctSvcrRef ?: msgId!! + override fun toString(): String = buildString { + append('(') + if (msgId != null && msgId != endToEndId) { + append("msg=") + append(msgId.toString()) + } + if (endToEndId != null) { + if (length != 1) append(" ") + append("e2e=") + append(endToEndId) + } + if (acctSvcrRef != null) { + if (length != 1) append(" ") + append("ref=") + append(acctSvcrRef) + } + append(')') + } +} + +/** ID for outgoing batches */ +data class BatchId( + /** + * Unique msg ID generated by libeufin-nexus + * ISO20022 MessageId + **/ + val msgId: String, + /** + * Unique end-to-end ID generated by the bank + * ISO20022 AcctSvcrRef + **/ + val acctSvcrRef: String? = null, +): OutId { + fun ref(): String = msgId + override fun toString(): String = buildString { + append("(msg=") + append(msgId) + if (acctSvcrRef != null) { + if (length != 1) append(" ") + append("ref=") + append(acctSvcrRef) + } + append(')') + } +} + + +/** ISO20022 incoming payment */ +data class IncomingPayment( + val id: IncomingId, + val amount: TalerAmount, + val creditFee: TalerAmount? = null, + val subject: String?, + override val executionTime: Instant, + val debtor: IbanPayto? +): TxNotification { + override fun toString(): String = buildString { + append("IN ") + append(executionTime.fmtDate()) + append(" ") + append(amount) + if (creditFee != null) { + append("-") + append(creditFee) + } + append(" ") + append(id) + if (debtor != null) { + append(" debtor=") + append(debtor.fmt()) + } + if (subject != null) { + append(" subject='") + append(subject) + append("'") + } + } +} + +/** ISO20022 outgoing payment */ +data class OutgoingPayment( + val id: OutgoingId, + val amount: TalerAmount, + val debitFee: TalerAmount? = null, + val subject: String?, + override val executionTime: Instant, + val creditor: IbanPayto? +): TxNotification { + override fun toString(): String = buildString { + append("OUT ") + append(executionTime.fmtDate()) + append(" ") + append(amount) + if (debitFee != null) { + append("-") + append(debitFee) + } + append(" ") + append(id) + if (creditor != null) { + append(" creditor=") + append(creditor.fmt()) + } + if (subject != null) { + append(" subject='") + append(subject) + append("'") + } + } +} + +/** ISO20022 outgoing batch */ +data class OutgoingBatch( + /** ISO20022 MessageId */ + val msgId: String, + override val executionTime: Instant, +): TxNotification { + override fun toString(): String { + return "BATCH ${executionTime.fmtDate()} $msgId" + } +} + +/** ISO20022 outgoing reversal */ +data class OutgoingReversal( + /** ISO20022 EndToEndId */ + val endToEndId: String, + /** ISO20022 MessageId */ + val msgId: String? = null, + val reason: String?, + override val executionTime: Instant +): TxNotification { + override fun toString(): String { + val msgIdFmt = if (msgId == null) "" else "$msgId." + return "BOUNCE ${executionTime.fmtDate()} $msgIdFmt$endToEndId: $reason" + } +} + +private class IncompleteTx(val msg: String): Exception(msg) + +private enum class Kind { + CRDT, + DBIT +} + +/** Parse a payto */ +private fun XmlDestructor.payto(prefix: String): IbanPayto? { + return opt("RltdPties") { + val iban = opt("${prefix}Acct")?.one("Id")?.opt("IBAN")?.text() + if (iban != null) { + val name = opt(prefix) { opt("Nm")?.text() ?: opt("Pty")?.one("Nm")?.text() } + // TODO more performant option + ibanPayto(iban, name) + } else { + null + } + } +} + +/** Check if an entry status is BOOK */ +private fun XmlDestructor.isBooked(): Boolean { + // We check at the Sts or Sts/Cd level for retrocompatibility + return one("Sts") { + val status = opt("Cd")?.text() ?: text() + status == "BOOK" + } +} + +/** Parse the instruction execution date */ +private fun XmlDestructor.executionDate(): Instant { + // Value date if present else booking date + val date = opt("ValDt") ?: one("BookgDt") + val parsed = date.opt("Dt") { + date().atStartOfDay() + } ?: date.one("DtTm") { + dateTime() + } + return parsed.toInstant(ZoneOffset.UTC) +} + +/** Parse batch message ID and transaction end-to-end ID as generated by libeufin-nexus */ +private fun XmlDestructor.outgoingId(ref: String?): OutId = + opt("Refs") { + val endToEndId = opt("EndToEndId")?.text() + val msgId = opt("MsgId")?.text() + val ref = if (ref != "NOTPROVIDED") ref else null + if (msgId != null && endToEndId == null) { + // This is a batch representation + BatchId(msgId, ref) + } else if (endToEndId == "NOTPROVIDED") { + // If not set use MsgId as end-to-end ID for retrocompatibility + OutgoingId(msgId, msgId, ref) + } else { + OutgoingId(msgId, endToEndId, ref) + } + } ?: OutgoingId(acctSvcrRef = ref) + +/** Parse transaction ids as provided by bank*/ +private fun XmlDestructor.incomingId(ref: String?): IncomingId = + opt("Refs") { + val uetr = opt("UETR")?.uuid() + val txId = opt("TxId")?.text() + IncomingId(uetr, txId, ref) + } ?: IncomingId(acctSvcrRef = ref) + + +/** Parse and format transaction return reasons */ +private fun XmlDestructor.returnReason(): String = opt("RtrInf") { + val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>() + val info = map("AddtlInf") { text() }.joinToString("") + buildString { + append("${code.isoCode} '${code.description}'") + if (info.isNotEmpty()) { + append(" - '$info'") + } + } +} ?: opt("RmtInf") { + map("Ustrd") { text() }.joinToString("") +} ?: "" + +/** Parse amount */ +private fun XmlDestructor.amount() = one("Amt") { + val currency = attr("Ccy") + val amount = text() + val concat = if (amount.startsWith('.')) { + "$currency:0$amount" + } else { + "$currency:$amount" + } + TalerAmount(concat) +} + +data class ComplexAmount( + // Transaction amount + val amount: TalerAmount, + // The applied fee + private val fee: TalerAmount, +) { + /// The fees to register in database + fun fee(): TalerAmount? = if (fee.isZero()) { null } else { fee } + + /// Check that entry and tx amount are compatible and return the result + fun resolve(child: ComplexAmount): ComplexAmount { + // Most time transaction will match + if (this.amount == child.amount && this.fee == child.fee) { + return this + } + + // Or one of the level is missing the fee + if ( + (child.amount > child.fee && child.amount - child.fee == this.amount) || + this.amount - this.fee == child.amount + ) { + if (child.fee.isZero()) { + return this + } else { + return child + } + } + + // Or the conversion information are only present at the entry layer + if (child.amount.currency != this.amount.currency) { + return this + } + + throw Error("Amount mismatch, got ${this} in the entry and ${child} in the tx") + } +} + +private fun XmlDestructor.complexAmount(charges: List<ChargeRecord>): ComplexAmount? { + // Amount before charges + var amount = opt("Amt") { + val currency = attr("Ccy") + // In case of fee overflow it's possible to have a negative amount here + // We ignore this as it will be handled elsewhere correctly + val amount = text().trimStart('-') + TalerAmount("$currency:0$amount") + } ?: return null + + var fee: TalerAmount = TalerAmount.zero(amount.currency) + + for (chr in charges) { + if (chr.included) { + fee += chr.amount + if (chr.kind == Kind.DBIT) { + if (chr.bearer == ChargeBearer.DEBT) { + if (chr.amount > amount) { + // This can happen when an incoming transaction fail because of debit fee + amount = chr.amount - amount + } else { + amount -= chr.amount + } + } else if (chr.bearer == ChargeBearer.CRED) { + amount += chr.amount + } else { + throw Error("Included charge ${chr.kind} with bearer ${chr.bearer}") + } + } + } + } + + return ComplexAmount(amount, fee) +} + +/** Parse bank transaction code */ +private fun XmlDestructor.bankTransactionCode(): BankTransactionCode { + return one("BkTxCd").one("Domn") { + val domain = one("Cd").enum<ExternalBankTransactionDomainCode>() + one("Fmly") { + val family = one("Cd").enum<ExternalBankTransactionFamilyCode>() + val subFamily = one("SubFmlyCd").enum<ExternalBankTransactionSubFamilyCode>() + + BankTransactionCode(domain, family, subFamily) + } + } +} + +/** Parse optional bank transaction code */ +private fun XmlDestructor.optBankTransactionCode(): BankTransactionCode? { + return opt("BkTxCd")?.one("Domn") { + val domain = one("Cd").enum<ExternalBankTransactionDomainCode>() + one("Fmly") { + val family = one("Cd").enum<ExternalBankTransactionFamilyCode>() + val subFamily = one("SubFmlyCd").enum<ExternalBankTransactionSubFamilyCode>() + + BankTransactionCode(domain, family, subFamily) + } + } +} + +/** Parse transaction wire transfer subject */ +private fun XmlDestructor.wireTransferSubject(): String? = opt("RmtInf") { + map("Ustrd") { text() }.joinToString("").trim() +} + +/** Parse account information */ +private fun XmlDestructor.account(): Pair<String, String?> = one("Acct") { + Pair( + one("Id") { + (opt("IBAN") ?: one("Othr").one("Id")).text() + }, + opt("Ccy")?.text() + ) +} + +private data class ChargeRecord( + val amount: TalerAmount, + val kind: Kind, + val included: Boolean, + val bearer: ChargeBearer +) +private fun XmlDestructor.charges(): List<ChargeRecord> = opt("Chrgs")?.map("Rcrd") { + val amount = amount() + val kind = opt("CdtDbtInd")?.enum<Kind>() ?: Kind.CRDT + val included = opt("ChrgInclInd")?.bool() ?: true // TODO not clear in spec + val bearer = opt("Br")?.enum<ChargeBearer>() ?: ChargeBearer.SHAR + ChargeRecord(amount, kind, included, bearer) +} ?: emptyList() + +data class AccountTransactions( + val iban: String?, + val currency: String?, + val txs: List<TxNotification> +) { + companion object { + internal fun fromParts(iban: String?, currency: String?, txsInfos: List<TxInfo>): AccountTransactions { + val txs = txsInfos.mapNotNull { + try { + it.parse() + } catch (e: IncompleteTx) { + // TODO: add more info in doc or in log message? + logger.warn("skip incomplete tx: ${e.msg}") + null + } + } + return AccountTransactions(iban, currency, txs) + } + } +} + +/** Parse camt.054 or camt.053 file */ +fun parseTx(notifXml: InputStream): List<AccountTransactions> { + /* + In ISO 20022 specifications, most fields are optional and the same information + can be written several times in different places. For libeufin, we're only + interested in a subset of the available values that can be found in both camt.052, + camt.053 and camt.054. This function should not fail on legitimate files and should + simply warn when available information are insufficient. + + EBICS and ISO20022 do not provide a perfect transaction identifier. The best is the + UETR (unique end-to-end transaction reference), which is a universally unique + identifier (UUID). However, it is not supplied by all banks. TxId (TransactionIdentification) + is a unique identification as assigned by the first instructing agent. As its format + is ambiguous, its uniqueness is not guaranteed by the standard, and it is only + supposed to be unique for a “pre-agreed period”, whatever that means. These two + identifiers are optional in the standard, but have the advantage of being unique + and can be used to track a transaction between banks so we use them when available. + + It is also possible to use AccountServicerReference, which is a unique reference + assigned by the account servicing institution. They can be present at several levels + (batch level, transaction level, etc.) and are often optional. They also have the + disadvantage of being known only by the account servicing institution. They should + therefore only be used as a last resort. + */ + logger.trace("Parse transactions camt file") + val accountTxs = mutableListOf<AccountTransactions>() + + /** Common parsing logic for camt.052, camt.053 and camt.054 */ + fun XmlDestructor.parseInner() { + val (iban, currency) = account() + val txInfos = mutableListOf<TxInfo>() + val batches = each("Ntry") { + if (!isBooked()) return@each + val entryCode = bankTransactionCode() + val reversal = opt("RvslInd")?.text() == "true" + val entryKind = opt("CdtDbtInd")?.enum<Kind>(); + val entryRef = opt("AcctSvcrRef")?.text() + val bookDate = executionDate() + val entryCharges = charges() + val entryAmount = complexAmount(entryCharges)!! + + // When an entry only contain a single transactions information will sometimes only be stored at the entry level + val tmp = opt("NtryDtls")?.map("TxDtls") { this } ?: return@each + val unique = tmp.size == 1 + + for (it in tmp) {it.run { + // Check information are present and coherent + val kind = requireNotNull(opt("CdtDbtInd")?.enum<Kind>() ?: entryKind) { "WTF" } + + // Sometimes the transaction level have a more precise bank transaction code + val code = optBankTransactionCode() ?: entryCode + + // Amount + val amount = if (unique) { + // When unique the charges can be only at the entry level + val txCharges = charges() + val txAmount = complexAmount(if (txCharges.isEmpty()) entryCharges else txCharges) + // Check coherence + if (txAmount != null) entryAmount.resolve(txAmount) else entryAmount + } else { + // When many inner transaction the entry level is an aggregate of them + // We only use the transaction level information + requireNotNull(complexAmount(charges())) { "Missing tx amount" } + } + + // We can only use the entry ref as the transaction ref if there is a single transaction in the batch + val ref = opt("Refs")?.opt("AcctSvcrRef")?.text() ?: if (unique) entryRef else null + + if (code.isReversal() || reversal) { + val outgoingId = outgoingId(ref) + when (kind) { + Kind.CRDT -> { + val reason = returnReason() + txInfos.add(TxInfo.CreditReversal( + bookDate = bookDate, + id = outgoingId, + reason = reason, + code = code + )) + } + Kind.DBIT -> { + val id = incomingId(ref) + val subject = wireTransferSubject() + val debtor = payto("Dbtr") + val fee = amount.fee() + txInfos.add(TxInfo.Credit( + bookDate = bookDate, + id = id, + amount = amount.amount, + subject = subject, + debtor = debtor, + code = code, + creditFee = fee + )) + } + } + } else { + val subject = wireTransferSubject() + when (kind) { + Kind.CRDT -> { + val id = incomingId(ref) + val debtor = payto("Dbtr") + txInfos.add(TxInfo.Credit( + bookDate = bookDate, + id = id, + amount = amount.amount, + subject = subject, + debtor = debtor, + code = code, + creditFee = amount.fee() + )) + } + Kind.DBIT -> { + val outgoingId = outgoingId(ref) + val creditor = payto("Cdtr") + txInfos.add(TxInfo.Debit( + bookDate = bookDate, + id = outgoingId, + amount = amount.amount, + subject = subject, + creditor = creditor, + code = code, + debitFee = amount.fee() + )) + } + } + } + }} + } + accountTxs.add(AccountTransactions.fromParts(iban, currency, txInfos)) + } + XmlDestructor.parse(notifXml, "Document") { + // Camt.053 + opt("BkToCstmrStmt")?.each("Stmt") { parseInner() } + // Camt.052 + opt("BkToCstmrAcctRpt")?.each("Rpt") { parseInner() } + // Camt.054 + opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { parseInner() } + } + return accountTxs +} + +sealed interface TxInfo { + data class CreditReversal( + val bookDate: Instant, + val code: BankTransactionCode, + val id: OutId, + val reason: String + ): TxInfo + data class Credit( + val bookDate: Instant, + val code: BankTransactionCode, + val id: IncomingId, + val amount: TalerAmount, + val creditFee: TalerAmount?, + val subject: String?, + val debtor: IbanPayto? + ): TxInfo + data class Debit( + val bookDate: Instant, + val code: BankTransactionCode, + val id: OutId, + val amount: TalerAmount, + val debitFee: TalerAmount?, + val subject: String?, + val creditor: IbanPayto? + ): TxInfo + + fun parse(): TxNotification { + return when (this) { + is TxInfo.CreditReversal -> { + if (id !is OutgoingId || id.endToEndId == null) + throw IncompleteTx("missing unique ID for Credit reversal $id") + OutgoingReversal( + endToEndId = id.endToEndId!!, + msgId = id.msgId, + reason = reason, + executionTime = bookDate + ) + } + is TxInfo.Credit -> { + if (id.uetr == null && id.txId == null && id.acctSvcrRef == null) + throw IncompleteTx("missing unique ID for Credit $id") + IncomingPayment( + amount = amount, + creditFee = creditFee, + id = id, + debtor = debtor, + executionTime = bookDate, + subject = subject, + ) + } + is TxInfo.Debit -> { + when (id) { + is OutgoingId -> { + if (id.endToEndId == null && id.msgId == null && id.acctSvcrRef == null) { + throw IncompleteTx("missing unique ID for Debit $id") + } else { + OutgoingPayment( + id = OutgoingId( + endToEndId = id.endToEndId, + acctSvcrRef = id.acctSvcrRef, + msgId = id.msgId, + ), + amount = amount, + debitFee = debitFee, + executionTime = bookDate, + creditor = creditor, + subject = subject + ) + } + } + is BatchId -> { + OutgoingBatch( + msgId = id.msgId, + executionTime = bookDate, + ) + } + } + } + } + } +} + +data class BankTransactionCode( + val domain: ExternalBankTransactionDomainCode, + val family: ExternalBankTransactionFamilyCode, + val subFamily: ExternalBankTransactionSubFamilyCode +) { + fun isReversal(): Boolean = REVERSAL_CODE.contains(subFamily) + fun isPayment(): Boolean = domain == ExternalBankTransactionDomainCode.PMNT || subFamily == ExternalBankTransactionSubFamilyCode.PSTE + + override fun toString(): String = + "${domain.name} ${family.name} ${subFamily.name} - '${domain.description}' '${family.description}' '${subFamily.description}'" + + companion object { + private val REVERSAL_CODE = setOf( + ExternalBankTransactionSubFamilyCode.RPCR, + ExternalBankTransactionSubFamilyCode.RRTN, + ExternalBankTransactionSubFamilyCode.PSTE, + ) + } +} +\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/hac.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/hac.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain001.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain001.kt diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain002.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/pain002.kt diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt @@ -0,0 +1,94 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 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.nexus.test + +import io.ktor.client.* +import tech.libeufin.common.* +import tech.libeufin.nexus.ebics.* +import tech.libeufin.nexus.* +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +private val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") + +data class TxCheckResult( + var concurrentFetchAndFetch: Boolean = false, + var concurrentFetchAndSubmit: Boolean = false, + var concurrentSubmitAndSubmit: Boolean = false, + var idempotentClose: Boolean = false +) + +/** + * Test EBICS implementation's transactions semantic: + * - Can two fetch transactions run concurrently ? + * - Can a fetch & submit transactions run concurrently ? + * - Can two submit transactions run concurrently ? + * - Is closing a submit transaction idempotent + */ +suspend fun txCheck( + client: HttpClient, + cfg: NexusEbicsConfig, + clientKeys: ClientPrivateKeysFile, + bankKeys: BankPublicKeysFile, + fetchOrder: EbicsOrder, + submitOrder: EbicsOrder +): TxCheckResult { + val result = TxCheckResult() + val fetch = EbicsBTS(cfg, bankKeys, clientKeys, fetchOrder) + val submit = EbicsBTS(cfg, bankKeys, clientKeys, submitOrder) + val ebicsLogger = EbicsLogger(null).tx("test").step("step") + + suspend fun EbicsBTS.close(id: String, phase: String, ebicsLogger: StepLogger) { + val xml = downloadReceipt(id, false) + postBTS(client, xml, phase, ebicsLogger) + } + + val firstTxId = fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init first fetch", ebicsLogger) + .transactionID!! + try { + try { + val id = fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init second fetch", ebicsLogger).transactionID!! + result.concurrentFetchAndFetch = true + fetch.close(id, "Init second fetch", ebicsLogger) + } catch (e: EbicsError.Code) {} + + var paylod = prepareUploadPayload(cfg, clientKeys, bankKeys, ByteArray(2000000).rand()) + try { + val submitId = submit.postBTS(client, submit.uploadInitialization(paylod), "Init first submit", ebicsLogger). transactionID!! + result.concurrentFetchAndSubmit = true + submit.postBTS(client, submit.uploadTransfer(submitId, paylod, 1), "Submit first upload", ebicsLogger) + try { + submit.postBTS(client, submit.uploadInitialization(paylod), "Init second submit", ebicsLogger) + result.concurrentSubmitAndSubmit = true + } catch (e: EbicsError.Code) {} + } catch (e: EbicsError.Code) {} + } finally { + fetch.close(firstTxId, "Close first fetch", ebicsLogger) + } + + try { + fetch.close(firstTxId, "Close first fetch a second time", ebicsLogger) + result.idempotentClose = true + } catch (e: Exception) { + logger.debug { e.fmt() } + } + + return result +} +\ No newline at end of file diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/xml.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/xml.kt @@ -0,0 +1,368 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2020-2025 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.nexus + +import tech.libeufin.common.decodeBase64 +import org.w3c.dom.Document +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import org.w3c.dom.Element +import org.xml.sax.InputSource +import java.io.InputStream +import java.io.StringWriter +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.security.PrivateKey +import java.security.PublicKey +import javax.xml.XMLConstants +import javax.xml.crypto.* +import javax.xml.crypto.dom.DOMURIReference +import javax.xml.crypto.dsig.* +import javax.xml.crypto.dsig.dom.DOMSignContext +import javax.xml.crypto.dsig.dom.DOMValidateContext +import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec +import javax.xml.crypto.dsig.spec.TransformParameterSpec +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import javax.xml.stream.XMLOutputFactory +import javax.xml.stream.XMLStreamWriter +import javax.xml.xpath.XPath +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory + +interface XmlBuilder { + fun el(path: String, lambda: XmlBuilder.() -> Unit = {}) + fun el(path: String, content: String) { + el(path) { + text(content) + } + } + fun attr(namespace: String, name: String, value: String) + fun attr(name: String, value: String) + fun text(content: String) + + companion object { + fun toBytes(root: String, f: XmlBuilder.() -> Unit): ByteArray { + val factory = XMLOutputFactory.newFactory() + val stream = StringWriter() + val writer = factory.createXMLStreamWriter(stream) + /** + * NOTE: commenting out because it wasn't obvious how to output the + * "standalone = 'yes' directive". Manual forge was therefore preferred. + */ + stream.write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>") + XmlStreamBuilder(writer).el(root) { + this.f() + } + writer.writeEndDocument() + return stream.buffer.toString().toByteArray() + } + + fun toDom(root: String, schema: String?, f: XmlBuilder.() -> Unit): Document { + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = true + val builder = factory.newDocumentBuilder() + val doc = builder.newDocument() + doc.xmlVersion = "1.0" + doc.xmlStandalone = true + val root = doc.createElementNS(schema, root) + doc.appendChild(root) + XmlDOMBuilder(doc, schema, root).f() + doc.normalize() + return doc + } + } +} + +private class XmlStreamBuilder(private val w: XMLStreamWriter): XmlBuilder { + override fun el(path: String, lambda: XmlBuilder.() -> Unit) { + path.splitToSequence('/').forEach { + w.writeStartElement(it) + } + lambda() + path.splitToSequence('/').forEach { + w.writeEndElement() + } + } + + override fun attr(namespace: String, name: String, value: String) { + w.writeAttribute(namespace, name, value) + } + + override fun attr(name: String, value: String) { + w.writeAttribute(name, value) + } + + override fun text(content: String) { + w.writeCharacters(content) + } +} + +private class XmlDOMBuilder(private val doc: Document, private val schema: String?, private var node: Element): XmlBuilder { + override fun el(path: String, lambda: XmlBuilder.() -> Unit) { + val current = node + path.splitToSequence('/').forEach { + val new = doc.createElementNS(schema, it) + node.appendChild(new) + node = new + } + lambda() + node = current + } + + override fun attr(namespace: String, name: String, value: String) { + node.setAttributeNS(namespace, name, value) + } + + override fun attr(name: String, value: String) { + node.setAttribute(name, value) + } + + override fun text(content: String) { + node.appendChild(doc.createTextNode(content)) + } +} + +private fun Element.childrenByTag(tag: String, signed: Boolean): Sequence<Element> = sequence { + for (i in 0..childNodes.length) { + val el = childNodes.item(i) + if (el is Element + && el.localName == tag + && (!signed || el.getAttribute("authenticate") == "true")) { + yield(el) + } + } +} + +class XmlDestructor internal constructor(private val el: Element) { + fun each(path: String, signed: Boolean = false, f: XmlDestructor.() -> Unit) { + el.childrenByTag(path, signed).forEach { + f(XmlDestructor(it)) + } + } + + fun <T> map(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): List<T> { + return el.childrenByTag(path, signed).map { + f(XmlDestructor(it)) + }.toList() + } + + fun one(tag: String, signed: Boolean = false): XmlDestructor { + val children = el.childrenByTag(tag, signed).iterator() + if (!children.hasNext()) { + throw Exception("expected unique '${el.tagName}.$tag', got none") + } + val child = children.next() + if (children.hasNext()) { + throw Exception("expected unique '${el.tagName}.$tag', got ${children.asSequence().count() + 1}") + } + return XmlDestructor(child) + } + fun opt(tag: String, signed: Boolean = false): XmlDestructor? { + val children = el.childrenByTag(tag, signed).iterator() + if (!children.hasNext()) { + return null + } + val child = children.next() + if (children.hasNext()) { + throw Exception("expected optional '${el.tagName}.$tag', got ${children.asSequence().count() + 1}") + } + return XmlDestructor(child) + } + + fun <T> one(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T = f(one(path, signed)) + fun <T> opt(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T? = opt(path, signed)?.run(f) + + fun uuid(): UUID = UUID.fromString(text()) + fun text(): String = el.textContent + fun base64(): ByteArray = el.textContent.decodeBase64() + fun bool(): Boolean = el.textContent.toBoolean() + fun float(): Float = el.textContent.toFloat() + fun date(): LocalDate = LocalDate.parse(text(), DateTimeFormatter.ISO_DATE) + fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME) + inline fun <reified T : Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text()) + + fun optAttr(index: String): String? { + val attr = el.getAttribute(index) + if (attr == "") { + return null + } else { + return attr + } + } + fun attr(index: String): String { + val attr = optAttr(index) + if (attr == null) { + throw Exception("missing attribute '$index' at '${el.tagName}'") + } + return attr + } + + + companion object { + fun <T> parse(xml: String, root: String, f: XmlDestructor.() -> T): T { + val inputStream = ByteArrayInputStream(xml.toByteArray()) + return parse(inputStream, root, f) + } + + fun <T> parse(xml: InputStream, root: String, f: XmlDestructor.() -> T): T { + val doc = XMLUtil.parseIntoDom(xml) + return parse(doc, root, f) + } + + fun <T> parse(doc: Document, root: String, f: XmlDestructor.() -> T): T { + if (doc.documentElement.localName != root) { + throw Exception("expected root '$root' got '${doc.documentElement.localName}'") + } + val destr = XmlDestructor(doc.documentElement) + return f(destr) + } + } +} + + +/** + * This URI dereferencer allows handling the resource reference used for + * XML signatures in EBICS. + */ +private class EbicsSigUriDereferencer : URIDereferencer { + override fun dereference(myRef: URIReference?, myCtx: XMLCryptoContext?): Data { + if (myRef !is DOMURIReference) + throw Exception("invalid type") + if (myRef.uri != "#xpointer(//*[@authenticate='true'])") + throw Exception("invalid EBICS XML signature URI: '${myRef.uri}'") + val xp: XPath = XPathFactory.newInstance().newXPath() + val nodeSet = xp.compile("//*[@authenticate='true']/descendant-or-self::node()").evaluate( + myRef.here.ownerDocument, XPathConstants.NODESET + ) + if (nodeSet !is NodeList) + throw Exception("invalid type") + if (nodeSet.length <= 0) { + throw Exception("no nodes to sign") + } + val nodeList = ArrayList<Node>() + for (i in 0 until nodeSet.length) { + val node = nodeSet.item(i) + nodeList.add(node) + } + return NodeSetData { nodeList.iterator() } + } +} + +/** + * Helpers for dealing with XML in EBICS. + */ +object XMLUtil { + fun convertDomToBytes(document: Document): ByteArray { + val w = ByteArrayOutputStream() + val transformer = TransformerFactory.newInstance().newTransformer() + transformer.setOutputProperty(OutputKeys.STANDALONE, "yes") + transformer.transform(DOMSource(document), StreamResult(w)) + return w.toByteArray() + } + + /** Parse [xml] into a XML DOM */ + fun parseIntoDom(xml: InputStream): Document { + val factory = DocumentBuilderFactory.newInstance().apply { + // Enable secure processing + setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) + // Disable all external access + setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "") + setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") + isNamespaceAware = true + } + val builder = factory.newDocumentBuilder() + return xml.use { + builder.parse(InputSource(it)) + } + } + + /** Sign an EBICS document with the authentication and identity signature */ + fun signEbicsDocument( + doc: Document, + signingPriv: PrivateKey + ) { + val authSigNode = XPathFactory.newInstance().newXPath() + .evaluate("/*[1]/*[local-name()='AuthSignature']", doc, XPathConstants.NODE) + if (authSigNode !is Node) + throw java.lang.Exception("sign: no AuthSignature") + val fac = XMLSignatureFactory.getInstance("DOM") + val c14n = fac.newTransform(CanonicalizationMethod.INCLUSIVE, null as TransformParameterSpec?) + val ref: Reference = + fac.newReference( + "#xpointer(//*[@authenticate='true'])", + fac.newDigestMethod(DigestMethod.SHA256, null), + listOf(c14n), + null, + null + ) + val canon: CanonicalizationMethod = + fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, null as C14NMethodParameterSpec?) + val signatureMethod = fac.newSignatureMethod("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", null) + val si: SignedInfo = fac.newSignedInfo(canon, signatureMethod, listOf(ref)) + val sig: XMLSignature = fac.newXMLSignature(si, null) + val dsc = DOMSignContext(signingPriv, authSigNode) + dsc.defaultNamespacePrefix = "ds" + dsc.uriDereferencer = EbicsSigUriDereferencer() + dsc.setProperty("javax.xml.crypto.dsig.cacheReference", true) + sig.sign(dsc) + val innerSig = authSigNode.firstChild + while (innerSig.hasChildNodes()) { + authSigNode.appendChild(innerSig.firstChild) + } + authSigNode.removeChild(innerSig) + } + + /** Check an EBICS document signature */ + fun verifyEbicsDocument( + doc: Document, + signingPub: PublicKey + ) { + // Find SignedInfo + val sigInfos = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "SignedInfo"); + if (sigInfos.length == 0) { + throw Exception("missing SignedInfo") + } else if (sigInfos.length != 1) { + throw Exception("many SignedInfo") + } + val sigInfo = sigInfos.item(0) + + // Rename AuthSignature + val authSig = sigInfo.parentNode + doc.renameNode(authSig, XMLSignature.XMLNS, "${sigInfo.prefix}:Signature") + + // Check signature + val fac = XMLSignatureFactory.getInstance("DOM") + val dvc = DOMValidateContext(signingPub, authSig) + dvc.setProperty("javax.xml.crypto.dsig.cacheReference", true) + dvc.uriDereferencer = EbicsSigUriDereferencer() + val sig = fac.unmarshalXMLSignature(dvc) + if (!sig.validate(dvc)) { + throw Exception("bank signature did not verify") + } + } +} +\ No newline at end of file diff --git a/nexus/src/test/kotlin/CliTest.kt b/libeufin-nexus/src/test/kotlin/CliTest.kt diff --git a/nexus/src/test/kotlin/DatabaseTest.kt b/libeufin-nexus/src/test/kotlin/DatabaseTest.kt diff --git a/nexus/src/test/kotlin/EbicsTest.kt b/libeufin-nexus/src/test/kotlin/EbicsTest.kt diff --git a/nexus/src/test/kotlin/Iso20022Test.kt b/libeufin-nexus/src/test/kotlin/Iso20022Test.kt diff --git a/nexus/src/test/kotlin/Keys.kt b/libeufin-nexus/src/test/kotlin/Keys.kt diff --git a/nexus/src/test/kotlin/MySerializers.kt b/libeufin-nexus/src/test/kotlin/MySerializers.kt diff --git a/nexus/src/test/kotlin/ObservabilityTest.kt b/libeufin-nexus/src/test/kotlin/ObservabilityTest.kt diff --git a/nexus/src/test/kotlin/RegistrationTest.kt b/libeufin-nexus/src/test/kotlin/RegistrationTest.kt diff --git a/nexus/src/test/kotlin/RevenueApiTest.kt b/libeufin-nexus/src/test/kotlin/RevenueApiTest.kt diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt diff --git a/nexus/src/test/kotlin/WsTest.kt b/libeufin-nexus/src/test/kotlin/WsTest.kt diff --git a/nexus/src/test/kotlin/XmlCombinatorsTest.kt b/libeufin-nexus/src/test/kotlin/XmlCombinatorsTest.kt diff --git a/nexus/src/test/kotlin/XmlUtilTest.kt b/libeufin-nexus/src/test/kotlin/XmlUtilTest.kt diff --git a/nexus/src/test/kotlin/bench.kt b/libeufin-nexus/src/test/kotlin/bench.kt diff --git a/nexus/src/test/kotlin/helpers.kt b/libeufin-nexus/src/test/kotlin/helpers.kt diff --git a/nexus/src/test/kotlin/routines.kt b/libeufin-nexus/src/test/kotlin/routines.kt diff --git a/nexus/src/test/resources/signature1/doc.xml b/libeufin-nexus/src/test/resources/signature1/doc.xml diff --git a/nexus/src/test/resources/signature1/public_key.txt b/libeufin-nexus/src/test/resources/signature1/public_key.txt diff --git a/nexus/build.gradle b/nexus/build.gradle @@ -1,75 +0,0 @@ -plugins { - id("kotlin") - id("application") - id("com.gradleup.shadow") version "$shadow_version" - id("org.jetbrains.kotlin.plugin.serialization") version "$kotlin_version" -} - -version = rootProject.version - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -compileKotlin.kotlinOptions.jvmTarget = "17" -compileTestKotlin.kotlinOptions.jvmTarget = "17" - -sourceSets.main.java.srcDirs = ["src/main/kotlin"] - -dependencies { - // Core language libraries - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") - - implementation(project(":common")) - - // Metrics - implementation("io.prometheus:prometheus-metrics-core:$prometheus_version") - implementation("io.prometheus:prometheus-metrics-instrumentation-jvm:$prometheus_version") - implementation("io.prometheus:prometheus-metrics-exposition-formats:$prometheus_version") - - // Command line parsing - implementation("com.github.ajalt.clikt:clikt:$clikt_version") - implementation("org.postgresql:postgresql:$postgres_version") - // Ktor client library - implementation("io.ktor:ktor-server-core:$ktor_version") - implementation("io.ktor:ktor-client-cio:$ktor_version") - implementation("io.ktor:ktor-client-mock:$ktor_version") - implementation("io.ktor:ktor-client-websockets:$ktor_version") - - // PDF generation - implementation("com.itextpdf:itext-core:9.3.0") - - // UNIX domain sockets support (used to connect to PostgreSQL) - implementation("com.kohlschutter.junixsocket:junixsocket-core:$junixsocket_version") - - // Serialization - implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") - - // Unit testing - testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") - testImplementation("io.ktor:ktor-server-test-host:$ktor_version") - testImplementation("io.ktor:ktor-server-cio:$ktor_version") -} - -application { - mainClass = "tech.libeufin.nexus.MainKt" - applicationName = "libeufin-nexus" -} - -shadowJar { - minimize { - // Kotlin serialization - exclude(dependency("io.ktor:ktor-serialization-kotlinx-json:.*")) - // Postgres unix socket driver - exclude(dependency("com.kohlschutter.junixsocket:junixsocket-core:.*")) - // CIO engine - exclude(dependency("io.ktor:ktor-client-cio:.*")) - // Crypto - exclude(dependency("org.bouncycastle:.*")) - // CLI - exclude(dependency("com.github.ajalt.mordant:mordant:.*")) - // PDF - exclude(dependency("com.itextpdf:itext-core:.*")) - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -1,216 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus - -import tech.libeufin.common.* -import tech.libeufin.common.db.DatabaseConfig -import tech.libeufin.nexus.db.Database -import tech.libeufin.nexus.ebics.Dialect -import java.nio.file.Path -import java.time.Instant - -val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-nexus", "libeufin-nexus") - -data class NexusIngestConfig( - val accountType: AccountType, - val ignoreTransactionsBefore: Instant, - val ignoreBouncesBefore: Instant, - val restrictionPaytoRegex: Regex?, - val bounceDeduceFee: Boolean, - val bounceFee: TalerAmount -) { - companion object { - fun default(accountType: AccountType, currency: String = "KUDOS") - = NexusIngestConfig(accountType, Instant.MIN, Instant.MIN, null, false, TalerAmount.zero(currency)) - } -} - -class NexusFetchConfig(config: TalerConfig, currency: String) { - private val section = config.section("nexus-fetch") - val frequency = section.duration("frequency").require() - val frequencyRaw = section.string("frequency").require() - val checkpointTime = section.time("checkpoint_time_of_day").require() - val ignoreTransactionsBefore = section.date("ignore_transactions_before").default(Instant.MIN) - val ignoreBouncesBefore = section.date("ignore_bounces_before").default(Instant.MIN) - val restrictionPaytoRegex = section.regex("restriction_payto_regex").orNull() - val bounceDeduceFee = section.boolean("bounce_deduce_fee").default(false) - val bounceFee = section.amount("bounce_fee", currency).default(TalerAmount.zero(currency)) -} - -class NexusSubmitConfig(config: TalerConfig) { - private val section = config.section("nexus-submit") - val frequency = section.duration("frequency").require() - val frequencyRaw = section.string("frequency").require() - val requireAck = section.boolean("manual_ack").default(false) -} - -class NexusSetupConfig(config: TalerConfig) { - private val section = config.section("nexus-setup") - val bankAuthPubKey = section.hex("bank_authentication_pub_key_hash").orNull() - val bankEncPubKey = section.hex("bank_encryption_pub_key_hash").orNull() -} - -class NexusHostConfig(sect: TalerConfigSection) { - /** The bank base URL */ - val baseUrl = sect.string("host_base_url").require() - /** The bank EBICS host ID */ - val ebicsHostId = sect.string("host_id").require() - /** EBICS user ID */ - val ebicsUserId = sect.string("user_id").require() - /** EBICS partner ID */ - val ebicsPartnerId = sect.string("partner_id").require() -} - -class NexusEbicsConfig( - sect: TalerConfigSection, -) { - val host by lazy { NexusHostConfig(sect) } - /** Bank account metadata */ - val account = IbanAccountMetadata( - iban = sect.string("iban").require(), - bic = sect.string("bic").require(), - name = sect.string("name").require() - ) - /** Bank account payto */ - val payto = IbanPayto.build(account.iban, account.bic, account.name) - - val dialect = sect.map("bank_dialect", "bank dialect", mapOf( - "postfinance" to Dialect.postfinance, - "gls" to Dialect.gls, - "maerki_baumann" to Dialect.maerki_baumann, - "valiant" to Dialect.valiant, - )).require() - - /** Path where we store the bank public keys */ - val bankPublicKeysPath = sect.path("bank_public_keys_file").require() - /** Path where we store our private keys */ - val clientPrivateKeysPath = sect.path("client_private_keys_file").require() -} - -class ApiConfig(section: TalerConfigSection) { - val authMethod = section.requireAuthMethod() -} - -/** Configuration for libeufin-nexus */ -class NexusConfig internal constructor (val cfg: TalerConfig) { - private val sect = cfg.section("nexus-ebics") - - val dbCfg by lazy { cfg.dbConfig() } - val serverCfg by lazy { - cfg.loadServerConfig("nexus-httpd") - } - - /** The bank's currency */ - val currency = sect.string("currency").require() - - val accountType = sect.map("account_type", "account type", mapOf( - "normal" to AccountType.normal, - "exchange" to AccountType.exchange - )).require() - - val fetch by lazy { NexusFetchConfig(cfg, currency) } - val submit by lazy { NexusSubmitConfig(cfg) } - val ebics by lazy { NexusEbicsConfig(sect) } - val setup by lazy { NexusSetupConfig(cfg) } - - val ingest get() = NexusIngestConfig( - accountType, - fetch.ignoreTransactionsBefore, - fetch.ignoreBouncesBefore, - fetch.restrictionPaytoRegex, - fetch.bounceDeduceFee, - fetch.bounceFee - ) - - val wireGatewayApiCfg = cfg.section("nexus-httpd-wire-gateway-api").apiConf() - val revenueApiCfg = cfg.section("nexus-httpd-revenue-api").apiConf() - val observabilityApiCfg = cfg.section("nexus-httpd-observability-api").apiConf() -} - -fun NexusConfig.checkCurrency(amount: TalerAmount) { - if (amount.currency != currency) throw badRequest( - "Wrong currency: expected $currency got ${amount.currency}", - TalerErrorCode.GENERIC_CURRENCY_MISMATCH - ) -} - -private fun TalerConfigSection.requireAuthMethod(): AuthMethod { - return mapLambda("auth_method", "auth method", mapOf( - "none" to { AuthMethod.None }, - "bearer-token" to { - logger.warn("Deprecated auth method option 'auth_method' used deprecated value 'bearer-token'") - val token = string("auth_bearer_token").require() - AuthMethod.Bearer(token) - }, - "bearer" to { - val token = string("token").require() - AuthMethod.Bearer(token) - }, - "basic" to { - val username = string("username").require() - val password = string("password").require() - AuthMethod.Basic("$username:$password".encodeBase64()) - } - )).require() -} - -private fun TalerConfigSection.apiConf(): ApiConfig? { - val enabled = boolean("enabled").require() - return if (enabled) { - return ApiConfig(this) - } else { - null - } -} - -sealed interface AuthMethod { - data object None: AuthMethod - data class Bearer(val token: String): AuthMethod - data class Basic(val token: String): AuthMethod -} - -enum class AccountType { - normal, - exchange -} - -private fun TalerConfig.dbConfig(): DatabaseConfig { - val sect = section("libeufin-nexusdb-postgres") - val configOption = sect.string("config") - return DatabaseConfig( - dbConnStr = configOption.orNull() ?: section("nexus-postgres").string("config").orNull() ?: configOption.require(), - sqlDir = sect.path("sql_dir").require() - ) -} - -/** Load nexus config at [configPath] */ -fun nexusConfig(configPath: Path?): NexusConfig { - val config = NEXUS_CONFIG_SOURCE.fromFile(configPath) - return NexusConfig(config) -} - -/** Load nexus db config at [configPath] */ -fun dbConfig(configPath: Path?): DatabaseConfig = - NEXUS_CONFIG_SOURCE.fromFile(configPath).dbConfig() - -/** Run [lambda] with access to a database conn pool */ -suspend fun NexusConfig.withDb(lambda: suspend (Database, NexusConfig) -> Unit) { - Database(dbCfg, currency).use { lambda(it, this) } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsLogger.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsLogger.kt @@ -1,145 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 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.nexus - -import tech.libeufin.common.* -import tech.libeufin.nexus.ebics.EbicsOrder -import java.io.* -import java.nio.file.* -import java.time.* -import java.time.format.DateTimeFormatter -import kotlin.io.* -import kotlin.io.path.* -import io.ktor.client.statement.* - -/** Log EBICS transactions steps and payload if [path] is not null */ -class EbicsLogger(private val dir: Path?) { - - init { - if (dir != null) { - try { - // Create logging directory if missing - dir.createDirectories() - } catch (e: Exception) { - throw Exception("Failed to init EBICS debug logging directory", e) - } - logger.info("Logging to '$dir'") - } - } - - /** Create a new [name] EBICS transaction logger */ - fun tx(name: String): TxLogger { - if (dir == null) return TxLogger(null) - val utcDateTime = Instant.now().atOffset(ZoneOffset.UTC) - val txDir = dir - // yyyy-MM-dd per day directory - .resolve(utcDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE)) - // HH:mm:ss.SSS-name per transaction directory - .resolve("${utcDateTime.format(TIME_WITH_MS)}-$name") - txDir.createDirectories() - return TxLogger(txDir) - } - - /** Create a new [order] EBICS transaction logger */ - fun tx(order: EbicsOrder): TxLogger { - if (dir == null) return TxLogger(null) - return tx(order.description()) - } - - companion object { - private val TIME_WITH_MS = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") - } -} - -/** Log EBICS transaction steps and payload */ -class TxLogger internal constructor( - private val dir: Path? -) { - /** Create a new [name] EBICS transaction step logger*/ - fun step(name: String? = null) = StepLogger(dir, name) - - /** Log a [stream] EBICS transaction payload of [type] */ - fun payload(stream: InputStream, type: String = "xml"): InputStream { - if (dir == null) return stream - return payload(stream.readBytes(), type).inputStream() - } - - /** Log a [content] EBICS transaction payload of [type] */ - fun payload(content: ByteArray, type: String = "xml"): ByteArray { - if (dir == null) return content - val type = type.lowercase() - if (type == "zip") { - val payloadDir = dir.resolve("payload") - payloadDir.createDirectory() - content.inputStream().unzipEach { fileName, xmlContent -> - xmlContent.use { - Files.copy(it, payloadDir.resolve(fileName)) - } - } - } else { - dir.resolve("payload.$type").writeBytes(content, StandardOpenOption.CREATE_NEW) - } - return content - } -} - -/** Log EBICS transaction protocol step */ -class StepLogger internal constructor( - private val dir: Path?, - private val name: String? -) { - private val prefix = if (name != null) "$name-" else "" - - /** Log a protocol step [request] */ - fun logRequest(request: ByteArray) { - if (dir != null) { - dir.resolve("${prefix}request.xml") - .writeBytes(request, StandardOpenOption.CREATE_NEW) - } - } - - /** Log a protocol step failure */ - suspend fun logFailure(res: HttpResponse) { - if (dir != null) { - // TODO reduce allocation - // TODO silent io error - val bytes = buildString { - append("${res.version} ${res.status}\n") - for ((k, vs) in res.headers.entries()) { - for (v in vs) { - append("${k}: ${v}\n") - } - } - append('\n') - }.toByteArray() + res.readBytes() - dir.resolve("${prefix}failure") - .writeBytes(bytes, StandardOpenOption.CREATE_NEW) - } - } - - /** Log a protocol step [response] */ - fun logResponse(response: InputStream): InputStream { - if (dir == null) return response - val bytes = response.readBytes() - dir.resolve("${prefix}response.xml") - .writeBytes(bytes, StandardOpenOption.CREATE_NEW) - return bytes.inputStream() - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -1,68 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2023-2025 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/> - */ - -/** - * This file collects all the CLI subcommands and runs - * them. The actual implementation of each subcommand is - * kept in their respective files. - */ -package tech.libeufin.nexus - -import io.ktor.server.application.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import kotlinx.serialization.Serializable -import kotlinx.serialization.Contextual -import tech.libeufin.common.api.talerApi -import tech.libeufin.common.setupSecurityProperties -import tech.libeufin.nexus.api.revenueApi -import tech.libeufin.nexus.api.wireGatewayApi -import tech.libeufin.nexus.api.observabilityApi -import tech.libeufin.nexus.cli.LibeufinNexus -import tech.libeufin.nexus.db.Database -import com.github.ajalt.clikt.core.main -import java.time.Instant - -internal val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") - -/** Triple identifying one IBAN bank account */ -data class IbanAccountMetadata( - val iban: String, - val bic: String?, - val name: String -) - -fun Application.nexusApi(db: Database, cfg: NexusConfig) = talerApi(LoggerFactory.getLogger("libeufin-nexus-api")) { - wireGatewayApi(db, cfg) - revenueApi(db, cfg) - observabilityApi(db, cfg) -} - -fun main(args: Array<String>) { - setupSecurityProperties() - LibeufinNexus().main(args) -} - -@Serializable -data class TaskStatus( - @Contextual - val last_successfull: Instant? = null, - @Contextual - val last_trial: Instant? = null -) -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/XMLUtil.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/XMLUtil.kt @@ -1,167 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus - -import org.w3c.dom.Document -import org.w3c.dom.Node -import org.w3c.dom.NodeList -import org.xml.sax.InputSource -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.security.PrivateKey -import java.security.PublicKey -import javax.xml.XMLConstants -import javax.xml.crypto.* -import javax.xml.crypto.dom.DOMURIReference -import javax.xml.crypto.dsig.* -import javax.xml.crypto.dsig.dom.DOMSignContext -import javax.xml.crypto.dsig.dom.DOMValidateContext -import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec -import javax.xml.crypto.dsig.spec.TransformParameterSpec -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.transform.OutputKeys -import javax.xml.transform.TransformerFactory -import javax.xml.transform.dom.DOMSource -import javax.xml.transform.stream.StreamResult -import javax.xml.xpath.XPath -import javax.xml.xpath.XPathConstants -import javax.xml.xpath.XPathFactory - -/** - * This URI dereferencer allows handling the resource reference used for - * XML signatures in EBICS. - */ -private class EbicsSigUriDereferencer : URIDereferencer { - override fun dereference(myRef: URIReference?, myCtx: XMLCryptoContext?): Data { - if (myRef !is DOMURIReference) - throw Exception("invalid type") - if (myRef.uri != "#xpointer(//*[@authenticate='true'])") - throw Exception("invalid EBICS XML signature URI: '${myRef.uri}'") - val xp: XPath = XPathFactory.newInstance().newXPath() - val nodeSet = xp.compile("//*[@authenticate='true']/descendant-or-self::node()").evaluate( - myRef.here.ownerDocument, XPathConstants.NODESET - ) - if (nodeSet !is NodeList) - throw Exception("invalid type") - if (nodeSet.length <= 0) { - throw Exception("no nodes to sign") - } - val nodeList = ArrayList<Node>() - for (i in 0 until nodeSet.length) { - val node = nodeSet.item(i) - nodeList.add(node) - } - return NodeSetData { nodeList.iterator() } - } -} - -/** - * Helpers for dealing with XML in EBICS. - */ -object XMLUtil { - fun convertDomToBytes(document: Document): ByteArray { - val w = ByteArrayOutputStream() - val transformer = TransformerFactory.newInstance().newTransformer() - transformer.setOutputProperty(OutputKeys.STANDALONE, "yes") - transformer.transform(DOMSource(document), StreamResult(w)) - return w.toByteArray() - } - - /** Parse [xml] into a XML DOM */ - fun parseIntoDom(xml: InputStream): Document { - val factory = DocumentBuilderFactory.newInstance().apply { - // Enable secure processing - setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) - // Disable all external access - setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "") - setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") - isNamespaceAware = true - } - val builder = factory.newDocumentBuilder() - return xml.use { - builder.parse(InputSource(it)) - } - } - - /** Sign an EBICS document with the authentication and identity signature */ - fun signEbicsDocument( - doc: Document, - signingPriv: PrivateKey - ) { - val authSigNode = XPathFactory.newInstance().newXPath() - .evaluate("/*[1]/*[local-name()='AuthSignature']", doc, XPathConstants.NODE) - if (authSigNode !is Node) - throw java.lang.Exception("sign: no AuthSignature") - val fac = XMLSignatureFactory.getInstance("DOM") - val c14n = fac.newTransform(CanonicalizationMethod.INCLUSIVE, null as TransformParameterSpec?) - val ref: Reference = - fac.newReference( - "#xpointer(//*[@authenticate='true'])", - fac.newDigestMethod(DigestMethod.SHA256, null), - listOf(c14n), - null, - null - ) - val canon: CanonicalizationMethod = - fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, null as C14NMethodParameterSpec?) - val signatureMethod = fac.newSignatureMethod("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", null) - val si: SignedInfo = fac.newSignedInfo(canon, signatureMethod, listOf(ref)) - val sig: XMLSignature = fac.newXMLSignature(si, null) - val dsc = DOMSignContext(signingPriv, authSigNode) - dsc.defaultNamespacePrefix = "ds" - dsc.uriDereferencer = EbicsSigUriDereferencer() - dsc.setProperty("javax.xml.crypto.dsig.cacheReference", true) - sig.sign(dsc) - val innerSig = authSigNode.firstChild - while (innerSig.hasChildNodes()) { - authSigNode.appendChild(innerSig.firstChild) - } - authSigNode.removeChild(innerSig) - } - - /** Check an EBICS document signature */ - fun verifyEbicsDocument( - doc: Document, - signingPub: PublicKey - ) { - // Find SignedInfo - val sigInfos = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "SignedInfo"); - if (sigInfos.length == 0) { - throw Exception("missing SignedInfo") - } else if (sigInfos.length != 1) { - throw Exception("many SignedInfo") - } - val sigInfo = sigInfos.item(0) - - // Rename AuthSignature - val authSig = sigInfo.parentNode - doc.renameNode(authSig, XMLSignature.XMLNS, "${sigInfo.prefix}:Signature") - - // Check signature - val fac = XMLSignatureFactory.getInstance("DOM") - val dvc = DOMValidateContext(signingPub, authSig) - dvc.setProperty("javax.xml.crypto.dsig.cacheReference", true) - dvc.uriDereferencer = EbicsSigUriDereferencer() - val sig = fac.unmarshalXMLSignature(dvc) - if (!sig.validate(dvc)) { - throw Exception("bank signature did not verify") - } - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt @@ -1,226 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2020-2025 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.nexus - -import tech.libeufin.common.decodeBase64 -import org.w3c.dom.Document -import org.w3c.dom.Element -import java.io.InputStream -import java.io.StringWriter -import java.io.ByteArrayInputStream -import java.util.UUID -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.stream.XMLOutputFactory -import javax.xml.stream.XMLStreamWriter - -interface XmlBuilder { - fun el(path: String, lambda: XmlBuilder.() -> Unit = {}) - fun el(path: String, content: String) { - el(path) { - text(content) - } - } - fun attr(namespace: String, name: String, value: String) - fun attr(name: String, value: String) - fun text(content: String) - - companion object { - fun toBytes(root: String, f: XmlBuilder.() -> Unit): ByteArray { - val factory = XMLOutputFactory.newFactory() - val stream = StringWriter() - val writer = factory.createXMLStreamWriter(stream) - /** - * NOTE: commenting out because it wasn't obvious how to output the - * "standalone = 'yes' directive". Manual forge was therefore preferred. - */ - stream.write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>") - XmlStreamBuilder(writer).el(root) { - this.f() - } - writer.writeEndDocument() - return stream.buffer.toString().toByteArray() - } - - fun toDom(root: String, schema: String?, f: XmlBuilder.() -> Unit): Document { - val factory = DocumentBuilderFactory.newInstance() - factory.isNamespaceAware = true - val builder = factory.newDocumentBuilder() - val doc = builder.newDocument() - doc.xmlVersion = "1.0" - doc.xmlStandalone = true - val root = doc.createElementNS(schema, root) - doc.appendChild(root) - XmlDOMBuilder(doc, schema, root).f() - doc.normalize() - return doc - } - } -} - -private class XmlStreamBuilder(private val w: XMLStreamWriter): XmlBuilder { - override fun el(path: String, lambda: XmlBuilder.() -> Unit) { - path.splitToSequence('/').forEach { - w.writeStartElement(it) - } - lambda() - path.splitToSequence('/').forEach { - w.writeEndElement() - } - } - - override fun attr(namespace: String, name: String, value: String) { - w.writeAttribute(namespace, name, value) - } - - override fun attr(name: String, value: String) { - w.writeAttribute(name, value) - } - - override fun text(content: String) { - w.writeCharacters(content) - } -} - -private class XmlDOMBuilder(private val doc: Document, private val schema: String?, private var node: Element): XmlBuilder { - override fun el(path: String, lambda: XmlBuilder.() -> Unit) { - val current = node - path.splitToSequence('/').forEach { - val new = doc.createElementNS(schema, it) - node.appendChild(new) - node = new - } - lambda() - node = current - } - - override fun attr(namespace: String, name: String, value: String) { - node.setAttributeNS(namespace, name, value) - } - - override fun attr(name: String, value: String) { - node.setAttribute(name, value) - } - - override fun text(content: String) { - node.appendChild(doc.createTextNode(content)) - } -} - -class DestructionError(m: String) : Exception(m) - -private fun Element.childrenByTag(tag: String, signed: Boolean): Sequence<Element> = sequence { - for (i in 0..childNodes.length) { - val el = childNodes.item(i) - if (el is Element - && el.localName == tag - && (!signed || el.getAttribute("authenticate") == "true")) { - yield(el) - } - } -} - -class XmlDestructor internal constructor(private val el: Element) { - fun each(path: String, signed: Boolean = false, f: XmlDestructor.() -> Unit) { - el.childrenByTag(path, signed).forEach { - f(XmlDestructor(it)) - } - } - - fun <T> map(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): List<T> { - return el.childrenByTag(path, signed).map { - f(XmlDestructor(it)) - }.toList() - } - - fun one(tag: String, signed: Boolean = false): XmlDestructor { - val children = el.childrenByTag(tag, signed).iterator() - if (!children.hasNext()) { - throw DestructionError("expected unique '${el.tagName}.$tag', got none") - } - val child = children.next() - if (children.hasNext()) { - throw DestructionError("expected unique '${el.tagName}.$tag', got ${children.asSequence().count() + 1}") - } - return XmlDestructor(child) - } - fun opt(tag: String, signed: Boolean = false): XmlDestructor? { - val children = el.childrenByTag(tag, signed).iterator() - if (!children.hasNext()) { - return null - } - val child = children.next() - if (children.hasNext()) { - throw DestructionError("expected optional '${el.tagName}.$tag', got ${children.asSequence().count() + 1}") - } - return XmlDestructor(child) - } - - fun <T> one(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T = f(one(path, signed)) - fun <T> opt(path: String, signed: Boolean = false, f: XmlDestructor.() -> T): T? = opt(path, signed)?.run(f) - - fun uuid(): UUID = UUID.fromString(text()) - fun text(): String = el.textContent - fun base64(): ByteArray = el.textContent.decodeBase64() - fun bool(): Boolean = el.textContent.toBoolean() - fun float(): Float = el.textContent.toFloat() - fun date(): LocalDate = LocalDate.parse(text(), DateTimeFormatter.ISO_DATE) - fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME) - inline fun <reified T : Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text()) - - fun optAttr(index: String): String? { - val attr = el.getAttribute(index) - if (attr == "") { - return null - } else { - return attr - } - } - fun attr(index: String): String { - val attr = optAttr(index) - if (attr == null) { - throw DestructionError("missing attribute '$index' at '${el.tagName}'") - } - return attr - } - - - companion object { - fun <T> parse(xml: String, root: String, f: XmlDestructor.() -> T): T { - val inputStream = ByteArrayInputStream(xml.toByteArray()) - return parse(inputStream, root, f) - } - - fun <T> parse(xml: InputStream, root: String, f: XmlDestructor.() -> T): T { - val doc = XMLUtil.parseIntoDom(xml) - return parse(doc, root, f) - } - - fun <T> parse(doc: Document, root: String, f: XmlDestructor.() -> T): T { - if (doc.documentElement.localName != root) { - throw DestructionError("expected root '$root' got '${doc.documentElement.localName}'") - } - val destr = XmlDestructor(doc.documentElement) - return f(destr) - } - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/DbInit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/DbInit.kt @@ -1,44 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus.cli - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.Context -import com.github.ajalt.clikt.parameters.groups.provideDelegate -import com.github.ajalt.clikt.parameters.options.flag -import com.github.ajalt.clikt.parameters.options.option -import tech.libeufin.common.TalerCmd -import tech.libeufin.common.db.dbInit -import tech.libeufin.common.db.pgDataSource -import tech.libeufin.nexus.dbConfig -import tech.libeufin.nexus.logger - -class DbInit : TalerCmd("dbinit") { - override fun help(context: Context) = "Initialize the libeufin-nexus database" - - private val reset by option( - "--reset", "-r", - help = "Reset database (DANGEROUS: All existing data is lost)" - ).flag() - - override fun run() = cliCmd(logger) { - val cfg = dbConfig(config) - pgDataSource(cfg.dbConnStr).dbInit(cfg, "libeufin-nexus", reset) - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -1,554 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus.cli - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.Context -import com.github.ajalt.clikt.core.ProgramResult -import com.github.ajalt.clikt.parameters.arguments.* -import com.github.ajalt.clikt.parameters.groups.provideDelegate -import com.github.ajalt.clikt.parameters.options.* -import com.github.ajalt.clikt.parameters.types.enum -import kotlin.math.min -import kotlinx.coroutines.* -import kotlinx.serialization.Serializable -import kotlinx.serialization.Contextual -import tech.libeufin.common.* -import tech.libeufin.nexus.* -import tech.libeufin.nexus.db.* -import tech.libeufin.nexus.db.PaymentDAO.* -import tech.libeufin.nexus.ebics.* -import tech.libeufin.nexus.iso20022.* -import java.io.IOException -import java.io.InputStream -import java.time.* -import java.time.temporal.* - -/** Register an outgoing [payment] into [db] */ -suspend fun registerOutgoingPayment( - db: Database, - payment: OutgoingPayment -): OutgoingRegistrationResult { - val metadata: Pair<ShortHashCode, BaseURL>? = payment.subject?.let { - runCatching { parseOutgoingSubject(it) }.getOrNull() - } - val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second) - if (result.new) { - if (result.initiated) - logger.info("$payment") - else - logger.warn("$payment recovered") - } else { - logger.debug("{} already seen", payment) - } - return result -} - -/** Register an outgoing [payment] into [db] */ -suspend fun registerOutgoingBatch( - db: Database, - batch: OutgoingBatch -) { - logger.info("BATCH ${batch.executionTime.fmtDate()} ${batch.msgId}") - for (it in db.initiated.unsettledTxInBatch(batch.msgId, batch.executionTime)) { - registerOutgoingPayment(db, it) - } -} - -/** - * Register an incoming [payment] into [db] - * Stores the payment into valid talerable ones or bounces it - */ -suspend fun registerIncomingPayment( - db: Database, - cfg: NexusIngestConfig, - payment: IncomingPayment, -) { - fun logRes(res: InResult, kind: String = "", suffix: String = "") { - val fmt = buildString { - append(payment) - if (kind != "") { - append(" ") - append(kind) - } - if (res.new) { - if (res.bounceId != null) { - append(" bounced in ${res.bounceId}") - } - } else { - if (res.completed) { - append(" completed") - if (res.bounceId != null) { - append(" bounced in ${res.bounceId}") - } - } else { - if (res.bounceId != null) { - append(" already bounced in ${res.bounceId}") - } - } - } - if (suffix != "") { - append(" ") - append(suffix) - } - } - if (res.completed || res.new) { - logger.info(fmt) - } else { - logger.debug(fmt) - } - } - suspend fun bounce(cause: String) { - if (payment.id == null) { - logger.debug("{} ignored: missing bank ID", payment) - return - } - when (cfg.accountType) { - AccountType.exchange -> { - if (payment.executionTime < cfg.ignoreBouncesBefore) { - val res = db.payment.registerIncoming(payment) - logRes(res, suffix = "ignored bounce: $cause") - } else { - var bounceAmount = payment.amount - if (payment.creditFee != null && cfg.bounceDeduceFee) { - if (payment.creditFee > bounceAmount) { - val res = db.payment.registerIncoming(payment) - logRes(res, suffix = "skip bounce (transfer fee higher than amount): $cause") - return - } - bounceAmount -= payment.creditFee - } - if (cfg.bounceFee > bounceAmount) { - val res = db.payment.registerIncoming(payment) - logRes(res, suffix = "skip bounce (bounce fee higher than amount): $cause") - return - } - bounceAmount -= cfg.bounceFee - val res = db.payment.registerMalformedIncoming( - payment, - bounceAmount, - randEbicsId(), - Instant.now(), - cause - ) - when (res) { - IncomingBounceRegistrationResult.Talerable -> - logger.warn("{} tried to bounce a talerable transaction", payment) - is IncomingBounceRegistrationResult.Success -> - logRes(res, suffix=": $cause") - } - } - } - AccountType.normal -> { - val res = db.payment.registerIncoming(payment) - logRes(res) - } - } - } - // Check we have enough info to handle this transaction - if (payment.debtor == null) { - val res = db.payment.registerIncoming(payment) - logRes(res, kind = "incomplete") - return - } - if (cfg.restrictionPaytoRegex != null) { - if (!cfg.restrictionPaytoRegex.matches(payment.debtor.toString())) { - bounce("restricted account") - return - } - } - // Else we try to parse the incoming subject - runCatching { parseIncomingSubject(payment.subject) }.fold( - onSuccess = { metadata -> - if (metadata is IncomingSubject.AdminBalanceAdjust) { - val res = db.payment.registerIncoming(payment) - logRes(res, kind = "admin balance adjust") - } else { - when (val res = db.payment.registerTalerableIncoming(payment, metadata)) { - IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") - is IncomingRegistrationResult.Success -> logRes(res) - } - } - }, - onFailure = { e -> bounce(e.fmt())} - ) -} - -/** Register a [tx] notification into [db] */ -suspend fun registerTransaction( - db: Database, - cfg: NexusIngestConfig, - tx: TxNotification, -) { - if (tx.executionTime < cfg.ignoreTransactionsBefore) { - logger.debug("IGNORE {}", tx) - } else { - when (tx) { - is IncomingPayment -> registerIncomingPayment(db, cfg, tx) - is OutgoingPayment -> registerOutgoingPayment(db, tx) - is OutgoingBatch -> registerOutgoingBatch(db, tx) - is OutgoingReversal -> { - logger.error("{}", tx) - db.initiated.txStatusUpdate(tx.endToEndId, tx.msgId, StatusUpdate.permanent_failure, "Payment bounced: ${tx.reason}") - } - } - } -} - -/** Register a single EBICS [xml] txs [document] into [db] */ -suspend fun registerTxs( - db: Database, - cfg: NexusConfig, - xml: InputStream -): Int { - var nbTx: Int = 0 - parseTx(xml).forEach { accountTx -> - if (accountTx.iban == cfg.ebics.account.iban) { - require(accountTx.currency == null || accountTx.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${accountTx.currency}" } - accountTx.txs.forEach { tx -> - when (tx) { - is IncomingPayment -> - require(tx.amount.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${tx.amount.currency}" } - is OutgoingPayment -> - require(tx.amount.currency == cfg.currency) { "Expected transactions of currency ${cfg.currency} got ${tx.amount.currency}" } - is OutgoingBatch, is OutgoingReversal -> {} - } - registerTransaction(db, cfg.ingest, tx) - nbTx += 1 - } - } else { - logger.debug("Skip transaction for unknown account ${accountTx.iban}") - } - } - return nbTx -} - -/** Register a single EBICS [xml] [document] into [db] */ -suspend fun registerFile( - db: Database, - cfg: NexusConfig, - xml: InputStream, - doc: OrderDoc -) { - when (doc) { - OrderDoc.report, OrderDoc.statement, OrderDoc.notification -> { - try { - registerTxs(db, cfg, xml) - } catch (e: Exception) { - throw Exception("Ingesting transactions files failed", e) - } - } - OrderDoc.acknowledgement -> { - val acks = parseCustomerAck(xml) - for (ack in acks) { - when (ack.actionType) { - HacAction.ORDER_HAC_FINAL_POS -> { - logger.debug("{}", ack) - db.initiated.orderSuccess(ack.orderId!!)?.let { messageId -> - logger.info("Batch $messageId order ${ack.orderId} accepted at ${ack.timestamp.fmtDateTime()}") - } - } - HacAction.ORDER_HAC_FINAL_NEG -> { - logger.debug("{}", ack) - db.initiated.orderFailure(ack.orderId!!)?.let { (messageId, msg) -> - logger.error("Batch $messageId order ${ack.orderId} refused at ${ack.timestamp.fmtDateTime()}${if (msg != null) ": $msg" else ""}") - } - } - else -> { - logger.debug("{}", ack) - if (ack.orderId != null) { - db.initiated.orderStep(ack.orderId, ack.msg()) - } - } - } - } - } - OrderDoc.status -> { - val msgStatus = parseCustomerPaymentStatusReport(xml) - logger.debug("{}", msgStatus) - if (msgStatus.code != null) { - val msg = msgStatus.msg() - val state = when (msgStatus.code) { - ExternalPaymentGroupStatusCode.ACSC -> StatusUpdate.success - ExternalPaymentGroupStatusCode.RJCT -> { - logger.error("Batch ${msgStatus.id} failed: $msg") - StatusUpdate.permanent_failure - } - else -> StatusUpdate.pending - } - db.initiated.batchStatusUpdate(msgStatus.id, state, msg) - } - for (pmtStatus in msgStatus.payments) { - if (pmtStatus.id != "NOTPROVIDED") { - logger.warn("Unexpected payment status for ${msgStatus.id}.${pmtStatus.id}") - } else if (pmtStatus.code != null) { - val msg = pmtStatus.msg() - val state = when (pmtStatus.code) { - ExternalPaymentGroupStatusCode.ACSC -> StatusUpdate.success - ExternalPaymentGroupStatusCode.RJCT -> { - logger.error("Batch ${msgStatus.id} failed: $msg") - StatusUpdate.permanent_failure - } - else -> StatusUpdate.pending - } - db.initiated.batchStatusUpdate(msgStatus.id, state, msg) - } - for (txStatus in pmtStatus.transactions) { - val msg = txStatus.msg() - val state = when (txStatus.code) { - ExternalPaymentTransactionStatusCode.RJCT, - ExternalPaymentTransactionStatusCode.BLCK -> { - logger.error("Transaction ${txStatus.endToEndId} failed: $msg") - StatusUpdate.permanent_failure - } - else -> StatusUpdate.pending - } - db.initiated.txStatusUpdate(txStatus.endToEndId, null, state, msg) - } - } - } - } -} - -/** Register an EBICS [payload] of [doc] into [db] */ -private suspend fun registerPayload( - db: Database, - cfg: NexusConfig, - payload: InputStream, - doc: OrderDoc -) { - // Unzip payload if necessary - when (doc) { - OrderDoc.status, - OrderDoc.report, - OrderDoc.statement, - OrderDoc.notification -> { - try { - payload.unzipEach { fileName, xml -> - logger.trace("parse $fileName") - registerFile(db, cfg, xml, doc) - } - } catch (e: IOException) { - throw Exception("Could not open any ZIP archive", e) - } - } - OrderDoc.acknowledgement -> registerFile(db, cfg, payload, doc) - } -} - -/** - * Fetch and register banking records from [orders] using EBICS [client] starting from [pinnedStart] - * - * If [pinnedStart] is null fetch new records. - */ -private suspend fun fetchEbicsDocuments( - client: EbicsClient, - orders: Collection<EbicsOrder>, - pinnedStart: Instant?, - peek: Boolean -): Boolean { - val lastExecutionTime: Instant? = pinnedStart - var success = true - for ((doc, orders) in orders.groupBy { it.doc() }) { - if (doc == null) { - logger.debug("Skip unsupported orders {}", orders) - } else { - if (lastExecutionTime == null) { - logger.info("Fetching new '${doc.fullDescription()}'") - } else { - logger.info("Fetching '${doc.fullDescription()}' from timestamp: $lastExecutionTime") - } - for (order in orders) { - try { - client.download( - order, - lastExecutionTime, - null, - peek - ) { payload -> - registerPayload(client.db, client.cfg, payload, doc) - } - } catch (e: EbicsError.Code) { - when (e.bankCode) { - EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE -> continue - EbicsReturnCode.EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED -> { - e.fmtLog(logger) - success = false - continue - } - else -> throw e - } - } - } - } - } - return success -} - -@Serializable -data class Checkpoint( - @Contextual - val last_successfull: Instant? = null, - @Contextual - val last_trial: Instant? = null -) - -class EbicsFetch: EbicsCmd() { - override fun help(context: Context) = "Downloads and parse EBICS files from the bank and register them into the database" - - private val documents: Set<OrderDoc> by argument( - help = "Which documents should be fetched? If none are specified, all supported documents will be fetched", - helpTags = OrderDoc.entries.associate { Pair(it.name, it.shortDescription()) }, - ).enum<OrderDoc>().multiple().unique() - private val pinnedStart by option( - help = "Only supported in --transient mode, this option lets specify the earliest timestamp of the downloaded documents", - metavar = "YYYY-MM-DD" - ).convert { dateToInstant(it) } - private val peek by option("--peek", - help = "Only supported in --transient mode, do not consume fetched documents" - ).flag() - private val transientCheckpoint by option("--checkpoint", - help = "Only supported in --transient mode, run a checkpoint" - ).flag() - - override fun run() = cliCmd(logger) { - nexusConfig(config).withDb { db, cfg -> - val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics) - val client = EbicsClient( - cfg, - httpClient(), - db, - EbicsLogger(ebicsLog), - clientKeys, - bankKeys - ) - val docs = if (documents.isEmpty()) OrderDoc.entries else documents.toList() - - // EBICS order than should be fetched - val selectedOrder = docs.flatMap { cfg.ebics.dialect.downloadDoc(it) } - - // Try to obtain real-time notification channel if not transient - val wssNotification = if (transient) { - logger.info("Transient mode: fetching once and returning") - null - } else { - val tmp = listenForNotification(client) - logger.info("Running with a frequency of ${cfg.fetch.frequencyRaw}") - tmp - } - - var lastFetch = Instant.EPOCH - - while (true) { - val checkpoint = db.kv.get<TaskStatus>(CHECKPOINT_KEY) ?: TaskStatus() - var nextFetch = lastFetch + cfg.fetch.frequency - var nextCheckpoint = run { - // We never ran, we must checkpoint now - if (checkpoint.last_trial == null) { - Instant.EPOCH - } else { - // We run today at checkpointTime - val checkpointDate = OffsetDateTime.now().with(cfg.fetch.checkpointTime) - val checkpointToday = checkpointDate.toInstant() - // If we already ran today we ruAn tomorrow - if (checkpoint.last_trial > checkpointToday) { - checkpointDate.plusDays(1).toInstant() - } else { - checkpointToday - } - } - } - - val now = Instant.now() - var success: Boolean = true - if ( - // Run transient checkpoint at request - (transient && transientCheckpoint) || - // Or run recurrent checkpoint - (!transient && now > nextCheckpoint) - ) { - logger.info("Running checkpoint") - - val since = if (transient && pinnedStart != null && (checkpoint.last_successfull == null || pinnedStart!!.isBefore(checkpoint.last_successfull))) { - pinnedStart - } else { - checkpoint.last_successfull - } - success = try { - /// We fetch HKD to only fetch supported EBICS orders and get the document versions - val orders = client.download(EbicsOrder.V3.HKD) { stream -> - val hkd = EbicsAdministrative.parseHKD(stream) - val supportedOrder = hkd.partner.orders.map { it.order } - logger.debug { - val fmt = supportedOrder.map(EbicsOrder::description).joinToString(" ") - "HKD: ${fmt}" - } - selectedOrder select supportedOrder - } - fetchEbicsDocuments(client, orders, since, transient && peek) - } catch (e: Exception) { - e.fmtLog(logger) - false - } - db.kv.updateTaskStatus(CHECKPOINT_KEY, now, success) - lastFetch = now - } else if (transient || now > nextFetch) { - if (!transient) logger.info("Running at frequency") - success = try { - /// We fetch HAA to only fetch pending & supported EBICS orders and get the document versions - val orders = client.download(EbicsOrder.V3.HAA) { stream -> - val haa = EbicsAdministrative.parseHAA(stream) - logger.debug { - val orders = haa.orders.map(EbicsOrder::description).joinToString(" ") - "HAA: ${orders}" - } - selectedOrder select haa.orders - } - fetchEbicsDocuments(client, orders, if (transient) pinnedStart else null, transient && peek) - } catch (e: Exception) { - e.fmtLog(logger) - false - } - lastFetch = now - } - db.kv.updateTaskStatus(SUBMIT_TASK_KEY, now, success) - - if (transient) throw ProgramResult(if (!success) 1 else 0) - - val delay = min(ChronoUnit.MILLIS.between(now, nextFetch), ChronoUnit.MILLIS.between(now, nextCheckpoint)) - if (wssNotification == null) { - delay(delay) - } else { - val notifications = withTimeoutOrNull(delay) { - wssNotification.receive() - } - if (notifications != null) { - // Only fetch requested and supported orders - val orders = selectedOrder select notifications - if (orders.isNotEmpty()) { - logger.info("Running at real-time notifications reception") - fetchEbicsDocuments(client, notifications, null, false) - } - } - } - } - } - } -} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/InitiatePayment.kt @@ -1,78 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus.cli - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.Context -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.arguments.convert -import com.github.ajalt.clikt.parameters.groups.provideDelegate -import com.github.ajalt.clikt.parameters.options.convert -import com.github.ajalt.clikt.parameters.options.option -import tech.libeufin.common.* -import tech.libeufin.nexus.db.InitiatedPayment -import tech.libeufin.nexus.ebics.randEbicsId -import tech.libeufin.nexus.logger -import tech.libeufin.nexus.nexusConfig -import tech.libeufin.nexus.withDb -import java.time.Instant - -class InitiatePayment: TalerCmd() { - override fun help(context: Context) = "Initiate an outgoing payment" - - private val amount by option( - "--amount", - help = "The amount to transfer, payto 'amount' parameter takes the precedence" - ).convert { TalerAmount(it) } - private val subject by option( - "--subject", - help = "The payment subject, payto 'message' parameter takes the precedence" - ) - private val endToEndId by option( - "--end-to-end-id", - "--request-uid", - help = "The payment end-to-end UID" - ) - private val payto by argument( - help = "The credited account IBAN payto URI" - ).convert { Payto.parse(it).expectIban() } - - override fun run() = cliCmd(logger) { - nexusConfig(config).withDb { db, cfg -> - val subject = requireNotNull(payto.message ?: subject) { "Missing subject" } - val amount = requireNotNull(payto.amount ?: amount) { "Missing amount" } - - requireNotNull(payto.receiverName) { "Missing receiver name in creditor payto" } - require(amount.currency == cfg.currency) { - "Wrong currency: expected ${cfg.currency} got ${amount.currency}" - } - - db.initiated.create( - InitiatedPayment( - id = -1, - amount = amount, - subject = subject, - creditor = payto, - initiationTime = Instant.now(), - endToEndId = endToEndId ?: randEbicsId() - ) - ) - } - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/LibeufinNexus.kt @@ -1,54 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 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.nexus.cli - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.subcommands -import com.github.ajalt.clikt.parameters.options.flag -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.options.versionOption -import com.github.ajalt.clikt.parameters.types.path -import tech.libeufin.common.* -import tech.libeufin.nexus.NEXUS_CONFIG_SOURCE - -fun CliktCommand.ebicsLogOption() = option( - "--debug-ebics", - help = "Log EBICS transactions steps and payload at log_dir", - metavar = "log_dir" -).path() - -fun CliktCommand.transientOption() = option( - "--transient", - help = "Execute once and return, ignoring the 'FREQUENCY' configuration value" -).flag(default = false) - -abstract class EbicsCmd(name: String? = null): TalerCmd(name) { - val ebicsLog by ebicsLogOption() - val transient by transientOption() -} - - -class LibeufinNexus : CliktCommand() { - init { - versionOption(VERSION) - subcommands(DbInit(), EbicsSetup(), EbicsSubmit(), EbicsFetch(), Serve(), InitiatePayment(), ManualCmd(), ListCmd(), CliConfigCmd(NEXUS_CONFIG_SOURCE), TestingCmd()) - } - override fun run() = Unit -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Serve.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/Serve.kt @@ -1,73 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus.cli - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.Context -import com.github.ajalt.clikt.core.ProgramResult -import com.github.ajalt.clikt.parameters.groups.provideDelegate -import com.github.ajalt.clikt.parameters.options.flag -import com.github.ajalt.clikt.parameters.options.option -import tech.libeufin.common.TalerCmd -import tech.libeufin.common.api.serve -import tech.libeufin.nexus.logger -import tech.libeufin.nexus.nexusApi -import tech.libeufin.nexus.nexusConfig -import tech.libeufin.nexus.withDb - - -class Serve : TalerCmd("serve") { - override fun help(context: Context) = "Run libeufin-nexus HTTP server" - - private val check by option( - help = "Check whether an API is in use (if it's useful to start the HTTP server). Exit with 0 if at least one API is enabled, otherwise 1" - ).flag() - - override fun run() = cliCmd(logger) { - val cfg = nexusConfig(config) - - if (check) { - // Check if the server is to be started - val apis = listOf( - cfg.wireGatewayApiCfg to "Wire Gateway API", - cfg.revenueApiCfg to "Revenue API" - ) - var startServer = false - for ((api, name) in apis) { - if (api != null) { - startServer = true - logger.info("$name is enabled: starting the server") - } - } - if (!startServer) { - logger.info("All APIs are disabled: not starting the server") - throw ProgramResult(1) - } else { - throw ProgramResult(0) - } - } - - cfg.withDb { db, cfg -> - serve(cfg.serverCfg) { - nexusApi(db, cfg) - } - } - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -1,437 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus.ebics - -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.utils.io.jvm.javaio.* -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.withContext -import org.w3c.dom.Document -import org.xml.sax.SAXException -import tech.libeufin.common.* -import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.nexus.* -import tech.libeufin.nexus.db.Database -import java.io.InputStream -import java.io.SequenceInputStream -import java.security.interfaces.RSAPrivateCrtKey -import java.time.Instant -import java.util.* - -/** Supported documents that can be downloaded via EBICS */ -enum class SupportedDocument { - PAIN_002, - PAIN_002_LOGS, - CAMT_053, - CAMT_052, - CAMT_054 -} - -/** EBICS related errors */ -sealed class EbicsError(msg: String, cause: Throwable? = null): Exception(msg, cause) { - /** Network errors */ - class Network(msg: String, cause: Throwable): EbicsError(msg, cause) - /** Http errors */ - class HTTP(msg: String, val status: HttpStatusCode): EbicsError(msg) - /** EBICS protocol & XML format error */ - class Protocol(msg: String, cause: Throwable? = null): EbicsError(msg, cause) - /** EBICS protocol & XML format error */ - class Code(msg: String, val technicalCode: EbicsReturnCode, val bankCode: EbicsReturnCode): EbicsError(msg) -} - -/** POST an EBICS request [msg] to [bankUrl] returning a parsed XML response */ -suspend fun HttpClient.postToBank( - bankUrl: String, - msg: ByteArray, - phase: String, - stepLogger: StepLogger -): Document { - stepLogger.logRequest(msg) - val res = try { - post(urlString = bankUrl) { - contentType(ContentType.Text.Xml) - setBody(msg) - } - } catch (e: Exception) { - throw EbicsError.Network("$phase: failed to contact bank", e) - } - - if (res.status != HttpStatusCode.OK) { - stepLogger.logFailure(res) - throw EbicsError.HTTP("$phase: bank HTTP error: ${res.status}", res.status) - } - try { - val bodyStream = res.bodyAsChannel().toInputStream() - val loggedStream = stepLogger.logResponse(bodyStream) - return XMLUtil.parseIntoDom(loggedStream) - } catch (e: SAXException) { - throw EbicsError.Protocol("$phase: invalid XML bank response", e) - } catch (e: Exception) { - throw EbicsError.Network("$phase: failed read bank response", e) - } -} - -/** POST an EBICS BTS request [xmlReq] using [client] returning a validated and parsed XML response */ -suspend fun EbicsBTS.postBTS( - client: HttpClient, - xmlReq: ByteArray, - phase: String, - stepLogger: StepLogger -): BTSResponse { - val doc = client.postToBank(cfg.host.baseUrl, xmlReq, phase, stepLogger) - try { - XMLUtil.verifyEbicsDocument( - doc, - bankKeys.bank_authentication_public_key - ) - } catch (e: Exception) { - throw EbicsError.Protocol("$phase ${order.description()}: invalid signature", e) - } - val response = try { - EbicsBTS.parseResponse(doc) - } catch (e: Exception) { - throw EbicsError.Protocol("$phase ${order.description()}: invalid ebics response", e) - } - logger.debug { - buildString { - append(phase) - response.content.transactionID?.let { - append(" for ") - append(it) - } - append(": ") - append(response.technicalCode) - append(" & ") - append(response.bankCode) - } - } - return response.okOrFail(phase) -} - -/** High level EBICS client */ -class EbicsClient( - val cfg: NexusConfig, - val client: HttpClient, - val db: Database, - val ebicsLogger: EbicsLogger, - val clientKeys: ClientPrivateKeysFile, - val bankKeys: BankPublicKeysFile -) { - /** - * Performs an EBICS download transaction of [order] between [startDate] and [endDate]. - * Download content is passed to [processing] - * - * It conducts init -> transfer -> processing -> receipt phases. - * - * Cancellations and failures are handled. - */ - suspend fun <T> download( - order: EbicsOrder, - startDate: Instant? = null, - endDate: Instant? = null, - peek: Boolean = false, - processing: suspend (InputStream) -> T, - ): T { - val description = order.description() - logger.debug { - buildString { - append("Download order ") - append(description) - if (startDate != null) { - append(" from ") - append(startDate) - if (endDate != null) { - append(" to ") - append(endDate) - } - } - } - } - val impl = EbicsBTS(cfg.ebics, bankKeys, clientKeys, order) - - // Close interrupted - val interruptedLog = ebicsLogger.tx("INTD") - while (true) { - val tId = db.ebics.first() - if (tId == null) break - val xml = impl.downloadReceipt(tId, false) - try { - impl.postBTS(client, xml, "Closing interrupted transaction ${tId}", interruptedLog.step(tId)) - } catch (e: Exception) { - when (e) { - // Transaction already closed or expired - EBICS protocol error - is EbicsError.Code if e.technicalCode == EbicsReturnCode.EBICS_TX_UNKNOWN_TXID -> {} - // Transaction already closed or expired - HTTP protocol error for non compliant banks - is EbicsError.HTTP if e.status == HttpStatusCode.BadRequest -> {} - // Unexpected error - else -> throw e - } - logger.debug("${e.fmt()}") - } - db.ebics.remove(tId) - } - - val txLog = ebicsLogger.tx(order) - - // We need to run the logic in a non-cancelable context because we need to send - // a receipt for each open download transaction, otherwise we'll be stuck in an - // error loop until the pending transaction timeout. - val (tId, initContent) = withContext(NonCancellable) { - // Init phase - val initReq = impl.downloadInitialization(startDate, endDate) - val initContent = impl.postBTS(client, initReq, "Download init $description", txLog.step("init")) - val tId = requireNotNull(initContent.transactionID) { - "Download init $description: missing transaction ID" - } - db.ebics.register(tId) - Pair(tId, initContent) - } - val howManySegments = requireNotNull(initContent.numSegments) { - "Download init $description: missing num segments" - } - val firstSegment = requireNotNull(initContent.segment) { - "Download init $description: missing OrderData" - } - val dataEncryptionInfo = requireNotNull(initContent.dataEncryptionInfo) { - "Download init $description: missing EncryptionInfo" - } - - // Transfer phase - val segments = mutableListOf(firstSegment) - for (x in 2 .. howManySegments) { - val transReq = impl.downloadTransfer(x, howManySegments, tId) - val transResp = impl.postBTS(client, transReq, "Download transfer $description", txLog.step("transfer$x")) - val segment = requireNotNull(transResp.segment) { - "Download transfer: missing encrypted segment" - } - segments.add(segment) - } - - - // Decompress encrypted chunks - val payloadStream = try { - decryptAndDecompressPayload( - clientKeys.encryption_private_key, - dataEncryptionInfo, - segments - ) - } catch (e: Exception) { - throw EbicsError.Protocol("invalid chunks", e) - } - - val container = when (order) { - is EbicsOrder.V2_5 -> "rax" // TODO infer ? - is EbicsOrder.V3 -> order.container ?: "xml" - } - val loggedStream = txLog.payload(payloadStream, container) - - // Run business logic - val res = runCatching { - processing(loggedStream) - } - - // First send a proper EBICS transaction receipt - val xml = impl.downloadReceipt(tId, res.isSuccess && !peek) - impl.postBTS(client, xml, "Download receipt $description", txLog.step("receipt")) - runCatching { db.ebics.remove(tId) } - // Then throw business logic exception if any - return res.getOrThrow() - } - - /** - * Performs an EBICS upload transaction of [order] using [payload]. - * - * It conducts init -> upload phases. - * - * Returns upload orderID - */ - suspend fun upload( - order: EbicsOrder, - payload: ByteArray, - ): String { - val description = order.description(); - logger.debug { "Upload order $description" } - val txLog = ebicsLogger.tx(order) - val impl = EbicsBTS(cfg.ebics, bankKeys, clientKeys, order) - val preparedPayload = prepareUploadPayload(cfg.ebics, clientKeys, bankKeys, payload) - - // Init phase - val initXml = impl.uploadInitialization(preparedPayload) - val initResp = impl.postBTS(client, initXml, "Upload init $description", txLog.step("init")) - val tId = requireNotNull(initResp.transactionID) { - "Upload init $description: missing transaction ID" - } - val orderId = requireNotNull(initResp.orderID) { - "Upload init $description: missing order ID" - } - - txLog.payload(payload, "xml") - - // Transfer phase - for (i in 1..preparedPayload.segments.size) { - val transferXml = impl.uploadTransfer(tId, preparedPayload, i) - impl.postBTS(client, transferXml, "Upload transfer $description", txLog.step("transfer$i")) - } - return orderId - } -} - -suspend fun HEV( - client: HttpClient, - cfg: NexusEbicsConfig, - ebicsLogger: EbicsLogger -): List<VersionNumber> { - logger.info("Doing administrative request HEV") - val txLog = ebicsLogger.tx("HEV") - val req = EbicsAdministrative.HEV(cfg) - val xml = client.postToBank(cfg.host.baseUrl, req, "HEV", txLog.step()) - return EbicsAdministrative.parseHEV(xml).okOrFail("HEV") -} - -suspend fun keyManagement( - cfg: NexusEbicsConfig, - privs: ClientPrivateKeysFile, - client: HttpClient, - ebicsLogger: EbicsLogger, - order: EbicsKeyMng.Order -): EbicsResponse<InputStream?> { - logger.info("Doing key request $order") - val txLog = ebicsLogger.tx(order.name) - val ebics3 = when (cfg.dialect) { - // TODO GLS needs EBICS 2.5 for key management - Dialect.gls -> false - else -> true - } - val req = EbicsKeyMng(cfg, privs, ebics3).request(order) - val xml = client.postToBank(cfg.host.baseUrl, req, order.name, txLog.step()) - return EbicsKeyMng.parseResponse(xml, privs.encryption_private_key) -} - -class PreparedUploadData( - val transactionKey: ByteArray, - val userSignatureDataEncrypted: String, - val dataDigest: ByteArray, - val segments: List<String> -) - -/** Signs, encrypts and format EBICS BTS payload */ -fun prepareUploadPayload( - cfg: NexusEbicsConfig, - clientKeys: ClientPrivateKeysFile, - bankKeys: BankPublicKeysFile, - payload: ByteArray, -): PreparedUploadData { - val payloadDigest = CryptoUtil.digestEbicsOrderA006(payload) - val innerSignedEbicsXml = XmlBuilder.toBytes("UserSignatureData") { - attr("xmlns", "http://www.ebics.org/S002") - el("OrderSignatureData") { - el("SignatureVersion", "A006") - el("SignatureValue", CryptoUtil.signEbicsA006( - payloadDigest, - clientKeys.signature_private_key, - ).encodeBase64()) - el("PartnerID", cfg.host.ebicsPartnerId) - el("UserID", cfg.host.ebicsUserId) - } - } - // Generate ephemeral transaction key - val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(bankKeys.bank_encryption_public_key) - // Compress and encrypt order signature - val orderSignature = CryptoUtil.encryptEbicsE002( - transactionKey, - innerSignedEbicsXml.inputStream().deflate() - ).encodeBase64() - // Compress and encrypt payload - val encrypted = CryptoUtil.encryptEbicsE002( - transactionKey, - payload.inputStream().deflate() - ) - // Chunks of 1MB and encode segments - val segments = encrypted.encodeBase64().chunked(1000000) - - return PreparedUploadData( - encryptedTransactionKey, - orderSignature, - payloadDigest, - segments - ) -} - -/** Decrypts and decompresses EBICS BTS payload */ -fun decryptAndDecompressPayload( - clientEncryptionKey: RSAPrivateCrtKey, - encryptionInfo: DataEncryptionInfo, - segments: List<ByteArray> -): InputStream { - val transactionKey = CryptoUtil.decryptEbicsE002Key(clientEncryptionKey, encryptionInfo.transactionKey) - return SequenceInputStream(Collections.enumeration(segments.map { it.inputStream() })) // Aggregate - .run { - CryptoUtil.decryptEbicsE002( - transactionKey, - this - ) - }.inflate() -} - -/** Generate a secure random nonce of [size] bytes */ -fun getNonce(size: Int): ByteArray { - return ByteArray(size / 8).secureRand() -} - -private val EBICS_ID_ALPHABET = ('A'..'Z') + ('0'..'9') - -fun randEbicsId(): String { - return List(34) { EBICS_ID_ALPHABET.random() }.joinToString("") -} - -class DataEncryptionInfo( - val transactionKey: ByteArray, - val bankPubDigest: ByteArray -) - -class EbicsResponse<T>( - val technicalCode: EbicsReturnCode, - val bankCode: EbicsReturnCode, - internal val content: T -) { - /** Checks that return codes are both EBICS_OK */ - fun ok(): T? { - return if (technicalCode.kind() != EbicsReturnCode.Kind.Error && - bankCode.kind() != EbicsReturnCode.Kind.Error) { - content - } else { - null - } - } - - /** Checks that return codes are both EBICS_OK or throw an exception */ - fun okOrFail(phase: String): T { - if (technicalCode.kind() == EbicsReturnCode.Kind.Error) { - throw EbicsError.Code("$phase has technical error: $technicalCode", technicalCode, bankCode) - } else if (bankCode.kind() == EbicsReturnCode.Kind.Error) { - throw EbicsError.Code("$phase has bank error: $bankCode", technicalCode, bankCode) - } else { - return content - } - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsWS.kt @@ -1,206 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus.ebics - -import io.ktor.client.* -import io.ktor.client.plugins.websocket.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.* -import io.ktor.websocket.* -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.* -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.* -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.common.* - -private val logger: Logger = LoggerFactory.getLogger("libeufin-nexus-ws") - -@Serializable -data class WssParams( - val URL: String, - val TOKEN: String, - val OTT: String, - val VALIDITY: String, - val PARTNERID: String, - val USERID: String? = null, -) - -@Serializable -data class WssNotificationClass( - val NAME: String, - val VERS: String, - val TIMESTAMP: String, -) - -@Serializable -data class WssNotificationBTF( - val SERVICE: String, - val SCOPE: String? = null, - val OPTION: String? = null, - val CONTTYPE: String? = null, - val MSGNAME: String, - val VARIANT: String? = null, - val VERSION: String? = null, - val FORMAT: String? = null, -) - -@Serializable -data class WssNewData( - val MCLASS: List<WssNotificationClass>, - val PARTNERID: String, - val USERID: String? = null, - val BTF: List<WssNotificationBTF>, - val ORDERTYPE: List<String>? = null -): WssNotification - -@Serializable -data class WssInfo( - val LANG: String, - val FREE: String -) - -@Serializable -data class WssGeneralInfo( - val MCLASS: List<WssNotificationClass>, - val INFO: List<WssInfo> -): WssNotification - -@Serializable(with = WssNotification.Serializer::class) -sealed interface WssNotification { - companion object Serializer : JsonContentPolymorphicSerializer<WssNotification>(WssNotification::class) { - override fun selectDeserializer(element: JsonElement) = when { - "INFO" in element.jsonObject -> WssGeneralInfo.serializer() - else -> WssNewData.serializer() - } - } -} - -/** Download EBICS real-time notifications websocket params */ -suspend fun EbicsClient.wssParams(): WssParams = - download(EbicsOrder.V3.WSS_PARAMS) { stream -> - Json.decodeFromStream(stream) - } - -/** Receive a JSON message from a websocket session */ -private suspend inline fun <reified T> DefaultClientWebSocketSession.receiveJson(): T { - val frame = incoming.receive() - val content = frame.readBytes() - val msg = Json.decodeFromStream(kotlinx.serialization.serializer<T>(), content.inputStream()) - return msg -} - -/** Connect to the EBICS real-time notifications websocket */ -suspend fun WssParams.connect(client: HttpClient, lambda: suspend (WssNotification) -> Unit) { - val client = client.config { - install(WebSockets) { - contentConverter = KotlinxWebsocketSerializationConverter(Json) - } - } - // TODO check PARTNERID and USERID match conf ? - val credentials = buildString { - // Username - append(PARTNERID) - if (USERID != null) { - append('_') - append(USERID) - } - // Password - append(':') - append(TOKEN) - }.encodeBase64() - - client.wss(URL.replace("https://", "wss://"), request = { - headers { - append(HttpHeaders.Authorization, "Basic $credentials") - } - }) { - while (true) { - logger.trace("wait for ws msg") - // TODO use receiveDeserialized from ktor when it works - val msg = receiveJson<WssNotification>() - logger.trace("received: {}", msg) - lambda(msg) - } - } -} - -suspend fun listenForNotification(client: EbicsClient): ReceiveChannel<List<EbicsOrder>>? { - val channel = Channel<List<EbicsOrder>>() - val backoff = ExpoBackoffDecorr( - 30 * 1000, // 30 seconds - 30 * 60 * 1000 // 30 min - ) - kotlin.concurrent.thread(isDaemon = true) { - runBlocking { - while (true) { - try { - // Try to get params - val params = try { - client.wssParams() - } catch (e: EbicsError) { - if ( - // Expected EBICS error - (e is EbicsError.Code && e.technicalCode == EbicsReturnCode.EBICS_INVALID_ORDER_IDENTIFIER) || - // Netzbon HTTP error - (e is EbicsError.HTTP && e.status == HttpStatusCode.BadRequest) - ) { - // Failure is expected if this wss is not supported - logger.info("Real-time EBICS notifications is not supported") - return@runBlocking - } else throw e - } - logger.info("Listening to real-time EBICS notifications") - logger.trace("{}", params) - params.connect(client.client) { msg -> - backoff.reset() - when (msg) { - is WssGeneralInfo -> { - for (info in msg.INFO) { - logger.info("info: {}", info.FREE) - } - } - is WssNewData -> { - val orders = msg.BTF.map { - EbicsOrder.V3( - type = "BTD", - service = it.SERVICE, - scope = it.SCOPE, - message = it.MSGNAME, - version = it.VERSION, - container = it.CONTTYPE, - option = it.OPTION - ) - } - channel.send(orders) - } - } - } - } catch (e: Exception) { - e.fmtLog(logger) - delay(backoff.next()) - } - } - } - } - return channel -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/ExternalCodeSets.kt @@ -1,481 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024 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/> - */ - -// THIS FILE IS GENERATED, DO NOT EDIT - -package tech.libeufin.nexus.iso20022 - -enum class ExternalStatusReasonCode(val isoCode: String, val description: String) { - AB01("AbortedClearingTimeout", "Clearing process aborted due to timeout."), - AB02("AbortedClearingFatalError", "Clearing process aborted due to a fatal error."), - AB03("AbortedSettlementTimeout", "Settlement aborted due to timeout."), - AB04("AbortedSettlementFatalError", "Settlement process aborted due to a fatal error."), - AB05("TimeoutCreditorAgent", "Transaction stopped due to timeout at the Creditor Agent."), - AB06("TimeoutInstructedAgent", "Transaction stopped due to timeout at the Instructed Agent."), - AB07("OfflineAgent", "Agent of message is not online."), - AB08("OfflineCreditorAgent", "Creditor Agent is not online."), - AB09("ErrorCreditorAgent", "Transaction stopped due to error at the Creditor Agent."), - AB10("ErrorInstructedAgent", "Transaction stopped due to error at the Instructed Agent."), - AB11("TimeoutDebtorAgent", "Transaction stopped due to timeout at the Debtor Agent."), - AB12("InvalidConcurrentBatch", "Duplicate Concurrent Batch Sequence number– for Settlement Instructions."), - AB13("InvalidRoutingCodeUtilised", "Wrong Message Routing Type for Return-of-Funds."), - AB15("InvalidAccountNumberForSettlementType", "Instruction may not be placed on the Continuous Processing Line settlement processor."), - AB21("InvalidSettlementAgreementNumberSpecified", "Agreement number not valid (beneficiary)."), - AB26("InvalidBatchSettlementInstruction", "Settlement Instruction does not exist."), - AC01("IncorrectAccountNumber", "Account number is invalid or missing."), - AC02("InvalidDebtorAccountNumber", "Debtor account number invalid or missing"), - AC03("InvalidCreditorAccountNumber", "Creditor account number invalid or missing"), - AC04("ClosedAccountNumber", "Account number specified has been closed on the bank of account's books."), - AC05("ClosedDebtorAccountNumber", "Debtor account number closed"), - AC06("BlockedAccount", "Account specified is blocked, prohibiting posting of transactions against it."), - AC07("ClosedCreditorAccountNumber", "Creditor account number closed"), - AC08("InvalidBranchCode", "Branch code is invalid or missing"), - AC09("InvalidAccountCurrency", "Account currency is invalid or missing"), - AC10("InvalidDebtorAccountCurrency", "Debtor account currency is invalid or missing"), - AC11("InvalidCreditorAccountCurrency", "Creditor account currency is invalid or missing"), - AC12("InvalidAccountType", "Account type missing or invalid."), - AC13("InvalidDebtorAccountType", "Debtor account type missing or invalid"), - AC14("InvalidCreditorAccountType", "Creditor account type missing or invalid"), - AC15("AccountDetailsChanged", "The account details for the counterparty have changed."), - AC16("CardNumberInvalid", "Credit or debit card number is invalid."), - AEXR("AlreadyExpiredRTP", "Request-to-pay Expiry Date and Time has already passed."), - AG01("TransactionForbidden", "Transaction forbidden on this type of account (formerly NoAgreement)"), - AG02("InvalidBankOperationCode", "Bank Operation code specified in the message is not valid for receiver"), - AG03("TransactionNotSupported", "Transaction type not supported/authorized on this account"), - AG04("InvalidAgentCountry", "Agent country code is missing or invalid."), - AG05("InvalidDebtorAgentCountry", "Debtor agent country code is missing or invalid"), - AG06("InvalidCreditorAgentCountry", "Creditor agent country code is missing or invalid"), - AG07("UnsuccesfulDirectDebit", "Debtor account cannot be debited for a generic reason."), - AG08("InvalidAccessRights", "Transaction failed due to invalid or missing user or access right"), - AG09("PaymentNotReceived", "Original payment never received."), - AG10("AgentSuspended", "Agent of message is suspended from the Real Time Payment system."), - AG11("CreditorAgentSuspended", "Creditor Agent of message is suspended from the Real Time Payment system."), - AG12("NotAllowedBookTransfer", "Payment orders made by transferring funds from one account to another at the same financial institution (bank or payment institution) are not allowed."), - AG13("ForbiddenReturnPayment", "Returned payments derived from previously returned transactions are not allowed."), - AGNT("IncorrectAgent", "Agent in the payment workflow is incorrect"), - ALAC("AlreadyAcceptedRTP", "Request-to-pay has already been accepted by the Debtor."), - AM01("ZeroAmount", "Specified message amount is equal to zero"), - AM02("NotAllowedAmount", "Specific transaction/message amount is greater than allowed maximum"), - AM03("NotAllowedCurrency", "Specified message amount is an non processable currency outside of existing agreement"), - AM04("InsufficientFunds", "Amount of funds available to cover specified message amount is insufficient."), - AM05("Duplication", "Duplication"), - AM06("TooLowAmount", "Specified transaction amount is less than agreed minimum."), - AM07("BlockedAmount", "Amount specified in message has been blocked by regulatory authorities."), - AM09("WrongAmount", "Amount received is not the amount agreed or expected"), - AM10("InvalidControlSum", "Sum of instructed amounts does not equal the control sum."), - AM11("InvalidTransactionCurrency", "Transaction currency is invalid or missing"), - AM12("InvalidAmount", "Amount is invalid or missing"), - AM13("AmountExceedsClearingSystemLimit", "Transaction amount exceeds limits set by clearing system"), - AM14("AmountExceedsAgreedLimit", "Transaction amount exceeds limits agreed between bank and client"), - AM15("AmountBelowClearingSystemMinimum", "Transaction amount below minimum set by clearing system"), - AM16("InvalidGroupControlSum", "Control Sum at the Group level is invalid"), - AM17("InvalidPaymentInfoControlSum", "Control Sum at the Payment Information level is invalid"), - AM18("InvalidNumberOfTransactions", "Number of transactions is invalid or missing."), - AM19("InvalidGroupNumberOfTransactions", "Number of transactions at the Group level is invalid or missing"), - AM20("InvalidPaymentInfoNumberOfTransactions", "Number of transactions at the Payment Information level is invalid"), - AM21("LimitExceeded", "Transaction amount exceeds limits agreed between bank and client."), - AM22("ZeroAmountNotApplied", "Unable to apply zero amount to designated account. For example, where the rules of a service allow the use of zero amount payments, however the back-office system is unable to apply the funds to the account. If the rules of a service prohibit the use of zero amount payments, then code AM01 is used to report the error condition."), - AM23("AmountExceedsSettlementLimit", "Transaction amount exceeds settlement limit."), - AMSE("AttachmentMaximumSize", "Size of the attachment exceeds the allowed maximum."), - APAR("AlreadyPaidRTP", "Request To Pay has already been paid by the Debtor."), - ARFR("AlreadyRefusedRTP", "Request-to-pay has already been refused by the Debtor."), - ARJR("AlreadyRejectedRTP", "Request-to-pay has already been rejected."), - ATNS("AttachementsNotSupported", "Attachments to the request-to-pay are not supported."), - BDAY("NotBusinessDay", "Settlement Cycle Day and Calendar day should be the same."), - BE01("InconsistenWithEndCustomer", "Identification of end customer is not consistent with associated account number. (formerly CreditorConsistency)."), - BE04("MissingCreditorAddress", "Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress)."), - BE05("UnrecognisedInitiatingParty", "Party who initiated the message is not recognised by the end customer"), - BE06("UnknownEndCustomer", "End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books"), - BE07("MissingDebtorAddress", "Specification of debtor's address, which is required for payment, is missing/not correct."), - BE08("MissingDebtorName", "Debtor name is missing"), - BE09("InvalidCountry", "Country code is missing or Invalid."), - BE10("InvalidDebtorCountry", "Debtor country code is missing or invalid"), - BE11("InvalidCreditorCountry", "Creditor country code is missing or invalid"), - BE12("InvalidCountryOfResidence", "Country code of residence is missing or Invalid."), - BE13("InvalidDebtorCountryOfResidence", "Country code of debtor's residence is missing or Invalid"), - BE14("InvalidCreditorCountryOfResidence", "Country code of creditor's residence is missing or Invalid"), - BE15("InvalidIdentificationCode", "Identification code missing or invalid."), - BE16("InvalidDebtorIdentificationCode", "Debtor or Ultimate Debtor identification code missing or invalid"), - BE17("InvalidCreditorIdentificationCode", "Creditor or Ultimate Creditor identification code missing or invalid"), - BE18("InvalidContactDetails", "Contact details missing or invalid"), - BE19("InvalidChargeBearerCode", "Charge bearer code for transaction type is invalid"), - BE20("InvalidNameLength", "Name length exceeds local rules for payment type."), - BE21("MissingName", "Name missing or invalid. Generic usage if cannot specifically identify debtor or creditor."), - BE22("MissingCreditorName", "Creditor name is missing"), - BE23("AccountProxyInvalid", "Phone number or email address, or any other proxy, used as the account proxy is unknown or invalid."), - CERI("CheckERI", "Credit transfer is not tagged as an Extended Remittance Information (ERI) transaction but contains ERI."), - CH03("RequestedExecutionDateOrRequestedCollectionDateTooFarInFuture", "Value in Requested Execution Date or Requested Collection Date is too far in the future"), - CH04("RequestedExecutionDateOrRequestedCollectionDateTooFarInPast", "Value in Requested Execution Date or Requested Collection Date is too far in the past"), - CH07("ElementIsNotToBeUsedAtB-andC-Level", "Element is not to be used at B- and C-Level"), - CH09("MandateChangesNotAllowed", "Mandate changes are not allowed"), - CH10("InformationOnMandateChangesMissing", "Information on mandate changes are missing"), - CH11("CreditorIdentifierIncorrect", "Value in Creditor Identifier is incorrect"), - CH12("CreditorIdentifierNotUnambiguouslyAtTransaction-Level", "Creditor Identifier is ambiguous at Transaction Level"), - CH13("OriginalDebtorAccountIsNotToBeUsed", "Original Debtor Account is not to be used"), - CH14("OriginalDebtorAgentIsNotToBeUsed", "Original Debtor Agent is not to be used"), - CH15("ElementContentIncludesMoreThan140Characters", "Content Remittance Information/Structured includes more than 140 characters"), - CH16("ElementContentFormallyIncorrect", "Content is incorrect"), - CH17("ElementNotAdmitted", "Element is not allowed"), - CH19("ValuesWillBeSetToNextTARGETday", "Values in Interbank Settlement Date or Requested Collection Date will be set to the next TARGET day"), - CH20("DecimalPointsNotCompatibleWithCurrency", "Number of decimal points not compatible with the currency"), - CH21("RequiredCompulsoryElementMissing", "Mandatory element is missing"), - CH22("COREandB2BwithinOnemessage", "SDD CORE and B2B not permitted within one message"), - CHCO("UnacceptedChargeCodeType", "Related to a Charge message to convey that the code in Charge Breakdown / Type / Code is not accepted by the receiving party."), - CHQC("ChequeSettledOnCreditorAccount", "Cheque has been presented in cheque clearing and settled on the creditor’s account."), - CHRG("UnderlyingChargeBearerWasNotDebt", "Related to a Charge message to convey that the charge bearer code used in the corresponding Payment message was not debt."), - CN01("AuthorisationCancelled", "Authorisation is cancelled."), - CNNS("CreditNotesNotSupported", "Credit notes are not supported."), - CNOR("CreditorBankIsNotRegistered", "Creditor bank is not registered under this BIC in the CSM"), - CURR("IncorrectCurrency", "Currency of the payment is incorrect"), - CUST("RequestedByCustomer", "Cancellation requested by the Debtor"), - DC02("SettlementNotReceived", "Rejection of a payment due to covering FI settlement not being received."), - DNOR("DebtorBankIsNotRegistered", "Debtor bank is not registered under this BIC in the CSM"), - DS01("ElectronicSignaturesCorrect", "The electronic signature(s) is/are correct"), - DS02("OrderCancelled", "An authorized user has cancelled the order"), - DS03("OrderNotCancelled", "The user’s attempt to cancel the order was not successful"), - DS04("OrderRejected", "The order was rejected by the bank side (for reasons concerning content)"), - DS05("OrderForwardedForPostprocessing", "The order was correct and could be forwarded for postprocessing"), - DS06("TransferOrder", "The order was transferred to VEU"), - DS07("ProcessingOK", "All actions concerning the order could be done by the EBICS bank server"), - DS08("DecompressionError", "The decompression of the file was not successful"), - DS09("DecryptionError", "The decryption of the file was not successful"), - DS0A("DataSignRequested", "Data signature is required."), - DS0B("UnknownDataSignFormat", "Data signature for the format is not available or invalid."), - DS0C("SignerCertificateRevoked", "The signer certificate is revoked."), - DS0D("SignerCertificateNotValid", "The signer certificate is not valid (revoked or not active)."), - DS0E("IncorrectSignerCertificate", "The signer certificate is not present."), - DS0F("SignerCertificationAuthoritySignerNotValid", "The authority of the signer certification sending the certificate is unknown."), - DS0G("NotAllowedPayment", "Signer is not allowed to sign this operation type."), - DS0H("NotAllowedAccount", "Signer is not allowed to sign for this account."), - DS0K("NotAllowedNumberOfTransaction", "The number of transaction is over the number allowed for this signer."), - DS10("Signer1CertificateRevoked", "The certificate is revoked for the first signer."), - DS11("Signer1CertificateNotValid", "The certificate is not valid (revoked or not active) for the first signer."), - DS12("IncorrectSigner1Certificate", "The certificate is not present for the first signer."), - DS13("SignerCertificationAuthoritySigner1NotValid", "The authority of signer certification sending the certificate is unknown for the first signer."), - DS14("UserDoesNotExist", "The user is unknown on the server"), - DS15("IdenticalSignatureFound", "The same signature has already been sent to the bank"), - DS16("PublicKeyVersionIncorrect", "The public key version is not correct. This code is returned when a customer sends signature files to the financial institution after conversion from an older program version (old ES format) to a new program version (new ES format) without having carried out re-initialisation with regard to a public key change."), - DS17("DifferentOrderDataInSignatures", "Order data and signatures don’t match"), - DS18("RepeatOrder", "File cannot be tested, the complete order has to be repeated. This code is returned in the event of a malfunction during the signature check, e.g. not enough storage space."), - DS19("ElectronicSignatureRightsInsufficient", "The user’s rights (concerning his signature) are insufficient to execute the order"), - DS20("Signer2CertificateRevoked", "The certificate is revoked for the second signer."), - DS21("Signer2CertificateNotValid", "The certificate is not valid (revoked or not active) for the second signer."), - DS22("IncorrectSigner2Certificate", "The certificate is not present for the second signer."), - DS23("SignerCertificationAuthoritySigner2NotValid", "The authority of signer certification sending the certificate is unknown for the second signer."), - DS24("WaitingTimeExpired", "Waiting time expired due to incomplete order"), - DS25("OrderFileDeleted", "The order file was deleted by the bank server"), - DS26("UserSignedMultipleTimes", "The same user has signed multiple times"), - DS27("UserNotYetActivated", "The user is not yet activated (technically)"), - DS28("ReturnForTechnicalReason", "Message routed to the wrong environment."), - DT01("InvalidDate", "Invalid date (eg, wrong or missing settlement date)"), - DT02("InvalidCreationDate", "Invalid creation date and time in Group Header (eg, historic date)"), - DT03("InvalidNonProcessingDate", "Invalid non bank processing date (eg, weekend or local public holiday)"), - DT04("FutureDateNotSupported", "Future date not supported"), - DT05("InvalidCutOffDate", "Associated message, payment information block or transaction was received after agreed processing cut-off date, i.e., date in the past."), - DT06("ExecutionDateChanged", "Execution Date has been modified in order for transaction to be processed"), - DU01("DuplicateMessageID", "Message Identification is not unique."), - DU02("DuplicatePaymentInformationID", "Payment Information Block is not unique."), - DU03("DuplicateTransaction", "Transaction is not unique."), - DU04("DuplicateEndToEndID", "End To End ID is not unique."), - DU05("DuplicateInstructionID", "Instruction ID is not unique."), - DUPL("DuplicatePaymentOrCharge", "Payment or charge is a duplicate of another payment or charge."), - ED01("CorrespondentBankNotPossible", "Correspondent bank not possible."), - ED03("BalanceInfoRequest", "Balance of payments complementary info is requested"), - ED05("SettlementFailed", "Settlement of the transaction has failed."), - ED06("SettlementSystemNotAvailable", "Interbank settlement system not available."), - EDNA("ExecutionDateNotAccepted", "Requested execution date of the payment is not accepted."), - EDTL("ExpiryDateTooLong", "Expiry date time of the request-to-pay is too far in the future."), - EDTR("ExpiryDateTimeReached", "Expiry date time of the request-to-pay is already reached."), - EOL1("EndOfLife", "Expiration of the payment authorisation due to no use for too long."), - ERIN("ERIOptionNotSupported", "Extended Remittance Information (ERI) option is not supported."), - FF01("InvalidFileFormat", "File Format incomplete or invalid"), - FF02("SyntaxError", "Syntax error reason is provided as narrative information in the additional reason information."), - FF03("InvalidPaymentTypeInformation", "Payment Type Information is missing or invalid."), - FF04("InvalidServiceLevelCode", "Service Level code is missing or invalid"), - FF05("InvalidLocalInstrumentCode", "Local Instrument code is missing or invalid"), - FF06("InvalidCategoryPurposeCode", "Category Purpose code is missing or invalid"), - FF07("InvalidPurpose", "Purpose is missing or invalid"), - FF08("InvalidEndToEndId", "End to End Id missing or invalid"), - FF09("InvalidChequeNumber", "Cheque number missing or invalid"), - FF10("BankSystemProcessingError", "File or transaction cannot be processed due to technical issues at the bank side"), - FF11("ClearingRequestAborted", "Clearing request rejected due it being subject to an abort operation."), - FF12("OriginalTransactionNotEligibleForRequestedReturn", "Original payment is not eligible to be returned given its current status."), - FF13("RequestForCancellationNotFound", "No record of request for cancellation found."), - FOCR("FollowingCancellationRequest", "Return following a cancellation request."), - FR01("Fraud", "Returned as a result of fraud."), - FRAD("FraudulentOrigin", "Cancellation requested following a transaction that was originated fraudulently. The use of the FraudulentOrigin code should be governed by jurisdictions."), - G000("PaymentTransferredAndTracked", "In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure. The payment transfer is tracked. No further updates will follow from the Status Originator."), - G001("PaymentTransferredAndNotTracked", "In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure. The payment transfer is not tracked. No further updates will follow from the Status Originator."), - G002("CreditDebitNotConfirmed", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account may not be confirmed same day. Update will follow from the Status Originator."), - G003("CreditPendingDocuments", "In a FIToFI Customer Credit Transfer: Credit to creditor’s account is pending receipt of required documents. The Status Originator has requested creditor to provide additional documentation. Update will follow from the Status Originator."), - G004("CreditPendingFunds", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover. Update will follow from the Status Originator."), - G005("DeliveredWithServiceLevel", "Payment has been delivered to creditor agent with service level."), - G006("DeliveredWIthoutServiceLevel", "Payment has been delivered to creditor agent without service level."), - ID01("CorrespondingOriginalFileStillNotSent", "Signature file was sent to the bank but the corresponding original file has not been sent yet."), - IEDT("IncorrectExpiryDateTime", "Expiry date time of the request-to-pay is incorrect."), - INAR("InvalidActivationReference", "Payer’s activation reference is invalid."), - INDT("InvalidDetails", "Details not valid for this field."), - IPNS("InstalmentPaymentsNotSupported", "Payments in instalments are not supported."), - IRNR("InitialRTPNeverReceived", "No initial request-to-pay has been received."), - ISWS("InvalidSettlementWindow", "Cannot schedule instruction for Night Window."), - MD01("NoMandate", "No Mandate"), - MD02("MissingMandatoryInformationInMandate", "Mandate related information data required by the scheme is missing."), - MD05("CollectionNotDue", "Creditor or creditor's agent should not have collected the direct debit"), - MD06("RefundRequestByEndCustomer", "Return of funds requested by end customer"), - MD07("EndCustomerDeceased", "End customer is deceased."), - MINF("MissingInformation", "Information missing for the field or cannot be empty."), - MS02("NotSpecifiedReasonCustomerGenerated", "Reason has not been specified by end customer"), - MS03("NotSpecifiedReasonAgentGenerated", "Reason has not been specified by agent."), - NARR("Narrative", "Reason is provided as narrative information in the additional reason information."), - NERI("NoERI", "Credit transfer is tagged as an Extended Remittance Information (ERI) transaction but does not contain ERI."), - NOAR("NonAgreedRTP", "No existing agreement for receiving request-to-pay messages."), - NOAS("NoAnswerFromCustomer", "No response from Beneficiary."), - NOCM("NotCompliantGeneric", "Customer account is not compliant with regulatory requirements, for example FICA (in South Africa) or any other regulatory requirements which render an account inactive for certain processing."), - NOFR("OutstandingFundingForSettlement", "Continuous Processing Line on Hold Instruction."), - NOPG("NoPaymentGuarantee", "Requested payment guarantee (by Creditor) related to a request-to-pay cannot be provided."), - NRCH("PayerOrPayerRTPSPNotReachable", "Recipient side of the request-to-pay (payer or its request-to-pay service provider) is not reachable."), - OSNS("OptionalServiceNotSupported", "Requested optional service (for example instalment payments) is not supported."), - PINS("TypeOfPaymentInstrumentNotSupported", "Type of payment requested in the request-to-pay is not supported by the payer."), - RC01("BankIdentifierIncorrect", "Bank identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode)."), - RC02("InvalidBankIdentifier", "Bank identifier is invalid or missing."), - RC03("InvalidDebtorBankIdentifier", "Debtor bank identifier is invalid or missing"), - RC04("InvalidCreditorBankIdentifier", "Creditor bank identifier is invalid or missing"), - RC05("InvalidBICIdentifier", "BIC identifier is invalid or missing."), - RC06("InvalidDebtorBICIdentifier", "Debtor BIC identifier is invalid or missing"), - RC07("InvalidCreditorBICIdentifier", "Creditor BIC identifier is invalid or missing"), - RC08("InvalidClearingSystemMemberIdentifier", "ClearingSystemMemberidentifier is invalid or missing."), - RC09("InvalidDebtorClearingSystemMemberIdentifier", "Debtor ClearingSystemMember identifier is invalid or missing"), - RC10("InvalidCreditorClearingSystemMemberIdentifier", "Creditor ClearingSystemMember identifier is invalid or missing"), - RC11("InvalidIntermediaryAgent", "Intermediary Agent is invalid or missing"), - RC12("MissingCreditorSchemeId", "Creditor Scheme Id is invalid or missing"), - RC13("ParticipantNotAnActiveMemberofRTGS", "Originator not active any more."), - RC15("ParticipantNotActiveMemberSettlementType", "Settlement agreement required."), - RC16("ParticipantNotActiveMemberofSADCRTGS", "Participant blocked from SADC-RTGS."), - RCON("RMessageConflict", "Conflict with R-Message"), - RECI("ReceiverCustomerInformation", "Further information regarding the intended recipient."), - REPR("RTPReceivedCanBeProcessed", "Request-to-pay has been received and can be processed further."), - RF01("NotUniqueTransactionReference", "Transaction reference is not unique within the message."), - RQNR("RequestNotRecognized", "Payer did not recognize the request from Payee Participant,"), - RR01("MissingDebtorAccountOrIdentification", "Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing"), - RR02("MissingDebtorNameOrAddress", "Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing."), - RR03("MissingCreditorNameOrAddress", "Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing."), - RR04("RegulatoryReason", "Regulatory Reason"), - RR05("RegulatoryInformationInvalid", "Regulatory or Central Bank Reporting information missing, incomplete or invalid."), - RR06("TaxInformationInvalid", "Tax information missing, incomplete or invalid."), - RR07("RemittanceInformationInvalid", "Remittance information structure does not comply with rules for payment type."), - RR08("RemittanceInformationTruncated", "Remittance information truncated to comply with rules for payment type."), - RR09("InvalidStructuredCreditorReference", "Structured creditor reference invalid or missing."), - RR10("InvalidCharacterSet", "Character set supplied not valid for the country and payment type."), - RR11("InvalidDebtorAgentServiceID", "Invalid or missing identification of a bank proprietary service."), - RR12("InvalidPartyID", "Invalid or missing identification required within a particular country or payment type."), - RTNS("RTPNotSupportedForDebtor", "Debtor does not support request-to-pay transactions."), - RUTA("ReturnUponUnableToApply", "Return following investigation request and no remediation possible."), - S000("ValidRequestForCancellationAcknowledged", "Request for Cancellation is acknowledged following validation."), - S001("UETRFlaggedForCancellation", "Unique End-to-end Transaction Reference (UETR) relating to a payment has been identified as being associated with a Request for Cancellation."), - S002("NetworkStopOfUETR", "Unique End-to-end Transaction Reference (UETR) relating to a payment has been prevent from traveling across a messaging network."), - S003("RequestForCancellationForwarded", "Request for Cancellation has been forwarded to the payment processing/last payment processing agent."), - S004("RequestForCancellationDeliveryAcknowledgement", "Request for Cancellation has been acknowledged as delivered to payment processing/last payment processing agent."), - SBRN("SettlementBatchRemovalNotification", "Remove Concurrent Batch Processing Line on hold instruction."), - SL01("SpecificServiceOfferedByDebtorAgent", "Due to specific service offered by the Debtor Agent."), - SL02("SpecificServiceOfferedByCreditorAgent", "Due to specific service offered by the Creditor Agent."), - SL03("ServiceofClearingSystem", "Due to a specific service offered by the clearing system."), - SL11("CreditorNotOnWhitelistOfDebtor", "Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet). In the Whitelist the Debtor may list all allowed Creditors to debit Debtor bank account."), - SL12("CreditorOnBlacklistOfDebtor", "Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist”. In the Blacklist the Debtor may list all Creditors not allowed to debit Debtor bank account."), - SL13("MaximumNumberOfDirectDebitTransactionsExceeded", "Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent."), - SL14("MaximumDirectDebitTransactionAmountExceeded", "Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent."), - SL15("MaximumNumberOfCreditTransactionsExceeded", "Maximum number of credit transactions allowed by the account servicer per service period exceeded."), - SL16("MaximumCreditTransactionsAmountExceeded", "Maximum total credit amount allowed by the account servicer per service period exceeded."), - SL17("DebtorNotOnWhitelistOfCreditorSide", "Whitelisting service offered by payment system operator or financial institution. Debtor is not included on the Creditor side whitelist."), - SL18("DebtorOnBlacklistOfCreditorSide", "Blacklisting service offered by payment system operator or financial institution. Debtor included on the Creditor side blacklist."), - SNRD("ServiceNotRendered", "Services are not yet rendered by the Payee Participant (Creditor)."), - SPII("RTPServiceProviderIdentifierIncorrect", "Identifier of the request-to-pay service provider is incorrect."), - TA01("TransmissonAborted", "The transmission of the file was not successful – it had to be aborted (for technical reasons)"), - TD01("NoDataAvailable", "There is no data available (for download)"), - TD02("FileNonReadable", "The file cannot be read (e.g. unknown format)"), - TD03("IncorrectFileStructure", "The file format is incomplete or invalid"), - TK01("TokenInvalid", "Token is invalid."), - TK02("SenderTokenNotFound", "Token used for the sender does not exist."), - TK03("ReceiverTokenNotFound", "Token used for the receiver does not exist."), - TK09("TokenMissing", "Token required for request is missing."), - TKCM("TokenCounterpartyMismatch", "Token found with counterparty mismatch."), - TKSG("TokenSingleUse", "Single Use Token already used."), - TKSP("TokenSuspended", "Token found with suspended status."), - TKVE("TokenValueLimitExceeded", "Token found with value limit rule violation."), - TKXP("TokenExpired", "Token expired."), - TM01("InvalidCutOffTime", "Associated message, payment information block, or transaction was received after agreed processing cut-off time."), - TS01("TransmissionSuccessful", "The (technical) transmission of the file was successful."), - TS04("TransferToSignByHand", "The order was transferred to pass by accompanying note signed by hand"), - UCRD("UnknownCreditor", "Unknown Creditor."), - UPAY("UnduePayment", "Payment is not justified."), -} - -enum class ExternalPaymentGroupStatusCode(val isoCode: String, val description: String) { - ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the creditor's account has been completed."), - ACCP("AcceptedCustomerProfile", "Preceding check of technical validation was successful. Customer profile check was also successful."), - ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement on the debtor's account has been completed."), - ACSP("AcceptedSettlementInProcess", "All preceding checks such as technical validation and customer profile were successful and therefore the payment initiation has been accepted for execution."), - ACTC("AcceptedTechnicalValidation", "Authentication and syntactical and semantical validation are successful"), - ACWC("AcceptedWithChange", "Instruction is accepted but a change will be made, such as date or remittance not sent."), - PART("PartiallyAccepted", "A number of transactions have been accepted, whereas another number of transactions have not yet achieved"), - PDNG("Pending", "Payment initiation or individual transaction included in the payment initiation is pending. Further checks and status update will be performed."), - RCVC("ReceivedVerificationCompleted", "Verification of Payee check have been applied to received transactions stating to be complete without mismatching data."), - RCVD("Received", "Payment initiation has been received by the receiving agent"), - RJCT("Rejected", "Payment initiation or individual transaction included in the payment initiation has been rejected."), - RVCM("ReceivedVerificationCompletedWithMismatches", "Verification of Payee checks have been applied to received transactions stating to be complete containing mismatching data."), - RVNC("ReceivedVerificationNotCompleted", "Verification of party check on transactions received is not yet completed."), -} - -enum class ExternalPaymentTransactionStatusCode(val isoCode: String, val description: String) { - ACCC("AcceptedSettlementCompletedCreditorAccount", "Settlement on the creditor's account has been completed."), - ACCP("AcceptedCustomerProfile", "Preceding check of technical validation was successful. Customer profile check was also successful."), - ACFC("AcceptedFundsChecked", "Preceding check of technical validation and customer profile was successful and an automatic funds check was positive."), - ACFW("AcceptedFundsCheckedWaitingConfirmation", "Preceding check of technical validation and customer profile was successful, and an automatic funds check was positive, but an explicit confirmation by the initiating party is outstanding."), - ACIS("AcceptedandChequeIssued", "Payment instruction to issue a cheque has been accepted, and the cheque has been issued but not yet been deposited or cleared."), - ACPD("AcceptedClearingProcessed", "Status of transaction released from the Debtor Agent and accepted by the clearing."), - ACSC("AcceptedSettlementCompletedDebitorAccount", "Settlement completed."), - ACSP("AcceptedSettlementInProcess", "All preceding checks such as technical validation and customer profile were successful and therefore the payment instruction has been accepted for execution."), - ACTC("AcceptedTechnicalValidation", "Authentication and syntactical and semantical validation are successful"), - ACWC("AcceptedWithChange", "Instruction is accepted but a change will be made, such as date or remittance not sent."), - ACWP("AcceptedWithoutPosting", "Payment instruction included in the credit transfer is accepted without being posted to the creditor customer’s account."), - BLCK("Blocked", "Payment transaction previously reported with status 'ACWP' is blocked, for example, funds will neither be posted to the Creditor's account, nor be returned to the Debtor."), - CANC("Cancelled", "Payment initiation has been successfully cancelled after having received a request for cancellation."), - CPUC("CashPickedUpByCreditor", "Cash has been picked up by the Creditor."), - PATC("PartiallyAcceptedTechnicalCorrect", "Payment initiation needs multiple authentications, where some but not yet all have been performed. Syntactical and semantical validations are successful."), - PDNG("Pending", "Payment instruction is pending. Further checks and status update will be performed."), - PRES("Presented", "Request for Payment has been presented to the Debtor."), - RCVC("ReceivedVerificationCompleted", "Verification of Payee check has been applied to received transaction stating to be complete without mismatching data."), - RCVD("Received", "Payment instruction has been received."), - RJCT("Rejected", "Payment instruction has been rejected."), - RVCM("ReceivedVerificationCompletedWithMismatches", "Verification of Payee checks have been applied to received transaction stating to be completed containing mismatching data."), - RVMC("ReceivedVerificationCompletedMatchClosely", "Verification of Payee check has been applied to received transaction stating to be complete with data matching closely."), - RVNA("ReceivedVerificationCompletedNotApplicable", "Verification of Payee check has been applied to received transaction stating to be complete with not applicable data."), - RVNC("ReceivedVerificationNotCompleted", "Verification of party check on the transaction is not yet completed."), - RVNM("ReceivedVerificationCompletedNoMatch", "Verification of Payee check has been applied to received transaction stating to be complete with mismatching data."), -} - -enum class ExternalReturnReasonCode(val isoCode: String, val description: String) { - AC01("IncorrectAccountNumber", "Format of the account number specified is not correct"), - AC02("InvalidDebtorAccountNumber", "Debtor account number invalid or missing."), - AC03("InvalidCreditorAccountNumber", "Wrong IBAN in SCT"), - AC04("ClosedAccountNumber", "Account number specified has been closed on the bank of account's books"), - AC06("BlockedAccount", "Account specified is blocked, prohibiting posting of transactions against it."), - AC07("ClosedCreditorAccountNumber", "Creditor account number closed."), - AC13("InvalidDebtorAccountType", "Debtor account type is missing or invalid"), - AC14("InvalidAgent", "An agent in the payment chain is invalid."), - AC15("AccountDetailsChanged", "Account details have changed."), - AC16("AccountInSequestration", "Account is in sequestration."), - AC17("AccountInLiquidation", "Account is in liquidation."), - AG01("TransactionForbidden", "Transaction forbidden on this type of account (formerly NoAgreement)"), - AG02("InvalidBankOperationCode", "Bank Operation code specified in the message is not valid for receiver"), - AG07("UnsuccesfulDirectDebit", "Debtor account cannot be debited for a generic reason."), - AGNT("IncorrectAgent", "Agent in the payment workflow is incorrect."), - AM01("ZeroAmount", "Specified message amount is equal to zero"), - AM02("NotAllowedAmount", "Specific transaction/message amount is greater than allowed maximum"), - AM03("NotAllowedCurrency", "Specified message amount is an non processable currency outside of existing agreement"), - AM04("InsufficientFunds", "Amount of funds available to cover specified message amount is insufficient."), - AM05("Duplication", "Duplication"), - AM06("TooLowAmount", "Specified transaction amount is less than agreed minimum."), - AM07("BlockedAmount", "Amount specified in message has been blocked by regulatory authorities."), - AM09("WrongAmount", "Amount received is not the amount agreed or expected"), - AM10("InvalidControlSum", "Sum of instructed amounts does not equal the control sum."), - ARDT("AlreadyReturnedTransaction", "Already returned original SCT"), - BE01("InconsistenWithEndCustomer", "Identification of end customer is not consistent with associated account number, organisation ID or private ID."), - BE04("MissingCreditorAddress", "Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress)."), - BE05("UnrecognisedInitiatingParty", "Party who initiated the message is not recognised by the end customer"), - BE06("UnknownEndCustomer", "End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books"), - BE07("MissingDebtorAddress", "Specification of debtor's address, which is required for payment, is missing/not correct."), - BE08("BankError", "Returned as a result of a bank error."), - BE10("InvalidDebtorCountry", "Debtor country code is missing or invalid."), - BE11("InvalidCreditorCountry", "Creditor country code is missing or invalid."), - BE16("InvalidDebtorIdentificationCode", "Debtor or Ultimate Debtor identification code missing or invalid."), - BE17("InvalidCreditorIdentificationCode", "Creditor or Ultimate Creditor identification code missing or invalid."), - CN01("AuthorisationCancelled", "Authorisation is cancelled."), - CNOR("CreditorBankIsNotRegistered", "Creditor bank is not registered under this BIC in the CSM"), - CNPC("CashNotPickedUp", "Cash not picked up by Creditor or cash could not be delivered to Creditor"), - CURR("IncorrectCurrency", "Currency of the payment is incorrect"), - CUST("RequestedByCustomer", "Cancellation requested by the Debtor"), - DC04("NoCustomerCreditTransferReceived", "Return of Covering Settlement due to the underlying Credit Transfer details not being received."), - DNOR("DebtorBankIsNotRegistered", "Debtor bank is not registered under this BIC in the CSM"), - DS28("ReturnForTechnicalReason", "Return following technical problems resulting in erroneous transaction."), - DT01("InvalidDate", "Invalid date (eg, wrong settlement date)"), - DT02("ChequeExpired", "Cheque has been issued but not deposited and is considered expired."), - DT04("FutureDateNotSupported", "Future date not supported."), - DUPL("DuplicatePayment", "Payment is a duplicate of another payment."), - ED01("CorrespondentBankNotPossible", "Correspondent bank not possible."), - ED03("BalanceInfoRequest", "Balance of payments complementary info is requested"), - ED05("SettlementFailed", "Settlement of the transaction has failed."), - EMVL("EMVLiabilityShift", "The card payment is fraudulent and was not processed with EMV technology for an EMV card."), - ERIN("ERIOptionNotSupported", "The Extended Remittance Information (ERI) option is not supported."), - FF03("InvalidPaymentTypeInformation", "Payment Type Information is missing or invalid."), - FF04("InvalidServiceLevelCode", "Service Level code is missing or invalid."), - FF05("InvalidLocalInstrumentCode", "Local Instrument code is missing or invalid"), - FF06("InvalidCategoryPurposeCode", "Category Purpose code is missing or invalid."), - FF07("InvalidPurpose", "Purpose is missing or invalid."), - FOCR("FollowingCancellationRequest", "Return following a cancellation request"), - FR01("Fraud", "Returned as a result of fraud."), - FRTR("FinalResponseMandateCancelled", "Final response/tracking is recalled as mandate is cancelled."), - G004("CreditPendingFunds", "In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover. Update will follow from the Status Originator."), - MD01("NoMandate", "No Mandate"), - MD02("MissingMandatoryInformationInMandate", "Mandate related information data required by the scheme is missing."), - MD05("CollectionNotDue", "Creditor or creditor's agent should not have collected the direct debit."), - MD06("RefundRequestByEndCustomer", "Return of funds requested by end customer"), - MD07("EndCustomerDeceased", "End customer is deceased."), - MS02("NotSpecifiedReasonCustomerGenerated", "Reason has not been specified by end customer"), - MS03("NotSpecifiedReasonAgentGenerated", "Reason has not been specified by agent."), - NARR("Narrative", "Reason is provided as narrative information in the additional reason information."), - NOAS("NoAnswerFromCustomer", "No response from Beneficiary"), - NOCM("NotCompliant", "Customer account is not compliant with regulatory requirements, for example FICA (in South Africa) or any other regulatory requirements which render an account inactive for certain processing."), - NOOR("NoOriginalTransactionReceived", "Original SCT never received"), - PINL("PINLiabilityShift", "The card payment is fraudulent (lost and stolen fraud) and was processed as EMV transaction without PIN verification."), - RC01("BankIdentifierIncorrect", "Bank Identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode)."), - RC03("InvalidDebtorBankIdentifier", "Debtor bank identifier is invalid or missing."), - RC04("InvalidCreditorBankIdentifier", "Creditor bank identifier is invalid or missing."), - RC07("InvalidCreditorBICIdentifier", "Incorrrect BIC of the beneficiary Bank in the SCTR"), - RC08("InvalidClearingSystemMemberIdentifier", "ClearingSystemMemberidentifier is invalid or missing."), - RC11("InvalidIntermediaryAgent", "Intermediary Agent is invalid or missing."), - RF01("NotUniqueTransactionReference", "Transaction reference is not unique within the message."), - RR01("MissingDebtorAccountOrIdentification", "Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing"), - RR02("MissingDebtorNameOrAddress", "Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing."), - RR03("MissingCreditorNameOrAddress", "Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing."), - RR04("RegulatoryReason", "Regulatory Reason"), - RR05("RegulatoryInformationInvalid", "Regulatory or Central Bank Reporting information missing, incomplete or invalid."), - RR06("TaxInformationInvalid", "Tax information missing, incomplete or invalid."), - RR07("RemittanceInformationInvalid", "Remittance information structure does not comply with rules for payment type."), - RR08("RemittanceInformationTruncated", "Remittance information truncated to comply with rules for payment type."), - RR09("InvalidStructuredCreditorReference", "Structured creditor reference invalid or missing."), - RR11("InvalidDebtorAgentServiceIdentification", "Invalid or missing identification of a bank proprietary service."), - RR12("InvalidPartyIdentification", "Invalid or missing identification required within a particular country or payment type."), - RUTA("ReturnUponUnableToApply", "Return following investigation request and no remediation possible."), - SL01("SpecificServiceOfferedByDebtorAgent", "Due to specific service offered by the Debtor Agent"), - SL02("SpecificServiceOfferedByCreditorAgent", "Due to specific service offered by the Creditor Agent"), - SL11("CreditorNotOnWhitelistOfDebtor", "Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet). In the Whitelist the Debtor may list all allowed Creditors to debit Debtor bank account."), - SL12("CreditorOnBlacklistOfDebtor", "Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist”. In the Blacklist the Debtor may list all Creditors not allowed to debit Debtor bank account."), - SL13("MaximumNumberOfDirectDebitTransactionsExceeded", "Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent."), - SL14("MaximumDirectDebitTransactionAmountExceeded", "Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent."), - SP01("PaymentStopped", "Payment is stopped by account holder."), - SP02("PreviouslyStopped", "Previously stopped by means of a stop payment advise."), - SVNR("ServiceNotRendered", "The card payment is returned since a cash amount rendered was not correct or goods or a service was not rendered to the customer, e.g. in an e-commerce situation."), - TM01("CutOffTime", "Associated message was received after agreed processing cut-off time."), - TRAC("RemovedFromTracking", "Return following direct debit being removed from tracking process."), - UPAY("UnduePayment", "Payment is not justified."), -} - -enum class ChargeBearerTypeCode(val isoCode: String, val description: String) { -} - diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/iso20022/camt.kt @@ -1,708 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus.iso20022 - -import tech.libeufin.common.* -import tech.libeufin.nexus.* -import java.io.InputStream -import java.time.Instant -import java.time.ZoneOffset -import java.util.UUID - - -sealed interface TxNotification { - val executionTime: Instant -} - -/** ID for incoming transactions */ -data class IncomingId( - /** ISO20022 UETR */ - val uetr: UUID? = null, - /** ISO20022 TxID */ - val txId: String? = null, - /** ISO20022 AcctSvcrRef */ - val acctSvcrRef: String? = null, -) { - constructor(uetr: String, txId: String?, acctSvcrRef: String?) : this(UUID.fromString(uetr), txId, acctSvcrRef); - - fun ref(): String = uetr?.toString() ?: txId ?: acctSvcrRef!! - - override fun toString(): String = buildString { - append('(') - if (uetr != null) { - append("uetr=") - append(uetr.toString()) - } - if (txId != null) { - if (length != 1) append(" ") - append("tx=") - append(txId) - } - if (acctSvcrRef != null) { - if (length != 1) append(" ") - append("ref=") - append(acctSvcrRef) - } - append(')') - } -} - -sealed interface OutId {} - -/** ID for outgoing transactions */ -data class OutgoingId( - /** - * Unique msg ID generated by libeufin-nexus - * ISO20022 MessageId - **/ - val msgId: String? = null, - /** - * Unique end-to-end ID generated by libeufin-nexus - * ISO20022 EndToEndId or MessageId (retrocompatibility) - **/ - val endToEndId: String? = null, - /** - * Unique end-to-end ID generated by the bank - * ISO20022 AcctSvcrRef - **/ - val acctSvcrRef: String? = null, -): OutId { - fun ref(): String = endToEndId ?: acctSvcrRef ?: msgId!! - override fun toString(): String = buildString { - append('(') - if (msgId != null && msgId != endToEndId) { - append("msg=") - append(msgId.toString()) - } - if (endToEndId != null) { - if (length != 1) append(" ") - append("e2e=") - append(endToEndId) - } - if (acctSvcrRef != null) { - if (length != 1) append(" ") - append("ref=") - append(acctSvcrRef) - } - append(')') - } -} - -/** ID for outgoing batches */ -data class BatchId( - /** - * Unique msg ID generated by libeufin-nexus - * ISO20022 MessageId - **/ - val msgId: String, - /** - * Unique end-to-end ID generated by the bank - * ISO20022 AcctSvcrRef - **/ - val acctSvcrRef: String? = null, -): OutId { - fun ref(): String = msgId - override fun toString(): String = buildString { - append("(msg=") - append(msgId) - if (acctSvcrRef != null) { - if (length != 1) append(" ") - append("ref=") - append(acctSvcrRef) - } - append(')') - } -} - - -/** ISO20022 incoming payment */ -data class IncomingPayment( - val id: IncomingId, - val amount: TalerAmount, - val creditFee: TalerAmount? = null, - val subject: String?, - override val executionTime: Instant, - val debtor: IbanPayto? -): TxNotification { - override fun toString(): String = buildString { - append("IN ") - append(executionTime.fmtDate()) - append(" ") - append(amount) - if (creditFee != null) { - append("-") - append(creditFee) - } - append(" ") - append(id) - if (debtor != null) { - append(" debtor=") - append(debtor.fmt()) - } - if (subject != null) { - append(" subject='") - append(subject) - append("'") - } - } -} - -/** ISO20022 outgoing payment */ -data class OutgoingPayment( - val id: OutgoingId, - val amount: TalerAmount, - val debitFee: TalerAmount? = null, - val subject: String?, - override val executionTime: Instant, - val creditor: IbanPayto? -): TxNotification { - override fun toString(): String = buildString { - append("OUT ") - append(executionTime.fmtDate()) - append(" ") - append(amount) - if (debitFee != null) { - append("-") - append(debitFee) - } - append(" ") - append(id) - if (creditor != null) { - append(" creditor=") - append(creditor.fmt()) - } - if (subject != null) { - append(" subject='") - append(subject) - append("'") - } - } -} - -/** ISO20022 outgoing batch */ -data class OutgoingBatch( - /** ISO20022 MessageId */ - val msgId: String, - override val executionTime: Instant, -): TxNotification { - override fun toString(): String { - return "BATCH ${executionTime.fmtDate()} $msgId" - } -} - -/** ISO20022 outgoing reversal */ -data class OutgoingReversal( - /** ISO20022 EndToEndId */ - val endToEndId: String, - /** ISO20022 MessageId */ - val msgId: String? = null, - val reason: String?, - override val executionTime: Instant -): TxNotification { - override fun toString(): String { - val msgIdFmt = if (msgId == null) "" else "$msgId." - return "BOUNCE ${executionTime.fmtDate()} $msgIdFmt$endToEndId: $reason" - } -} - -private class IncompleteTx(val msg: String): Exception(msg) - -private enum class Kind { - CRDT, - DBIT -} - -/** Parse a payto */ -private fun XmlDestructor.payto(prefix: String): IbanPayto? { - return opt("RltdPties") { - val iban = opt("${prefix}Acct")?.one("Id")?.opt("IBAN")?.text() - if (iban != null) { - val name = opt(prefix) { opt("Nm")?.text() ?: opt("Pty")?.one("Nm")?.text() } - // TODO more performant option - ibanPayto(iban, name) - } else { - null - } - } -} - -/** Check if an entry status is BOOK */ -private fun XmlDestructor.isBooked(): Boolean { - // We check at the Sts or Sts/Cd level for retrocompatibility - return one("Sts") { - val status = opt("Cd")?.text() ?: text() - status == "BOOK" - } -} - -/** Parse the instruction execution date */ -private fun XmlDestructor.executionDate(): Instant { - // Value date if present else booking date - val date = opt("ValDt") ?: one("BookgDt") - val parsed = date.opt("Dt") { - date().atStartOfDay() - } ?: date.one("DtTm") { - dateTime() - } - return parsed.toInstant(ZoneOffset.UTC) -} - -/** Parse batch message ID and transaction end-to-end ID as generated by libeufin-nexus */ -private fun XmlDestructor.outgoingId(ref: String?): OutId = - opt("Refs") { - val endToEndId = opt("EndToEndId")?.text() - val msgId = opt("MsgId")?.text() - val ref = if (ref != "NOTPROVIDED") ref else null - if (msgId != null && endToEndId == null) { - // This is a batch representation - BatchId(msgId, ref) - } else if (endToEndId == "NOTPROVIDED") { - // If not set use MsgId as end-to-end ID for retrocompatibility - OutgoingId(msgId, msgId, ref) - } else { - OutgoingId(msgId, endToEndId, ref) - } - } ?: OutgoingId(acctSvcrRef = ref) - -/** Parse transaction ids as provided by bank*/ -private fun XmlDestructor.incomingId(ref: String?): IncomingId = - opt("Refs") { - val uetr = opt("UETR")?.uuid() - val txId = opt("TxId")?.text() - IncomingId(uetr, txId, ref) - } ?: IncomingId(acctSvcrRef = ref) - - -/** Parse and format transaction return reasons */ -private fun XmlDestructor.returnReason(): String = opt("RtrInf") { - val code = one("Rsn").one("Cd").enum<ExternalReturnReasonCode>() - val info = map("AddtlInf") { text() }.joinToString("") - buildString { - append("${code.isoCode} '${code.description}'") - if (info.isNotEmpty()) { - append(" - '$info'") - } - } -} ?: opt("RmtInf") { - map("Ustrd") { text() }.joinToString("") -} ?: "" - -/** Parse amount */ -private fun XmlDestructor.amount() = one("Amt") { - val currency = attr("Ccy") - val amount = text() - val concat = if (amount.startsWith('.')) { - "$currency:0$amount" - } else { - "$currency:$amount" - } - TalerAmount(concat) -} - -data class ComplexAmount( - // Transaction amount - val amount: TalerAmount, - // The applied fee - private val fee: TalerAmount, -) { - /// The fees to register in database - fun fee(): TalerAmount? = if (fee.isZero()) { null } else { fee } - - /// Check that entry and tx amount are compatible and return the result - fun resolve(child: ComplexAmount): ComplexAmount { - // Most time transaction will match - if (this.amount == child.amount && this.fee == child.fee) { - return this - } - - // Or one of the level is missing the fee - if ( - (child.amount > child.fee && child.amount - child.fee == this.amount) || - this.amount - this.fee == child.amount - ) { - if (child.fee.isZero()) { - return this - } else { - return child - } - } - - // Or the conversion information are only present at the entry layer - if (child.amount.currency != this.amount.currency) { - return this - } - - throw Error("Amount mismatch, got ${this} in the entry and ${child} in the tx") - } -} - -private fun XmlDestructor.complexAmount(charges: List<ChargeRecord>): ComplexAmount? { - // Amount before charges - var amount = opt("Amt") { - val currency = attr("Ccy") - // In case of fee overflow it's possible to have a negative amount here - // We ignore this as it will be handled elsewhere correctly - val amount = text().trimStart('-') - TalerAmount("$currency:0$amount") - } ?: return null - - var fee: TalerAmount = TalerAmount.zero(amount.currency) - - for (chr in charges) { - if (chr.included) { - fee += chr.amount - if (chr.kind == Kind.DBIT) { - if (chr.bearer == ChargeBearer.DEBT) { - if (chr.amount > amount) { - // This can happen when an incoming transaction fail because of debit fee - amount = chr.amount - amount - } else { - amount -= chr.amount - } - } else if (chr.bearer == ChargeBearer.CRED) { - amount += chr.amount - } else { - throw Error("Included charge ${chr.kind} with bearer ${chr.bearer}") - } - } - } - } - - return ComplexAmount(amount, fee) -} - -/** Parse bank transaction code */ -private fun XmlDestructor.bankTransactionCode(): BankTransactionCode { - return one("BkTxCd").one("Domn") { - val domain = one("Cd").enum<ExternalBankTransactionDomainCode>() - one("Fmly") { - val family = one("Cd").enum<ExternalBankTransactionFamilyCode>() - val subFamily = one("SubFmlyCd").enum<ExternalBankTransactionSubFamilyCode>() - - BankTransactionCode(domain, family, subFamily) - } - } -} - -/** Parse optional bank transaction code */ -private fun XmlDestructor.optBankTransactionCode(): BankTransactionCode? { - return opt("BkTxCd")?.one("Domn") { - val domain = one("Cd").enum<ExternalBankTransactionDomainCode>() - one("Fmly") { - val family = one("Cd").enum<ExternalBankTransactionFamilyCode>() - val subFamily = one("SubFmlyCd").enum<ExternalBankTransactionSubFamilyCode>() - - BankTransactionCode(domain, family, subFamily) - } - } -} - -/** Parse transaction wire transfer subject */ -private fun XmlDestructor.wireTransferSubject(): String? = opt("RmtInf") { - map("Ustrd") { text() }.joinToString("").trim() -} - -/** Parse account information */ -private fun XmlDestructor.account(): Pair<String, String?> = one("Acct") { - Pair( - one("Id") { - (opt("IBAN") ?: one("Othr").one("Id")).text() - }, - opt("Ccy")?.text() - ) -} - -private data class ChargeRecord( - val amount: TalerAmount, - val kind: Kind, - val included: Boolean, - val bearer: ChargeBearer -) -private fun XmlDestructor.charges(): List<ChargeRecord> = opt("Chrgs")?.map("Rcrd") { - val amount = amount() - val kind = opt("CdtDbtInd")?.enum<Kind>() ?: Kind.CRDT - val included = opt("ChrgInclInd")?.bool() ?: true // TODO not clear in spec - val bearer = opt("Br")?.enum<ChargeBearer>() ?: ChargeBearer.SHAR - ChargeRecord(amount, kind, included, bearer) -} ?: emptyList() - -data class AccountTransactions( - val iban: String?, - val currency: String?, - val txs: List<TxNotification> -) { - companion object { - internal fun fromParts(iban: String?, currency: String?, txsInfos: List<TxInfo>): AccountTransactions { - val txs = txsInfos.mapNotNull { - try { - it.parse() - } catch (e: IncompleteTx) { - // TODO: add more info in doc or in log message? - logger.warn("skip incomplete tx: ${e.msg}") - null - } - } - return AccountTransactions(iban, currency, txs) - } - } -} - -/** Parse camt.054 or camt.053 file */ -fun parseTx(notifXml: InputStream): List<AccountTransactions> { - /* - In ISO 20022 specifications, most fields are optional and the same information - can be written several times in different places. For libeufin, we're only - interested in a subset of the available values that can be found in both camt.052, - camt.053 and camt.054. This function should not fail on legitimate files and should - simply warn when available information are insufficient. - - EBICS and ISO20022 do not provide a perfect transaction identifier. The best is the - UETR (unique end-to-end transaction reference), which is a universally unique - identifier (UUID). However, it is not supplied by all banks. TxId (TransactionIdentification) - is a unique identification as assigned by the first instructing agent. As its format - is ambiguous, its uniqueness is not guaranteed by the standard, and it is only - supposed to be unique for a “pre-agreed period”, whatever that means. These two - identifiers are optional in the standard, but have the advantage of being unique - and can be used to track a transaction between banks so we use them when available. - - It is also possible to use AccountServicerReference, which is a unique reference - assigned by the account servicing institution. They can be present at several levels - (batch level, transaction level, etc.) and are often optional. They also have the - disadvantage of being known only by the account servicing institution. They should - therefore only be used as a last resort. - */ - logger.trace("Parse transactions camt file") - val accountTxs = mutableListOf<AccountTransactions>() - - /** Common parsing logic for camt.052, camt.053 and camt.054 */ - fun XmlDestructor.parseInner() { - val (iban, currency) = account() - val txInfos = mutableListOf<TxInfo>() - val batches = each("Ntry") { - if (!isBooked()) return@each - val entryCode = bankTransactionCode() - val reversal = opt("RvslInd")?.text() == "true" - val entryKind = opt("CdtDbtInd")?.enum<Kind>(); - val entryRef = opt("AcctSvcrRef")?.text() - val bookDate = executionDate() - val entryCharges = charges() - val entryAmount = complexAmount(entryCharges)!! - - // When an entry only contain a single transactions information will sometimes only be stored at the entry level - val tmp = opt("NtryDtls")?.map("TxDtls") { this } ?: return@each - val unique = tmp.size == 1 - - for (it in tmp) {it.run { - // Check information are present and coherent - val kind = requireNotNull(opt("CdtDbtInd")?.enum<Kind>() ?: entryKind) { "WTF" } - - // Sometimes the transaction level have a more precise bank transaction code - val code = optBankTransactionCode() ?: entryCode - - // Amount - val amount = if (unique) { - // When unique the charges can be only at the entry level - val txCharges = charges() - val txAmount = complexAmount(if (txCharges.isEmpty()) entryCharges else txCharges) - // Check coherence - if (txAmount != null) entryAmount.resolve(txAmount) else entryAmount - } else { - // When many inner transaction the entry level is an aggregate of them - // We only use the transaction level information - requireNotNull(complexAmount(charges())) { "Missing tx amount" } - } - - // We can only use the entry ref as the transaction ref if there is a single transaction in the batch - val ref = opt("Refs")?.opt("AcctSvcrRef")?.text() ?: if (unique) entryRef else null - - if (code.isReversal() || reversal) { - val outgoingId = outgoingId(ref) - when (kind) { - Kind.CRDT -> { - val reason = returnReason() - txInfos.add(TxInfo.CreditReversal( - bookDate = bookDate, - id = outgoingId, - reason = reason, - code = code - )) - } - Kind.DBIT -> { - val id = incomingId(ref) - val subject = wireTransferSubject() - val debtor = payto("Dbtr") - val fee = amount.fee() - txInfos.add(TxInfo.Credit( - bookDate = bookDate, - id = id, - amount = amount.amount, - subject = subject, - debtor = debtor, - code = code, - creditFee = fee - )) - } - } - } else { - val subject = wireTransferSubject() - when (kind) { - Kind.CRDT -> { - val id = incomingId(ref) - val debtor = payto("Dbtr") - txInfos.add(TxInfo.Credit( - bookDate = bookDate, - id = id, - amount = amount.amount, - subject = subject, - debtor = debtor, - code = code, - creditFee = amount.fee() - )) - } - Kind.DBIT -> { - val outgoingId = outgoingId(ref) - val creditor = payto("Cdtr") - txInfos.add(TxInfo.Debit( - bookDate = bookDate, - id = outgoingId, - amount = amount.amount, - subject = subject, - creditor = creditor, - code = code, - debitFee = amount.fee() - )) - } - } - } - }} - } - accountTxs.add(AccountTransactions.fromParts(iban, currency, txInfos)) - } - XmlDestructor.parse(notifXml, "Document") { - // Camt.053 - opt("BkToCstmrStmt")?.each("Stmt") { parseInner() } - // Camt.052 - opt("BkToCstmrAcctRpt")?.each("Rpt") { parseInner() } - // Camt.054 - opt("BkToCstmrDbtCdtNtfctn")?.each("Ntfctn") { parseInner() } - } - return accountTxs -} - -sealed interface TxInfo { - data class CreditReversal( - val bookDate: Instant, - val code: BankTransactionCode, - val id: OutId, - val reason: String - ): TxInfo - data class Credit( - val bookDate: Instant, - val code: BankTransactionCode, - val id: IncomingId, - val amount: TalerAmount, - val creditFee: TalerAmount?, - val subject: String?, - val debtor: IbanPayto? - ): TxInfo - data class Debit( - val bookDate: Instant, - val code: BankTransactionCode, - val id: OutId, - val amount: TalerAmount, - val debitFee: TalerAmount?, - val subject: String?, - val creditor: IbanPayto? - ): TxInfo - - fun parse(): TxNotification { - return when (this) { - is TxInfo.CreditReversal -> { - if (id !is OutgoingId || id.endToEndId == null) - throw IncompleteTx("missing unique ID for Credit reversal $id") - OutgoingReversal( - endToEndId = id.endToEndId!!, - msgId = id.msgId, - reason = reason, - executionTime = bookDate - ) - } - is TxInfo.Credit -> { - if (id.uetr == null && id.txId == null && id.acctSvcrRef == null) - throw IncompleteTx("missing unique ID for Credit $id") - IncomingPayment( - amount = amount, - creditFee = creditFee, - id = id, - debtor = debtor, - executionTime = bookDate, - subject = subject, - ) - } - is TxInfo.Debit -> { - when (id) { - is OutgoingId -> { - if (id.endToEndId == null && id.msgId == null && id.acctSvcrRef == null) { - throw IncompleteTx("missing unique ID for Debit $id") - } else { - OutgoingPayment( - id = OutgoingId( - endToEndId = id.endToEndId, - acctSvcrRef = id.acctSvcrRef, - msgId = id.msgId, - ), - amount = amount, - debitFee = debitFee, - executionTime = bookDate, - creditor = creditor, - subject = subject - ) - } - } - is BatchId -> { - OutgoingBatch( - msgId = id.msgId, - executionTime = bookDate, - ) - } - } - } - } - } -} - -data class BankTransactionCode( - val domain: ExternalBankTransactionDomainCode, - val family: ExternalBankTransactionFamilyCode, - val subFamily: ExternalBankTransactionSubFamilyCode -) { - fun isReversal(): Boolean = REVERSAL_CODE.contains(subFamily) - fun isPayment(): Boolean = domain == ExternalBankTransactionDomainCode.PMNT || subFamily == ExternalBankTransactionSubFamilyCode.PSTE - - override fun toString(): String = - "${domain.name} ${family.name} ${subFamily.name} - '${domain.description}' '${family.description}' '${subFamily.description}'" - - companion object { - private val REVERSAL_CODE = setOf( - ExternalBankTransactionSubFamilyCode.RPCR, - ExternalBankTransactionSubFamilyCode.RRTN, - ExternalBankTransactionSubFamilyCode.PSTE, - ) - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt @@ -1,90 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 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.nexus.test - -import io.ktor.client.* -import tech.libeufin.common.* -import tech.libeufin.nexus.ebics.* -import tech.libeufin.nexus.* - -data class TxCheckResult( - var concurrentFetchAndFetch: Boolean = false, - var concurrentFetchAndSubmit: Boolean = false, - var concurrentSubmitAndSubmit: Boolean = false, - var idempotentClose: Boolean = false -) - -/** - * Test EBICS implementation's transactions semantic: - * - Can two fetch transactions run concurrently ? - * - Can a fetch & submit transactions run concurrently ? - * - Can two submit transactions run concurrently ? - * - Is closing a submit transaction idempotent - */ -suspend fun txCheck( - client: HttpClient, - cfg: NexusEbicsConfig, - clientKeys: ClientPrivateKeysFile, - bankKeys: BankPublicKeysFile, - fetchOrder: EbicsOrder, - submitOrder: EbicsOrder -): TxCheckResult { - val result = TxCheckResult() - val fetch = EbicsBTS(cfg, bankKeys, clientKeys, fetchOrder) - val submit = EbicsBTS(cfg, bankKeys, clientKeys, submitOrder) - val ebicsLogger = EbicsLogger(null).tx("test").step("step") - - suspend fun EbicsBTS.close(id: String, phase: String, ebicsLogger: StepLogger) { - val xml = downloadReceipt(id, false) - postBTS(client, xml, phase, ebicsLogger) - } - - val firstTxId = fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init first fetch", ebicsLogger) - .transactionID!! - try { - try { - val id = fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init second fetch", ebicsLogger).transactionID!! - result.concurrentFetchAndFetch = true - fetch.close(id, "Init second fetch", ebicsLogger) - } catch (e: EbicsError.Code) {} - - var paylod = prepareUploadPayload(cfg, clientKeys, bankKeys, ByteArray(2000000).rand()) - try { - val submitId = submit.postBTS(client, submit.uploadInitialization(paylod), "Init first submit", ebicsLogger). transactionID!! - result.concurrentFetchAndSubmit = true - submit.postBTS(client, submit.uploadTransfer(submitId, paylod, 1), "Submit first upload", ebicsLogger) - try { - submit.postBTS(client, submit.uploadInitialization(paylod), "Init second submit", ebicsLogger) - result.concurrentSubmitAndSubmit = true - } catch (e: EbicsError.Code) {} - } catch (e: EbicsError.Code) {} - } finally { - fetch.close(firstTxId, "Close first fetch", ebicsLogger) - } - - try { - fetch.close(firstTxId, "Close first fetch a second time", ebicsLogger) - result.idempotentClose = true - } catch (e: Exception) { - logger.debug { e.fmt() } - } - - return result -} -\ No newline at end of file diff --git a/settings.gradle b/settings.gradle @@ -1,5 +1,5 @@ rootProject.name = 'libeufin' -include("bank") -include("nexus") -include("common") +include("libeufin-bank") +include("libeufin-nexus") +include("libeufin-common") include("testbench") \ No newline at end of file diff --git a/testbench/build.gradle b/testbench/build.gradle @@ -17,9 +17,9 @@ sourceSets.main.java.srcDirs = ["src/main/kotlin"] dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") - implementation(project(":common")) - implementation(project(":bank")) - implementation(project(":nexus")) + implementation(project(":libeufin-common")) + implementation(project(":libeufin-bank")) + implementation(project(":libeufin-nexus")) implementation("com.github.ajalt.clikt:clikt:$clikt_version")