libeufin

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

commit e5ba71f533773395048627e4f45c9c46dfff4407
parent ee6824621640a553408d4cc97c8549b3a788305f
Author: Antoine A <>
Date:   Fri, 24 Apr 2026 14:05:13 +0200

common: split libeufin-nexus and liibeufin-ebics

Diffstat:
MCargo.lock | 348+++++++++++++++++++++++++++----------------------------------------------------
MCargo.toml | 90+++++++++++++++++++++++++++++++------------------------------------------------
Acrates/libeufin-ebics/Cargo.toml | 45+++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/bin/iso20022-codegen.rs | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/cli.rs | 28++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/config.rs | 33+++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/crypto.rs | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/db.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/dialect.rs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/ebics.rs | 757+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/ebics/administrative.rs | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/ebics/bts.rs | 496+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/ebics/ebics_code.rs | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/ebics/key_management.rs | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/ebics/logger.rs -> crates/libeufin-ebics/src/ebics/logger.rs | 0
Acrates/libeufin-ebics/src/ebics/order.rs | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/iso20022.rs | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/iso20022/bank_tx_code.rs | 745+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/iso20022/camt.rs | 1249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/iso20022/hac.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/iso20022/model.rs | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/iso20022/pain001.rs | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/iso20022/pain002.rs | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/iso20022/status_code.rs | 1374+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/keys.rs | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/lib.rs | 33+++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/setup.rs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/test.rs | 574+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/utils.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/ws.rs | 436+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/xml.rs | 471+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-ebics/src/xml_sign.rs | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/Cargo.toml | 36++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/api.rs | 417+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/bench.rs | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/bin/testbench.rs | 358+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/config.rs | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/db.rs -> crates/libeufin-nexus/src/db.rs | 0
Acrates/libeufin-nexus/src/db/exchange.rs | 325+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/db/initiated.rs | 894+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/db/list.rs | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/db/payment.rs | 1131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/db/transfer.rs -> crates/libeufin-nexus/src/db/transfer.rs | 0
Acrates/libeufin-nexus/src/fetch.rs | 658+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/lib.rs | 455+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/list.rs -> crates/libeufin-nexus/src/list.rs | 0
Acrates/libeufin-nexus/src/main.rs | 27+++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/model.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/test.rs | 490+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/libeufin-nexus/src/testing.rs | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arustfmt.toml | 2++
Dsrc/api.rs | 417-------------------------------------------------------------------------------
Dsrc/bench.rs | 288-------------------------------------------------------------------------------
Dsrc/bin/iso20022-codegen.rs | 206-------------------------------------------------------------------------------
Dsrc/bin/testbench.rs | 363-------------------------------------------------------------------------------
Dsrc/config.rs | 276-------------------------------------------------------------------------------
Dsrc/crypto.rs | 277-------------------------------------------------------------------------------
Dsrc/db/exchange.rs | 325-------------------------------------------------------------------------------
Dsrc/db/initiated.rs | 892-------------------------------------------------------------------------------
Dsrc/db/list.rs | 269-------------------------------------------------------------------------------
Dsrc/db/payment.rs | 1130-------------------------------------------------------------------------------
Dsrc/dialect.rs | 196-------------------------------------------------------------------------------
Dsrc/ebics/administrative.rs | 224-------------------------------------------------------------------------------
Dsrc/ebics/bts.rs | 496-------------------------------------------------------------------------------
Dsrc/ebics/ebics_code.rs | 210-------------------------------------------------------------------------------
Dsrc/ebics/key_management.rs | 278-------------------------------------------------------------------------------
Dsrc/ebics/mod.rs | 1227-------------------------------------------------------------------------------
Dsrc/ebics/order.rs | 270-------------------------------------------------------------------------------
Dsrc/iso20022/bank_tx_code.rs | 745-------------------------------------------------------------------------------
Dsrc/iso20022/camt.rs | 1248-------------------------------------------------------------------------------
Dsrc/iso20022/hac.rs | 197-------------------------------------------------------------------------------
Dsrc/iso20022/mod.rs | 182-------------------------------------------------------------------------------
Dsrc/iso20022/pain001.rs | 246-------------------------------------------------------------------------------
Dsrc/iso20022/pain002.rs | 270-------------------------------------------------------------------------------
Dsrc/iso20022/status_code.rs | 1374-------------------------------------------------------------------------------
Dsrc/keys.rs | 235-------------------------------------------------------------------------------
Dsrc/lib.rs | 965-------------------------------------------------------------------------------
Dsrc/main.rs | 27---------------------------
Dsrc/model.rs | 485-------------------------------------------------------------------------------
Dsrc/test.rs | 568-------------------------------------------------------------------------------
Dsrc/testing.rs | 260-------------------------------------------------------------------------------
Dsrc/utils.rs | 51---------------------------------------------------
Dsrc/worker.rs | 243-------------------------------------------------------------------------------
Dsrc/ws.rs | 439-------------------------------------------------------------------------------
Dsrc/xml.rs | 471-------------------------------------------------------------------------------
Dsrc/xml_sign.rs | 292-------------------------------------------------------------------------------
86 files changed, 16139 insertions(+), 15925 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -100,7 +100,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror", "time", ] @@ -339,9 +339,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -350,12 +350,6 @@ dependencies = [ ] [[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -490,18 +484,6 @@ dependencies = [ ] [[package]] -name = "console" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys 0.61.2", -] - -[[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -592,9 +574,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" @@ -708,9 +690,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "debug_unsafe" @@ -834,12 +816,6 @@ dependencies = [ ] [[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1431,9 +1407,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1452,19 +1428,6 @@ dependencies = [ ] [[package]] -name = "indicatif" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - -[[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1503,12 +1466,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" dependencies = [ "jiff-static", - "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", @@ -1518,9 +1480,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", @@ -1528,43 +1490,33 @@ dependencies = [ ] [[package]] -name = "jiff-tzdb" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] @@ -1598,9 +1550,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ "cfg-if", "futures-util", @@ -1640,57 +1592,74 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "libeufin" -version = "0.1.0" +name = "libeufin-ebics" +version = "1.5.0" dependencies = [ "anyhow", "aws-lc-rs", "axum", - "base64", "calamine", "clap", "compact_str", - "const_format", "flate2", "futures-util", "getrandom 0.4.2", - "hex", - "indicatif", "jiff", - "owo-colors", - "pem", "pretty_assertions", "rand 0.10.1", "rcgen", - "reedline", - "regex", "reqwest", "reqwest-websocket", "roxmltree", "serde", "serde_json", + "sqlx", + "taler-api", + "taler-common", + "taler-macros", + "taler-test-utils", + "tempfile", + "thiserror", + "tokio", + "tracing", + "uuid", + "x509-parser", + "zip 8.6.0", +] + +[[package]] +name = "libeufin-nexus" +version = "1.5.0" +dependencies = [ + "anyhow", + "aws-lc-rs", + "clap", + "compact_str", + "const_format", + "jiff", + "libeufin-ebics", + "owo-colors", + "reedline", + "regex", + "serde", + "serde_json", "shlex", "sqlx", "taler-api", "taler-build", "taler-common", - "taler-enum-meta", "taler-test-utils", - "tempfile", - "thiserror 2.0.18", "tokio", - "tokio-tungstenite", "tracing", "tracing-subscriber", "url", "uuid", - "x509-parser", - "zip 8.5.1", + "zip 8.6.0", ] [[package]] @@ -2126,7 +2095,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -2148,7 +2117,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -2311,7 +2280,7 @@ dependencies = [ "serde", "strip-ansi-escapes", "strum", - "thiserror 2.0.18", + "thiserror", "unicase", "unicode-segmentation", "unicode-width", @@ -2348,9 +2317,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", @@ -2394,7 +2363,7 @@ dependencies = [ "bytes", "futures-util", "reqwest", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-util", "tracing", @@ -2484,9 +2453,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -2510,9 +2479,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -2520,9 +2489,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -2795,6 +2764,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2879,7 +2864,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tracing", @@ -2963,7 +2948,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "tracing", "uuid", "whoami", @@ -3001,7 +2986,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "tracing", "uuid", "whoami", @@ -3026,7 +3011,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror", "tracing", "url", "uuid", @@ -3155,7 +3140,6 @@ version = "1.5.0" dependencies = [ "aws-lc-rs", "axum", - "base64", "compact_str", "dashmap", "http-body-util", @@ -3165,9 +3149,10 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", + "serde_urlencoded", "sqlx", "taler-common", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "url", @@ -3197,9 +3182,9 @@ dependencies = [ "serde_urlencoded", "serde_with", "sqlx", - "taler-enum-meta", + "taler-macros", "tempfile", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "tracing-subscriber", @@ -3207,10 +3192,9 @@ dependencies = [ ] [[package]] -name = "taler-enum-meta" +name = "taler-macros" version = "1.5.0" dependencies = [ - "proc-macro2", "quote", "syn", ] @@ -3252,31 +3236,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -3552,7 +3516,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1", - "thiserror 2.0.18", + "thiserror", "utf-8", ] @@ -3569,7 +3533,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -3636,12 +3600,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - -[[package]] name = "untrusted" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3774,9 +3732,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -3787,9 +3745,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" dependencies = [ "js-sys", "wasm-bindgen", @@ -3797,9 +3755,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3807,9 +3765,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -3820,9 +3778,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -3863,9 +3821,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" dependencies = [ "js-sys", "wasm-bindgen", @@ -4021,15 +3979,6 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" @@ -4075,21 +4024,6 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" @@ -4138,12 +4072,6 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" @@ -4162,12 +4090,6 @@ checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" @@ -4186,12 +4108,6 @@ checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" @@ -4222,12 +4138,6 @@ checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" @@ -4246,12 +4156,6 @@ checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" @@ -4270,12 +4174,6 @@ checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" @@ -4294,12 +4192,6 @@ checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" @@ -4430,7 +4322,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror 2.0.18", + "thiserror", "time", ] @@ -4568,9 +4460,9 @@ dependencies = [ [[package]] name = "zip" -version = "8.5.1" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "crc32fast", "flate2", diff --git a/Cargo.toml b/Cargo.toml @@ -1,64 +1,45 @@ -[package] -name = "libeufin" -version = "0.1.0" +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "1.5.0" edition = "2024" +authors = ["Taler Systems SA <deb@taler.net>"] +homepage = "https://taler.net/" +repository = "https://git.taler.net/libeufin.git" +license-file = "COPYING" -[dependencies] -reqwest = "*" -tokio = { version = "*", features = ["macros", "rt-multi-thread"] } -tracing = "*" -thiserror = "*" -roxmltree = "*" -base64 = "*" -pem = "*" -anyhow = "*" -jiff ={ version = "*", features = ["serde"]} -rand = "*" -getrandom = "*" -serde_json = "*" -rcgen = { version = "*", features = [ - "aws_lc_rs", - "pem", -], default-features = false } -x509-parser = { version = "*", features = ["verify-aws"] } -flate2 = { version = "1.0", features = ["zlib-rs"], default-features = false } +[workspace.dependencies] +axum = { version = "0.8", features = ["ws"] } +tracing = "0.1" +thiserror = "2" +anyhow = "1.0" +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.42", features = ["macros"] } +sqlx = { version = "0.8", default-features = false, features = [ + "postgres", + "runtime-tokio", + "tls-rustls-aws-lc-rs", + "uuid", + "json", +] } +aws-lc-rs = "1.15" +compact_str = { version = "0.9.0", features = ["serde", "sqlx-postgres"] } +reqwest = "0.13.2" taler-common = { path = "../taler-rust/common/taler-common" } taler-api = { path = "../taler-rust/common/taler-api" } taler-build = { path = "../taler-rust/common/taler-build" } taler-test-utils = { path = "../taler-rust/common/taler-test-utils" } -taler-enum-meta = { path = "../taler-rust/common/taler-enum-meta" } +taler-macros = { path = "../taler-rust/common/taler-macros" } +libeufin-ebics = { path = "crates/libeufin-ebics" } +jiff = { version = "0.2", default-features = false, features = ["tz-system"] } +clap = { version = "4.5", features = ["derive"] } +uuid = { version = "1.0", features = ["v4", "fast-rng"] } +getrandom = "0.4.2" +rand = "0.10" #taler-common = { git = "git://git.taler.net/taler-rust.git/" } #taler-api = { git = "git://git.taler.net/taler-rust.git/" } #taler-build = { git = "git://git.taler.net/taler-rust.git/" } #taler-test-utils = { git = "git://git.taler.net/taler-rust.git/" } -hex = "*" -url = "*" -clap = { version = "4.5", features = ["derive"] } -pretty_assertions = "*" -aws-lc-rs = { version = "*" } -serde = { version = "*", features = ["derive"] } -reedline = "*" -sqlx = { version = "0.8", default-features = false, features = [ - "postgres", - "runtime-tokio", - "tls-rustls-aws-lc-rs", - "uuid", - "json" -] } -compact_str = { version = "0.9.0", features = ["serde", "sqlx-postgres"] } -uuid = { version = "1.0", features = ["v4", "fast-rng"] } -regex = "*" -const_format = { version = "0.2", features = ["rust_1_83"] } -zip = { version = "*", default-features = false, features = [ - "deflate-flate2-zlib-rs", -] } -calamine = "*" -indicatif = "0.18.0" -tracing-subscriber = "*" -owo-colors = "*" -shlex = "*" -axum = { version = "*", features = ["ws", "macros"]} -reqwest-websocket = "*" -futures-util = "*" -tokio-tungstenite = "*" -tempfile = "*" -\ No newline at end of file diff --git a/crates/libeufin-ebics/Cargo.toml b/crates/libeufin-ebics/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "libeufin-ebics" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true + +[dependencies] +compact_str.workspace = true +aws-lc-rs.workspace = true +reqwest.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tracing.workspace = true +serde.workspace = true +serde_json.workspace = true +taler-common.workspace = true +taler-api.workspace = true +taler-macros.workspace = true +taler-test-utils.workspace = true +axum.workspace = true +anyhow.workspace = true +tokio.workspace = true +jiff.workspace = true +clap.workspace = true +uuid.workspace = true +getrandom.workspace = true +rand.workspace = true +tempfile = "3" +flate2 = { version = "1.0", features = ["zlib-rs"], default-features = false } +zip = { version = "8.5", default-features = false, features = [ + "deflate-flate2-zlib-rs", +] } +pretty_assertions = "1" +futures-util = "0.3" +reqwest-websocket = "0.6.0" +roxmltree = "0.21.1" +rcgen = { version = "0.14.7", features = [ + "aws_lc_rs", + "pem", +], default-features = false } +x509-parser = { version = "0.18.1", features = ["verify-aws"] } +calamine = { version = "0.34.0" } diff --git a/crates/libeufin-ebics/bin/iso20022-codegen.rs b/crates/libeufin-ebics/bin/iso20022-codegen.rs @@ -0,0 +1,206 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{ + collections::BTreeMap, + fmt::Write as _, + io::{Cursor, Read as _}, +}; + +use calamine::{DataType, Reader as _, Xlsx}; +use reqwest::StatusCode; +use tokio::join; +use zip::ZipArchive; + +pub async fn iso20022codegen_external_code_set() { + let res = reqwest::get( + "https://www.iso20022.org/sites/default/files/media/file/ExternalCodeSets_XLSX.zip", + ) + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let zipped = res.bytes().await.unwrap(); + let mut zip = ZipArchive::new(Cursor::new(&zipped)).unwrap(); + assert_eq!(zip.len(), 1); + + let mut bytes = Vec::new(); + zip.by_index(0).unwrap().read_to_end(&mut bytes).unwrap(); + let mut excel: Xlsx<_> = calamine::open_workbook_from_rs(Cursor::new(&bytes)).unwrap(); + + let mut code_sets: BTreeMap<_, Vec<_>> = BTreeMap::new(); + + let range = excel.worksheet_range("AllCodeSets").unwrap(); + for row in range.rows() { + let set = row[0].as_string().unwrap(); + let code = row[1].as_string().unwrap(); + let name = row[2].as_string().unwrap().replace('-', ""); + let definition = row[3] + .as_string() + .unwrap() + .split(['.', '\n']) + .next() + .unwrap() + .trim() + .replace("_x000D_", ""); + let vec = code_sets.entry(set).or_default(); + vec.push((code, name, definition)) + } + + let mut out = " +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 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 + +use taler_macros::EnumMeta; + " + .to_string(); + + for (set, enum_name) in [ + ("ExternalStatusReason1Code", "StatusReason"), + ("ExternalPaymentGroupStatus1Code", "PaymentGroupStatus"), + ( + "ExternalPaymentTransactionStatus1Code", + "PaymentTransactionStatus", + ), + ("ExternalReturnReason1Code", "ReturnReason"), + ] { + let set = code_sets.get_mut(set).unwrap(); + set.sort_unstable_by_key(|(code, _, _)| code.clone()); + writeln!( + &mut out, + " + #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] + #[enum_meta(DomainCode, Description, Str)] + pub enum {enum_name} {{ + " + ) + .unwrap(); + for (code, name, description) in set.iter() { + writeln!(&mut out, "/// {description}").unwrap(); + writeln!(&mut out, "#[code = \"{code}\"]").unwrap(); + writeln!(&mut out, "{name},").unwrap(); + } + writeln!(&mut out, "}}").unwrap(); + } + std::fs::write("src/iso20022/status_code.rs", out).unwrap(); +} + +pub async fn iso20022codegen_bank_transaction_code() { + let res = reqwest::get( + "https://www.iso20022.org/sites/default/files/media/file/BTC_Codification_21March2024.xlsx", + ) + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let bytes = res.bytes().await.unwrap(); + let mut excel: Xlsx<_> = calamine::open_workbook_from_rs(Cursor::new(&bytes)).unwrap(); + + let mut domain = BTreeMap::new(); + let mut family = BTreeMap::new(); + let mut subfamily = BTreeMap::new(); + + let range = excel.worksheet_range("BTC_Codification").unwrap(); + + for row in range.rows().skip(3) { + for (i, set) in [&mut domain, &mut family, &mut subfamily] + .into_iter() + .enumerate() + { + let name = row[i].as_string().unwrap(); + let code = row[i + 3].as_string().unwrap(); + let code = code.trim().to_string(); + set.insert(code, name); + } + } + + let mut out = " +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 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 + +use taler_macros::EnumMeta; + " + .to_string(); + + for (set, enum_name) in [ + (domain, "BankTxDomainCode"), + (family, "BankTxFamilyCode"), + (subfamily, "BankTxSubFamilyCode"), + ] { + writeln!( + &mut out, + " + #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] + #[enum_meta(Description, Str)] + pub enum {enum_name} {{ + " + ) + .unwrap(); + for (code, description) in set.iter() { + writeln!(&mut out, "/// {description}").unwrap(); + writeln!(&mut out, "{code},").unwrap(); + } + writeln!(&mut out, "}}").unwrap(); + } + std::fs::write("src/iso20022/bank_tx_code.rs", out).unwrap(); +} + +#[tokio::main] +pub async fn main() { + join!( + iso20022codegen_external_code_set(), + iso20022codegen_bank_transaction_code() + ); +} diff --git a/crates/libeufin-ebics/src/cli.rs b/crates/libeufin-ebics/src/cli.rs @@ -0,0 +1,28 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::path::PathBuf; + +#[derive(clap::Parser, Debug, Clone)] +pub struct EbicsLogs { + /// Log EBICS transactions steps and payload at log_dir + #[clap(long = "debug-ebics", value_name = "log_dir")] + #[arg(global = true)] + pub dir: Option<PathBuf>, +} diff --git a/crates/libeufin-ebics/src/config.rs b/crates/libeufin-ebics/src/config.rs @@ -0,0 +1,33 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +#[derive(Debug, Clone, Copy)] +pub struct EbicsKeysCfg<'a> { + pub bank: &'a str, + pub client: &'a str, +} + +#[derive(Debug, Clone, Copy)] +pub struct EbicsHostCfg<'a> { + pub base_url: &'a str, + pub unix_path: Option<&'a str>, + pub host_id: &'a str, + pub user_id: &'a str, + pub partner_id: &'a str, +} diff --git a/crates/libeufin-ebics/src/crypto.rs b/crates/libeufin-ebics/src/crypto.rs @@ -0,0 +1,278 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use aws_lc_rs::{ + cipher::{ + AES_128, DecryptingKey, DecryptionContext, EncryptingKey, EncryptionContext, + UnboundCipherKey, + }, + digest::{Context, Digest, SHA256}, + encoding::AsDer, + iv::FixedLength, + rand::SystemRandom, + rsa::{ + KeyPair, Pkcs1PrivateDecryptingKey, Pkcs1PublicEncryptingKey, PrivateDecryptingKey, + PublicEncryptingKey, PublicKey, + }, + signature::{RSA_PSS_2048_8192_SHA256, RSA_PSS_SHA256, UnparsedPublicKey}, +}; +use jiff::{Timestamp, Zoned, tz::TimeZone}; +use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyUsagePurpose}; +use taler_common::encoding::{base64, hex}; +use x509_parser::prelude::{FromDer as _, X509Certificate}; + +use crate::keys::RsaPub; + +/// Generate a self-signed X.509 certificate from an RSA private key (PEM or DER) +pub fn x509_certificate_from_rsa_private( + pem: &str, + name: &str, +) -> Result<rcgen::Certificate, rcgen::Error> { + let keys = rcgen::KeyPair::from_pem(pem).unwrap(); + let mut params = CertificateParams::new(vec![])?; + + // Set subject/issuer CN + params.distinguished_name.push(DnType::CommonName, name); + + let now = Zoned::new(Timestamp::now(), TimeZone::UTC).date(); + + // 1000-year validity + params.not_before = rcgen::date_time_ymd(now.year() as i32, now.month() as u8, now.day() as u8); + params.not_after = + rcgen::date_time_ymd(now.year() as i32 + 1000, now.month() as u8, now.day() as u8); + + // CA: true (basicConstraints) + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + + // Key usage flags + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::ContentCommitment, // NonRepudiation + KeyUsagePurpose::KeyEncipherment, + KeyUsagePurpose::DataEncipherment, + KeyUsagePurpose::KeyAgreement, + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::CrlSign, + KeyUsagePurpose::EncipherOnly, + KeyUsagePurpose::DecipherOnly, + ]; + + let cert = params.self_signed(&keys)?; + Ok(cert) +} + +/** Create an RSA public key from its components: [modulus] and [exponent] */ +pub fn rsa_pub_from_component(modulus: &[u8], exponent: &[u8]) -> anyhow::Result<RsaPub> { + let key: PublicEncryptingKey = aws_lc_rs::rsa::PublicKeyComponents { + n: modulus, + e: exponent, + } + .try_into()?; + Ok(RsaPub::from_der(key.as_der()?.as_ref())?) +} + +/// Extract an RSA public key from a X.509 certificate +pub fn rsa_private_from_b64_x509_certificate(encoded: &str) -> anyhow::Result<RsaPub> { + let der = base64::decode(encoded)?; + let (_, cert) = X509Certificate::from_der(&der)?; + let issuer_public_key = cert.public_key(); + cert.verify_signature(Some(issuer_public_key))?; + Ok(RsaPub::from_der(issuer_public_key.raw)?) +} + +/// Hash an RSA public key according to the EBICS standard (EBICS 2.5: 4.4.1.2.3). +pub fn ebics_pub_key_hash(public_key: &PublicKey) -> Digest { + let mut ctx = Context::new(&SHA256); + let hex_encoded = |input: &[u8], ctx: &mut Context| { + let encoded = hex::encode(input); + if encoded.starts_with('0') { + ctx.update(&encoded.as_bytes()[1..]); + } else { + ctx.update(encoded.as_bytes()); + } + }; + + hex_encoded( + public_key.exponent().big_endian_without_leading_zero(), + &mut ctx, + ); + ctx.update(b" "); + hex_encoded( + public_key.modulus().big_endian_without_leading_zero(), + &mut ctx, + ); + ctx.finish() +} + +pub fn gen_ebics_e002_key(pub_key: PublicEncryptingKey) -> ([u8; 16], Vec<u8>) { + let mut transaction_key = [0u8; 16]; + getrandom::fill(&mut transaction_key).unwrap(); + + let key = Pkcs1PublicEncryptingKey::new(pub_key).unwrap(); + let mut encrypted_key = vec![0; key.ciphertext_size()]; + key.encrypt(&transaction_key, &mut encrypted_key).unwrap(); + + (transaction_key, encrypted_key) +} + +pub fn encrypt_ebics_e002(transaction_key: &[u8; 16], mut data: Vec<u8>) -> Vec<u8> { + let block_size = 16; + let padding_len = block_size - (data.len() % block_size); + + // Add padding + for i in 0..padding_len { + if i == padding_len - 1 { + data.push(padding_len as u8); + } else { + data.push(0); + } + } + + let iv = FixedLength::from([0u8; 16]); + let enc_key = + EncryptingKey::cbc(UnboundCipherKey::new(&AES_128, transaction_key).unwrap()).unwrap(); + enc_key + .less_safe_encrypt(&mut data, EncryptionContext::Iv128(iv)) + .unwrap(); + + data +} + +pub fn decrypt_ebics_e002(transaction_key: &DecryptingKey, mut encrypted_data: Vec<u8>) -> Vec<u8> { + let iv = FixedLength::from([0u8; 16]); + + let plaintext = transaction_key + .decrypt(&mut encrypted_data, DecryptionContext::Iv128(iv)) + .unwrap(); + + // Strip X9.23 / ANSI X9.23 padding: + // The last byte holds the number of padding bytes to remove. + let pad_len = *plaintext.last().unwrap() as usize; + if pad_len == 0 || pad_len > 16 || pad_len > plaintext.len() { + panic!("WTF"); + } + let decoded = plaintext.len() - pad_len; + encrypted_data.truncate(decoded); + encrypted_data +} + +pub fn decrypt_ebics_e002_key( + private_key: PrivateDecryptingKey, + encrypted_transaction_key: &[u8], +) -> DecryptingKey { + let private_key = Pkcs1PrivateDecryptingKey::new(private_key).unwrap(); + let mut plaintext = vec![0u8; private_key.min_output_size()]; + let cipher = private_key + .decrypt(encrypted_transaction_key, &mut plaintext) + .unwrap(); + let cipher_key = UnboundCipherKey::new(&AES_128, cipher).unwrap(); + DecryptingKey::cbc(cipher_key).unwrap() +} + +pub fn digest_ebics_order_a006(order_data: &[u8]) -> Digest { + let mut digest = Context::new(&SHA256); + for chunk in order_data.split(|b| matches!(b, b'\r' | b'\n' | b'\x1a')) { + digest.update(chunk); + } + digest.finish() +} + +pub fn sign_ebics_a006(data: &[u8], key_pair: &KeyPair) -> Vec<u8> { + let mut sig = vec![0; key_pair.public_modulus_len()]; + key_pair + .sign(&RSA_PSS_SHA256, &SystemRandom::new(), data, &mut sig) + .unwrap(); + sig +} + +pub fn verify_ebics_a006(sig: &[u8], data: &[u8], public_key_der: &PublicKey) -> bool { + UnparsedPublicKey::new(&RSA_PSS_2048_8192_SHA256, public_key_der.as_ref()) + .verify(data, sig) + .is_ok() +} + +#[cfg(test)] +mod test { + use aws_lc_rs::{ + rsa::{KeyPair, KeySize, PrivateDecryptingKey}, + signature::KeyPair as _, + }; + use taler_common::encoding::hex; + + use crate::crypto::{ + decrypt_ebics_e002, decrypt_ebics_e002_key, ebics_pub_key_hash, encrypt_ebics_e002, + gen_ebics_e002_key, rsa_pub_from_component, sign_ebics_a006, verify_ebics_a006, + }; + + #[test] + fn e002() { + let data = b"Hello, World!"; + let key = PrivateDecryptingKey::generate(KeySize::Rsa2048).unwrap(); + + let (tx_key, encrypted_key) = gen_ebics_e002_key(key.public_key()); + let enc = encrypt_ebics_e002(&tx_key, data.to_vec()); + let key = decrypt_ebics_e002_key(key, &encrypted_key); + let dec = decrypt_ebics_e002(&key, enc); + assert_eq!(&data, &dec.as_slice()); + } + + #[test] + fn a006() { + let data = b"Hello, World!"; + let key_pair = KeyPair::generate(KeySize::Rsa2048).unwrap(); + let sig = sign_ebics_a006(data, &key_pair); + assert!(verify_ebics_a006(&sig, data, key_pair.public_key())); + } + + #[test] + fn public_key_hash() { + let exponent = "01 00 01".replace(|it: char| it.is_whitespace(), ""); + let modulus = " + EB BD B8 E3 73 45 60 06 44 A1 AD 6A 25 33 65 F5 + 9C EB E5 93 E0 51 72 77 90 6B F0 58 A8 89 EB 00 + C6 0B 37 38 F3 3C 55 F2 4D 83 D0 33 C3 A8 F0 3C + 82 4E AF 78 51 D6 F4 71 6A CC 9C 10 2A 58 C9 5F + 3D 30 B4 31 D7 1B 79 6D 43 AA F9 75 B5 7E 0B 4A + 55 52 1D 7C AC 8F 92 B0 AE 9F CF 5F 16 5C 6A D1 + 88 DB E2 48 E7 78 43 F9 18 63 29 45 ED 6C 08 6C + 16 1C DE F3 02 01 23 8A 58 35 43 2B 2E C5 3F 6F + 33 B7 A3 46 E1 75 BD 98 7C 6D 55 DE 71 11 56 3D + 7A 2C 85 42 98 42 DF 94 BF E8 8B 76 84 13 3E CA + 0E 8D 12 57 D6 8A CF 82 DE B7 D7 BB BC 45 AE 25 + 95 76 00 19 08 AA D2 C8 A7 D8 10 37 88 96 B9 98 + 14 B4 B0 65 F3 36 CE 93 F7 46 12 58 9F E7 79 33 + D5 BE 0D 0E F8 E7 E0 A9 C3 10 51 A1 3E A4 4F 67 + 5E 75 8C 9D E6 FE 27 B6 3C CF 61 9B 31 D4 D0 22 + B9 2E 4C AF 5F D6 4B 1F F0 4D 06 5F 68 EB 0B 71 + " + .replace(|it: char| it.is_whitespace(), ""); + let expected = " + 72 71 D5 83 B4 24 A6 DA 0B 7B 22 24 3B E2 B8 8C + 6E A6 0F 9F 76 11 FD 18 BE 2C E8 8B 21 03 A9 41 + " + .replace(|it: char| it.is_whitespace(), ""); + let key = rsa_pub_from_component( + &hex::decode(modulus).unwrap(), + &hex::decode(exponent).unwrap(), + ) + .unwrap(); + let hash = ebics_pub_key_hash(&key.key); + assert_eq!(&hex::decode(expected).unwrap(), hash.as_ref()); + } +} diff --git a/crates/libeufin-ebics/src/db.rs b/crates/libeufin-ebics/src/db.rs @@ -0,0 +1,66 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use compact_str::CompactString; +use sqlx::{PgPool, Row, postgres::PgRow}; + +/** Register a pending transaction */ +pub async fn ebics_register(db: &PgPool, id: &str) -> sqlx::Result<()> { + sqlx::query( + "INSERT INTO pending_ebics_transactions (tx_id) VALUES ($1) ON CONFLICT DO NOTHING", + ) + .bind(id) + .execute(db) + .await?; + Ok(()) +} + +/** Register a pending transaction */ +pub async fn ebics_remove(db: &PgPool, id: &str) -> sqlx::Result<()> { + sqlx::query("DELETE FROM pending_ebics_transactions WHERE tx_id = $1") + .bind(id) + .execute(db) + .await?; + Ok(()) +} + +/** Register a pending transaction */ +pub async fn ebics_first(db: &PgPool) -> sqlx::Result<Option<CompactString>> { + sqlx::query("SELECT tx_id FROM pending_ebics_transactions LIMIT 1") + .try_map(|r: PgRow| r.try_get(0)) + .fetch_optional(db) + .await +} + +#[cfg(test)] +pub mod test { + use sqlx::PgPool; + + use crate::db::{ebics_first, ebics_register, ebics_remove}; + + pub async fn ebics_routine(db: &PgPool) { + let ids = ["first", "second", "third"]; + + for id in ids { + ebics_register(&db, id).await.unwrap(); + } + for id in ids { + assert_eq!(Some(id), ebics_first(&db).await.unwrap().as_deref()); + ebics_remove(&db, id).await.unwrap(); + } + assert_eq!(ebics_first(&db).await.unwrap(), None); + } +} diff --git a/crates/libeufin-ebics/src/dialect.rs b/crates/libeufin-ebics/src/dialect.rs @@ -0,0 +1,196 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use taler_macros::EnumMeta; + +use crate::ebics::order::{BTF, Order, OrderDoc}; + +/** Supported EBICS standard */ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Standard { + /// Swiss Payment Standards + SIX, + /// German Banking Industry Committee + GBIC, +} + +impl Standard { + pub fn downloads(&self, doc: &OrderDoc) -> Vec<Order> { + match self { + Standard::SIX => match doc { + OrderDoc::acknowledgement => vec![Order::HAC], + OrderDoc::status => vec![Order::BTD(BTF { + service: "PSR".into(), + scope: Some("CH".into()), + option: None, + container: Some("ZIP".into()), + msg: "pain.002".into(), + version: Some("10".into()), + })], + OrderDoc::report => vec![Order::BTD(BTF { + service: "STM".into(), + scope: Some("CH".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.052".into(), + version: Some("08".into()), + })], + OrderDoc::statement => vec![Order::BTD(BTF { + service: "EOP".into(), + scope: Some("CH".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.053".into(), + version: Some("08".into()), + })], + OrderDoc::notification => vec![Order::BTD(BTF { + service: "REP".into(), + scope: Some("CH".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.054".into(), + version: Some("08".into()), + })], + }, + Standard::GBIC => match doc { + OrderDoc::acknowledgement => vec![Order::HAC], + OrderDoc::status => vec![ + Order::BTD(BTF { + service: "REP".into(), + scope: Some("DE".into()), + option: Some("SCI".into()), + container: Some("ZIP".into()), + msg: "pain.002".into(), + version: None, + }), + Order::BTD(BTF { + service: "REP".into(), + scope: Some("DE".into()), + option: Some("SCT".into()), + container: Some("ZIP".into()), + msg: "pain.002".into(), + version: None, + }), + ], + OrderDoc::report => vec![Order::BTD(BTF { + service: "STM".into(), + scope: Some("DE".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.052".into(), + version: None, + })], + OrderDoc::statement => vec![Order::BTD(BTF { + service: "EOP".into(), + scope: Some("DE".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.053".into(), + version: None, + })], + OrderDoc::notification => vec![ + Order::BTD(BTF { + service: "STM".into(), + scope: Some("DE".into()), + option: None, + container: Some("ZIP".into()), + msg: "camt.054".into(), + version: None, + }), + Order::BTD(BTF { + service: "STM".into(), + scope: Some("DE".into()), + option: Some("SCI".into()), + container: Some("ZIP".into()), + msg: "camt.054".into(), + version: None, + }), + ], + }, + } + } + + pub fn direct_debit(&self) -> Order { + match self { + Standard::SIX => Order::BTU(BTF { + service: "MCT".into(), + scope: Some("CH".into()), + option: None, + container: None, + msg: "pain.001".into(), + version: Some("09".into()), + }), + Standard::GBIC => Order::BTU(BTF { + service: "SCT".into(), + scope: None, + option: None, + container: None, + msg: "pain.001".into(), + version: None, + }), + } + } + + pub fn instant_direct_debit(&self) -> Option<Order> { + match self { + Standard::SIX => None, + Standard::GBIC => Some(Order::BTU(BTF { + service: "SCI".into(), + scope: Some("DE".into()), + option: None, + container: None, + msg: "pain.001".into(), + version: None, + })), + } + } + + /* + + /** All orders required for a dialect implementation to work */ + fun downloadOrders(): Set<EbicsOrder> = ( + // Administrative orders + sequenceOf(EbicsOrder.V3.HAA, EbicsOrder.V3.HKD) + // and documents orders + + OrderDoc.entries.flatMap { downloadDoc(it) } + ).toSet() */ +} + +/** Supported bank dialects */ +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Str)] +#[allow(non_camel_case_types)] +pub enum Dialect { + valiant, + raiffeisen, + postfinance, + gls, + maerki_baumann, +} + +impl Dialect { + pub fn standard(&self) -> Standard { + match self { + Self::valiant | Self::raiffeisen | Self::postfinance | Self::maerki_baumann => { + Standard::SIX + } + Self::gls => Standard::GBIC, + } + } +} diff --git a/crates/libeufin-ebics/src/ebics.rs b/crates/libeufin-ebics/src/ebics.rs @@ -0,0 +1,757 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{borrow::Cow, io::Write as _}; + +use aws_lc_rs::{digest::Digest, rsa::PrivateDecryptingKey}; +use compact_str::CompactString; +use flate2::write::ZlibDecoder; +use jiff::Timestamp; +use rand::{RngExt as _, distr::Alphanumeric, seq::IndexedRandom as _}; +use reqwest::{ + Client, ClientBuilder, StatusCode, + header::{CONTENT_TYPE, HeaderValue}, +}; +use sqlx::PgPool; +use taler_common::encoding::base64; +use tracing::{debug, info, trace, warn}; + +use crate::{ + cli::EbicsLogs, + config::EbicsHostCfg, + crypto::{ + decrypt_ebics_e002, decrypt_ebics_e002_key, digest_ebics_order_a006, encrypt_ebics_e002, + gen_ebics_e002_key, sign_ebics_a006, + }, + db::{ebics_first, ebics_register, ebics_remove}, + ebics::{ + administrative::{HAA, HKD, VersionNumber, hev_msg, parse_haa, parse_hev, parse_hkd}, + bts::{ + DInit, DTransfer, DataEncryptionInfo, U, d_init, d_transfer, parse_d_init, + parse_d_transfer, parse_receipt, parse_u_init, parse_u_transfer, receipt, u_init, + u_transfer, + }, + ebics_code::EbicsReturnCode, + logger::EbicsLogger, + order::Order, + }, + keys::{BankKeys, ClientKeys}, + utils::deflate, + xml, +}; + +pub mod administrative; +pub mod bts; +pub mod ebics_code; +pub mod key_management; +pub mod logger; +pub mod order; + +const EBICS_ID_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +pub fn rand_ebics_id() -> CompactString { + let mut rng = rand::rng(); + (0..34) + .map(|_| *EBICS_ID_ALPHABET.choose(&mut rng).unwrap() as char) + .collect() +} + +#[derive(Debug, Clone, Copy)] +pub enum Phase { + Interrupt, + Init, + Transfer(usize), + Process, + Receipt, +} + +impl std::fmt::Display for Phase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Phase::Interrupt => f.write_str("interrupt"), + Phase::Init => f.write_str("init"), + Phase::Transfer(i) => write!(f, "transfer{i}"), + Phase::Process => f.write_str("process"), + Phase::Receipt => f.write_str("receipt"), + } + } +} + +#[derive(Debug)] +pub struct EbicsCtx<'a> { + pub now: Timestamp, + pub order: Cow<'a, Order>, + pub phase: Option<Phase>, + pub tx_id: Option<CompactString>, +} + +impl<'a> EbicsCtx<'a> { + pub fn new(order: &'a Order) -> Self { + Self { + now: Timestamp::now(), + order: Cow::Borrowed(order), + phase: None, + tx_id: None, + } + } + + pub fn init(self) -> Self { + Self { + phase: Some(Phase::Init), + tx_id: None, + ..self + } + } + + pub fn interrupt(self, id: &str) -> Self { + Self { + phase: Some(Phase::Interrupt), + tx_id: Some( + self.tx_id + .filter(|it| it != id) + .unwrap_or_else(|| id.into()), + ), + ..self + } + } + + pub fn transfer(self, id: &str, segment: usize) -> Self { + Self { + phase: Some(Phase::Transfer(segment)), + tx_id: Some( + self.tx_id + .filter(|it| it != id) + .unwrap_or_else(|| id.into()), + ), + ..self + } + } + + pub fn process(self, id: &str) -> Self { + Self { + phase: Some(Phase::Process), + tx_id: Some( + self.tx_id + .filter(|it| it != id) + .unwrap_or_else(|| id.into()), + ), + ..self + } + } + + pub fn receipt(self, id: &str) -> Self { + Self { + phase: Some(Phase::Receipt), + tx_id: Some( + self.tx_id + .filter(|it| it != id) + .unwrap_or_else(|| id.into()), + ), + ..self + } + } +} + +impl std::fmt::Display for EbicsCtx<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + order, + phase, + tx_id, + .. + } = self; + write!(f, "{order}")?; + if let Some(phase) = phase { + write!(f, " {phase}")?; + } + if let Some(tx_id) = tx_id { + write!(f, " {tx_id}")?; + } + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub struct EbicsError { + pub ctx: Box<EbicsCtx<'static>>, + pub kind: EbicsErrKind, +} + +impl std::fmt::Display for EbicsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { ctx, kind } = self; + write!(f, "{ctx}: {kind}") + } +} + +fn fmt_code( + f: &mut std::fmt::Formatter<'_>, + technical: &EbicsReturnCode, + bank: &EbicsReturnCode, +) -> std::fmt::Result { + if technical.is_error() { + write!(f, "technical error: {technical}") + } else { + write!(f, "technical error: {bank}") + } +} + +pub trait EbicsErrorHelper<T> { + fn ctx(self, ctx: &EbicsCtx<'_>) -> Result<T, EbicsError>; +} + +impl<T, E: Into<EbicsErrKind>> EbicsErrorHelper<T> for Result<T, E> { + fn ctx(self, ctx: &EbicsCtx<'_>) -> Result<T, EbicsError> { + self.map_err(|e| e.into().ctx(ctx)) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum EbicsErrKind { + #[error(transparent)] + Network(#[from] reqwest::Error), + + #[error(transparent)] + IO(#[from] std::io::Error), + + #[error("ebics HTTP error {0}")] + HTTP(StatusCode), + + #[error(transparent)] + XML(#[from] xml::Error), + + #[error("{}", std::fmt::from_fn(|f| fmt_code(f, technical, bank)))] + Code { + technical: EbicsReturnCode, + bank: EbicsReturnCode, + }, + + #[error(transparent)] + Db(#[from] sqlx::Error), + + #[error(transparent)] + Zip(#[from] zip::result::ZipError), + + #[error("{0}")] + Custom(Cow<'static, str>), +} + +impl EbicsErrKind { + pub fn ctx(self, ctx: &EbicsCtx<'_>) -> EbicsError { + EbicsError { + ctx: Box::new(EbicsCtx { + now: ctx.now, + phase: ctx.phase, + tx_id: ctx.tx_id.clone(), + order: Cow::Owned(ctx.order.as_ref().clone()), + }), + kind: self, + } + } +} +pub struct EbicsResponse<T> { + pub technical_code: EbicsReturnCode, + pub bank_code: EbicsReturnCode, + pub technical_text: String, + pub content: Option<T>, +} + +impl<T> EbicsResponse<T> { + fn ok_or_fail(self) -> Result<T, EbicsErrKind> { + if let Some(content) = self.content + && !self.technical_code.is_error() + && !self.bank_code.is_error() + { + Ok(content) + } else { + Err(EbicsErrKind::Code { + technical: self.technical_code, + bank: self.bank_code, + }) + } + } +} + +pub struct EbicsClient<'a> { + cfg: EbicsHostCfg<'a>, + pub http: Client, + logger: EbicsLogger, +} + +impl<'a> EbicsClient<'a> { + pub fn new(cfg: EbicsHostCfg<'a>, log: EbicsLogs) -> anyhow::Result<Self> { + let mut builder = ClientBuilder::new(); + if let Some(unix_path) = cfg.unix_path { + builder = builder.unix_socket(unix_path); + } + Ok(Self { + cfg, + http: builder.build()?, + logger: EbicsLogger::new(log.dir)?, + }) + } + + async fn post_to_bank(&self, xml: String, ctx: &EbicsCtx<'_>) -> Result<Vec<u8>, EbicsError> { + self.logger.log_request(ctx, &xml)?; + let res = self + .http + .post(self.cfg.base_url) + .header(CONTENT_TYPE, HeaderValue::from_static("application/xml")) + .body(xml) + .send() + .await + .ctx(ctx)?; + let status = res.status(); + if status != StatusCode::OK { + self.logger.log_failure(ctx, res).await?; + return Err(EbicsErrKind::HTTP(status).ctx(ctx)); + } + let xml = res.bytes().await.ctx(ctx)?; + self.logger.log_response(ctx, &xml)?; + Ok(xml.into()) + } + + /** POST an EBICS BTS request [xmlReq] using [client] returning a validated and parsed XML response */ + pub async fn post_bts<T>( + &self, + xml: String, + ctx: &EbicsCtx<'_>, + parse: impl FnOnce(&[u8]) -> xml::Result<EbicsResponse<T>>, + ) -> Result<T, EbicsError> { + let xml = self.post_to_bank(xml, ctx).await?; + // TODO verify ebics signature + let res = parse(&xml).ctx(ctx)?; + trace!(target: "ebics", + "{ctx}: {} {} - {}", + res.technical_code, + res.bank_code, + res.technical_text + ); + res.ok_or_fail().ctx(ctx) + } + + pub async fn hev(&self) -> Result<Box<[VersionNumber]>, EbicsError> { + let order = Order::HEV; + info!(target: "ebics", "Doing administrative request {order}"); + let msg = hev_msg(&self.cfg); + let ctx = EbicsCtx::new(&order); + let res = self.post_to_bank(msg, &ctx).await?; + parse_hev(&res).ctx(&ctx)?.ok_or_fail().ctx(&ctx) + } + + pub async fn haa( + &self, + db: &PgPool, + client: &ClientKeys, + bank: &BankKeys, + peek: bool, + ) -> Result<HAA, EbicsError> { + self.download( + db, + client, + bank, + &Order::HAA, + &None, + peek, + async |content| Ok(parse_haa(&content)?), + ) + .await + } + + pub async fn hkd( + &self, + db: &PgPool, + client: &ClientKeys, + bank: &BankKeys, + peek: bool, + ) -> Result<HKD, EbicsError> { + self.download( + db, + client, + bank, + &Order::HKD, + &None, + peek, + async |content| Ok(parse_hkd(&content)?), + ) + .await + } + + /** + * 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. + */ + pub async fn download<T>( + &self, + db: &PgPool, + client: &ClientKeys, + bank: &BankKeys, + order: &Order, + range: &Option<(Timestamp, Timestamp)>, + peek: bool, + processing: impl AsyncFnOnce(Vec<u8>) -> Result<T, EbicsErrKind>, + ) -> Result<T, EbicsError> { + let mut ctx = EbicsCtx::new(order); + debug!(target: "ebics", "Downloading order {order} {}", std::fmt::from_fn(|f| { + if let Some((start, end)) = range { + write!(f, " from {start} to {end}")? + } + Ok(()) + })); + + // Close interrupted + while let Some(tx_id) = ebics_first(db).await.ctx(&ctx)? { + let ctx = EbicsCtx::new(order).interrupt(&tx_id); + let xml = receipt(&self.cfg, client, order, &tx_id, false); + if let Err(e) = self.post_bts(xml, &ctx, parse_d_init).await { + if !matches!( + e.kind, + // Transaction already closed or expired - EBICS protocol error + EbicsErrKind::Code { + technical: EbicsReturnCode::EBICS_TX_UNKNOWN_TXID, + .. + } | + // Transaction already closed or expired - HTTP protocol error for non compliant banks + EbicsErrKind::HTTP(StatusCode::BAD_REQUEST) + ) { + return Err(e); + } else { + debug!(target: "ebics", "{e}") + } + } + ebics_remove(db, &tx_id).await.ctx(&ctx)?; + } + + // Init phase + ctx = ctx.init(); + let xml = d_init(&self.cfg, bank, client, order, range); + let DInit { + tx_id, + nb_segments, + segment, + data_encryption_info, + } = self.post_bts(xml, &ctx, parse_d_init).await?; + ebics_register(db, &tx_id).await.ctx(&ctx)?; + + // Transfer phase + let mut segments = vec![segment]; + for segment_nb in 2..=nb_segments { + ctx = ctx.transfer(&tx_id, segment_nb); + let xml = d_transfer(&self.cfg, client, order, nb_segments, segment_nb, &tx_id); + let DTransfer { segment, .. } = self.post_bts(xml, &ctx, parse_d_transfer).await?; + segments.push(segment); + } + + // Processing phase + ctx = ctx.process(&tx_id); + let payload = decrypt_and_decompress_payload(&client.enc, data_encryption_info, segments); + self.logger.log_payload(&ctx, &payload, order.file_type())?; + let res = processing(payload).await.ctx(&ctx); + + // Receipt phase + ctx = ctx.receipt(&tx_id); + let xml = receipt(&self.cfg, client, order, &tx_id, res.is_ok() && !peek); + if let Err(e) = async { + self.post_bts(xml, &ctx, parse_receipt).await?; + ebics_remove(db, &tx_id).await.ctx(&ctx) + } + .await + { + warn!(target: "ebics", "{e}") + } + + res + } + + /** + * Performs an EBICS upload transaction of [order] using [payload]. + * + * It conducts init -> upload phases. + * + * Returns upload orderID + */ + pub async fn upload( + &self, + client: &ClientKeys, + bank: &BankKeys, + order: &Order, + payload: &str, + ) -> Result<CompactString, EbicsError> { + debug!(target: "ebics", "Uploading order {order}"); + let mut ctx = EbicsCtx::new(order); + + self.logger.log_payload(&ctx, payload.as_bytes(), "xml")?; + let payload = prepare_upload_payload(&self.cfg, client, bank, payload); + + // Init phase + ctx = ctx.init(); + let xml = u_init(&self.cfg, bank, client, order, &payload); + let U { tx_id, order_id } = self.post_bts(xml, &ctx, parse_u_init).await?; + + // Transfer phase + for segment_nb in 1..=payload.nb_segments() { + ctx = ctx.transfer(&tx_id, segment_nb); + let xml = u_transfer(&self.cfg, client, order, &tx_id, &payload, segment_nb); + self.post_bts(xml, &ctx, parse_u_transfer).await?; + } + + Ok(order_id) + } +} + +pub struct PreparedUploadData { + encrypted_key: Vec<u8>, + signature_data: String, + digest: Digest, + payload: String, +} + +impl PreparedUploadData { + const CHUNK_SIZE: usize = 1000000; + + pub fn nb_segments(&self) -> usize { + self.payload.len().div_ceil(Self::CHUNK_SIZE) + } + + pub fn segment(&self, nb: usize) -> &str { + let start = (nb - 1) * Self::CHUNK_SIZE; + let end = (start + Self::CHUNK_SIZE).min(self.payload.len()); + &self.payload[start..end] + } +} + +/** Decrypts and decompresses EBICS BTS payload */ +fn decrypt_and_decompress_payload( + client_encryption_key: &PrivateDecryptingKey, + encryption_info: DataEncryptionInfo, + segments: Vec<Vec<u8>>, +) -> Vec<u8> { + // TODO check bank_pub_digest + let tx_key = decrypt_ebics_e002_key(client_encryption_key.clone(), &encryption_info.tx_key); + let mut decoder = ZlibDecoder::new(Vec::new()); + for segment in segments { + let decrypted = decrypt_ebics_e002(&tx_key, segment); + decoder.write_all(&decrypted).unwrap(); + } + decoder.finish().unwrap() +} + +/** Signs, encrypts and format EBICS BTS payload */ +fn prepare_upload_payload( + cfg: &EbicsHostCfg, + client: &ClientKeys, + bank: &BankKeys, + payload: &str, +) -> PreparedUploadData { + let digest = digest_ebics_order_a006(payload.as_bytes()); + + // Generate ephemeral transaction key + let (tx_key, encrypted_key) = gen_ebics_e002_key(bank.enc.enc.clone()); + + // Compress and encrypt order signature + let signature_data = { + let signed = sign_ebics_a006(digest.as_ref(), &client.sign); + let inner_signed_xml = xml!( + "UserSignatureData" "xmlns"="http://www.ebics.org/S002" { + "OrderSignatureData" { + "SignatureVersion": "A006", + "SignatureValue": base64::fmt(signed), + "PartnerID": cfg.partner_id, + "UserID": cfg.user_id + } + } + ); + let deflated = deflate(inner_signed_xml.as_bytes()); + let encrypted = encrypt_ebics_e002(&tx_key, deflated); + base64::encode(encrypted) + }; + + // Compress and encrypt payload + let payload = { + let deflated = deflate(payload.as_bytes()); + let encrypted = encrypt_ebics_e002(&tx_key, deflated); + base64::encode(encrypted) + }; + PreparedUploadData { + encrypted_key, + signature_data, + digest, + payload, + } +} + +#[derive(Debug)] +pub struct TxCheckResult { + pub concurrent_fetch_and_fetch: bool, + pub concurrent_fetch_and_submit: bool, + pub concurrent_submit_and_submit: bool, + pub idempotent_close: bool, +} + +/** + * 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 + */ +pub async fn tx_check( + ebics: &EbicsClient<'_>, + db: &PgPool, + client: &ClientKeys, + bank: &BankKeys, + fetch: &Order, + submit: &Order, +) -> anyhow::Result<TxCheckResult> { + let mut result = TxCheckResult { + concurrent_fetch_and_fetch: false, + concurrent_fetch_and_submit: false, + concurrent_submit_and_submit: false, + idempotent_close: false, + }; + + let ctx = EbicsCtx::new(fetch).init(); + let DInit { tx_id, .. } = ebics + .post_bts( + d_init(&ebics.cfg, bank, client, fetch, &None), + &ctx, + parse_d_init, + ) + .await?; + ebics_register(db, &tx_id).await?; + { + let ctx = EbicsCtx::new(fetch).init(); + match ebics + .post_bts( + d_init(&ebics.cfg, bank, client, fetch, &None), + &ctx, + parse_d_init, + ) + .await + { + Ok(DInit { tx_id, .. }) => { + ebics_register(db, &tx_id).await?; + result.concurrent_fetch_and_fetch = true; + let ctx = ctx.receipt(&tx_id); + ebics + .post_bts( + receipt(&ebics.cfg, client, fetch, &tx_id, false), + &ctx, + parse_receipt, + ) + .await?; + ebics_remove(db, &tx_id).await?; + } + Err(e) => { + if !matches!(e.kind, EbicsErrKind::Code { .. }) { + return Err(e.into()); + } else { + debug!(target: "testing", "concurrent_fetch_and_fetch {e}") + } + } + } + } + + { + let ctx = EbicsCtx::new(submit).init(); + let random_string: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(2000000) + .map(char::from) + .collect(); + let payload = prepare_upload_payload(&ebics.cfg, client, bank, &random_string); + match ebics + .post_bts( + u_init(&ebics.cfg, bank, client, submit, &payload), + &ctx, + parse_u_init, + ) + .await + { + Ok(U { tx_id, .. }) => { + result.concurrent_fetch_and_submit = true; + let ctx = ctx.transfer(&tx_id, 1); + ebics + .post_bts( + u_transfer(&ebics.cfg, client, fetch, &tx_id, &payload, 1), + &ctx, + parse_u_transfer, + ) + .await?; + let ctx = EbicsCtx::new(submit).init(); + if let Err(e) = ebics + .post_bts( + u_init(&ebics.cfg, bank, client, submit, &payload), + &ctx, + parse_u_init, + ) + .await + { + if !matches!(e.kind, EbicsErrKind::Code { .. }) { + return Err(e.into()); + } else { + debug!(target: "testing", "concurrent_submit_and_submit {e}") + } + } else { + result.concurrent_submit_and_submit = true; + } + } + Err(e) => { + if !matches!(e.kind, EbicsErrKind::Code { .. }) { + return Err(e.into()); + } else { + debug!(target: "testing", "concurrent_fetch_and_submit {e}") + } + } + } + } + + // Close first fetch + let ctx = ctx.receipt(&tx_id); + ebics + .post_bts( + receipt(&ebics.cfg, client, fetch, &tx_id, false), + &ctx, + parse_receipt, + ) + .await?; + + ebics_remove(db, &tx_id).await?; + + // Close first fetch again + let ctx = ctx.interrupt(&tx_id); + if let Err(e) = ebics + .post_bts( + receipt(&ebics.cfg, client, fetch, &tx_id, false), + &ctx, + parse_receipt, + ) + .await + { + debug!(target: "testing", "idempotent_close {e}") + } else { + result.idempotent_close = true + } + + Ok(result) +} diff --git a/crates/libeufin-ebics/src/ebics/administrative.rs b/crates/libeufin-ebics/src/ebics/administrative.rs @@ -0,0 +1,224 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::fmt::Display; + +use compact_str::CompactString; +use taler_common::types::{ + amount::Currency, + iban::{BIC, IBAN}, +}; +use taler_macros::EnumMeta; + +use crate::{ + config::EbicsHostCfg, + ebics::{ + EbicsResponse, + ebics_code::EbicsReturnCode, + order::{BTF, Order}, + }, + xml, + xml::{Xml, XmlAccess as _}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionNumber { + pub number: CompactString, + pub schema: CompactString, +} + +impl Display for VersionNumber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { number, schema } = self; + write!(f, "{number}:{schema}") + } +} + +pub struct HKD { + pub partner: PartnerInfo, + pub users: Box<[UserInfo]>, +} +pub struct PartnerInfo { + pub name: Option<CompactString>, + pub accounts: Box<[AccountInfo]>, + pub orders: Box<[OrderInfo]>, +} +pub struct OrderInfo { + pub order: Order, + pub description: String, +} +pub struct AccountInfo { + pub currency: Currency, + pub iban: IBAN, + pub bic: BIC, +} +pub struct UserInfo { + pub id: CompactString, + pub status: UserStatus, + pub permissions: Box<[Order]>, +} + +pub struct HAA { + pub orders: Vec<Order>, +} + +#[derive(Debug, Clone, PartialEq, Eq, EnumMeta)] +#[enum_meta(Description)] +pub enum UserStatus { + /// "Subscriber is permitted access" + Ready, + /// "Subscriber is established, pending access permission" + New, + /// "Subscriber has sent INI file, but no HIA file yet" + INI, + /// "Subscriber has sent HIA order, but no INI file yet" + HIA, + /// "Subscriber has sent both HIA order and INI file" + Initialised, + /// "Suspended after several failed attempts, new initialisation via INI and HIA possible" + SuspendedFailedAttempts, + /// "Suspended after SPR order, new initialisation via INI and HIA possible" + SuspendedSPR, + /// "Suspended by bank, new initialisation via INI and HIA is not possible, suspension can only be revoked by the bank" + SuspendedBank, +} + +pub fn hev_msg(cfg: &EbicsHostCfg) -> String { + xml!( + "ebicsHEVRequest" "xmlns"="http://www.ebics.org/H000" { + "HostID": &cfg.host_id + } + ) +} + +pub fn parse_hev(xml: &[u8]) -> xml::Result<EbicsResponse<Box<[VersionNumber]>>> { + Xml::parse(xml, "ebicsHEVResponse", |root| { + let s = root.one("SystemReturnCode")?; + Ok(EbicsResponse { + technical_code: s.one("ReturnCode").parse()?, + technical_text: s.one("ReportText").parse()?, + bank_code: EbicsReturnCode::EBICS_OK, + content: Some( + root.many("VersionNumber") + .map(|n| { + Ok(VersionNumber { + number: n.parse()?, + schema: n.attr("ProtocolVersion")?.into(), + }) + }) + .collect::<xml::Result<_>>()?, + ), + }) + }) +} + +fn service(n: Xml) -> xml::Result<BTF> { + let msg = n.one("MsgName")?; + Ok(BTF { + service: n.one("ServiceName").parse()?, + scope: n.opt("Scope").parse()?, + option: n.opt("ServiceOption").parse()?, + container: n.opt("Container").parse_attr("containerType")?, + msg: msg.parse()?, + version: msg.parse_opt_attr("version")?, + }) +} + +pub fn parse_hkd(xml: &[u8]) -> xml::Result<HKD> { + fn order(n: Xml) -> xml::Result<Order> { + let ty = n.one("AdminOrderType")?.text(); + Order::from_parts(ty, n.opt("Service")?.map(service).transpose()?) + .ok_or_else(|| n.parse_err(format_args!("Unknown order type {ty}"))) + } + Xml::parse(xml, "HKDResponseOrderData", |root| { + let partner = root.one("PartnerInfo")?; + + Ok(HKD { + partner: PartnerInfo { + name: partner.one("AddressInfo").opt("Name").parse()?, + accounts: partner + .many("AccountInfo") + .map(|account| { + let currency = account.parse_attr("Currency")?; + let iban = account + .many("AccountNumber") + .find(|nb| nb.opt_attr("international") == Some("true")) + .unwrap() + .parse()?; + let bic = account + .many("BankCode") + .find(|nb| nb.opt_attr("international") == Some("true")) + .unwrap() + .parse()?; + Ok(AccountInfo { + currency, + iban, + bic, + }) + }) + .collect::<xml::Result<_>>()?, + orders: partner + .many("OrderInfo") + .map(|n| { + Ok(OrderInfo { + order: order(n)?, + description: n.one("Description").parse()?, + }) + }) + .collect::<xml::Result<_>>()?, + }, + users: root + .many("UserInfo") + .map(|n| { + let id = n.one("UserID")?; + Ok(UserInfo { + id: id.parse()?, + status: match id.attr("Status")? { + "1" => UserStatus::Ready, + "2" => UserStatus::New, + "3" => UserStatus::INI, + "4" => UserStatus::HIA, + "5" => UserStatus::Initialised, + "6" => UserStatus::SuspendedFailedAttempts, + // 7 is not applicable per spec + "8" => UserStatus::SuspendedSPR, + "9" => UserStatus::SuspendedBank, + s => return Err(id.parse_err(format_args!("Unknown user status {s}"))), + }, + permissions: n + .many("Permission") + .map(|p| order(p)) + .collect::<xml::Result<_>>()?, + }) + }) + .collect::<xml::Result<_>>()?, + }) + }) +} + +pub fn parse_haa(xml: &[u8]) -> xml::Result<HAA> { + Xml::parse(xml, "HAAResponseOrderData", |root| { + Ok(HAA { + orders: root + .many("Service") + .map(|n| Ok(Order::BTD(service(n)?))) + .collect::<xml::Result<_>>()?, + }) + }) +} diff --git a/crates/libeufin-ebics/src/ebics/bts.rs b/crates/libeufin-ebics/src/ebics/bts.rs @@ -0,0 +1,496 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +/*! EBICS protocol for business transactions */ + +use compact_str::CompactString; +use jiff::{Timestamp, Zoned, tz::TimeZone}; +use taler_common::encoding::base64; + +use crate::{ + config::EbicsHostCfg, + crypto::ebics_pub_key_hash, + ebics::{ + EbicsResponse, PreparedUploadData, + ebics_code::EbicsReturnCode, + order::{BTF, Order}, + }, + keys::{BankKeys, ClientKeys}, + xml, + xml::{Xml, XmlAccess, XmlWriter}, + xml_sign::sign_ebics, +}; + +fn signed_request( + order: &Order, + client: &ClientKeys, + lambda: impl FnOnce(&mut XmlWriter), +) -> String { + let schema = order.schema(); + let doc = xml!( + "ebicsRequest" + "xmlns"=(format_args!("urn:org:ebics:{schema}")) + "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" + "Version"=schema + "Revision"="1" + { + @ lambda + } + ); + sign_ebics(doc, &client.auth) +} + +fn bank_digest(w: &mut XmlWriter, bank: &BankKeys) { + xml!(w => + "BankPubKeyDigests" { + "Authentication" "Version"="X002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256" : base64::fmt(ebics_pub_key_hash(&bank.auth.key)), + "Encryption" "Version"="E002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256" : base64::fmt(ebics_pub_key_hash(&bank.enc.key)) + }, + "SecurityMedium": "0000" + ) +} + +fn service(w: &mut XmlWriter, service: &BTF) { + let BTF { + service: name, + scope, + msg, + version, + container, + option, + } = service; + xml!(w => + "Service" { + "ServiceName": name, + @ |w: &mut XmlWriter| { + if let Some(scope) = scope { + xml!(w => "Scope": scope) + } + if let Some(option) = option { + xml!(w => "ServiceOption": option) + } + if let Some(container) = container { + xml!(w => "Container" "containerType"=container) + } + + if let Some(version) = version { + xml!(w => "MsgName" "version"=version : msg) + } else { + xml!(w => "MsgName": msg) + } + } + } + ) +} + +pub fn d_init( + cfg: &EbicsHostCfg, + bank: &BankKeys, + client: &ClientKeys, + order: &Order, + range: &Option<(Timestamp, Timestamp)>, +) -> String { + let nonce: u128 = rand::random(); + signed_request(order, client, |w| { + xml!(w => + "header" "authenticate"="true" { + "static" { + "HostID": cfg.host_id, + "Nonce": format_args!("{:032x}", nonce), + "Timestamp": jiff::Timestamp::now(), + "PartnerID": cfg.partner_id, + "UserID": cfg.user_id, + "OrderDetails" { + "AdminOrderType": order.ty(), + @ |w: &mut XmlWriter| if let Order::BTD(s) = order { + xml!(w => "BTDOrderParams" { + @ |w: &mut XmlWriter| { + service(w, s); + if let Some((start, end)) = range { + xml!(w => + "DateRange" { + "Start": Zoned::new(*start, TimeZone::UTC).date(), + "End": Zoned::new(*end, TimeZone::UTC).date() + } + ) + } + } + }) + } else { + xml!(w => "StandardOrderParams") + } + }, + @ |w: &mut XmlWriter| bank_digest(w, bank) + }, + "mutable" { + "TransactionPhase": "Initialisation" + } + }, + "AuthSignature", + "body" + ) + }) +} + +pub fn d_transfer( + cfg: &EbicsHostCfg, + client: &ClientKeys, + order: &Order, + nb_segment: usize, + segment_nb: usize, + tx_id: &str, +) -> String { + signed_request(order, client, |w| { + xml!(w => + "header" "authenticate"="true" { + "static" { + "HostID": cfg.host_id, + "TransactionID": tx_id + }, + "mutable" { + "TransactionPhase": "Transfer", + "SegmentNumber" "lastSegment"=(nb_segment == segment_nb) : segment_nb + } + }, + "AuthSignature", + "body" + ) + }) +} + +pub fn receipt( + cfg: &EbicsHostCfg, + client: &ClientKeys, + order: &Order, + tx_id: &str, + success: bool, +) -> String { + signed_request(order, client, |w| { + xml!(w => + "header" "authenticate"="true" { + "static" { + "HostID": cfg.host_id, + "TransactionID": tx_id + }, + "mutable" { + "TransactionPhase": "Receipt" + } + }, + "AuthSignature", + "body" { + "TransferReceipt" "authenticate"="true" { + "ReceiptCode": (if success { "0" } else { "1"}) + } + } + ) + }) +} + +pub fn u_init( + cfg: &EbicsHostCfg, + bank: &BankKeys, + client: &ClientKeys, + order: &Order, + data: &PreparedUploadData, +) -> String { + let nonce: u128 = rand::random(); + signed_request(order, client, |w| { + xml!(w => + "header" "authenticate"="true" { + "static" { + "HostID": cfg.host_id, + "Nonce": format_args!("{:032x}", nonce), + "Timestamp": jiff::Timestamp::now(), + "PartnerID": cfg.partner_id, + "UserID": cfg.user_id, + "OrderDetails" { + "AdminOrderType": order.ty(), + @ |w: &mut XmlWriter| if let Order::BTU(s) = order { + xml!(w => "BTUOrderParams" { + @ |w: &mut XmlWriter| service(w, s), + "SignatureFlag" + }) + } else { + xml!(w => "StandardOrderParams") + } + }, + @ |w: &mut XmlWriter| bank_digest(w, bank), + "NumSegments": data.nb_segments() + }, + "mutable" { + "TransactionPhase": "Initialisation" + } + }, + "AuthSignature", + "body" { + "DataTransfer" { + "DataEncryptionInfo" "authenticate"="true" { + "EncryptionPubKeyDigest" "Version"="E002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256": base64::fmt(ebics_pub_key_hash(&bank.enc.key)), + "TransactionKey": base64::fmt(&data.encrypted_key) + }, + "SignatureData" "authenticate"="true" : data.signature_data, + "DataDigest" "SignatureVersion"="A006" : base64::fmt(data.digest) + } + } + ) + }) +} + +pub fn u_transfer( + cfg: &EbicsHostCfg, + client: &ClientKeys, + order: &Order, + tx_id: &str, + data: &PreparedUploadData, + segment_nb: usize, +) -> String { + signed_request(order, client, |w| { + xml!(w => + "header" "authenticate"="true" { + "static" { + "HostID": cfg.host_id, + "TransactionID": tx_id + }, + "mutable" { + "TransactionPhase": "Transfer", + "SegmentNumber" "lastSegment"=(data.nb_segments() == segment_nb) : segment_nb + } + }, + "AuthSignature", + "body" { + "DataTransfer" { + "OrderData": data.segment(segment_nb) + } + } + ) + }) +} + +pub struct DataEncryptionInfo { + pub tx_key: Vec<u8>, + pub bank_pub_digest: Vec<u8>, +} + +fn expect_phase(n: Xml<'_>, phase: &str) -> xml::Result<()> { + let n = n.one("TransactionPhase")?; + if n.text() != phase { + Err(n.parse_err(format_args!("Expected phase '{phase}' got '{}'", n.text()))) + } else { + Ok(()) + } +} + +pub struct DInit { + pub tx_id: CompactString, + pub data_encryption_info: DataEncryptionInfo, + pub segment: Vec<u8>, + pub nb_segments: usize, +} + +pub fn parse_d_init(xml: &[u8]) -> xml::Result<EbicsResponse<DInit>> { + Xml::parse(xml, "ebicsResponse", |root| { + let header = root.one_signed("header")?; + let st = header.one("static")?; + let mutable = header.one("mutable")?; + let body = root.one("body")?; + + let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; + let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; + let technical_text = mutable.one("ReportText").parse()?; + + if technical_code.is_error() || bank_code.is_error() { + return Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: None, + }); + } + + expect_phase(mutable, "Initialisation")?; + + let data: Xml<'_> = body.one("DataTransfer")?; + let enc_info = data.one_signed("DataEncryptionInfo")?; + Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: Some(DInit { + tx_id: st.one("TransactionID").parse()?, + data_encryption_info: DataEncryptionInfo { + tx_key: enc_info.one("TransactionKey").b64()?, + bank_pub_digest: enc_info.one("EncryptionPubKeyDigest").b64()?, + }, + segment: data.one("OrderData").b64()?, + nb_segments: st.one("NumSegments").parse()?, + }), + }) + }) +} + +pub struct DTransfer { + pub tx_id: CompactString, + pub segment: Vec<u8>, + pub nb_segments: usize, +} + +pub fn parse_d_transfer(xml: &[u8]) -> xml::Result<EbicsResponse<DTransfer>> { + Xml::parse(xml, "ebicsResponse", |root| { + let header = root.one_signed("header")?; + let st = header.one("static")?; + let mutable = header.one("mutable")?; + let body = root.one("body")?; + + let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; + let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; + let technical_text = mutable.one("ReportText").parse()?; + + if technical_code.is_error() || bank_code.is_error() { + return Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: None, + }); + } + + expect_phase(mutable, "Transfer")?; + + Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: Some(DTransfer { + tx_id: st.one("TransactionID").parse()?, + segment: body.one("DataTransfer").one("OrderData").b64()?, + nb_segments: st.one("NumSegments").parse()?, + }), + }) + }) +} + +pub struct Receipt { + pub tx_id: CompactString, +} + +pub fn parse_receipt(xml: &[u8]) -> xml::Result<EbicsResponse<Receipt>> { + Xml::parse(xml, "ebicsResponse", |root| { + let header = root.one_signed("header")?; + let st = header.one("static")?; + let mutable = header.one("mutable")?; + let body = root.one("body")?; + + let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; + let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; + let technical_text = mutable.one("ReportText").parse()?; + + if technical_code.is_error() || bank_code.is_error() { + return Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: None, + }); + } + + expect_phase(mutable, "Receipt")?; + + Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: Some(Receipt { + tx_id: st.one("TransactionID").parse()?, + }), + }) + }) +} + +pub struct U { + pub tx_id: CompactString, + pub order_id: CompactString, +} + +pub fn parse_u_init(xml: &[u8]) -> xml::Result<EbicsResponse<U>> { + Xml::parse(xml, "ebicsResponse", |root| { + let header = root.one_signed("header")?; + let st = header.one("static")?; + let mutable = header.one("mutable")?; + let body = root.one("body")?; + + let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; + let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; + let technical_text = mutable.one("ReportText").parse()?; + + if technical_code.is_error() || bank_code.is_error() { + return Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: None, + }); + } + + expect_phase(mutable, "Initialisation")?; + + Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: Some(U { + order_id: mutable.one("OrderID").parse()?, + tx_id: st.one("TransactionID").parse()?, + }), + }) + }) +} + +pub fn parse_u_transfer(xml: &[u8]) -> xml::Result<EbicsResponse<U>> { + Xml::parse(xml, "ebicsResponse", |root| { + let header = root.one_signed("header")?; + let st = header.one("static")?; + let mutable = header.one("mutable")?; + let body = root.one("body")?; + + let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; + let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; + let technical_text = mutable.one("ReportText").parse()?; + + if technical_code.is_error() || bank_code.is_error() { + return Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: None, + }); + } + + expect_phase(mutable, "Transfer")?; + + Ok(EbicsResponse { + technical_code, + bank_code, + technical_text, + content: Some(U { + order_id: mutable.one("OrderID").parse()?, + tx_id: st.one("TransactionID").parse()?, + }), + }) + }) +} diff --git a/crates/libeufin-ebics/src/ebics/ebics_code.rs b/crates/libeufin-ebics/src/ebics/ebics_code.rs @@ -0,0 +1,210 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use taler_macros::EnumMeta; + +/// EBICS Error Class (First two digits of the return code) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EbicsKind { + /// 00 - Success / General Information + Information, + /// 01 - Positive notification, but action might be required + Note, + /// 03 - Warning + Warning, + /// 06 - Recoverable Error + RecoverableError, + /// 09 - Non-recoverable Error + NonRecoverableError, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(DomainCode, Str)] +#[allow(non_camel_case_types)] +pub enum EbicsReturnCode { + // --- 00: Information --- + #[code = "000000"] + EBICS_OK, + + // --- 01: Notes --- + #[code = "011000"] + EBICS_DOWNLOAD_POSTPROCESS_DONE, + #[code = "011001"] + EBICS_DOWNLOAD_POSTPROCESS_SKIPPED, + #[code = "011101"] + EBICS_TX_SEGMENT_NUMBER_UNDERRUN, + #[code = "011301"] + EBICS_NO_ONLINE_CHECKS, + + // --- 03: Warnings --- + #[code = "031001"] + EBICS_ORDER_PARAMS_IGNORED, + + // --- 06: Technical Errors (Recoverable) --- + #[code = "061001"] + EBICS_AUTHENTICATION_FAILED, + #[code = "061002"] + EBICS_INVALID_REQUEST, + #[code = "061099"] + EBICS_INTERNAL_ERROR, + #[code = "061101"] + EBICS_TX_RECOVERY_SYNC, + + // --- 09: Business Errors (Non-Recoverable) --- + #[code = "090003"] + EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED, + #[code = "090004"] + EBICS_INVALID_ORDER_DATA_FORMAT, + #[code = "090005"] + EBICS_NO_DOWNLOAD_DATA_AVAILABLE, + #[code = "090006"] + EBICS_UNSUPPORTED_REQUEST_FOR_ORDER_INSTANCE, + + // --- 09: Transaction Administration --- + #[code = "091002"] + EBICS_INVALID_USER_OR_USER_STATE, + #[code = "091003"] + EBICS_USER_UNKNOWN, + #[code = "091004"] + EBICS_INVALID_USER_STATE, + #[code = "091005"] + EBICS_INVALID_ORDER_TYPE, + #[code = "091006"] + EBICS_UNSUPPORTED_ORDER_TYPE, + #[code = "091007"] + EBICS_DISTRIBUTED_SIGNATURE_AUTHORISATION_FAILED, + #[code = "091008"] + EBICS_BANK_PUBKEY_UPDATE_REQUIRED, + #[code = "091009"] + EBICS_SEGMENT_SIZE_EXCEEDED, + #[code = "091010"] + EBICS_INVALID_XML, + #[code = "091011"] + EBICS_INVALID_HOST_ID, + + // --- 09: Transaction Processing --- + #[code = "091101"] + EBICS_TX_UNKNOWN_TXID, + #[code = "091102"] + EBICS_TX_ABORT, + #[code = "091103"] + EBICS_TX_MESSAGE_REPLAY, + #[code = "091104"] + EBICS_TX_SEGMENT_NUMBER_EXCEEDED, + #[code = "091105"] + EBICS_RECOVERY_NOT_SUPPORTED, + #[code = "091111"] + EBICS_INVALID_SIGNATURE_FILE_FORMAT, + #[code = "091112"] + EBICS_INVALID_ORDER_PARAMS, + #[code = "091113"] + EBICS_INVALID_REQUEST_CONTENT, + #[code = "091114"] + EBICS_ORDERID_UNKNOWN, + #[code = "091115"] + EBICS_ORDERID_ALREADY_FINAL, + #[code = "091116"] + EBICS_PROCESSING_ERROR, + #[code = "091117"] + EBICS_MAX_ORDER_DATA_SIZE_EXCEEDED, + #[code = "091118"] + EBICS_MAX_SEGMENTS_EXCEEDED, + #[code = "091119"] + EBICS_MAX_TRANSACTIONS_EXCEEDED, + #[code = "091120"] + EBICS_PARTNER_ID_MISMATCH, + #[code = "091121"] + EBICS_INCOMPATIBLE_ORDER_ATTRIBUTE, + #[code = "091122"] + EBICS_ORDER_ALREADY_EXISTS, + + // --- 09: Key Management (X.509 & Keys) --- + #[code = "091201"] + EBICS_KEYMGMT_UNSUPPORTED_VERSION_SIGNATURE, + #[code = "091202"] + EBICS_KEYMGMT_UNSUPPORTED_VERSION_AUTHENTICATION, + #[code = "091203"] + EBICS_KEYMGMT_UNSUPPORTED_VERSION_ENCRYPTION, + #[code = "091204"] + EBICS_KEYMGMT_KEYLENGTH_ERROR_SIGNATURE, + #[code = "091205"] + EBICS_KEYMGMT_KEYLENGTH_ERROR_AUTHENTICATION, + #[code = "091206"] + EBICS_KEYMGMT_KEYLENGTH_ERROR_ENCRYPTION, + #[code = "091207"] + EBICS_KEYMGMT_NO_X509_SUPPORT, + #[code = "091208"] + EBICS_X509_CERTIFICATE_EXPIRED, + #[code = "091209"] + EBICS_X509_CERTIFICATE_NOT_VALID_YET, + #[code = "091210"] + EBICS_X509_WRONG_KEY_USAGE, + #[code = "091211"] + EBICS_X509_WRONG_ALGORITHM, + #[code = "091212"] + EBICS_X509_INVALID_THUMBPRINT, + #[code = "091213"] + EBICS_X509_CTL_INVALID, + #[code = "091214"] + EBICS_X509_UNKNOWN_CERTIFICATE_AUTHORITY, + #[code = "091215"] + EBICS_X509_INVALID_POLICY, + #[code = "091216"] + EBICS_X509_INVALID_BASIC_CONSTRAINTS, + #[code = "091217"] + EBICS_ONLY_X509_SUPPORT, + #[code = "091218"] + EBICS_KEYMGMT_DUPLICATE_KEY, + #[code = "091219"] + EBICS_CERTIFICATES_VALIDATION_ERROR, + + // --- 09: Pre-verification / Signature Logic --- + #[code = "091301"] + EBICS_SIGNATURE_VERIFICATION_FAILED, + #[code = "091302"] + EBICS_ACCOUNT_AUTHORISATION_FAILED, + #[code = "091303"] + EBICS_AMOUNT_CHECK_FAILED, + #[code = "091304"] + EBICS_SIGNER_UNKNOWN, + #[code = "091305"] + EBICS_INVALID_SIGNER_STATE, + #[code = "091306"] + EBICS_DUPLICATE_SIGNATURE, +} + +impl EbicsReturnCode { + /// Automatically classifies the severity/kind based on standard EBICS prefixes. + pub fn kind(&self) -> EbicsKind { + match &self.code()[..2] { + "00" => EbicsKind::Information, + "01" => EbicsKind::Note, + "03" => EbicsKind::Warning, + "06" => EbicsKind::RecoverableError, + "09" => EbicsKind::NonRecoverableError, + prefix => unreachable!("Internal parser mapping error {prefix}"), + } + } + + pub fn is_error(&self) -> bool { + matches!( + self.kind(), + EbicsKind::RecoverableError | EbicsKind::NonRecoverableError + ) + } +} diff --git a/crates/libeufin-ebics/src/ebics/key_management.rs b/crates/libeufin-ebics/src/ebics/key_management.rs @@ -0,0 +1,277 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{borrow::Cow, io::Write as _}; + +use anyhow::bail; +use aws_lc_rs::encoding::{AsDer, Pkcs8V1Der}; +use flate2::{Compression, write::ZlibEncoder}; +use taler_common::encoding::base64; +use tracing::info; + +use crate::{ + config::{EbicsHostCfg, EbicsKeysCfg}, + crypto::{rsa_private_from_b64_x509_certificate, x509_certificate_from_rsa_private}, + ebics::{ + EbicsClient, EbicsCtx, EbicsErrKind, EbicsError, EbicsErrorHelper, EbicsResponse, + bts::DataEncryptionInfo, decrypt_and_decompress_payload, ebics_code::EbicsReturnCode, + order::Order, + }, + keys::{self, BankKeys, ClientKeys, RsaPub}, + xml, + xml::{Xml, XmlAccess as _, XmlWriter}, + xml_sign::sign_ebics, +}; + +impl EbicsClient<'_> { + /** Perform an EBICS public key management [order] using [client] and update on disk state */ + pub async fn submit_client_keys( + &self, + cfg: &EbicsKeysCfg<'_>, + client: &mut ClientKeys, + order: Order, + ) -> Result<(), EbicsError> { + let ctx = EbicsCtx::new(&order); + if !matches!(order, Order::INI | Order::HIA) { + unreachable!("Only INI & HIA are supported for client keys"); + } + let res = self.key_management(client, &order).await?; + + if res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_STATE + || res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_OR_USER_STATE + { + return Err(EbicsErrKind::Custom(Cow::Owned(format!( + "status code {}: either your IDs are incorrect, or you already have keys registered with this bank", + res.technical_code + ))).ctx(&ctx)); + } + res.ok_or_fail().ctx(&ctx)?; + match order { + Order::INI => client.submitted_ini = true, + Order::HIA => client.submitted_hia = true, + _ => unreachable!("Only INI & HIA are supported for client keys"), + } + keys::persist_client_keys(client, cfg.client.as_ref()).ctx(&ctx)?; + // TODO better error: Could not update the $order state on disk + Ok(()) + } + + /** Perform an EBICS private key management HPB using [client] */ + pub async fn hpb(&self, client: &ClientKeys) -> anyhow::Result<BankKeys> { + let order = Order::HPB; + let res = self.key_management(client, &order).await?; + if res.technical_code == EbicsReturnCode::EBICS_AUTHENTICATION_FAILED { + bail!( + "{order} status code {}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank", + res.technical_code + ) + } + let order_data = res.ok_or_fail()?.expect("{order}: missing order data"); + + Ok(Xml::parse(&order_data, "HPBResponseOrderData", |root| { + let auth_pub = root.one("AuthenticationPubKeyInfo")?; + let version = auth_pub.one("AuthenticationVersion")?.text(); + assert_eq!( + version, "X002", + "Expected authentication version X002 got unsupported {version}" + ); + let auth_pub = rsa_pub_key(auth_pub)?; + + let enc_pub = root.one("EncryptionPubKeyInfo")?; + let version = enc_pub.one("EncryptionVersion")?.text(); + assert_eq!( + version, "E002", + "Expected encryption version E002 got unsupported {version}" + ); + let enc_pub = rsa_pub_key(enc_pub)?; + + Ok(BankKeys { + auth: auth_pub, + enc: enc_pub, + accepted: false, + }) + })?) + } + + async fn key_management( + &self, + client: &ClientKeys, + order: &Order, + ) -> Result<EbicsResponse<Option<Vec<u8>>>, EbicsError> { + let EbicsHostCfg { + host_id, + user_id, + partner_id, + .. + } = &self.cfg; + let ctx = EbicsCtx::new(order); + info!("Doing key request {order}"); + + let (name, security_medium) = match order { + Order::INI | Order::HIA => ("ebicsUnsecuredRequest", "0200"), + Order::HPB => ("ebicsNoPubKeyDigestsRequest", "0000"), + _ => unreachable!(), + }; + + fn xml_order_data( + cfg: &EbicsHostCfg, + name: &str, + schema: &str, + build: impl FnOnce(&mut XmlWriter), + ) -> String { + let xml = xml!(name "xmlns"=schema "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { + @ build, + "PartnerID": &cfg.partner_id, + "UserID": &cfg.user_id + }); + // Deflate TODO write inside the compressor directly + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(xml.as_bytes()).unwrap(); + let compressed = encoder.finish().unwrap(); + base64::encode(&compressed) + } + + let data = match order { + Order::INI => Some(xml_order_data( + &self.cfg, + "SignaturePubKeyOrderData", + "http://www.ebics.org/S002", + |w| { + xml!(w => "SignaturePubKeyInfo" { + @ |w| rsa_key_xml(w, &client.sign), + "SignatureVersion": "A006" + }) + }, + )), + Order::HIA => Some(xml_order_data( + &self.cfg, + "HIARequestOrderData", + "urn:org:ebics:H005", + |w| { + xml!(w => + "AuthenticationPubKeyInfo" { + @ |w| rsa_key_xml(w, &client.auth), + "AuthenticationVersion": "X002" + }, + "EncryptionPubKeyInfo" { + @ |w| rsa_key_xml(w, &client.enc), + "EncryptionVersion": "E002" + } + ) + }, + )), + Order::HPB => None, + _ => unreachable!(), + }; + let sign = matches!(order, Order::HPB); + let msg = xml!( + name + "xmlns"="urn:org:ebics:H005" + "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" + "Version"="H005" + "Revision"="1" + { + "header" "authenticate"="true" { + "static" { + "HostID": host_id, + @ |w: &mut XmlWriter| if *order == Order::HPB { + let nonce: u128 = rand::random(); + xml!(w => + "Nonce": format_args!("{:032x}", nonce), + "Timestamp": jiff::Timestamp::now() + ) + }, + "PartnerID": partner_id, + "UserID": user_id, + "OrderDetails" { + "AdminOrderType": order + }, + "SecurityMedium": security_medium + }, + "mutable" + }, + @ |w: &mut XmlWriter| if sign { + xml!(w => "AuthSignature") + }, + "body" { + @ |w: &mut XmlWriter| if let Some(data) = data { + xml!(w => "DataTransfer" { + "OrderData": data + }) + } + } + } + ); + let signed = if sign { + sign_ebics(msg, &client.auth) + } else { + msg + }; + let res = self.post_to_bank(signed, &ctx).await?; + Xml::parse(&res, "ebicsKeyManagementResponse", |root| { + let body = root.one("body")?; + let mutable = root.one_signed("header").one("mutable")?; + Ok(EbicsResponse { + technical_code: mutable.one("ReturnCode").parse()?, + technical_text: mutable.one("ReportText").parse()?, + bank_code: body.one_signed("ReturnCode").parse()?, + content: Some(if let Some(data) = body.opt("DataTransfer")? { + let info = data.one_signed("DataEncryptionInfo")?; + let info = DataEncryptionInfo { + tx_key: info.one("TransactionKey").b64()?, + bank_pub_digest: info.one("EncryptionPubKeyDigest").b64()?, + }; + let chunk = data.one("OrderData").b64()?; + let decoded = decrypt_and_decompress_payload(&client.enc, info, vec![chunk]); + Some(decoded) + } else { + None + }), + }) + }) + .ctx(&ctx) + } +} + +pub fn rsa_pub_key(xml: Xml) -> xml::Result<RsaPub> { + xml.one("X509Data") + .one("X509Certificate") + .decode(rsa_private_from_b64_x509_certificate) +} + +pub fn rsa_key_xml<K>(w: &mut XmlWriter, key: &K) +where + K: AsDer<Pkcs8V1Der<'static>>, +{ + let der = key.as_der().unwrap(); + let b64 = base64::encode(der.as_ref()); + let lines = b64 + .as_bytes() + .chunks(64) + .map(|c| std::str::from_utf8(c).unwrap()) + .collect::<Vec<_>>() + .join("\n"); + let pem = format!("-----BEGIN RSA PRIVATE KEY-----\n{lines}\n-----END RSA PRIVATE KEY-----\n"); + let cert = x509_certificate_from_rsa_private(&pem, "LibEuFin EBICS").unwrap(); + let der = cert.der(); + + xml!(w => "ds:X509Data" { + "ds:X509Certificate": base64::fmt(der) + }) +} diff --git a/src/ebics/logger.rs b/crates/libeufin-ebics/src/ebics/logger.rs diff --git a/crates/libeufin-ebics/src/ebics/order.rs b/crates/libeufin-ebics/src/ebics/order.rs @@ -0,0 +1,270 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use compact_str::CompactString; +use taler_macros::EnumMeta; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Download, + Upload, +} + +#[derive(Debug, Clone)] +pub struct BTF { + pub service: CompactString, + pub scope: Option<CompactString>, + pub option: Option<CompactString>, + pub container: Option<CompactString>, + pub msg: CompactString, + pub version: Option<CompactString>, +} + +impl PartialEq for BTF { + fn eq(&self, other: &Self) -> bool { + self.service == other.service + && self.scope == other.scope + && self.option == other.option + && self.container == other.container + && self.msg == other.msg + // Ignore msg version + } +} + +impl std::fmt::Display for BTF { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let BTF { + service: name, + scope, + option, + container, + msg, + version, + } = self; + write!(f, "{name}")?; + for part in [scope, container, option].into_iter().flatten() { + write!(f, "-{part}")?; + } + write!(f, "-{msg}")?; + if let Some(version) = version { + write!(f, ".{version}")?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Order { + /// Download of a file identified by a BTF structure (Mandatory) + BTD(BTF), + /// Upload of a file identified by a BTF structure (Mandatory) + BTU(BTF), + /// Download retrievable order types (Optional) + HAA, + /// Download customer acknowledgment (Mandatory) + HAC, + /// Send amendment of the subscriber key for identification and authentication and encryption (Mandatory) + HCA, + /// Transmission of the subscriber key for ES identification and authentication and encryption (Mandatory) + HCS, + /// Download supported EBICS versions (Mandatory) + HEV, + /// Transmission of the subscriber key for identification and authentication and encryption within the framework of subscriber initialization (Mandatory) + HIA, + /// Download customer’s customer and subscriber data (Optional) + HKD, + /// Transfer the public bank key (Mandatory) + HPB, + /// Download bank parameters (Mandatory) + HPD, + /// Download subscriber’s customer and subscriber data (Mandatory) + HTD, + /// Download subscriber’s customer and subscriber data (Optional) + HVD, + /// Add EDSsignature (Mandatory) + HVE, + /// Cancellation of orders in the EDS (Mandatory) + HVS, + /// Retrieve EDS transaction details (Mandatory) + HVT, + /// Download EDS overview (Mandatory) + HVU, + /// Download EDS overview with additional informations (Mandatory) + HVZ, + /// Transmission of all public keys (subscriber key, key for identification and authentication and key for encryption) for initialisation in case of CA-issued certificates (Optional) + H3K, + /// Send password initialization + INI, + /// Send public key for signature verification + PUB, + /// Suspension of access authorisation + SPR, + /// deprecated + PTK, +} + +impl Order { + pub const WSS_PARAMS: Self = Self::BTD(BTF { + service: CompactString::const_new("OTH"), + scope: Some(CompactString::const_new("DE")), + msg: CompactString::const_new("wssparam"), + version: None, + container: None, + option: None, + }); + + pub fn doc(&self) -> Option<OrderDoc> { + match self { + Self::HAC => Some(OrderDoc::acknowledgement), + Self::BTD(BTF { msg, .. }) => match msg.as_str() { + "pain.002" => Some(OrderDoc::status), + "camt.052" => Some(OrderDoc::report), + "camt.053" => Some(OrderDoc::statement), + "camt.054" => Some(OrderDoc::notification), + _ => None, + }, + _ => None, + } + } + + /** Check if EBICS order is a downloadable one */ + pub fn is_downloadable(&self) -> bool { + matches!( + self.doc(), + Some(OrderDoc::acknowledgement) + | Some(OrderDoc::status) + | Some(OrderDoc::report) + | Some(OrderDoc::statement) + | Some(OrderDoc::notification) + ) + } + + /** Check if EBICS order is an uploadable one */ + pub fn is_upload(&self) -> bool { + matches!(self, Self::BTU { .. }) + } + + pub fn schema(&self) -> &'static str { + "H005" + } + + pub fn file_type(&self) -> &str { + match self { + Order::BTD(BTF { container, .. }) | Order::BTU(BTF { container, .. }) => { + container.as_deref().unwrap_or("xml") + } + _ => "xml", + } + } + + pub fn ty(&self) -> &'static str { + match self { + Order::BTD { .. } => "BTD", + Order::BTU { .. } => "BTU", + Order::HAA => "HAA", + Order::HAC => "HAC", + Order::HCA => "HCA", + Order::HCS => "HCS", + Order::HEV => "HEV", + Order::HIA => "HIA", + Order::HKD => "HKD", + Order::HPB => "HPB", + Order::HPD => "HPD", + Order::HTD => "HTD", + Order::HVD => "HVD", + Order::HVE => "HVE", + Order::HVS => "HVS", + Order::HVT => "HVT", + Order::HVU => "HVU", + Order::HVZ => "HVZ", + Order::H3K => "H3K", + Order::INI => "INI", + Order::PUB => "PUB", + Order::SPR => "SPR", + Order::PTK => "PTK", + } + } + + pub fn from_parts(ty: &str, btf: Option<BTF>) -> Option<Self> { + match (ty, btf) { + ("BTU", Some(btf)) => Some(Self::BTU(btf)), + ("BTD", Some(btf)) => Some(Self::BTD(btf)), + ("HAA", None) => Some(Self::HAA), + ("HAC", None) => Some(Self::HAC), + ("HCA", None) => Some(Self::HCA), + ("HCS", None) => Some(Self::HCS), + ("HEV", None) => Some(Self::HEV), + ("HIA", None) => Some(Self::HIA), + ("HKD", None) => Some(Self::HKD), + ("HPB", None) => Some(Self::HPB), + ("HPD", None) => Some(Self::HPD), + ("HTD", None) => Some(Self::HTD), + ("HVD", None) => Some(Self::HVD), + ("HVE", None) => Some(Self::HVE), + ("HVS", None) => Some(Self::HVS), + ("HVT", None) => Some(Self::HVT), + ("HVU", None) => Some(Self::HVU), + ("HVZ", None) => Some(Self::HVZ), + ("H3K", None) => Some(Self::H3K), + ("INI", None) => Some(Self::INI), + ("PUB", None) => Some(Self::PUB), + ("SPR", None) => Some(Self::SPR), + ("PTK", None) => Some(Self::PTK), + _ => None, + } + } +} + +impl std::fmt::Display for Order { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.ty())?; + match self { + Order::BTD(btf) | Order::BTU(btf) => write!(f, "-{btf}"), + _ => Ok(()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta, PartialOrd, Ord)] +#[enum_meta(Str, Description)] +#[allow(non_camel_case_types)] +pub enum OrderDoc { + /// EBICS acknowledgement - CustomerAcknowledgement HAC pain.002 + acknowledgement, + /// Payment status - CustomerPaymentStatusReport pain.002 + status, + /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054 + notification, + /// Account statements - BankToCustomerStatement camt.053 + statement, + /// Account intraday reports - BankToCustomerAccountReport camt.052 + report, +} + +impl OrderDoc { + pub fn short_description(&self) -> &'static str { + match self { + Self::acknowledgement => "EBICS acknowledgement", + Self::status => "Payment status", + Self::report => "Account intraday reports", + Self::statement => "Account statements", + Self::notification => "Debit & credit notifications", + } + } +} diff --git a/crates/libeufin-ebics/src/iso20022.rs b/crates/libeufin-ebics/src/iso20022.rs @@ -0,0 +1,183 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use taler_macros::EnumMeta; + +pub mod bank_tx_code; +pub mod camt; +pub mod hac; +pub mod model; +pub mod pain001; +pub mod pain002; +pub mod status_code; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Description, Str)] +#[allow(non_camel_case_types)] +pub enum HacAction { + /// File submitted to the bank + FILE_UPLOAD, + /// File downloaded from the bank + FILE_DOWNLOAD, + /// Electronic signature submitted to the bank + ES_UPLOAD, + /// Electronic signature downloaded from the bank + ES_DOWNLOAD, + /// Signature verification + ES_VERIFICATION, + /// Forwarding to EDS + VEU_FORWARDING, + /// EDS signature verification + VEU_VERIFICATION, + /// Forwarded for postprocessing + VEU_VERIFICATION_END, + /// Cancellation of EDS order + VEU_CANCEL_ORDER, + /// Additional information + ADDITIONAL, + /// HAC end of order (positive) + ORDER_HAC_FINAL_POS, + /// HAC end of order (negative) + ORDER_HAC_FINAL_NEG, + // Not in the spec but Credit Suisse test suite use it + /// HAC end of order + ORDER_HAC_FINAL, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Description, Str)] +pub enum ChargeBearer { + /// BorneByDebtor + DEBT, + /// BorneByCreditor + CRED, + /// Shared + SHAR, + /// SLEV + SLEV, +} + +#[cfg(test)] +pub mod test { + use tracing::info; + + use crate::{ + ebics::administrative::{parse_haa, parse_hkd}, + iso20022::{camt::parse_camt, hac::parse_hac, pain002::parse_pain002}, + }; + + #[test] + pub fn sample() { + taler_test_utils::setup_tracing(); + let mut samples = Vec::new(); + for entry in std::fs::read_dir("../../testbench/sample").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + for entry in std::fs::read_dir(path).unwrap() { + let entry = entry.unwrap(); + samples.push((entry.path(), entry.file_name())); + } + } else { + samples.push((path, entry.file_name())); + } + } + for (path, name) in samples { + let xml = std::fs::read(&path).unwrap(); + let name = name.to_string_lossy(); + + info!("Parse sample {path:?}"); + + if name.contains("hac") { + parse_hac(&xml).unwrap(); + } else if name.contains("camt") { + parse_camt(&xml).unwrap(); + } else if name.contains("pain002") { + parse_pain002(&xml).unwrap(); + } else if name.contains("pain001") { + // Ignore + } else { + panic!("Unsupported file type {name}") + } + } + } + + #[test] + pub fn logs() { + taler_test_utils::setup_tracing(); + + if !std::fs::exists("testbench/test").unwrap() { + return; + } + for platform in std::fs::read_dir("testbench/test") + .unwrap() + .map(Result::unwrap) + { + let path = platform.path(); + if !path.is_dir() || platform.file_name() == "platform" { + continue; + } + + // List logs + let mut logs = Vec::new(); + for date in std::fs::read_dir(path).unwrap().map(Result::unwrap) { + let path = date.path(); + if !path.is_dir() { + continue; + } + for tx in std::fs::read_dir(path).unwrap().map(Result::unwrap) { + let payload = tx.path().join("payload"); + if payload.exists() { + logs.extend( + std::fs::read_dir(payload) + .unwrap() + .map(|it| it.unwrap().path()), + ); + } + let payload = tx.path().join("payload.xml"); + if payload.exists() { + logs.push(payload); + } + } + } + for path in logs { + let xml = std::fs::read(&path).unwrap(); + let path = path.to_string_lossy(); + + info!("Parse sample {path:?}"); + + if path.contains("HAC") { + parse_hac(&xml).unwrap(); + } else if path.contains("HKD") { + parse_hkd(&xml).unwrap(); + } else if path.contains("HAA") { + parse_haa(&xml).unwrap(); + } else if path.contains("camt") { + parse_camt(&xml).unwrap(); + } else if path.contains("pain.002") { + parse_pain002(&xml).unwrap(); + } else if path.contains("pain.001") { + // Ignore + } else { + panic!("Unsupported file type {path}") + } + } + } + } +} diff --git a/crates/libeufin-ebics/src/iso20022/bank_tx_code.rs b/crates/libeufin-ebics/src/iso20022/bank_tx_code.rs @@ -0,0 +1,745 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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 + +use taler_macros::EnumMeta; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Description, Str)] +pub enum BankTxDomainCode { + /// Account Management + ACMT, + /// Cash Management + CAMT, + /// Commodities + CMDT, + /// Derivatives + DERV, + /// Foreign Exchange + FORX, + /// Loans, Deposits & Syndications + LDAS, + /// Precious Metal + PMET, + /// Payments + PMNT, + /// Securities + SECU, + /// Trade Services + TRAD, + /// Extended Domain + XTND, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Description, Str)] +pub enum BankTxFamilyCode { + /// Account Balancing + ACCB, + /// Additional Miscellaneous Credit Operations + ACOP, + /// Additional Miscellaneous Debit Operations + ADOP, + /// Blocked Transactions + BLOC, + /// Cash Pooling + CAPL, + /// Miscellaneous Securities Operations + CASH, + /// Customer Card Transactions + CCRD, + /// Clean Collection + CLNC, + /// Counter Transactions + CNTR, + /// Custody Collection + COLC, + /// Collateral Management + COLL, + /// Corporate Action + CORP, + /// Consumer Loans + CSLN, + /// Custody + CUST, + /// Documentary Credit + DCCT, + /// Delivery + DLVR, + /// Documentary Collection + DOCC, + /// Drafts + DRFT, + /// Fixed Term Deposits + FTDP, + /// Fixed Term Loans + FTLN, + /// Futures + FTUR, + /// Forwards + FWRD, + /// Guarantees + GUAR, + /// Issued Cash Concentration Transactions + ICCN, + /// Issued Credit Transfers + ICDT, + /// Issued Cheques + ICHQ, + /// Issued Direct Debits + IDDT, + /// Issued Real-Time Credit Transfers + IRCT, + /// Lack + LACK, + /// Lockbox Transactions + LBOX, + /// Listed Derivatives - Futures + LFUT, + /// Stand-By Letter Of Credit + LOCT, + /// Listed Derivatives - Options + LOPT, + /// Miscellaneous Credit Operations + MCOP, + /// Merchant Card Transactions + MCRD, + /// Miscellaneous Debit Operations + MDOP, + /// Mortgage Loans + MGLN, + /// Non Deliverable + NDFX, + /// Non Settled + NSET, + /// Not Available + NTAV, + /// Notice Deposits + NTDP, + /// Notice Loans + NTLN, + /// OTC Derivatives - Bonds + OBND, + /// OTC Derivatives - Credit + OCRD, + /// OTC Derivatives - Equity + OEQT, + /// OTC Derivatives - Interest Rates + OIRT, + /// Opening & Closing + OPCL, + /// Options + OPTN, + /// OTC Derivatives - Structured Exotic Derivatives + OSED, + /// OTC Derivatives – Swaps + OSWP, + /// CSD Blocked transactions + OTHB, + /// Other + OTHR, + /// Received Cash Concentration Transactions + RCCN, + /// Received Credit Transfers + RCDT, + /// Received Cheques + RCHQ, + /// Received Direct Debits + RDDT, + /// Received Real-Time Credit Transfers + RRCT, + /// Trade, Clearing and Settlement + SETT, + /// Spots + SPOT, + /// Swaps + SWAP, + /// Syndications + SYDN, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Description, Str)] +pub enum BankTxSubFamilyCode { + /// Account Closing + ACCC, + /// Account Opening + ACCO, + /// Account Transfer + ACCT, + /// ACH Credit + ACDT, + /// ACH Concentration + ACON, + /// ACH Corporate Trade + ACOR, + /// ACH Debit + ADBT, + /// Adjustments (Generic) + ADJT, + /// ACH Pre-Authorised + APAC, + /// ACH Return + ARET, + /// ACH Reversal + AREV, + /// ARP Debit + ARPD, + /// ACH Settlement + ASET, + /// ACH Transaction + ATXN, + /// Automatic Transfer + AUTT, + /// Branch Account Transfer + BACT, + /// SEPA B2B Direct Debit + BBDD, + /// Branch Deposit + BCDP, + /// Bank Cheque + BCHQ, + /// Back Value + BCKV, + /// Branch Withdrawl + BCWD, + /// Bond Forward + BFWD, + /// Repurchase offer/Issuer Bid/Reverse Rights + BIDS, + /// Bank Fees + BKFE, + /// Bonus Issue/Capitalisation Issue + BONU, + /// Internal Book Transfer + BOOK, + /// Put Redemption + BPUT, + /// Brokerage Fee + BROK, + /// Sell Buy Back + BSBC, + /// Buy Sell Back + BSBO, + /// Credit Adjustments (Generic) + CAJT, + /// Capital Gains Distribution + CAPG, + /// Cash Letter + CASH, + /// Certified Customer Cheque + CCCH, + /// Cheque + CCHQ, + /// Cross Currency IRS + CCIR, + /// CCP Cleared Initial Margin + CCPC, + /// CCP Cleared Variation Margin + CCPM, + /// CCP Cleared Segregated Initial Margin + CCSM, + /// Controlled Disbursement + CDIS, + /// Cash Deposit + CDPT, + /// Charge/Fees + CHAR, + /// Check Deposit + CHKD, + /// Charges (Generic) + CHRG, + /// Compensation/Claims + CLAI, + /// Circular Cheque + CLCQ, + /// Corporate Mark Broker Owned + CMBO, + /// Corporate Mark Client Owned + CMCO, + /// Corporate Own Account Transfer + COAT, + /// Commission Excluding Taxes (Generic) + COME, + /// Commission Including Taxes (Generic) + COMI, + /// Commission (Generic) + COMM, + /// Non Taxable Commissions (Generic) + COMT, + /// Conversion + CONV, + /// Cover Transaction + COVE, + /// Cash Penalties + CPEN, + /// Corporate Rebate + CPRB, + /// Cheque Reversal + CQRV, + /// Crossed Cheque + CRCQ, + /// Credit DefaultSwap + CRDS, + /// Cross Trade + CROS, + /// Cross Product + CRPR, + /// Credit Support + CRSP, + /// Credit Line + CRTL, + /// Cash Letter Adjustment + CSHA, + /// Cash In Lieu + CSLI, + /// Cash Withdrawal + CWDL, + /// Debit Adjustments (Generic) + DAJT, + /// Discounted Draft + DDFT, + /// Drawdown + DDWN, + /// Decrease in Value + DECR, + /// Draft Maturity Change + DMCG, + /// Domestic Credit Transfer + DMCT, + /// Deposit + DPST, + /// Drawing + DRAW, + /// Dividend Reinvestment + DRIP, + /// Controlled Disbursement + DSBR, + /// Dutch Auction + DTCH, + /// Cash Dividend + DVCA, + /// Dividend Option + DVOP, + /// Nordic Payment Council Credit Transfer + ENCT, + /// Equity Mark Broker Owned + EQBO, + /// Equity Mark Client Owned + EQCO, + /// Equity Option + EQPT, + /// Equity Swap + EQUS, + /// Exchange Rate Adjustment + ERTA, + /// Lending Income + ERWA, + /// Borrowing Fee + ERWI, + /// SEPA Credit Transfer + ESCT, + /// SEPA Core Direct Debit + ESDD, + /// Exchange + EXOF, + /// Exotic Option + EXPT, + /// Call On Intermediate Securities + EXRI, + /// Exchange Traded Derivatives + EXTD, + /// Warrant Exercise/Warrant Conversion + EXWA, + /// Foreign Currencies Deposit + FCDP, + /// Factor Update + FCTA, + /// Foreign Currencies Withdrawal + FCWD, + /// Fees (Generic) + FEES, + /// Financial Institution Credit Transfer + FICT, + /// Financial Institution Direct Debit Payment + FIDD, + /// Financial Institution Own Account Transfer + FIOA, + /// Fixed Income + FIXI, + /// Float Adjustment + FLTA, + /// Freeze Of Funds + FRZF, + /// Futures Commission + FUCO, + /// Future Variation Margin + FUTU, + /// Forwards Broker Owned Collateral + FWBC, + /// Forwards Client Owned Collateral + FWCC, + /// MFA Segregated Broker Cash Collateral + FWSB, + /// MFA Segregated Client Cash Collateral + FWSC, + /// Withdrawal/Distribution + GEN1, + /// Deposit/Contribution + GEN2, + /// Invoice Accepted with Differed Due Date + IADD, + /// Intra Company Transfer + ICCT, + /// Fixed Deposit Interest Amount + INFD, + /// Inspeci/Share Exchange + INSP, + /// Interests (Generic) + INTR, + /// Depositary Receipt Issue + ISSU, + /// Credit Adjustment + LBCA, + /// Debit + LBDB, + /// Deposit + LBDP, + /// Liquidation Dividend / Liquidation Payment + LIQU, + /// Margin Payments + MARG, + /// Mortgage Back Segregated Broker Cash Collateral + MBSB, + /// Mortgage Back Segregated Client Cash Collateral + MBSC, + /// Full Call / Early Redemption + MCAL, + /// Margin Client Owned Cash Collateral + MGCC, + /// Initial Futures Margin Segregated Client Cash Collateral + MGSC, + /// Mixed Deposit + MIXD, + /// Management Fees + MNFE, + /// Merger + MRGR, + /// Miscellaneous Deposit + MSCD, + /// Netting + NETT, + /// Non Presented Circular Cheques + NPCC, + /// Non Syndicated + NSYN, + /// Not Available + NTAV, + /// New issue distribution + NWID, + /// Client owned OCC pledged collateral + OCCC, + /// Overdraft + ODFT, + /// Odd Lot Sale/Purchase + ODLT, + /// One-Off Direct Debit + OODD, + /// Option Broker Owned Collateral + OPBC, + /// Option Client Owned Collateral + OPCC, + /// Open Cheque + OPCQ, + /// OTC Option Segregated Broker Cash Collateral + OPSB, + /// OTC Option Segregated Client Cash Collateral + OPSC, + /// FX Option + OPTN, + /// Order Cheque + ORCQ, + /// OTC CCP + OTCC, + /// OTC Derivatives + OTCD, + /// OTC + OTCG, + /// OTC Non-CCP + OTCN, + /// Other + OTHR, + /// Overdraft Charge + OVCH, + /// External Account Transfer + OWNE, + /// Internal Account Transfer + OWNI, + /// Pre-Authorised Direct Debit + PADD, + /// Pair-Off + PAIR, + /// Partial Redemption with reduction of nominal value + PCAL, + /// Placement + PLAC, + /// Direct Debit + PMDD, + /// Portfolio Move + PORT, + /// Credit Card Payment + POSC, + /// Point-of-Sale (POS) Payment - Debit Card + POSD, + /// Point-of-Sale (POS) Payment + POSP, + /// Principal Payment + PPAY, + /// Priority Credit Transfer + PRCT, + /// Reversal Due To Payment Reversal + PRDD, + /// Partial Redemption Without Reduction of Nominal Value + PRED, + /// Interest Payment with Principles + PRII, + /// Interest Payment with Principles + PRIN, + /// Priority Issue + PRIO, + /// Principal Pay-Down/Pay-Up + PRUD, + /// Posting Error + PSTE, + /// Reversal Due To Payment Cancellation Request + RCDD, + /// Reversal due to a Cover Transaction Return + RCOV, + /// Redemption Asset Allocation + REAA, + /// Redemption + REDM, + /// Repo + REPU, + /// Futures Residual Amount + RESI, + /// Rights Issue/Subscription Rights/Rights Offer + RHTS, + /// Reimbursement (Generic) + RIMB, + /// Renewal + RNEW, + /// Bi-lateral repo broker owned collateral + RPBC, + /// Repo client owned collateral + RPCC, + /// Reversal Due To Payment Cancellation Request + RPCR, + /// Repayment + RPMT, + /// Bi-lateral Repo Segregated Broker Cash Collateral + RPSB, + /// Bi-lateral Repo Segregated Client Cash Collateral + RPSC, + /// Reversal Due To Payment Return + RRTN, + /// Reverse Repo + RVPO, + /// Redemption Withdrawing Plan + RWPL, + /// Settlement Against Bank Guarantee + SABG, + /// Payroll/Salary Payment + SALA, + /// Securities Buy Sell Sell Buy Back + SBSC, + /// Single Currency IRS Exotic + SCIE, + /// Single Currency IRS + SCIR, + /// Securities Cross Products + SCRP, + /// Same Day Value Credit Transfer + SDVA, + /// Securities Borrowing + SECB, + /// Securities Lending + SECL, + /// Broker owned collateral Short Sale + SHBC, + /// Client owned collateral Short Sale + SHCC, + /// Equity Premium Reserve + SHPR, + /// Short Sell + SHSL, + /// Lending Broker Owned Cash Collateral + SLBC, + /// Lending Client Owned Cash Collateral + SLCC, + /// Securities Lending And Borrowing + SLEB, + /// SecuredLoan + SLOA, + /// Smart-Card Payment + SMCD, + /// Smart-Card Payment + SMRT, + /// Settlement Of Sight Export Document + SOSE, + /// Settlement Of Sight Import Document + SOSI, + /// Subscription Savings Plan + SSPL, + /// Settlement After Collection + STAC, + /// Stamp Duty + STAM, + /// Standing Order + STDO, + /// Settlement + STLM, + /// Settlement Under Reserve + STLR, + /// Bill of Exchange Settlement on Demand + STOD, + /// Subscription Asset Allocation + SUAA, + /// Subscription + SUBS, + /// Swap Payment + SWAP, + /// Swap Broker Owned Collateral + SWBC, + /// Swap Client Owned Cash Collateral + SWCC, + /// Sweep + SWEP, + /// Final Payment + SWFP, + /// Switch + SWIC, + /// Partial Payment + SWPP, + /// Swaption + SWPT, + /// Reset Payment + SWRS, + /// ISDA/CSA Segregated Broker Cash Collateral + SWSB, + /// ISDA/CSA Segregated Client Cash Collateral + SWSC, + /// Upfront Payment + SWUF, + /// Syndicated + SYND, + /// Taxes (Generic) + TAXE, + /// TBA Closing + TBAC, + /// To Be Announced + TBAS, + /// TBA Broker owned cash collateral + TBBC, + /// TBA Client owned cash collateral + TBCC, + /// Travellers Cheques Deposit + TCDP, + /// Travellers Cheques Withdrawal + TCWD, + /// Tender + TEND, + /// Topping + TOPG, + /// Transfer Out + TOUT, + /// Trade + TRAD, + /// Treasury Cross Product + TRCP, + /// Tax Reclaim + TREC, + /// Transaction Fees + TRFE, + /// Transfer In + TRIN, + /// Triparty Repo + TRPO, + /// Triparty Reverse Repo + TRVO, + /// Treasury Tax And Loan Service + TTLS, + /// Turnaround + TURN, + /// Dishonoured/Unpaid Draft + UDFT, + /// Underwriting Commission + UNCO, + /// Unpaid Cheque + UPCQ, + /// Unpaid Card Transaction + UPCT, + /// Reversal Due To Return/Unpaid Direct Debit + UPDD, + /// Cheque Under Reserve + URCQ, + /// Direct Debit Under Reserve + URDD, + /// Value Date + VALD, + /// Credit Transfer With Agreed Commercial Information + VCOM, + /// Withholding Tax + WITH, + /// Cross-Border Credit Card Payment + XBCP, + /// Foreign Cheque + XBCQ, + /// Cross-Border Credit Transfer + XBCT, + /// Cross-Border Cash Withdrawal + XBCW, + /// Cross-Border Direct Debit + XBDD, + /// Cross-Border + XBRD, + /// Cross-Border Payroll/Salary Payment + XBSA, + /// Cross-Border Standing Order + XBST, + /// Exchange Traded CCP + XCHC, + /// Exchange Traded + XCHG, + /// Exchange Traded Non-CCP + XCHN, + /// Cross-Border Intra Company Transfer + XICT, + /// Unpaid Foreign Cheque + XPCQ, + /// Foreign Cheque Under Reserve + XRCQ, + /// Cross Border Reversal Due to Payment Return + XRTN, + /// YTD Adjustment + YTDA, + /// Zero Balancing + ZABA, +} diff --git a/crates/libeufin-ebics/src/iso20022/camt.rs b/crates/libeufin-ebics/src/iso20022/camt.rs @@ -0,0 +1,1249 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{fmt::Write as _, str::FromStr}; + +use compact_str::CompactString; +use jiff::{Timestamp, civil, tz::TimeZone}; +use taler_common::types::{ + amount::{Amount, Currency}, + iban::IBAN, + payto::{BankID, IbanPayto, PaytoImpl, PaytoURI}, +}; +use taler_macros::EnumMeta; +use tracing::{trace, warn}; +use uuid::Uuid; + +use crate::{ + iso20022::{ + ChargeBearer, + bank_tx_code::{BankTxDomainCode, BankTxFamilyCode, BankTxSubFamilyCode}, + model::{BatchId, InId, InTx, OutBatch, OutId, OutReversal, OutTx, Tx}, + status_code::ReturnReason, + }, + xml::{self, Xml, XmlAccess as _}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(Str)] +#[allow(clippy::upper_case_acronyms)] +enum Kind { + CRDT, + DBIT, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountId { + Iban(IBAN), + Other(CompactString), +} + +impl std::fmt::Display for AccountId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AccountId::Iban(iban) => iban.fmt(f), + AccountId::Other(id) => id.fmt(f), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountTransactions { + pub id: AccountId, + pub currency: Option<Currency>, + pub txs: Vec<Tx>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum OutIds { + Tx(OutId), + Batch(BatchId), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BankTxCode { + domain: BankTxDomainCode, + family: BankTxFamilyCode, + subfamily: BankTxSubFamilyCode, +} + +impl BankTxCode { + fn is_reversal(&self) -> bool { + matches!( + self.subfamily, + BankTxSubFamilyCode::RPCR | BankTxSubFamilyCode::RRTN | BankTxSubFamilyCode::PSTE + ) + } +} + +impl std::fmt::Display for BankTxCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + domain, + family, + subfamily, + } = self; + write!( + f, + "{domain} {family} {subfamily} - '{}' '{}' '{}'", + domain.description(), + family.description(), + subfamily.description() + ) + } +} + +/** Parse the instruction execution date */ +fn execution_date(n: Xml) -> xml::Result<Timestamp> { + // Value date if present else booking date + let date = n + .opt("ValDt") + .transpose() + .unwrap_or_else(|| n.one("BookgDt"))?; + let date = if let Some(date) = date.opt("Dt")? { + date.parse::<civil::Date>()?.into() + } else { + date.one("DtTm").parse::<civil::DateTime>()? + }; + Ok(date.to_zoned(TimeZone::UTC).unwrap().timestamp()) +} + +/** Parse a payto */ +fn payto(n: Xml, prefix: &str) -> xml::Result<Option<PaytoURI>> { + let Some(parties) = n.opt("RltdPties")? else { + return Ok(None); + }; + + let Some(iban) = parties + .opt(&format!("{prefix}Acct")) + .one("Id") + .opt("IBAN") + .parse()? + else { + return Ok(None); + }; + // TODO parse BIC + let bank_id = BankID { iban, bic: None }; + Ok(Some(if let Some(p) = parties.opt(prefix)? { + let name = p + .opt("Nm") + .transpose() + .unwrap_or_else(|| p.one("Pty").one("Nm"))? + .text(); + IbanPayto::new(bank_id).as_full_payto(name) + } else { + IbanPayto::new(bank_id).as_payto() + })) +} + +/** Parse batch message ID and transaction end-to-end ID as generated by libeufin-nexus */ +fn outgoing_id(n: Xml, sref: Option<&str>) -> xml::Result<OutIds> { + Ok(if let Some(refs) = n.opt("Refs")? { + let e2e_id: Option<CompactString> = refs.opt("EndToEndId").parse()?; + let msg_id: Option<CompactString> = refs.opt("MsgId").parse()?; + let sref: Option<CompactString> = + sref.filter(|it| *it != "NOTPROVIDED").map(|it| it.into()); + match (e2e_id, msg_id) { + // This is a batch representation + (None, Some(msg_id)) => OutIds::Batch(BatchId { msg_id, sref }), + // If not set use MsgId as end-to-end ID for retrocompatibility + (Some(e2e_id), msg_id) if &e2e_id == "NOTPROVIDED" => OutIds::Tx(OutId { + e2e_id: msg_id.clone(), + msg_id, + sref, + }), + (e2e_id, msg_id) => OutIds::Tx(OutId { + msg_id, + e2e_id, + sref, + }), + } + } else { + OutIds::Tx(OutId { + msg_id: None, + e2e_id: None, + sref: sref.map(|it| it.into()), + }) + }) +} + +/** Parse transaction ids as provided by bank */ +fn incoming_id(n: Xml, sref: Option<&str>) -> xml::Result<InId> { + if let Some(refs) = n.opt("Refs")? { + let uetr: Option<Uuid> = refs.opt("UETR").parse()?; + let tx_id: Option<CompactString> = refs.opt("TxId").parse()?; + Ok(InId { + uetr, + tx_id, + sref: sref.map(|it| it.into()), + }) + } else { + Ok(InId { + uetr: None, + tx_id: None, + sref: sref.map(|it| it.into()), + }) + } +} + +/** Parse transaction wire transfer subject */ +fn wire_transfer_subject(n: Xml) -> xml::Result<Option<String>> { + Ok(n.opt("RmtInf")? + .map(|n| n.many("Ustrd").map(|n| n.text()).collect::<String>())) +} + +/** Parse and format transaction return reasons */ +fn return_reason(n: Xml) -> xml::Result<String> { + let mut buf = String::new(); + if let Some(n) = n.opt("RtrInf")? { + let code: ReturnReason = n.one("Rsn").one("Cd").parse()?; + + write!(&mut buf, "{code} '{}'", code.description()).unwrap(); + let mut infos = n.many("AddtlInf"); + if let Some(first) = infos.next() { + buf.push_str(" - '"); + buf.push_str(first.text()); + for info in infos { + buf.push_str(info.text()); + } + buf.push('\''); + } + } else if let Some(n) = wire_transfer_subject(n)? { + return Ok(n); + } + Ok(buf) +} +/** Parse amount */ +fn amount(n: Xml) -> xml::Result<Amount> { + let amt = n.one("Amt")?; + let currency = amt.attr("Ccy")?; + let amount = amt.text(); + let concat = format!("{currency}:0{amount}"); + Amount::from_str(&concat).map_err(|e| amt.parse_err(e)) +} + +#[derive(Debug, Clone, Copy)] +struct ComplexAmount { + /// Transaction amount + amount: Amount, + /// The applied fee + fee: Amount, +} + +impl ComplexAmount { + /// Check that entry and tx amount are compatible and return the result + fn resolve(&self, tx: &ComplexAmount) -> xml::Result<ComplexAmount> { + // Most time transaction will match + if self.amount == tx.amount && self.fee == tx.fee { + return Ok(*self); + } + + // Or one of the level is missing the fee + if (tx.amount.decimal() > tx.fee.decimal() + && tx.amount.try_sub(&tx.fee).unwrap() == self.amount) + || self.amount.try_sub(&self.fee).unwrap() == tx.amount + { + return if tx.fee.is_zero() { Ok(*self) } else { Ok(*tx) }; + } + + // Or the conversion information are only present at the entry layer + if tx.amount.currency != self.amount.currency { + return Ok(*self); + } + + panic!("Amount mismatch, got {self:?} in the entry and {tx:?} in the tx") + } +} + +struct ChargeRecord { + amount: Amount, + kind: Kind, + included: bool, + bearer: ChargeBearer, +} + +fn charges(n: Xml) -> xml::Result<Vec<ChargeRecord>> { + if let Some(n) = n.opt("Chrgs")? { + n.many("Rcrd") + .map(|n| { + Ok(ChargeRecord { + amount: amount(n)?, + kind: n.opt("CdtDbtInd").parse()?.unwrap_or(Kind::CRDT), + included: n.opt("ChrgInclInd").parse()? == Some(true), // TODO not clear in spec + bearer: n.opt("Br").parse()?.unwrap_or(ChargeBearer::SHAR), + }) + }) + .collect() + } else { + Ok(Vec::new()) + } +} + +fn complex_amount(amt: Xml, charges: &[ChargeRecord]) -> xml::Result<ComplexAmount> { + // Amount before charges + let currency = amt.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 + let amount = amt.text().trim_start_matches('-'); + let concat = format!("{currency}:0{amount}"); + + let mut amount = Amount::from_str(&concat).map_err(|e| amt.parse_err(e))?; + let mut fee = Amount::zero(&amount.currency); + + for chr in charges { + if chr.included && !chr.amount.is_zero() { + fee = fee.try_add(&chr.amount).expect("Should never overflow"); + if chr.kind == Kind::DBIT { + if chr.bearer == ChargeBearer::DEBT { + if chr.amount.decimal() > amount.decimal() { + // This can happen when an incoming transaction fail because of debit fee + amount = chr.amount.try_sub(&amount).expect("Should never overflow"); + } else { + amount = amount.try_sub(&chr.amount).expect("Should never overflow"); + } + } else if chr.bearer == ChargeBearer::CRED { + amount = amount.try_add(&chr.amount).expect("Should never overflow"); + } else { + return Err(amt.parse_err(format_args!( + "Included charge {} with bearer {}", + chr.kind, chr.bearer + ))); + } + } + } + } + + Ok(ComplexAmount { amount, fee }) +} + +/** Parse bank transaction code */ +fn bank_tx_code(n: Xml) -> xml::Result<BankTxCode> { + let domnd = n.one("Domn")?; + let fmly = domnd.one("Fmly")?; + Ok(BankTxCode { + domain: domnd.one("Cd").parse()?, + family: fmly.one("Cd").parse()?, + subfamily: fmly.one("SubFmlyCd").parse()?, + }) +} + +/** Parse camt files */ +pub fn parse_camt(xml: &[u8]) -> xml::Result<Vec<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. + */ + trace!("Parse transactions camt file"); + + fn parse_inner(root: Xml) -> xml::Result<AccountTransactions> { + let (id, currency) = { + let account = root.one("Acct")?; + let id = account.one("Id")?; + let account_id = if let Some(iban) = id.opt("IBAN")? { + AccountId::Iban(iban.parse()?) + } else { + AccountId::Other(id.one("Othr").one("Id").parse()?) + }; + let currency: Option<Currency> = account.opt("Ccy").parse()?; + (account_id, currency) + }; + let txs = root.many("Ntry").try_fold(Vec::new(), |mut txs, entry| { + // Skip if not booked + if !{ + let status = entry.one("Sts")?; + let status = status + .opt("Cd")? + .map(|n| n.text()) + .unwrap_or_else(|| status.text()); + status == "BOOK" + } { + return Ok(txs); + } + + let reversal = entry.opt("RvslInd").parse()? == Some(true); + let entry_code = bank_tx_code(entry.one("BkTxCd")?)?; + let entry_kind = entry.opt("CdtDbtInd").parse::<Kind>()?; + let entry_ref = entry.opt("AcctSvcrRef").parse::<CompactString>()?; + let date = execution_date(entry)?; + let entry_charges = charges(entry)?; + let entry_amount = complex_amount(entry.one("Amt")?, &entry_charges)?; + + let Some(details) = entry.opt("NtryDtls")? else { + return Ok(txs); + }; + // When an entry only contain a single transactions information will sometimes only be stored at the entry level + let unique = details.many("TxDtls").count() == 1; + for tx in details.many("TxDtls") { + // Check information are present and coherent + let kind = tx.opt("CdtDbtInd").parse()?.or(entry_kind).unwrap(); + + // Sometimes the transaction level have a more precise bank transaction code + let code = tx + .opt("BkTxCd")? + .map(bank_tx_code) + .transpose()? + .unwrap_or(entry_code); + + let tx_charges = charges(tx)?; + // Amount + let amount = if unique { + // When unique the charges can be only at the entry level + if let Some(amt) = tx.opt("Amt")? { + let tx_amount = complex_amount( + amt, + if tx_charges.is_empty() { + &entry_charges + } else { + &tx_charges + }, + )?; + // Check coherence + entry_amount.resolve(&tx_amount)? + } else { + entry_amount + } + } else { + // When many inner transaction the entry level is an aggregate of them + // We only use the transaction level information + complex_amount(tx.one("Amt")?, &tx_charges)? + }; + + // We can only use the entry ref as the transaction ref if there is a single transaction in the batch + let sref: Option<CompactString> = tx + .opt("Refs") + .opt("AcctSvcrRef") + .parse::<CompactString>()? + .or_else(|| unique.then(|| entry_ref.clone()).flatten()); + + match (kind, code.is_reversal() || reversal) { + (Kind::CRDT, true) => { + let out_id = outgoing_id(tx, sref.as_deref())?; + if let OutIds::Tx(OutId { + msg_id, + e2e_id: Some(e2e_id), + .. + }) = out_id + { + txs.push(Tx::Reversal(OutReversal { + e2e_id, + msg_id, + reason: return_reason(tx)?, + execution_time: date, + })) + } else { + warn!("missing unique ID for Credit reversal {out_id:?}"); + } + } + (Kind::DBIT, true) | (Kind::CRDT, false) => { + let id = incoming_id(tx, sref.as_deref())?; + if id.uetr.is_none() && id.tx_id.is_none() && id.sref.is_none() { + warn!("missing unique ID for Credit") + } else { + txs.push(Tx::In(InTx { + id, + amount: amount.amount, + credit_fee: amount.fee, + subject: wire_transfer_subject(tx)?, + execution_time: date, + debtor: payto(tx, "Dbtr")?, + })); + } + } + (Kind::DBIT, false) => { + let id = outgoing_id(tx, sref.as_deref())?; + match id { + OutIds::Tx(id) => { + if id.e2e_id.is_none() && id.msg_id.is_none() && id.sref.is_none() { + warn!("missing unique ID for Debit") + } else { + txs.push(Tx::Out(OutTx { + id, + amount: amount.amount, + debit_fee: amount.fee, + subject: wire_transfer_subject(tx)?, + execution_time: date, + creditor: payto(tx, "Cdtr")?, + })); + } + } + OutIds::Batch(BatchId { msg_id, .. }) => { + txs.push(Tx::Batch(OutBatch { + msg_id, + execution_time: date, + })); + } + } + } + } + } + Ok(txs) + })?; + Ok(AccountTransactions { id, currency, txs }) + } + + Xml::parse(xml, "Document", |root| { + if let Some(camt053) = root.opt("BkToCstmrStmt")? { + camt053.many("Stmt").map(parse_inner).collect() + } else if let Some(camt052) = root.opt("BkToCstmrAcctRpt")? { + camt052.many("Rpt").map(parse_inner).collect() + } else if let Some(camt054) = root.opt("BkToCstmrDbtCdtNtfctn")? { + camt054.many("Ntfctn").map(parse_inner).collect() + } else { + Err(root.parse_err("Malformed camt file")) + } + }) +} + +#[cfg(test)] +pub mod test { + use std::str::FromStr; + + use jiff::{Timestamp, civil::Date}; + use taler_common::types::{ + amount::{Amount, Currency}, + iban::IBAN, + payto::{BankID, IbanPayto, PaytoImpl as _, PaytoURI}, + utils::date_to_utc_ts, + }; + + use crate::iso20022::{ + camt::{AccountId, parse_camt}, + model::{InId, InTx, OutBatch, OutId, OutReversal, OutTx, Tx}, + }; + + pub fn date_to_timestamp(date: &str) -> Timestamp { + date_to_utc_ts(&Date::from_str(date).unwrap()) + } + + fn iban_payto(iban: impl AsRef<str>, name: impl AsRef<str>) -> PaytoURI { + IbanPayto::new(BankID { + iban: iban.as_ref().parse().expect("invalid IBAN"), + bic: None, + }) + .as_full_payto(name.as_ref()) + } + + #[track_caller] + pub fn check_tx(path: &str, iban: &str, currency: Option<&str>, txs: &[Tx]) { + let content = std::fs::read(path).unwrap(); + let res = parse_camt(&content).unwrap(); + assert_eq!(res.len(), 1); + + let first = &res[0]; + assert_eq!(first.id, AccountId::Iban(IBAN::from_str(iban).unwrap())); + assert_eq!( + first.currency, + currency.map(|it| Currency::from_str(it).unwrap()) + ); + pretty_assertions::assert_eq!(first.txs, txs); + } + + pub fn tx_out( + id: (Option<&str>, Option<&str>, Option<&str>), + amount: &str, + debit_fee: &str, + subject: Option<&str>, + execution_time: &str, + creditor: Option<(&str, &str)>, + ) -> Tx { + Tx::Out(OutTx { + id: OutId::new( + id.0.map(Into::into), + id.1.map(Into::into), + id.2.map(Into::into), + ), + amount: Amount::from_str(amount).unwrap(), + debit_fee: Amount::from_str(debit_fee).unwrap(), + subject: subject.map(Into::into), + execution_time: date_to_timestamp(execution_time), + creditor: creditor.map(|(iban, name)| iban_payto(iban, name)), + }) + } + + pub fn tx_in( + id: (Option<&str>, Option<&str>, Option<&str>), + amount: &str, + credit_fee: &str, + subject: Option<&str>, + execution_time: &str, + debtor: Option<(&str, &str)>, + ) -> Tx { + Tx::In(InTx { + id: InId::new( + id.0.map(|it| it.parse().unwrap()), + id.1.map(Into::into), + id.2.map(Into::into), + ), + amount: Amount::from_str(amount).unwrap(), + credit_fee: Amount::from_str(credit_fee).unwrap(), + subject: subject.map(Into::into), + execution_time: date_to_timestamp(execution_time), + debtor: debtor.map(|(iban, name)| iban_payto(iban, name)), + }) + } + + pub fn tx_reversal( + e2e_id: &str, + msg_id: Option<&str>, + reason: &str, + execution_time: &str, + ) -> Tx { + Tx::Reversal(OutReversal { + e2e_id: e2e_id.parse().unwrap(), + msg_id: msg_id.map(Into::into), + reason: reason.into(), + execution_time: date_to_timestamp(execution_time), + }) + } + + pub fn tx_batch(msg_id: &str, execution_time: &str) -> Tx { + Tx::Batch(OutBatch { + msg_id: msg_id.into(), + execution_time: date_to_timestamp(execution_time), + }) + } + + #[test] + fn postfinance_camt054() { + check_tx( + "../../libeufin-nexus/sample/platform/postfinance_camt054.xml", + "CH9289144596463965762", + Some("CHF"), + &[ + tx_out( + ( + Some("ZS1PGNTSV0ZNDFAJBBWWB8015G"), + Some("ZS1PGNTSV0ZNDFAJBBWWB8015G"), + None, + ), + "CHF:3.00", + "CHF:0", + None, + "2024-01-15", + None, + ), + tx_in( + ( + Some("62e2b511-7313-4ccd-8d40-c9d8e612cd71"), + None, + Some("231121CH0AZWCR9T"), + ), + "CHF:10", + "CHF:0", + Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG"), + "2023-12-19", + Some(("CH7389144832588726658", "Mr Test")), + ), + tx_in( + ( + Some("62e2b511-7313-4ccd-8d40-c9d8e612cd71"), + None, + Some("231121CH0AZWCVR1"), + ), + "CHF:2.53", + "CHF:0", + Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB"), + "2023-12-19", + Some(("CH7389144832588726658", "Mr Test")), + ), + tx_reversal( + "50820f78-9024-44ff-978d-63a18c", + Some("50820f78-9024-44ff-978d-63a18c"), + "", + "2024-01-15", + ), + tx_batch("ZS1PGNTSV0ZNDFAJBBWWB8015G", "2024-01-15"), + ], + ); + } + + #[test] + fn postfinance_camt053() { + check_tx( + "../../libeufin-nexus/sample/platform/postfinance_camt053.xml", + "CH9289144596463965762", + Some("CHF"), + &[ + tx_reversal( + "889d1a80-1267-49bd-8fcc-85701a", + Some("889d1a80-1267-49bd-8fcc-85701a"), + "InconsistenWithEndCustomer 'Identification of end customer is not consistent with associated account number, organisation ID or private ID' - 'more info here ...'", + "2023-11-22", + ), + tx_reversal( + "4cc61cc7-6230-49c2-b5e2-b40bbb", + Some("4cc61cc7-6230-49c2-b5e2-b40bbb"), + "MissingCreditorNameOrAddress 'Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing' - 'more info here ...'", + "2023-11-22", + ), + tx_batch("EB4D22D428214261B2B3012D2A8CEC36", "2024-08-26"), + ], + ); + } + + #[test] + fn raiffeisen_camt053() { + check_tx( + "../../libeufin-nexus/sample/platform/raiffeisen_camt053.xml", + "CH7389144832588726658", + None, + &[ + tx_in( + (None, None, Some("A200020494367552")), + "CHF:20000", + "CHF:0", + Some("1. TZ 2025"), + "2025-12-23", + Some(("CH7389144832588726658", "KANTON BERN")), + ), + tx_out( + (None, None, Some("19868398389")), + "CHF:15", + "CHF:0", + None, + "2025-12-31", + None, + ), + tx_out( + (None, None, Some("19890406743")), + "CHF:2", + "CHF:0", + None, + "2025-12-31", + None, + ), + tx_out( + (None, None, Some("19885172770")), + "CHF:3", + "CHF:0", + None, + "2025-12-31", + None, + ), + ], + ); + } + + #[test] + fn valiant_camt052() { + check_tx( + "../../libeufin-nexus/sample/platform/valiant_camt052.xml", + "CH7389144832588726658", + Some("CHF"), + &[ + tx_out( + ( + Some("MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L"), + Some("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE"), + Some("ZV20251030/511372/1"), + ), + "CHF:0.1", + "CHF:0", + Some("single 2025-10-30T09:46:04.55293090 9Z"), + "2025-10-30", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), + Some("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8"), + Some("ZV20251030/511373/1"), + ), + "CHF:0.1", + "CHF:0", + Some("multi 0 2025-10-30T09:46:10.3877961 30Z"), + "2025-10-30", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), + Some("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN"), + Some("ZV20251030/511373/2"), + ), + "CHF:0.11", + "CHF:0", + Some("multi 1 2025-10-30T09:46:10.3877961 30Z"), + "2025-10-30", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), + Some("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7"), + Some("ZV20251030/511373/3"), + ), + "CHF:0.12", + "CHF:0", + Some("multi 2 2025-10-30T09:46:10.3877961 30Z"), + "2025-10-30", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), + Some("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28"), + Some("ZV20251030/511373/4"), + ), + "CHF:0.13", + "CHF:0", + Some("multi 3 2025-10-30T09:46:10.3877961 30Z"), + "2025-10-30", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_in( + (None, Some("51030655601.0001"), Some("ZV20251030/514778/1")), + "CHF:0.85", + "CHF:0", + Some("fun stuff"), + "2025-10-30", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_in( + (None, Some("51030655601.0002"), Some("ZV20251030/514779/1")), + "CHF:0.95", + "CHF:0", + Some("Taler PC2MKG0B7CK32K1T7DP08P6E1B7FHB6HY6R Q0PT3VTPBPRPYM1B0"), + "2025-10-30", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("X166701F6RV59LP71RVWVIW9SV2AFZYLG4"), + Some("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX"), + Some("ZV20251030/524078/1"), + ), + "CHF:0.21", + "CHF:0", + Some("bad name 2025-10-30T12:03:24.997478 811Z"), + "2025-10-30", + Some(("CH6208704048981247126", "John Smith")), + ), + tx_out( + ( + Some("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X"), + Some("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ"), + Some("ZV20251030/524079/1"), + ), + "CHF:0.1", + "CHF:0", + Some("single 2025-10-30T12:04:00.37042083 6Z"), + "2025-10-30", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X"), + Some("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y"), + Some("ZV20251030/524079/2"), + ), + "CHF:0.21", + "CHF:0", + Some("bad name 2025-10-30T12:03:53.042190 686Z"), + "2025-10-30", + Some(("CH6208704048981247126", "John Smith")), + ), + tx_reversal( + "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", + None, + "Error msg in german", + "2025-10-30", + ), + tx_reversal( + "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", + None, + "Error msg in german", + "2025-10-30", + ), + tx_out( + ( + Some("OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P"), + Some("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7"), + Some("ZV20251030/524077/1"), + ), + "CHF:0.23", + "CHF:5", + Some("foreign iban 2025-10-30T12:03:44.0972 63765Z"), + "2025-10-30", + Some(("DE48330605920000686018", "Christian Grothoff")), + ), + tx_out( + ( + Some("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X"), + Some("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE"), + Some("ZV20251030/524080/1"), + ), + "CHF:0.23", + "CHF:5", + Some("foreign iban 2025-10-30T12:03:58.0046 73747Z"), + "2025-10-30", + Some(("DE48330605920000686018", "Christian Grothoff")), + ), + tx_in( + ( + Some("7b76d488-05d5-44ab-9d77-31d4165ec158"), + Some("00204EQY370"), + Some("ZV20251118/685062/1"), + ), + "CHF:4.55", + "CHF:0", + Some("TEST"), + "2025-11-18", + None, + ), + ], + ) + } + + #[test] + fn gls_camt052() { + check_tx( + "../../libeufin-nexus/sample/platform/gls_camt052.xml", + "DE84500105177118117964", + Some("EUR"), + &[ + tx_out( + ( + Some("COMPAT_SUCCESS"), + Some("COMPAT_SUCCESS"), + Some("2024041801514102000"), + ), + "EUR:2", + "EUR:0", + Some("TestABC123"), + "2024-04-18", + Some(("DE20500105172419259181", "John Smith")), + ), + tx_reversal( + "8XK8Z7RAX224FGWK832FD40GYC", + None, + "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN fehlerhaft und ungültig'", + "2024-09-05", + ), + tx_in( + ( + None, + Some("BYLADEM1WOR-G2910276709458A2"), + Some("2024041210041357000"), + ), + "EUR:3", + "EUR:0", + Some("Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-"), + "2024-04-12", + Some(("DE84500105177118117964", "John Smith")), + ), + tx_reversal( + "COMPAT_FAILURE", + None, + "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'", + "2024-04-12", + ), + tx_out( + ( + Some("BATCH_SINGLE_SUCCESS"), + Some("FD622SMXKT5QWSAHDY0H8NYG3G"), + Some("2024090216552232000"), + ), + "EUR:1.1", + "EUR:0", + Some("single 2024-09-02T14:29:52.875253314Z"), + "2024-09-02", + Some(("DE89500105173198527518", "Grothoff Hans")), + ), + tx_out( + ( + Some("YF5QBARGQ0MNY0VK59S477VDG4"), + Some("YF5QBARGQ0MNY0VK59S477VDG4"), + Some("2024041810552821000"), + ), + "EUR:1.1", + "EUR:0", + Some("Simple tx"), + "2024-04-18", + Some(("DE20500105172419259181", "John Smith")), + ), + tx_batch("BATCH_MANY_SUCCESS", "2024-09-20"), + tx_out( + ( + Some("BATCH_SINGLE_RETURN"), + Some("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5"), + Some("2024092100252498000"), + ), + "EUR:0.42", + "EUR:0", + Some("This should fail because bad iban"), + "2024-09-23", + Some(("DE18500105173385245163", "John Smith")), + ), + tx_reversal( + "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", + None, + "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN fehlerhaft und ungültig'", + "2024-09-24", + ), + ], + ) + } + + #[test] + fn gls_camt053() { + check_tx( + "../../libeufin-nexus/sample/platform/gls_camt053.xml", + "DE84500105177118117964", + Some("EUR"), + &[ + tx_out( + ( + Some("COMPAT_SUCCESS"), + Some("COMPAT_SUCCESS"), + Some("2024041801514102000"), + ), + "EUR:2", + "EUR:0", + Some("TestABC123"), + "2024-04-18", + Some(("DE20500105172419259181", "John Smith")), + ), + tx_reversal( + "KGTDBASWTJ6JM89WXD3Q5KFQC4", + None, + "Retoure aus SEPA Überweisung multi line", + "2024-09-04", + ), + tx_batch("BATCH_MANY_PART", "2024-09-04"), + tx_in( + ( + None, + Some("BYLADEM1WOR-G2910276709458A2"), + Some("2024041210041357000"), + ), + "EUR:3", + "EUR:0", + Some("Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-"), + "2024-04-12", + Some(("DE84500105177118117964", "John Smith")), + ), + tx_reversal( + "COMPAT_FAILURE", + None, + "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'", + "2024-04-12", + ), + tx_out( + ( + Some("BATCH_SINGLE_SUCCESS"), + Some("FD622SMXKT5QWSAHDY0H8NYG3G"), + Some("2024090216552232000"), + ), + "EUR:1.1", + "EUR:0", + Some("single 2024-09-02T14:29:52.875253314Z"), + "2024-09-02", + Some(("DE89500105173198527518", "Grothoff Hans")), + ), + tx_out( + ( + Some("YF5QBARGQ0MNY0VK59S477VDG4"), + Some("YF5QBARGQ0MNY0VK59S477VDG4"), + Some("2024041810552821000"), + ), + "EUR:1.1", + "EUR:0", + Some("Simple tx"), + "2024-04-18", + Some(("DE20500105172419259181", "John Smith")), + ), + ], + ) + } + + #[test] + fn gls_camt054() { + check_tx( + "../../libeufin-nexus/sample/platform/gls_camt054.xml", + "DE84500105177118117964", + Some("EUR"), + &[tx_in( + (None, Some("IS11PGENODEFF2DA8899900378806"), None), + "EUR:2.5", + "EUR:0", + Some("Test ICT"), + "2024-05-05", + Some(("DE84500105177118117964", "Mr Test")), + )], + ); + } + + #[test] + fn maerki_baumann_camt053() { + check_tx( + "../../libeufin-nexus/sample/platform/maerki_baumann_camt053.xml", + "CH7389144832588726658", + Some("CHF"), + &[ + tx_in( + ( + Some("adbe4a5a-6cea-4263-b259-8ab964561a32"), + Some("41103099704.0002"), + Some("ZV20241104/765446/1"), + ), + "CHF:1", + "CHF:0.2", + Some("SFHP6H24C16A5J05Q3FJW2XN1PB3EK70ZPY 5SJ30ADGY68FWN68G"), + "2024-11-04", + Some(("CH7389144832588726658", "Mr Test")), + ), + tx_in( + ( + Some("7371795e-62fa-42dd-93b7-da89cc120faa"), + Some("41103099704.0003"), + Some("ZV20241104/765447/1"), + ), + "CHF:1", + "CHF:0.2", + Some("Random subject"), + "2024-11-04", + Some(("CH7389144832588726658", "Mr Test")), + ), + tx_in( + (None, Some("50523424675.0001"), Some("ZV20250523/851716/1")), + "CHF:0.5", + "CHF:0.2", + None, + "2025-05-23", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("BATCH_SINGLE_REPORTING"), + Some("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H"), + Some("ZV20241121/773541/1"), + ), + "CHF:0.1", + "CHF:0", + Some("multi 0 2024-11-21T15:21:59.8859234 63Z"), + "2024-11-27", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("BATCH_SINGLE_REPORTING"), + Some("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH"), + Some("ZV20241121/773541/4"), + ), + "CHF:0.13", + "CHF:0", + Some("multi 3 2024-11-21T15:21:59.8859234 63Z"), + "2024-11-27", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("BATCH_SINGLE_REPORTING"), + Some("A09R35EW0359SZ51464E7TC37A0P2CBK04"), + Some("ZV20241121/773541/3"), + ), + "CHF:0.12", + "CHF:0", + Some("multi 2 2024-11-21T15:21:59.8859234 63Z"), + "2024-11-27", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + ( + Some("BATCH_SINGLE_REPORTING"), + Some("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT"), + Some("ZV20241121/773541/2"), + ), + "CHF:0.11", + "CHF:0", + Some("multi 1 2024-11-21T15:21:59.8859234 63Z"), + "2024-11-27", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_in( + ( + Some("f203fbb4-6e13-4c78-9b2a-d852fea6374a"), + Some("41202060702.0001"), + Some("ZV20241202/778108/1"), + ), + "CHF:0.05", + "CHF:0.2", + Some("mini"), + "2024-12-02", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_in( + ( + Some("81b0d8c6-a677-4577-b75e-a639dcc03681"), + Some("41120636093.0001"), + Some("ZV20241121/773118/1"), + ), + "CHF:0.1", + "CHF:0.2", + Some("small transfer test"), + "2024-11-21", + Some(("CH7389144832588726658", "Grothoff Hans")), + ), + tx_out( + (None, None, Some("GB20241220/205792/1")), + "CHF:3000", + "CHF:0", + None, + "2024-12-20", + None, + ), + tx_in( + (None, None, Some("ZV20250114/796191/1")), + "CHF:3003", + "CHF:0", + Some("Fix bad payment by MB."), + "2025-01-27", + None, + ), + tx_in( + (None, Some("F000787951230001"), Some("ZV20250526/852733/1")), + "CHF:1.38", + "CHF:0.2", + Some("Taler XT3D9MADR4V85JBWX47SMJFDQD2FDZDHHPH8R25YDG1KNVTSEH6G"), + "2025-05-26", + Some(("DE20500105172419259181", "Mr German")), + ), + ], + ) + } +} diff --git a/crates/libeufin-ebics/src/iso20022/hac.rs b/crates/libeufin-ebics/src/iso20022/hac.rs @@ -0,0 +1,198 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::fmt::Display; + +use compact_str::CompactString; +use jiff::Timestamp; +use taler_common::types::utils::date_time_to_utc_ts; + +use crate::{ + iso20022::{HacAction, status_code::StatusReason}, + xml::{self, Xml, XmlAccess}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomerAck { + pub action: HacAction, + pub order_id: Option<CompactString>, + pub code: Option<StatusReason>, + pub info: Box<str>, + pub timestamp: Timestamp, +} + +impl CustomerAck { + fn msg_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + action, code, info, .. + } = self; + write!(f, "{action}")?; + if let Some(code) = code { + write!(f, "{}", code.code())?; + } + write!(f, " - '{}'", action.description())?; + if let Some(code) = code { + write!(f, " '{}'", code.description())?; + } + if !info.is_empty() { + write!(f, " - '{info}'")?; + } + Ok(()) + } +} + +impl Display for CustomerAck { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + order_id, + timestamp, + .. + } = self; + write!(f, "{timestamp}")?; + if let Some(id) = order_id { + write!(f, "{id}")?; + } + write!(f, " {}", std::fmt::from_fn(|f| self.msg_fmt(f))) + } +} +/** Parse HAC pain.002 XML file */ +pub fn parse_hac(xml: &[u8]) -> xml::Result<Vec<CustomerAck>> { + Xml::parse(xml, "Document", |root| { + root.one("CstmrPmtStsRpt")? + .many("OrgnlPmtInfAndSts") + .map(|n| { + let mut timestamp = None; + let mut order_id = None; + let info = n.one("StsRsnInf")?; + for entry in info.one("Orgtr").one("Id").one("OrgId")?.many("Othr") { + let value = entry.one("Id"); + let key = entry.one("SchmeNm").one("Prtry")?.text(); + match key { + "TimeStamp" => { + timestamp = Some(date_time_to_utc_ts( + &value.decode(|dt| dt.trim_end_matches('Z').parse())?, + )) + } + "OrderID" => order_id = Some(value.parse()?), + _ => {} + } + } + Ok(CustomerAck { + action: n.one("OrgnlPmtInfId").parse()?, + order_id, + code: info.opt("Rsn").one("Cd").parse()?, + info: info.many("AddtlInf").map(|n| n.text()).collect(), + timestamp: timestamp.unwrap(), + }) + }) + .collect() + }) +} + +#[cfg(test)] +mod test { + use taler_common::types::utils::date_time_to_utc_ts; + + use crate::iso20022::{ + HacAction, + hac::{CustomerAck, parse_hac}, + status_code::StatusReason, + }; + + #[test] + fn hac() { + pub fn ack( + action: HacAction, + order_id: Option<&str>, + code: Option<StatusReason>, + info: &str, + timestamp: &str, + ) -> CustomerAck { + CustomerAck { + action, + order_id: order_id.map(Into::into), + code, + info: info.into(), + timestamp: date_time_to_utc_ts(&timestamp.trim_end_matches('Z').parse().unwrap()), + } + } + pretty_assertions::assert_eq!( + parse_hac(&std::fs::read("../../libeufin-nexus/sample/platform/hac.xml").unwrap()) + .unwrap(), + [ + ack( + HacAction::FILE_DOWNLOAD, + None, + Some(StatusReason::TransmissionSuccessful), + "", + "2024-09-02T15:47:30.350Z" + ), + ack( + HacAction::FILE_UPLOAD, + Some("ORDER_SUCCESS"), + Some(StatusReason::TransmissionSuccessful), + "", + "2024-09-02T20:48:43.153Z" + ), + ack( + HacAction::ES_VERIFICATION, + Some("ORDER_SUCCESS"), + Some(StatusReason::ElectronicSignaturesCorrect), + "", + "2024-09-02T20:48:43.153Z" + ), + ack( + HacAction::ORDER_HAC_FINAL_POS, + Some("ORDER_SUCCESS"), + None, + "Some multiline info", + "2024-09-02T20:48:43.153Z" + ), + ack( + HacAction::FILE_DOWNLOAD, + None, + Some(StatusReason::NoDataAvailable), + "", + "2024-09-02T15:47:31.754Z" + ), + ack( + HacAction::FILE_UPLOAD, + Some("ORDER_FAILURE"), + Some(StatusReason::TransmissionSuccessful), + "", + "2024-08-23T15:34:11.987Z" + ), + ack( + HacAction::ES_VERIFICATION, + Some("ORDER_FAILURE"), + Some(StatusReason::IncorrectFileStructure), + "", + "2024-08-23T15:34:13.307Z" + ), + ack( + HacAction::ORDER_HAC_FINAL_NEG, + Some("ORDER_FAILURE"), + None, + "", + "2024-08-23T15:34:13.307Z" + ), + ] + ) + } +} diff --git a/crates/libeufin-ebics/src/iso20022/model.rs b/crates/libeufin-ebics/src/iso20022/model.rs @@ -0,0 +1,419 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::fmt::{Display, Write as _}; + +use compact_str::{CompactString, ToCompactString as _}; +use jiff::Timestamp; +use taler_common::types::{amount::Amount, payto::PaytoURI}; +use uuid::Uuid; + +/// ID for incoming transactions +#[derive(Clone, PartialEq, Eq)] +pub struct InId { + /** ISO20022 UETR */ + pub uetr: Option<Uuid>, + /// ISO20022 TxID + pub tx_id: Option<CompactString>, + /// ISO20022 AcctSvcrRef + pub sref: Option<CompactString>, +} + +impl InId { + pub fn new( + uetr: Option<Uuid>, + tx_id: Option<CompactString>, + acct_svcr_ref: Option<CompactString>, + ) -> Self { + assert!(uetr.is_some() || tx_id.is_some() || acct_svcr_ref.is_some()); + Self { + uetr, + tx_id, + sref: acct_svcr_ref, + } + } + + pub fn r#ref(&self) -> CompactString { + self.uetr + .map(|e| e.to_compact_string()) + .or(self.tx_id.clone()) + .or(self.sref.clone()) + .expect("must be at least one ref") + } +} + +impl std::fmt::Display for InId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_char('(')?; + let mut prepend = false; + if let Some(uetr) = &self.uetr { + write!(f, "uetr={uetr}")?; + prepend = true; + } + if let Some(tx_id) = &self.tx_id { + if prepend { + f.write_char(' ')?; + } + f.write_str("tx=")?; + f.write_str(tx_id)?; + prepend = true; + } + if let Some(acct_svcr_ref) = &self.sref { + if prepend { + f.write_char(' ')?; + } + f.write_str("ref=")?; + f.write_str(acct_svcr_ref)?; + } + f.write_char(')')?; + Ok(()) + } +} + +impl std::fmt::Debug for InId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + +/// ID for outgoing transactions +#[derive(Clone, PartialEq, Eq)] +pub struct OutId { + /// Unique msg ID generated by libeufin-nexus + /// ISO20022 MessageId + pub msg_id: Option<CompactString>, + /// Unique end-to-end ID generated by libeufin-nexus + /// ISO20022 EndToEndId or MessageId (retrocompatibility) + pub e2e_id: Option<CompactString>, + /// Unique end-to-end ID generated by the bank + /// ISO20022 AcctSvcrRef + pub sref: Option<CompactString>, +} + +impl OutId { + pub fn new( + msg_id: Option<CompactString>, + e2e_id: Option<CompactString>, + acct_svcr_ref: Option<CompactString>, + ) -> Self { + assert!(msg_id.is_some() || e2e_id.is_some() || acct_svcr_ref.is_some()); + Self { + msg_id, + e2e_id, + sref: acct_svcr_ref, + } + } + + pub fn r#ref(&self) -> CompactString { + self.e2e_id + .clone() + .or(self.sref.clone()) + .or(self.sref.clone()) + .expect("must be at least one ref") + } +} + +impl std::fmt::Display for OutId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_char('(')?; + let mut prepend = false; + if let Some(msg_id) = &self.msg_id + && self.msg_id != self.e2e_id + { + f.write_str("msg=")?; + f.write_str(msg_id)?; + prepend = true; + } + if let Some(end_to_end_id) = &self.e2e_id { + if prepend { + f.write_char(' ')?; + } + f.write_str("e2e=")?; + f.write_str(end_to_end_id)?; + prepend = true; + } + if let Some(acct_svcr_ref) = &self.sref { + if prepend { + f.write_char(' ')?; + } + f.write_str("ref=")?; + f.write_str(acct_svcr_ref)?; + } + f.write_char(')')?; + Ok(()) + } +} + +impl std::fmt::Debug for OutId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + +/// ID for outgoing batches +#[derive(Clone, PartialEq, Eq)] +pub struct BatchId { + /// Unique msg ID generated by libeufin-nexus + /// ISO20022 MessageId + pub msg_id: CompactString, + /// Unique end-to-end ID generated by the bank + /// ISO20022 AcctSvcrRef + pub sref: Option<CompactString>, +} + +impl BatchId { + pub fn r#ref(&self) -> CompactString { + self.msg_id.clone() + } +} + +impl std::fmt::Display for BatchId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("(msg=")?; + f.write_str(&self.msg_id)?; + if let Some(acct_svcr_ref) = &self.sref { + f.write_str("ref=")?; + f.write_str(acct_svcr_ref)?; + } + f.write_char(')')?; + Ok(()) + } +} + +impl std::fmt::Debug for BatchId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + +/// ISO20022 incoming payment +#[derive(Clone, PartialEq, Eq)] +pub struct InTx { + pub id: InId, + pub amount: Amount, + pub credit_fee: Amount, + pub subject: Option<String>, + pub execution_time: Timestamp, + pub debtor: Option<PaytoURI>, +} + +impl InTx { + pub fn with_execution_time(self, execution_time: Timestamp) -> Self { + Self { + execution_time, + ..self + } + } +} + +impl Display for InTx { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + id, + amount, + credit_fee, + subject, + execution_time, + debtor, + } = self; + write!(f, "IN {execution_time} {amount}")?; + if !credit_fee.is_zero() { + write!(f, "-{credit_fee}")?; + } + write!(f, " {id}")?; + if let Some(creditor) = debtor { + write!(f, " creditor={creditor}")?; + } + if let Some(subject) = subject { + write!(f, " subject='{subject}'")?; + } + Ok(()) + } +} + +impl std::fmt::Debug for InTx { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + +/// ISO20022 outgoing payment +#[derive(Clone, PartialEq, Eq)] +pub struct OutTx { + pub id: OutId, + pub amount: Amount, + pub debit_fee: Amount, + pub subject: Option<String>, + pub execution_time: Timestamp, + pub creditor: Option<PaytoURI>, +} + +impl OutTx { + pub fn with_execution_time(self, execution_time: Timestamp) -> Self { + Self { + execution_time, + ..self + } + } + + pub fn with_e2e_id(self, end_to_end_id: impl Into<CompactString>) -> Self { + Self { + id: OutId { + e2e_id: Some(end_to_end_id.into()), + ..self.id + }, + ..self + } + } + + pub fn with_msg_id(self, msg_id: impl Into<CompactString>) -> Self { + Self { + id: OutId { + msg_id: Some(msg_id.into()), + ..self.id + }, + ..self + } + } +} + +impl Display for OutTx { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + id, + amount, + debit_fee, + subject, + execution_time, + creditor, + } = self; + write!(f, "OUT {execution_time} {amount}")?; + if !debit_fee.is_zero() { + write!(f, "-{debit_fee}")?; + } + write!(f, " {id}")?; + if let Some(creditor) = creditor { + write!(f, " creditor={creditor}")?; + } + if let Some(subject) = subject { + write!(f, " subject='{subject}'")?; + } + Ok(()) + } +} + +impl std::fmt::Debug for OutTx { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + +/** ISO20022 outgoing batch */ +#[derive(Clone, PartialEq, Eq)] +pub struct OutBatch { + /** ISO20022 MessageId */ + pub msg_id: CompactString, + pub execution_time: Timestamp, +} + +impl Display for OutBatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + msg_id, + execution_time, + } = self; + // TODO fmt date + write!(f, "BATCH {execution_time} {msg_id}") + } +} + +impl std::fmt::Debug for OutBatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + +/** ISO20022 outgoing reversal */ +#[derive(Clone, PartialEq, Eq)] +pub struct OutReversal { + /** ISO20022 EndToEndId */ + pub e2e_id: CompactString, + /** ISO20022 MessageId */ + pub msg_id: Option<CompactString>, + pub reason: String, + pub execution_time: Timestamp, +} + +impl Display for OutReversal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + e2e_id, + msg_id, + reason, + execution_time, + } = self; + // TODO fmt date + match msg_id { + Some(msg_id) => write!(f, "BATCH {execution_time} {msg_id}.{e2e_id}: {reason}"), + None => write!(f, "BATCH {execution_time} {e2e_id}: {reason}"), + } + } +} + +impl std::fmt::Debug for OutReversal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub enum Tx { + In(InTx), + Out(OutTx), + Batch(OutBatch), + Reversal(OutReversal), +} + +impl Tx { + pub fn execution_time(&self) -> &Timestamp { + match self { + Tx::In(InTx { execution_time, .. }) + | Tx::Out(OutTx { execution_time, .. }) + | Tx::Batch(OutBatch { execution_time, .. }) + | Tx::Reversal(OutReversal { execution_time, .. }) => execution_time, + } + } +} + +impl Display for Tx { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Tx::In(incoming_payment) => incoming_payment.fmt(f), + Tx::Out(outgoing_payment) => outgoing_payment.fmt(f), + Tx::Batch(outgoing_batch) => outgoing_batch.fmt(f), + Tx::Reversal(outgoing_reversal) => outgoing_reversal.fmt(f), + } + } +} + +impl std::fmt::Debug for Tx { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self, f) + } +} diff --git a/crates/libeufin-ebics/src/iso20022/pain001.rs b/crates/libeufin-ebics/src/iso20022/pain001.rs @@ -0,0 +1,246 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use jiff::{Timestamp, Zoned, tz::TimeZone}; +use taler_common::types::{ + amount::{Amount, Decimal}, + payto::FullIbanPayto, +}; + +use crate::{ + dialect::{Dialect, Standard}, + ebics::EbicsErrKind, + xml, + xml::XmlWriter, +}; + +/** pain.001 transaction metadata */ +pub struct Pain001Tx<'a> { + pub creditor: FullIbanPayto, + pub amount: Amount, + pub subject: &'a str, + pub e2e_id: &'a str, +} + +/** pain.001 message metadata */ +pub struct Pain001Msg<'a> { + pub msg_id: &'a str, + pub timestamp: &'a Timestamp, + pub debtor: &'a FullIbanPayto, + pub sum: Amount, + pub txs: Vec<Pain001Tx<'a>>, +} + +/** Check EBICS compability of an amount */ +fn ebics_amount(amount: &Amount) -> Result<Decimal, EbicsErrKind> { + if amount.is_sub_cent() { + return Err(EbicsErrKind::Custom( + "Sub-cent amounts not supported".into(), + )); + } + Ok(amount.decimal()) +} + +/** Create a pain.001 XML document [msg] valid for [dialect] */ +pub fn create_pain001( + msg: &Pain001Msg, + dialect: &Dialect, + instant: bool, +) -> Result<String, EbicsErrKind> { + let version = "09"; + let suffix = match dialect.standard() { + Standard::SIX => ".ch.03", + Standard::GBIC => "", + }; + let total = ebics_amount(&msg.sum)?; + Ok(xml!( + "Document" + "xmlns"=(format_args!("urn:iso:std:iso:20022:tech:xsd:pain.001.001.{version}")) + "xmlns:xsi"=(format_args!("http://www.w3.org/2001/XMLSchema-instance")) + "xsi:schemaLocation"=(format_args!("urn:iso:std:iso:20022:tech:xsd:pain.001.001.{version} pain.001.001.{version}{suffix}.xsd")) + { + "CstmrCdtTrfInitn" { + "GrpHdr" { + // Used for idempotency as banks will refuse to process EBICS request with the same MsgId for a pre-agreed period + // Used to uniquely identify batches of transactions in other files + "MsgId": msg.msg_id, + "CreDtTm": msg.timestamp, + "NbOfTxs": msg.txs.len(), + "CtrlSum": total, + "InitgPty" { + "Nm": msg.debtor.name + }/* + // TODO fail with GLS: ES_VERIFICATION IncorrectFileStructure - 'Signature verification' 'The file format is incomplete or invalid' + "InitnSrc" { + "Nm": "LibEuFin", + "Prvdr": "Taler Systems SA", + "Vrsn": taler_build::long_version() + }*/ + }, + "PmtInf" { + "PmtInfId": "NOTPROVIDED", + "PmtMtd": "TRF", + "BtchBookg": "false", + "NbOfTxs": msg.txs.len(), + "CtrlSum": total, + @ |w: &mut XmlWriter| if dialect.standard() == Standard::GBIC { + xml!(w => "PmtTpInf" { + "SvcLvl" { + "Cd": "SEPA" + }, + @ |w: &mut XmlWriter| if instant { + xml!(w => "LclInstrm" { + "Cd": "INST" + }) + } + }) + }, + "ReqdExctnDt" { + "Dt": Zoned::new(*msg.timestamp, TimeZone::UTC).date().to_string() + "Z" + }, + "Dbtr" { + "Nm": msg.debtor.name + }, + "DbtrAcct" { + "Id" { + "IBAN": msg.debtor.iban + } + }, + "DbtrAgt" { + "FinInstnId" { + @ |w: &mut XmlWriter| if let Some(bic) = &msg.debtor.bic { + xml!(w => "BICFI": bic) + } else { + xml!(w => "Othr" { + "Id": "NOTPROVIDED" + }) + } + } + + }, + "ChrgBr": "SLEV", + @ |w: &mut XmlWriter| for tx in &msg.txs { + xml!(w => "CdtTrfTxInf" { + "PmtId" { + "InstrId": tx.e2e_id, + // Used to uniquely identify transactions in other files + "EndToEndId": tx.e2e_id + }, + "Amt" { + "InstdAmt" "Ccy"=(tx.amount.currency) : ebics_amount(&tx.amount).unwrap() + }, + @ |w: &mut XmlWriter| if let Some(bic) = &tx.creditor.bic { + xml!(w => "CdtrAgt" { + "FinInstnId" { + "BICFI": bic + } + }) + }, + "Cdtr" { + "Nm": tx.creditor.name + // Addr might become a requirement in the future + /*"PstlAdr" { + "TwnNm": "Bochum", + "Ctry": "DE" + }*/ + }, + "CdtrAcct" { + "Id" { + "IBAN": tx.creditor.iban + } + }, + "RmtInf" { + "Ustrd": tx.subject + } + }) + } + } + } + } + )) +} + +#[cfg(test)] +mod test { + use taler_common::types::{ + amount::amount, + payto::{BankID, FullIbanPayto}, + }; + + use crate::{ + dialect::Dialect, + iso20022::{ + camt::test::date_to_timestamp, + pain001::{Pain001Msg, Pain001Tx, create_pain001}, + }, + }; + + #[test] + fn pain001() { + let creditor = FullIbanPayto::new( + BankID { + iban: "CH4189144589712575493".parse().expect("invalid IBAN"), + bic: None, + }, + "Test", + ); + + let msg = Pain001Msg { + msg_id: "MESSAGE_ID".into(), + timestamp: &date_to_timestamp("2024-09-09"), + debtor: &FullIbanPayto::new( + BankID { + iban: "CH7789144474425692816".parse().expect("invalid IBAN"), + bic: Some("AAAABBCC123".parse().expect("invalid BIC")), + }, + "myname", + ), + sum: amount("CHF:47.32"), + txs: vec![ + Pain001Tx { + creditor: creditor.clone(), + amount: amount("CHF:42"), + subject: "Test 42", + e2e_id: "TX_FIRST", + }, + Pain001Tx { + creditor: creditor.clone(), + amount: amount("CHF:5.11"), + subject: "Test 5.11".into(), + e2e_id: "TX_SECOND", + }, + Pain001Tx { + creditor: creditor, + amount: amount("CHF:0.21"), + subject: "Test 0.21", + e2e_id: "TX_THIRD", + }, + ], + }; + for dialect in Dialect::entries { + pretty_assertions::assert_eq!( + std::fs::read_to_string(format!( + "../../libeufin-nexus/sample/platform/{dialect}_pain001.xml" + )) + .unwrap(), + create_pain001(&msg, dialect, false).unwrap() + ); + } + } +} diff --git a/crates/libeufin-ebics/src/iso20022/pain002.rs b/crates/libeufin-ebics/src/iso20022/pain002.rs @@ -0,0 +1,270 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::fmt::{Display, Formatter, Write, from_fn}; + +use compact_str::CompactString; + +use crate::{ + iso20022::status_code::{PaymentGroupStatus, PaymentTransactionStatus, StatusReason}, + xml::{self, Xml, XmlAccess}, +}; + +fn fmt_msg( + f: &mut Formatter<'_>, + code: Option<&str>, + description: Option<&str>, + reasons: &[Reason], +) -> std::fmt::Result { + if let Some(code) = code { + write!(f, "{code}")?; + if let Some(description) = description { + write!(f, " '{description}'")?; + } + if !reasons.is_empty() { + f.write_char(':')?; + } + } + for Reason { + code, + info: information, + } in reasons + { + if let Some(code) = code { + write!(f, " {} '{}'", code.code(), code.description())?; + } + if !information.is_empty() { + write!(f, " '{information}'")?; + } + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reason { + pub code: Option<StatusReason>, + pub info: Box<str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TxStatus { + pub id: CompactString, + pub e2e_id: CompactString, + pub status: PaymentTransactionStatus, + pub reasons: Box<[Reason]>, +} + +impl TxStatus { + fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fmt_msg( + f, + Some(self.status.code()), + Some(self.status.description()), + &self.reasons, + ) + } + + pub fn msg(&self) -> String { + format!("{}", from_fn(|f| self.fmt_msg(f))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PmtStatus { + pub id: CompactString, + pub status: Option<PaymentGroupStatus>, + pub reasons: Box<[Reason]>, + pub txs: Box<[TxStatus]>, +} + +impl PmtStatus { + fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fmt_msg( + f, + self.status.map(|it| it.code()), + self.status.map(|it| it.description()), + &self.reasons, + ) + } + pub fn msg(&self) -> String { + format!("{}", from_fn(|f| self.fmt_msg(f))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MsgStatus { + pub id: CompactString, + pub status: Option<PaymentGroupStatus>, + pub reasons: Box<[Reason]>, + pub payments: Box<[PmtStatus]>, +} + +impl MsgStatus { + fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fmt_msg( + f, + self.status.map(|it| it.code()), + self.status.map(|it| it.description()), + &self.reasons, + ) + } + pub fn msg(&self) -> String { + format!("{}", from_fn(|f| self.fmt_msg(f))) + } +} + +impl Display for MsgStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.id, from_fn(|f| self.fmt_msg(f)))?; + for p in &self.payments { + write!(f, "\n>{} {}", p.id, from_fn(|f| p.fmt_msg(f)))?; + for tx in &p.txs { + if tx.id != tx.e2e_id { + write!(f, "{} ", tx.id)?; + } + write!(f, "\n>>{} {}", tx.e2e_id, from_fn(|f| tx.fmt_msg(f)))?; + } + } + Ok(()) + } +} + +/** Parse pain.002 XML file */ +pub fn parse_pain002(xml: &[u8]) -> xml::Result<MsgStatus> { + fn reasons(x: Xml) -> xml::Result<Box<[Reason]>> { + x.many("StsRsnInf") + .map(|n| { + let code = n.opt("Rsn").one("Cd").parse()?; + let info = n.many("AddtlInf").map(Xml::text).collect(); + Ok(Reason { code, info }) + }) + .collect() + } + + Xml::parse(xml, "Document", |root| { + let n = root.one("CstmrPmtStsRpt")?; + let status = n.one("OrgnlGrpInfAndSts")?; + Ok(MsgStatus { + id: status.one("OrgnlMsgId").parse()?, + status: status.opt("GrpSts").parse()?, + reasons: reasons(status)?, + payments: n + .many("OrgnlPmtInfAndSts") + .map(|n| { + Ok(PmtStatus { + id: n.one("OrgnlPmtInfId").parse()?, + status: n.opt("PmtInfSts").parse()?, + reasons: reasons(n)?, + txs: n + .many("TxInfAndSts") + .map(|n| { + Ok(TxStatus { + id: n.one("OrgnlInstrId").parse()?, + e2e_id: n.one("OrgnlEndToEndId").parse()?, + status: n.one("TxSts").parse()?, + reasons: reasons(n)?, + }) + }) + .collect::<xml::Result<_>>()?, + }) + }) + .collect::<xml::Result<_>>()?, + }) + }) +} + +#[cfg(test)] +mod test { + use crate::iso20022::{ + pain002::{MsgStatus, PmtStatus, Reason, TxStatus, parse_pain002}, + status_code::{PaymentGroupStatus, PaymentTransactionStatus, StatusReason}, + }; + + #[test] + fn pain002() { + pretty_assertions::assert_eq!( + parse_pain002(&std::fs::read("../../libeufin-nexus/sample/platform/pain002_part.xml").unwrap()).unwrap(), + MsgStatus { + id: "05BD4C5B4A2649B5B08F6EF6A31F197A".into(), + status: Some(PaymentGroupStatus::PartiallyAccepted), + reasons: Box::default(), + payments: Box::new([PmtStatus { + id: "NOTPROVIDED".into(), + status: Some(PaymentGroupStatus::PartiallyAccepted), + reasons: Box::new([ + Reason { + code: Some(StatusReason::ExecutionDateChanged), + info: "Due date is not a working day. Order will be executed on the next working day".into() + } + ]), + txs: Box::new([ + TxStatus { + id: "AQCXNCPWD8PHW5JTN65Y5XTF7R".into(), + e2e_id: "AQCXNCPWD8PHW5JTN65Y5XTF7R".into(), + status: PaymentTransactionStatus::Rejected, + reasons: Box::new([ + Reason { + code: Some(StatusReason::ClosedAccountNumber), + info: "Error message".into() + } + ]) + }, + TxStatus { + id: "EE9SX76FC5YSC657EK3GMVZ9TC".into(), + e2e_id: "EE9SX76FC5YSC657EK3GMVZ9TC".into(), + status: PaymentTransactionStatus::Rejected, + reasons: Box::new([ + Reason { + code: Some(StatusReason::NotSpecifiedReasonAgentGenerated), + info: "Error message".into() + } + ]) + }, + TxStatus { + id: "V5B3MXPEWES9VQW1JDRD6VAET4".into(), + e2e_id: "V5B3MXPEWES9VQW1JDRD6VAET4".into(), + status: PaymentTransactionStatus::Rejected, + reasons: Box::new([ + Reason { + code: Some(StatusReason::MissingDebtorNameOrAddress), + info: "Error message".into() + } + ]) + } + ]) + }]) + } + ); + pretty_assertions::assert_eq!( + parse_pain002( + &std::fs::read("../../libeufin-nexus/sample/platform/pain002_accp.xml").unwrap() + ) + .unwrap(), + MsgStatus { + id: "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U".into(), + status: Some(PaymentGroupStatus::AcceptedCustomerProfile), + reasons: Box::new([Reason { + code: None, + info: "PN10630020F0297329.20251030104613.EBTUAAAC.PN1.0002372".into() + }]), + payments: Box::default() + } + ); + } +} diff --git a/crates/libeufin-ebics/src/iso20022/status_code.rs b/crates/libeufin-ebics/src/iso20022/status_code.rs @@ -0,0 +1,1374 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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 + +use taler_macros::EnumMeta; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(DomainCode, Description, Str)] +pub enum StatusReason { + /// Clearing process aborted due to timeout + #[code = "AB01"] + AbortedClearingTimeout, + /// Clearing process aborted due to a fatal error + #[code = "AB02"] + AbortedClearingFatalError, + /// Settlement aborted due to timeout + #[code = "AB03"] + AbortedSettlementTimeout, + /// Settlement process aborted due to a fatal error + #[code = "AB04"] + AbortedSettlementFatalError, + /// Transaction stopped due to timeout at the Creditor Agent + #[code = "AB05"] + TimeoutCreditorAgent, + /// Transaction stopped due to timeout at the Instructed Agent + #[code = "AB06"] + TimeoutInstructedAgent, + /// Agent of message is not online + #[code = "AB07"] + OfflineAgent, + /// Creditor Agent is not online + #[code = "AB08"] + OfflineCreditorAgent, + /// Transaction stopped due to error at the Creditor Agent + #[code = "AB09"] + ErrorCreditorAgent, + /// Transaction stopped due to error at the Instructed Agent + #[code = "AB10"] + ErrorInstructedAgent, + /// Transaction stopped due to timeout at the Debtor Agent + #[code = "AB11"] + TimeoutDebtorAgent, + /// Duplicate Concurrent Batch Sequence number– for Settlement Instructions + #[code = "AB12"] + InvalidConcurrentBatch, + /// Wrong Message Routing Type for Return-of-Funds + #[code = "AB13"] + InvalidRoutingCodeUtilised, + /// Instruction may not be placed on the Continuous Processing Line settlement processor + #[code = "AB15"] + InvalidAccountNumberForSettlementType, + /// Agreement number not valid (beneficiary) + #[code = "AB21"] + InvalidSettlementAgreementNumberSpecified, + /// Settlement Instruction does not exist + #[code = "AB26"] + InvalidBatchSettlementInstruction, + /// Account number is invalid or missing + #[code = "AC01"] + IncorrectAccountNumber, + /// Debtor account number invalid or missing + #[code = "AC02"] + InvalidDebtorAccountNumber, + /// Creditor account number invalid or missing + #[code = "AC03"] + InvalidCreditorAccountNumber, + /// Account number specified has been closed on the bank of account's books + #[code = "AC04"] + ClosedAccountNumber, + /// Debtor account number closed + #[code = "AC05"] + ClosedDebtorAccountNumber, + /// Account specified is blocked, prohibiting posting of transactions against it + #[code = "AC06"] + BlockedAccount, + /// Creditor account number closed + #[code = "AC07"] + ClosedCreditorAccountNumber, + /// Branch code is invalid or missing + #[code = "AC08"] + InvalidBranchCode, + /// Account currency is invalid or missing + #[code = "AC09"] + InvalidAccountCurrency, + /// Debtor account currency is invalid or missing + #[code = "AC10"] + InvalidDebtorAccountCurrency, + /// Creditor account currency is invalid or missing + #[code = "AC11"] + InvalidCreditorAccountCurrency, + /// Account type missing or invalid + #[code = "AC12"] + InvalidAccountType, + /// Debtor account type missing or invalid + #[code = "AC13"] + InvalidDebtorAccountType, + /// Creditor account type missing or invalid + #[code = "AC14"] + InvalidCreditorAccountType, + /// The account details for the counterparty have changed + #[code = "AC15"] + AccountDetailsChanged, + /// Credit or debit card number is invalid + #[code = "AC16"] + CardNumberInvalid, + /// Request-to-pay Expiry Date and Time has already passed + #[code = "AEXR"] + AlreadyExpiredRTP, + /// Transaction forbidden on this type of account (formerly NoAgreement) + #[code = "AG01"] + TransactionForbidden, + /// Bank Operation code specified in the message is not valid for receiver + #[code = "AG02"] + InvalidBankOperationCode, + /// Transaction type not supported/authorized on this account + #[code = "AG03"] + TransactionNotSupported, + /// Agent country code is missing or invalid + #[code = "AG04"] + InvalidAgentCountry, + /// Debtor agent country code is missing or invalid + #[code = "AG05"] + InvalidDebtorAgentCountry, + /// Creditor agent country code is missing or invalid + #[code = "AG06"] + InvalidCreditorAgentCountry, + /// Debtor account cannot be debited for a generic reason + #[code = "AG07"] + UnsuccesfulDirectDebit, + /// Transaction failed due to invalid or missing user or access right + #[code = "AG08"] + InvalidAccessRights, + /// Original payment never received + #[code = "AG09"] + PaymentNotReceived, + /// Agent of message is suspended from the Real Time Payment system + #[code = "AG10"] + AgentSuspended, + /// Creditor Agent of message is suspended from the Real Time Payment system + #[code = "AG11"] + CreditorAgentSuspended, + /// Payment orders made by transferring funds from one account to another at the same financial institution (bank or payment institution) are not allowed + #[code = "AG12"] + NotAllowedBookTransfer, + /// Returned payments derived from previously returned transactions are not allowed + #[code = "AG13"] + ForbiddenReturnPayment, + /// Agent in the payment workflow is incorrect + #[code = "AGNT"] + IncorrectAgent, + /// Request-to-pay has already been accepted by the Debtor + #[code = "ALAC"] + AlreadyAcceptedRTP, + /// Specified message amount is equal to zero + #[code = "AM01"] + ZeroAmount, + /// Specific transaction/message amount is greater than allowed maximum + #[code = "AM02"] + NotAllowedAmount, + /// Specified message amount is an non processable currency outside of existing agreement + #[code = "AM03"] + NotAllowedCurrency, + /// Amount of funds available to cover specified message amount is insufficient + #[code = "AM04"] + InsufficientFunds, + /// Duplication + #[code = "AM05"] + Duplication, + /// Specified transaction amount is less than agreed minimum + #[code = "AM06"] + TooLowAmount, + /// Amount specified in message has been blocked by regulatory authorities + #[code = "AM07"] + BlockedAmount, + /// Amount received is not the amount agreed or expected + #[code = "AM09"] + WrongAmount, + /// Sum of instructed amounts does not equal the control sum + #[code = "AM10"] + InvalidControlSum, + /// Transaction currency is invalid or missing + #[code = "AM11"] + InvalidTransactionCurrency, + /// Amount is invalid or missing + #[code = "AM12"] + InvalidAmount, + /// Transaction amount exceeds limits set by clearing system + #[code = "AM13"] + AmountExceedsClearingSystemLimit, + /// Transaction amount exceeds limits agreed between bank and client + #[code = "AM14"] + AmountExceedsAgreedLimit, + /// Transaction amount below minimum set by clearing system + #[code = "AM15"] + AmountBelowClearingSystemMinimum, + /// Control Sum at the Group level is invalid + #[code = "AM16"] + InvalidGroupControlSum, + /// Control Sum at the Payment Information level is invalid + #[code = "AM17"] + InvalidPaymentInfoControlSum, + /// Number of transactions is invalid or missing + #[code = "AM18"] + InvalidNumberOfTransactions, + /// Number of transactions at the Group level is invalid or missing + #[code = "AM19"] + InvalidGroupNumberOfTransactions, + /// Number of transactions at the Payment Information level is invalid + #[code = "AM20"] + InvalidPaymentInfoNumberOfTransactions, + /// Transaction amount exceeds limits agreed between bank and client + #[code = "AM21"] + LimitExceeded, + /// Unable to apply zero amount to designated account + #[code = "AM22"] + ZeroAmountNotApplied, + /// Transaction amount exceeds settlement limit + #[code = "AM23"] + AmountExceedsSettlementLimit, + /// Size of the attachment exceeds the allowed maximum + #[code = "AMSE"] + AttachmentMaximumSize, + /// Request To Pay has already been paid by the Debtor + #[code = "APAR"] + AlreadyPaidRTP, + /// Request-to-pay has already been refused by the Debtor + #[code = "ARFR"] + AlreadyRefusedRTP, + /// Request-to-pay has already been rejected + #[code = "ARJR"] + AlreadyRejectedRTP, + /// Attachments to the request-to-pay are not supported + #[code = "ATNS"] + AttachementsNotSupported, + /// Settlement Cycle Day and Calendar day should be the same + #[code = "BDAY"] + NotBusinessDay, + /// Identification of end customer is not consistent with associated account number + #[code = "BE01"] + InconsistenWithEndCustomer, + /// Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress) + #[code = "BE04"] + MissingCreditorAddress, + /// Party who initiated the message is not recognised by the end customer + #[code = "BE05"] + UnrecognisedInitiatingParty, + /// End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books + #[code = "BE06"] + UnknownEndCustomer, + /// Specification of debtor's address, which is required for payment, is missing/not correct + #[code = "BE07"] + MissingDebtorAddress, + /// Debtor name is missing + #[code = "BE08"] + MissingDebtorName, + /// Country code is missing or Invalid + #[code = "BE09"] + InvalidCountry, + /// Debtor country code is missing or invalid + #[code = "BE10"] + InvalidDebtorCountry, + /// Creditor country code is missing or invalid + #[code = "BE11"] + InvalidCreditorCountry, + /// Country code of residence is missing or Invalid + #[code = "BE12"] + InvalidCountryOfResidence, + /// Country code of debtor's residence is missing or Invalid + #[code = "BE13"] + InvalidDebtorCountryOfResidence, + /// Country code of creditor's residence is missing or Invalid + #[code = "BE14"] + InvalidCreditorCountryOfResidence, + /// Identification code missing or invalid + #[code = "BE15"] + InvalidIdentificationCode, + /// Debtor or Ultimate Debtor identification code missing or invalid + #[code = "BE16"] + InvalidDebtorIdentificationCode, + /// Creditor or Ultimate Creditor identification code missing or invalid + #[code = "BE17"] + InvalidCreditorIdentificationCode, + /// Contact details missing or invalid + #[code = "BE18"] + InvalidContactDetails, + /// Charge bearer code for transaction type is invalid + #[code = "BE19"] + InvalidChargeBearerCode, + /// Name length exceeds local rules for payment type + #[code = "BE20"] + InvalidNameLength, + /// Name missing or invalid + #[code = "BE21"] + MissingName, + /// Creditor name is missing + #[code = "BE22"] + MissingCreditorName, + /// Phone number or email address, or any other proxy, used as the account proxy is unknown or invalid + #[code = "BE23"] + AccountProxyInvalid, + /// Credit transfer is not tagged as an Extended Remittance Information (ERI) transaction but contains ERI + #[code = "CERI"] + CheckERI, + /// Value in Requested Execution Date or Requested Collection Date is too far in the future + #[code = "CH03"] + RequestedExecutionDateOrRequestedCollectionDateTooFarInFuture, + /// Value in Requested Execution Date or Requested Collection Date is too far in the past + #[code = "CH04"] + RequestedExecutionDateOrRequestedCollectionDateTooFarInPast, + /// Element is not to be used at B- and C-Level + #[code = "CH07"] + ElementIsNotToBeUsedAtBandCLevel, + /// Mandate changes are not allowed + #[code = "CH09"] + MandateChangesNotAllowed, + /// Information on mandate changes are missing + #[code = "CH10"] + InformationOnMandateChangesMissing, + /// Value in Creditor Identifier is incorrect + #[code = "CH11"] + CreditorIdentifierIncorrect, + /// Creditor Identifier is ambiguous at Transaction Level + #[code = "CH12"] + CreditorIdentifierNotUnambiguouslyAtTransactionLevel, + /// Original Debtor Account is not to be used + #[code = "CH13"] + OriginalDebtorAccountIsNotToBeUsed, + /// Original Debtor Agent is not to be used + #[code = "CH14"] + OriginalDebtorAgentIsNotToBeUsed, + /// Content Remittance Information/Structured includes more than 140 characters + #[code = "CH15"] + ElementContentIncludesMoreThan140Characters, + /// Content is incorrect + #[code = "CH16"] + ElementContentFormallyIncorrect, + /// Element is not allowed + #[code = "CH17"] + ElementNotAdmitted, + /// Values in Interbank Settlement Date or Requested Collection Date will be set to the next TARGET day + #[code = "CH19"] + ValuesWillBeSetToNextTARGETday, + /// Number of decimal points not compatible with the currency + #[code = "CH20"] + DecimalPointsNotCompatibleWithCurrency, + /// Mandatory element is missing + #[code = "CH21"] + RequiredCompulsoryElementMissing, + /// SDD CORE and B2B not permitted within one message + #[code = "CH22"] + COREandB2BwithinOnemessage, + /// Related to a Charge message to convey that the code in Charge Breakdown / Type / Code is not accepted by the receiving party + #[code = "CHCO"] + UnacceptedChargeCodeType, + /// Cheque has been presented in cheque clearing and settled on the creditor’s account + #[code = "CHQC"] + ChequeSettledOnCreditorAccount, + /// Related to a Charge message to convey that the charge bearer code used in the corresponding Payment message was not debt + #[code = "CHRG"] + UnderlyingChargeBearerWasNotDebt, + /// Authorisation is cancelled + #[code = "CN01"] + AuthorisationCancelled, + /// Credit notes are not supported + #[code = "CNNS"] + CreditNotesNotSupported, + /// Creditor bank is not registered under this BIC in the CSM + #[code = "CNOR"] + CreditorBankIsNotRegistered, + /// Currency of the payment is incorrect + #[code = "CURR"] + IncorrectCurrency, + /// Cancellation requested by the Debtor + #[code = "CUST"] + RequestedByCustomer, + /// Rejection of a payment due to covering FI settlement not being received + #[code = "DC02"] + SettlementNotReceived, + /// Debtor bank is not registered under this BIC in the CSM + #[code = "DNOR"] + DebtorBankIsNotRegistered, + /// The electronic signature(s) is/are correct + #[code = "DS01"] + ElectronicSignaturesCorrect, + /// An authorized user has cancelled the order + #[code = "DS02"] + OrderCancelled, + /// The user’s attempt to cancel the order was not successful + #[code = "DS03"] + OrderNotCancelled, + /// The order was rejected by the bank side (for reasons concerning content) + #[code = "DS04"] + OrderRejected, + /// The order was correct and could be forwarded for postprocessing + #[code = "DS05"] + OrderForwardedForPostprocessing, + /// The order was transferred to VEU + #[code = "DS06"] + TransferOrder, + /// All actions concerning the order could be done by the EBICS bank server + #[code = "DS07"] + ProcessingOK, + /// The decompression of the file was not successful + #[code = "DS08"] + DecompressionError, + /// The decryption of the file was not successful + #[code = "DS09"] + DecryptionError, + /// Data signature is required + #[code = "DS0A"] + DataSignRequested, + /// Data signature for the format is not available or invalid + #[code = "DS0B"] + UnknownDataSignFormat, + /// The signer certificate is revoked + #[code = "DS0C"] + SignerCertificateRevoked, + /// The signer certificate is not valid (revoked or not active) + #[code = "DS0D"] + SignerCertificateNotValid, + /// The signer certificate is not present + #[code = "DS0E"] + IncorrectSignerCertificate, + /// The authority of the signer certification sending the certificate is unknown + #[code = "DS0F"] + SignerCertificationAuthoritySignerNotValid, + /// Signer is not allowed to sign this operation type + #[code = "DS0G"] + NotAllowedPayment, + /// Signer is not allowed to sign for this account + #[code = "DS0H"] + NotAllowedAccount, + /// The number of transaction is over the number allowed for this signer + #[code = "DS0K"] + NotAllowedNumberOfTransaction, + /// The certificate is revoked for the first signer + #[code = "DS10"] + Signer1CertificateRevoked, + /// The certificate is not valid (revoked or not active) for the first signer + #[code = "DS11"] + Signer1CertificateNotValid, + /// The certificate is not present for the first signer + #[code = "DS12"] + IncorrectSigner1Certificate, + /// The authority of signer certification sending the certificate is unknown for the first signer + #[code = "DS13"] + SignerCertificationAuthoritySigner1NotValid, + /// The user is unknown on the server + #[code = "DS14"] + UserDoesNotExist, + /// The same signature has already been sent to the bank + #[code = "DS15"] + IdenticalSignatureFound, + /// The public key version is not correct + #[code = "DS16"] + PublicKeyVersionIncorrect, + /// Order data and signatures don’t match + #[code = "DS17"] + DifferentOrderDataInSignatures, + /// File cannot be tested, the complete order has to be repeated + #[code = "DS18"] + RepeatOrder, + /// The user’s rights (concerning his signature) are insufficient to execute the order + #[code = "DS19"] + ElectronicSignatureRightsInsufficient, + /// The certificate is revoked for the second signer + #[code = "DS20"] + Signer2CertificateRevoked, + /// The certificate is not valid (revoked or not active) for the second signer + #[code = "DS21"] + Signer2CertificateNotValid, + /// The certificate is not present for the second signer + #[code = "DS22"] + IncorrectSigner2Certificate, + /// The authority of signer certification sending the certificate is unknown for the second signer + #[code = "DS23"] + SignerCertificationAuthoritySigner2NotValid, + /// Waiting time expired due to incomplete order + #[code = "DS24"] + WaitingTimeExpired, + /// The order file was deleted by the bank server + #[code = "DS25"] + OrderFileDeleted, + /// The same user has signed multiple times + #[code = "DS26"] + UserSignedMultipleTimes, + /// The user is not yet activated (technically) + #[code = "DS27"] + UserNotYetActivated, + /// Message routed to the wrong environment + #[code = "DS28"] + ReturnForTechnicalReason, + /// Invalid date (eg, wrong or missing settlement date) + #[code = "DT01"] + InvalidDate, + /// Invalid creation date and time in Group Header (eg, historic date) + #[code = "DT02"] + InvalidCreationDate, + /// Invalid non bank processing date (eg, weekend or local public holiday) + #[code = "DT03"] + InvalidNonProcessingDate, + /// Future date not supported + #[code = "DT04"] + FutureDateNotSupported, + /// Associated message, payment information block or transaction was received after agreed processing cut-off date, i + #[code = "DT05"] + InvalidCutOffDate, + /// Execution Date has been modified in order for transaction to be processed + #[code = "DT06"] + ExecutionDateChanged, + /// Message Identification is not unique + #[code = "DU01"] + DuplicateMessageID, + /// Payment Information Block is not unique + #[code = "DU02"] + DuplicatePaymentInformationID, + /// Transaction is not unique + #[code = "DU03"] + DuplicateTransaction, + /// End To End ID is not unique + #[code = "DU04"] + DuplicateEndToEndID, + /// Instruction ID is not unique + #[code = "DU05"] + DuplicateInstructionID, + /// Payment or charge is a duplicate of another payment or charge + #[code = "DUPL"] + DuplicatePaymentOrCharge, + /// Correspondent bank not possible + #[code = "ED01"] + CorrespondentBankNotPossible, + /// Balance of payments complementary info is requested + #[code = "ED03"] + BalanceInfoRequest, + /// Settlement of the transaction has failed + #[code = "ED05"] + SettlementFailed, + /// Interbank settlement system not available + #[code = "ED06"] + SettlementSystemNotAvailable, + /// Requested execution date of the payment is not accepted + #[code = "EDNA"] + ExecutionDateNotAccepted, + /// Expiry date time of the request-to-pay is too far in the future + #[code = "EDTL"] + ExpiryDateTooLong, + /// Expiry date time of the request-to-pay is already reached + #[code = "EDTR"] + ExpiryDateTimeReached, + /// Expiration of the payment authorisation due to no use for too long + #[code = "EOL1"] + EndOfLife, + /// Extended Remittance Information (ERI) option is not supported + #[code = "ERIN"] + ERIOptionNotSupported, + /// File Format incomplete or invalid + #[code = "FF01"] + InvalidFileFormat, + /// Syntax error reason is provided as narrative information in the additional reason information + #[code = "FF02"] + SyntaxError, + /// Payment Type Information is missing or invalid + #[code = "FF03"] + InvalidPaymentTypeInformation, + /// Service Level code is missing or invalid + #[code = "FF04"] + InvalidServiceLevelCode, + /// Local Instrument code is missing or invalid + #[code = "FF05"] + InvalidLocalInstrumentCode, + /// Category Purpose code is missing or invalid + #[code = "FF06"] + InvalidCategoryPurposeCode, + /// Purpose is missing or invalid + #[code = "FF07"] + InvalidPurpose, + /// End to End Id missing or invalid + #[code = "FF08"] + InvalidEndToEndId, + /// Cheque number missing or invalid + #[code = "FF09"] + InvalidChequeNumber, + /// File or transaction cannot be processed due to technical issues at the bank side + #[code = "FF10"] + BankSystemProcessingError, + /// Clearing request rejected due it being subject to an abort operation + #[code = "FF11"] + ClearingRequestAborted, + /// Original payment is not eligible to be returned given its current status + #[code = "FF12"] + OriginalTransactionNotEligibleForRequestedReturn, + /// No record of request for cancellation found + #[code = "FF13"] + RequestForCancellationNotFound, + /// Return following a cancellation request + #[code = "FOCR"] + FollowingCancellationRequest, + /// Returned as a result of fraud + #[code = "FR01"] + Fraud, + /// Cancellation requested following a transaction that was originated fraudulently + #[code = "FRAD"] + FraudulentOrigin, + /// In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure + #[code = "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 + #[code = "G001"] + PaymentTransferredAndNotTracked, + /// In a FIToFI Customer Credit Transfer: Credit to the creditor’s account may not be confirmed same day + #[code = "G002"] + CreditDebitNotConfirmed, + /// In a FIToFI Customer Credit Transfer: Credit to creditor’s account is pending receipt of required documents + #[code = "G003"] + CreditPendingDocuments, + /// In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover + #[code = "G004"] + CreditPendingFunds, + /// Payment has been delivered to creditor agent with service level + #[code = "G005"] + DeliveredWithServiceLevel, + /// Payment has been delivered to creditor agent without service level + #[code = "G006"] + DeliveredWIthoutServiceLevel, + /// Signature file was sent to the bank but the corresponding original file has not been sent yet + #[code = "ID01"] + CorrespondingOriginalFileStillNotSent, + /// Expiry date time of the request-to-pay is incorrect + #[code = "IEDT"] + IncorrectExpiryDateTime, + /// Payer’s activation reference is invalid + #[code = "INAR"] + InvalidActivationReference, + /// Details not valid for this field + #[code = "INDT"] + InvalidDetails, + /// Payments in instalments are not supported + #[code = "IPNS"] + InstalmentPaymentsNotSupported, + /// No initial request-to-pay has been received + #[code = "IRNR"] + InitialRTPNeverReceived, + /// Cannot schedule instruction for Night Window + #[code = "ISWS"] + InvalidSettlementWindow, + /// No Mandate + #[code = "MD01"] + NoMandate, + /// Mandate related information data required by the scheme is missing + #[code = "MD02"] + MissingMandatoryInformationInMandate, + /// Creditor or creditor's agent should not have collected the direct debit + #[code = "MD05"] + CollectionNotDue, + /// Return of funds requested by end customer + #[code = "MD06"] + RefundRequestByEndCustomer, + /// End customer is deceased + #[code = "MD07"] + EndCustomerDeceased, + /// Information missing for the field or cannot be empty + #[code = "MINF"] + MissingInformation, + /// Reason has not been specified by end customer + #[code = "MS02"] + NotSpecifiedReasonCustomerGenerated, + /// Reason has not been specified by agent + #[code = "MS03"] + NotSpecifiedReasonAgentGenerated, + /// Reason is provided as narrative information in the additional reason information + #[code = "NARR"] + Narrative, + /// Credit transfer is tagged as an Extended Remittance Information (ERI) transaction but does not contain ERI + #[code = "NERI"] + NoERI, + /// No existing agreement for receiving request-to-pay messages + #[code = "NOAR"] + NonAgreedRTP, + /// No response from Beneficiary + #[code = "NOAS"] + NoAnswerFromCustomer, + /// 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 + #[code = "NOCM"] + NotCompliantGeneric, + /// Continuous Processing Line on Hold Instruction + #[code = "NOFR"] + OutstandingFundingForSettlement, + /// Requested payment guarantee (by Creditor) related to a request-to-pay cannot be provided + #[code = "NOPG"] + NoPaymentGuarantee, + /// Recipient side of the request-to-pay (payer or its request-to-pay service provider) is not reachable + #[code = "NRCH"] + PayerOrPayerRTPSPNotReachable, + /// Requested optional service (for example instalment payments) is not supported + #[code = "OSNS"] + OptionalServiceNotSupported, + /// Type of payment requested in the request-to-pay is not supported by the payer + #[code = "PINS"] + TypeOfPaymentInstrumentNotSupported, + /// Error code used for RTP-initiated CTR when the pacs + #[code = "PNRT"] + PaymentNotAlignedWithRTPRequest, + /// Bank identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode) + #[code = "RC01"] + BankIdentifierIncorrect, + /// Bank identifier is invalid or missing + #[code = "RC02"] + InvalidBankIdentifier, + /// Debtor bank identifier is invalid or missing + #[code = "RC03"] + InvalidDebtorBankIdentifier, + /// Creditor bank identifier is invalid or missing + #[code = "RC04"] + InvalidCreditorBankIdentifier, + /// BIC identifier is invalid or missing + #[code = "RC05"] + InvalidBICIdentifier, + /// Debtor BIC identifier is invalid or missing + #[code = "RC06"] + InvalidDebtorBICIdentifier, + /// Creditor BIC identifier is invalid or missing + #[code = "RC07"] + InvalidCreditorBICIdentifier, + /// ClearingSystemMemberidentifier is invalid or missing + #[code = "RC08"] + InvalidClearingSystemMemberIdentifier, + /// Debtor ClearingSystemMember identifier is invalid or missing + #[code = "RC09"] + InvalidDebtorClearingSystemMemberIdentifier, + /// Creditor ClearingSystemMember identifier is invalid or missing + #[code = "RC10"] + InvalidCreditorClearingSystemMemberIdentifier, + /// Intermediary Agent is invalid or missing + #[code = "RC11"] + InvalidIntermediaryAgent, + /// Creditor Scheme Id is invalid or missing + #[code = "RC12"] + MissingCreditorSchemeId, + /// Originator not active any more + #[code = "RC13"] + ParticipantNotAnActiveMemberofRTGS, + /// Settlement agreement required + #[code = "RC15"] + ParticipantNotActiveMemberSettlementType, + /// Participant blocked from SADC-RTGS + #[code = "RC16"] + ParticipantNotActiveMemberofSADCRTGS, + /// Conflict with R-Message + #[code = "RCON"] + RMessageConflict, + /// Further information regarding the intended recipient + #[code = "RECI"] + ReceiverCustomerInformation, + /// Request-to-pay has been received and can be processed further + #[code = "REPR"] + RTPReceivedCanBeProcessed, + /// Transaction reference is not unique within the message + #[code = "RF01"] + NotUniqueTransactionReference, + /// Payer did not recognize the request from Payee Participant, + #[code = "RQNR"] + RequestNotRecognized, + /// Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing + #[code = "RR01"] + MissingDebtorAccountOrIdentification, + /// Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing + #[code = "RR02"] + MissingDebtorNameOrAddress, + /// Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing + #[code = "RR03"] + MissingCreditorNameOrAddress, + /// Regulatory Reason + #[code = "RR04"] + RegulatoryReason, + /// Regulatory or Central Bank Reporting information missing, incomplete or invalid + #[code = "RR05"] + RegulatoryInformationInvalid, + /// Tax information missing, incomplete or invalid + #[code = "RR06"] + TaxInformationInvalid, + /// Remittance information structure does not comply with rules for payment type + #[code = "RR07"] + RemittanceInformationInvalid, + /// Remittance information truncated to comply with rules for payment type + #[code = "RR08"] + RemittanceInformationTruncated, + /// Structured creditor reference invalid or missing + #[code = "RR09"] + InvalidStructuredCreditorReference, + /// Character set supplied not valid for the country and payment type + #[code = "RR10"] + InvalidCharacterSet, + /// Invalid or missing identification of a bank proprietary service + #[code = "RR11"] + InvalidDebtorAgentServiceID, + /// Invalid or missing identification required within a particular country or payment type + #[code = "RR12"] + InvalidPartyID, + /// Debtor does not support request-to-pay transactions + #[code = "RTNS"] + RTPNotSupportedForDebtor, + /// Return following investigation request and no remediation possible + #[code = "RUTA"] + ReturnUponUnableToApply, + /// Request for Cancellation is acknowledged following validation + #[code = "S000"] + ValidRequestForCancellationAcknowledged, + /// Unique End-to-end Transaction Reference (UETR) relating to a payment has been identified as being associated with a Request for Cancellation + #[code = "S001"] + UETRFlaggedForCancellation, + /// Unique End-to-end Transaction Reference (UETR) relating to a payment has been prevent from traveling across a messaging network + #[code = "S002"] + NetworkStopOfUETR, + /// Request for Cancellation has been forwarded to the payment processing/last payment processing agent + #[code = "S003"] + RequestForCancellationForwarded, + /// Request for Cancellation has been acknowledged as delivered to payment processing/last payment processing agent + #[code = "S004"] + RequestForCancellationDeliveryAcknowledgement, + /// Remove Concurrent Batch Processing Line on hold instruction + #[code = "SBRN"] + SettlementBatchRemovalNotification, + /// Due to specific service offered by the Debtor Agent + #[code = "SL01"] + SpecificServiceOfferedByDebtorAgent, + /// Due to specific service offered by the Creditor Agent + #[code = "SL02"] + SpecificServiceOfferedByCreditorAgent, + /// Due to a specific service offered by the clearing system + #[code = "SL03"] + ServiceofClearingSystem, + /// Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet) + #[code = "SL11"] + CreditorNotOnWhitelistOfDebtor, + /// Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist” + #[code = "SL12"] + CreditorOnBlacklistOfDebtor, + /// Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent + #[code = "SL13"] + MaximumNumberOfDirectDebitTransactionsExceeded, + /// Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent + #[code = "SL14"] + MaximumDirectDebitTransactionAmountExceeded, + /// Maximum number of credit transactions allowed by the account servicer per service period exceeded + #[code = "SL15"] + MaximumNumberOfCreditTransactionsExceeded, + /// Maximum total credit amount allowed by the account servicer per service period exceeded + #[code = "SL16"] + MaximumCreditTransactionsAmountExceeded, + /// Whitelisting service offered by payment system operator or financial institution + #[code = "SL17"] + DebtorNotOnWhitelistOfCreditorSide, + /// Blacklisting service offered by payment system operator or financial institution + #[code = "SL18"] + DebtorOnBlacklistOfCreditorSide, + /// Services are not yet rendered by the Payee Participant (Creditor) + #[code = "SNRD"] + ServiceNotRendered, + /// Identifier of the request-to-pay service provider is incorrect + #[code = "SPII"] + RTPServiceProviderIdentifierIncorrect, + /// The transmission of the file was not successful – it had to be aborted (for technical reasons) + #[code = "TA01"] + TransmissonAborted, + /// There is no data available (for download) + #[code = "TD01"] + NoDataAvailable, + /// The file cannot be read (e + #[code = "TD02"] + FileNonReadable, + /// The file format is incomplete or invalid + #[code = "TD03"] + IncorrectFileStructure, + /// Token is invalid + #[code = "TK01"] + TokenInvalid, + /// Token used for the sender does not exist + #[code = "TK02"] + SenderTokenNotFound, + /// Token used for the receiver does not exist + #[code = "TK03"] + ReceiverTokenNotFound, + /// Token required for request is missing + #[code = "TK09"] + TokenMissing, + /// Token found with counterparty mismatch + #[code = "TKCM"] + TokenCounterpartyMismatch, + /// Single Use Token already used + #[code = "TKSG"] + TokenSingleUse, + /// Token found with suspended status + #[code = "TKSP"] + TokenSuspended, + /// Token found with value limit rule violation + #[code = "TKVE"] + TokenValueLimitExceeded, + /// Token expired + #[code = "TKXP"] + TokenExpired, + /// Associated message, payment information block, or transaction was received after agreed processing cut-off time + #[code = "TM01"] + InvalidCutOffTime, + /// The (technical) transmission of the file was successful + #[code = "TS01"] + TransmissionSuccessful, + /// The order was transferred to pass by accompanying note signed by hand + #[code = "TS04"] + TransferToSignByHand, + /// Unknown Creditor + #[code = "UCRD"] + UnknownCreditor, + /// Payment is not justified + #[code = "UPAY"] + UnduePayment, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(DomainCode, Description, Str)] +pub enum PaymentGroupStatus { + /// Settlement on the creditor's account has been completed + #[code = "ACCC"] + AcceptedSettlementCompletedCreditorAccount, + /// Preceding check of technical validation was successful + #[code = "ACCP"] + AcceptedCustomerProfile, + /// Settlement on the debtor's account has been completed + #[code = "ACSC"] + AcceptedSettlementCompletedDebitorAccount, + /// All preceding checks such as technical validation and customer profile were successful and therefore the payment initiation has been accepted for execution + #[code = "ACSP"] + AcceptedSettlementInProcess, + /// Authentication and syntactical and semantical validation are successful + #[code = "ACTC"] + AcceptedTechnicalValidation, + /// Instruction is accepted but a change will be made, such as date or remittance not sent + #[code = "ACWC"] + AcceptedWithChange, + /// A number of transactions have been accepted, whereas another number of transactions have not yet achieved + #[code = "PART"] + PartiallyAccepted, + /// Payment initiation or individual transaction included in the payment initiation is pending + #[code = "PDNG"] + Pending, + /// Verification of Payee check have been applied to received transactions stating to be complete without mismatching data + #[code = "RCVC"] + ReceivedVerificationCompleted, + /// Payment initiation has been received by the receiving agent + #[code = "RCVD"] + Received, + /// Payment initiation or individual transaction included in the payment initiation has been rejected + #[code = "RJCT"] + Rejected, + /// Verification of Payee checks have been applied to received transactions stating to be complete containing mismatching data + #[code = "RVCM"] + ReceivedVerificationCompletedWithMismatches, + /// Verification of party check on transactions received is not yet completed + #[code = "RVNC"] + ReceivedVerificationNotCompleted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(DomainCode, Description, Str)] +pub enum PaymentTransactionStatus { + /// Settlement on the creditor's account has been completed + #[code = "ACCC"] + AcceptedSettlementCompletedCreditorAccount, + /// Preceding check of technical validation was successful + #[code = "ACCP"] + AcceptedCustomerProfile, + /// Preceding check of technical validation and customer profile was successful and an automatic funds check was positive + #[code = "ACFC"] + AcceptedFundsChecked, + /// 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 + #[code = "ACFW"] + AcceptedFundsCheckedWaitingConfirmation, + /// Payment instruction to issue a cheque has been accepted, and the cheque has been issued but not yet been deposited or cleared + #[code = "ACIS"] + AcceptedandChequeIssued, + /// Status of transaction released from the Debtor Agent and accepted by the clearing + #[code = "ACPD"] + AcceptedClearingProcessed, + /// Settlement completed + #[code = "ACSC"] + AcceptedSettlementCompletedDebitorAccount, + /// All preceding checks such as technical validation and customer profile were successful and therefore the payment instruction has been accepted for execution + #[code = "ACSP"] + AcceptedSettlementInProcess, + /// Authentication and syntactical and semantical validation are successful + #[code = "ACTC"] + AcceptedTechnicalValidation, + /// Instruction is accepted but a change will be made, such as date or remittance not sent + #[code = "ACWC"] + AcceptedWithChange, + /// Payment instruction included in the credit transfer is accepted without being posted to the creditor customer’s account + #[code = "ACWP"] + AcceptedWithoutPosting, + /// 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 + #[code = "BLCK"] + Blocked, + /// Payment initiation has been successfully cancelled after having received a request for cancellation + #[code = "CANC"] + Cancelled, + /// Cash has been picked up by the Creditor + #[code = "CPUC"] + CashPickedUpByCreditor, + /// Payment initiation needs multiple authentications, where some but not yet all have been performed + #[code = "PATC"] + PartiallyAcceptedTechnicalCorrect, + /// Payment instruction is pending + #[code = "PDNG"] + Pending, + /// Request for Payment has been presented to the Debtor + #[code = "PRES"] + Presented, + /// Verification of Payee check has been applied to received transaction stating to be complete without mismatching data + #[code = "RCVC"] + ReceivedVerificationCompleted, + /// Payment instruction has been received + #[code = "RCVD"] + Received, + /// Payment instruction has been rejected + #[code = "RJCT"] + Rejected, + /// Verification of Payee checks have been applied to received transaction stating to be completed containing mismatching data + #[code = "RVCM"] + ReceivedVerificationCompletedWithMismatches, + /// Verification of Payee check has been applied to received transaction stating to be complete with data matching closely + #[code = "RVMC"] + ReceivedVerificationCompletedMatchClosely, + /// Verification of Payee check has been applied to received transaction stating to be complete with not applicable data + #[code = "RVNA"] + ReceivedVerificationCompletedNotApplicable, + /// Verification of party check on the transaction is not yet completed + #[code = "RVNC"] + ReceivedVerificationNotCompleted, + /// Verification of Payee check has been applied to received transaction stating to be complete with mismatching data + #[code = "RVNM"] + ReceivedVerificationCompletedNoMatch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] +#[enum_meta(DomainCode, Description, Str)] +pub enum ReturnReason { + /// Format of the account number specified is not correct + #[code = "AC01"] + IncorrectAccountNumber, + /// Debtor account number invalid or missing + #[code = "AC02"] + InvalidDebtorAccountNumber, + /// Wrong IBAN in SCT + #[code = "AC03"] + InvalidCreditorAccountNumber, + /// Account number specified has been closed on the bank of account's books + #[code = "AC04"] + ClosedAccountNumber, + /// Account specified is blocked, prohibiting posting of transactions against it + #[code = "AC06"] + BlockedAccount, + /// Creditor account number closed + #[code = "AC07"] + ClosedCreditorAccountNumber, + /// Debtor account type is missing or invalid + #[code = "AC13"] + InvalidDebtorAccountType, + /// An agent in the payment chain is invalid + #[code = "AC14"] + InvalidAgent, + /// Account details have changed + #[code = "AC15"] + AccountDetailsChanged, + /// Account is in sequestration + #[code = "AC16"] + AccountInSequestration, + /// Account is in liquidation + #[code = "AC17"] + AccountInLiquidation, + /// Transaction forbidden on this type of account (formerly NoAgreement) + #[code = "AG01"] + TransactionForbidden, + /// Bank Operation code specified in the message is not valid for receiver + #[code = "AG02"] + InvalidBankOperationCode, + /// Debtor account cannot be debited for a generic reason + #[code = "AG07"] + UnsuccesfulDirectDebit, + /// Agent in the payment workflow is incorrect + #[code = "AGNT"] + IncorrectAgent, + /// Specified message amount is equal to zero + #[code = "AM01"] + ZeroAmount, + /// Specific transaction/message amount is greater than allowed maximum + #[code = "AM02"] + NotAllowedAmount, + /// Specified message amount is an non processable currency outside of existing agreement + #[code = "AM03"] + NotAllowedCurrency, + /// Amount of funds available to cover specified message amount is insufficient + #[code = "AM04"] + InsufficientFunds, + /// Duplication + #[code = "AM05"] + Duplication, + /// Specified transaction amount is less than agreed minimum + #[code = "AM06"] + TooLowAmount, + /// Amount specified in message has been blocked by regulatory authorities + #[code = "AM07"] + BlockedAmount, + /// Amount received is not the amount agreed or expected + #[code = "AM09"] + WrongAmount, + /// Sum of instructed amounts does not equal the control sum + #[code = "AM10"] + InvalidControlSum, + /// Already returned original SCT + #[code = "ARDT"] + AlreadyReturnedTransaction, + /// Identification of end customer is not consistent with associated account number, organisation ID or private ID + #[code = "BE01"] + InconsistenWithEndCustomer, + /// Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress) + #[code = "BE04"] + MissingCreditorAddress, + /// Party who initiated the message is not recognised by the end customer + #[code = "BE05"] + UnrecognisedInitiatingParty, + /// End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books + #[code = "BE06"] + UnknownEndCustomer, + /// Specification of debtor's address, which is required for payment, is missing/not correct + #[code = "BE07"] + MissingDebtorAddress, + /// Returned as a result of a bank error + #[code = "BE08"] + BankError, + /// Debtor country code is missing or invalid + #[code = "BE10"] + InvalidDebtorCountry, + /// Creditor country code is missing or invalid + #[code = "BE11"] + InvalidCreditorCountry, + /// Debtor or Ultimate Debtor identification code missing or invalid + #[code = "BE16"] + InvalidDebtorIdentificationCode, + /// Creditor or Ultimate Creditor identification code missing or invalid + #[code = "BE17"] + InvalidCreditorIdentificationCode, + /// Authorisation is cancelled + #[code = "CN01"] + AuthorisationCancelled, + /// Creditor bank is not registered under this BIC in the CSM + #[code = "CNOR"] + CreditorBankIsNotRegistered, + /// Cash not picked up by Creditor or cash could not be delivered to Creditor + #[code = "CNPC"] + CashNotPickedUp, + /// Currency of the payment is incorrect + #[code = "CURR"] + IncorrectCurrency, + /// Cancellation requested by the Debtor + #[code = "CUST"] + RequestedByCustomer, + /// Return of Covering Settlement due to the underlying Credit Transfer details not being received + #[code = "DC04"] + NoCustomerCreditTransferReceived, + /// Debtor bank is not registered under this BIC in the CSM + #[code = "DNOR"] + DebtorBankIsNotRegistered, + /// Return following technical problems resulting in erroneous transaction + #[code = "DS28"] + ReturnForTechnicalReason, + /// Invalid date (eg, wrong settlement date) + #[code = "DT01"] + InvalidDate, + /// Cheque has been issued but not deposited and is considered expired + #[code = "DT02"] + ChequeExpired, + /// Future date not supported + #[code = "DT04"] + FutureDateNotSupported, + /// Payment is a duplicate of another payment + #[code = "DUPL"] + DuplicatePayment, + /// Correspondent bank not possible + #[code = "ED01"] + CorrespondentBankNotPossible, + /// Balance of payments complementary info is requested + #[code = "ED03"] + BalanceInfoRequest, + /// Settlement of the transaction has failed + #[code = "ED05"] + SettlementFailed, + /// The card payment is fraudulent and was not processed with EMV technology for an EMV card + #[code = "EMVL"] + EMVLiabilityShift, + /// The Extended Remittance Information (ERI) option is not supported + #[code = "ERIN"] + ERIOptionNotSupported, + /// Payment Type Information is missing or invalid + #[code = "FF03"] + InvalidPaymentTypeInformation, + /// Service Level code is missing or invalid + #[code = "FF04"] + InvalidServiceLevelCode, + /// Local Instrument code is missing or invalid + #[code = "FF05"] + InvalidLocalInstrumentCode, + /// Category Purpose code is missing or invalid + #[code = "FF06"] + InvalidCategoryPurposeCode, + /// Purpose is missing or invalid + #[code = "FF07"] + InvalidPurpose, + /// Return following a cancellation request + #[code = "FOCR"] + FollowingCancellationRequest, + /// Returned as a result of fraud + #[code = "FR01"] + Fraud, + /// Final response/tracking is recalled as mandate is cancelled + #[code = "FRTR"] + FinalResponseMandateCancelled, + /// In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover + #[code = "G004"] + CreditPendingFunds, + /// No Mandate + #[code = "MD01"] + NoMandate, + /// Mandate related information data required by the scheme is missing + #[code = "MD02"] + MissingMandatoryInformationInMandate, + /// Creditor or creditor's agent should not have collected the direct debit + #[code = "MD05"] + CollectionNotDue, + /// Return of funds requested by end customer + #[code = "MD06"] + RefundRequestByEndCustomer, + /// End customer is deceased + #[code = "MD07"] + EndCustomerDeceased, + /// Reason has not been specified by end customer + #[code = "MS02"] + NotSpecifiedReasonCustomerGenerated, + /// Reason has not been specified by agent + #[code = "MS03"] + NotSpecifiedReasonAgentGenerated, + /// Reason is provided as narrative information in the additional reason information + #[code = "NARR"] + Narrative, + /// No response from Beneficiary + #[code = "NOAS"] + NoAnswerFromCustomer, + /// 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 + #[code = "NOCM"] + NotCompliant, + /// Original SCT never received + #[code = "NOOR"] + NoOriginalTransactionReceived, + /// The card payment is fraudulent (lost and stolen fraud) and was processed as EMV transaction without PIN verification + #[code = "PINL"] + PINLiabilityShift, + /// Bank Identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode) + #[code = "RC01"] + BankIdentifierIncorrect, + /// Debtor bank identifier is invalid or missing + #[code = "RC03"] + InvalidDebtorBankIdentifier, + /// Creditor bank identifier is invalid or missing + #[code = "RC04"] + InvalidCreditorBankIdentifier, + /// Incorrrect BIC of the beneficiary Bank in the SCTR + #[code = "RC07"] + InvalidCreditorBICIdentifier, + /// ClearingSystemMemberidentifier is invalid or missing + #[code = "RC08"] + InvalidClearingSystemMemberIdentifier, + /// Intermediary Agent is invalid or missing + #[code = "RC11"] + InvalidIntermediaryAgent, + /// Transaction reference is not unique within the message + #[code = "RF01"] + NotUniqueTransactionReference, + /// Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing + #[code = "RR01"] + MissingDebtorAccountOrIdentification, + /// Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing + #[code = "RR02"] + MissingDebtorNameOrAddress, + /// Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing + #[code = "RR03"] + MissingCreditorNameOrAddress, + /// Regulatory Reason + #[code = "RR04"] + RegulatoryReason, + /// Regulatory or Central Bank Reporting information missing, incomplete or invalid + #[code = "RR05"] + RegulatoryInformationInvalid, + /// Tax information missing, incomplete or invalid + #[code = "RR06"] + TaxInformationInvalid, + /// Remittance information structure does not comply with rules for payment type + #[code = "RR07"] + RemittanceInformationInvalid, + /// Remittance information truncated to comply with rules for payment type + #[code = "RR08"] + RemittanceInformationTruncated, + /// Structured creditor reference invalid or missing + #[code = "RR09"] + InvalidStructuredCreditorReference, + /// Invalid or missing identification of a bank proprietary service + #[code = "RR11"] + InvalidDebtorAgentServiceIdentification, + /// Invalid or missing identification required within a particular country or payment type + #[code = "RR12"] + InvalidPartyIdentification, + /// Return following investigation request and no remediation possible + #[code = "RUTA"] + ReturnUponUnableToApply, + /// Due to specific service offered by the Debtor Agent + #[code = "SL01"] + SpecificServiceOfferedByDebtorAgent, + /// Due to specific service offered by the Creditor Agent + #[code = "SL02"] + SpecificServiceOfferedByCreditorAgent, + /// Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet) + #[code = "SL11"] + CreditorNotOnWhitelistOfDebtor, + /// Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist” + #[code = "SL12"] + CreditorOnBlacklistOfDebtor, + /// Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent + #[code = "SL13"] + MaximumNumberOfDirectDebitTransactionsExceeded, + /// Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent + #[code = "SL14"] + MaximumDirectDebitTransactionAmountExceeded, + /// Payment is stopped by account holder + #[code = "SP01"] + PaymentStopped, + /// Previously stopped by means of a stop payment advise + #[code = "SP02"] + PreviouslyStopped, + /// 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 + #[code = "SVNR"] + ServiceNotRendered, + /// Associated message was received after agreed processing cut-off time + #[code = "TM01"] + CutOffTime, + /// Return following direct debit being removed from tracking process + #[code = "TRAC"] + RemovedFromTracking, + /// Payment is not justified + #[code = "UPAY"] + UnduePayment, +} diff --git a/crates/libeufin-ebics/src/keys.rs b/crates/libeufin-ebics/src/keys.rs @@ -0,0 +1,235 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{borrow::Cow, io::ErrorKind, path::Path}; + +use anyhow::bail; +use aws_lc_rs::{ + encoding::{AsDer, Pkcs8V1Der}, + error::KeyRejected, + rsa::{KeySize, PrivateDecryptingKey, PublicEncryptingKey, PublicKey, PublicKeyComponents}, + signature::RsaKeyPair, +}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use taler_common::{ + encoding::base32::{self}, + json_file, +}; + +use crate::config::EbicsKeysCfg; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct ClientKeys { + #[serde( + rename = "signature_private_key", + serialize_with = "ser_pkcs8", + deserialize_with = "de_ras_sign_base32" + )] + pub sign: RsaKeyPair, + #[serde( + rename = "encryption_private_key", + serialize_with = "ser_pkcs8", + deserialize_with = "de_ras_priv_base32" + )] + pub enc: PrivateDecryptingKey, + #[serde( + rename = "authentication_private_key", + serialize_with = "ser_pkcs8", + deserialize_with = "de_ras_sign_base32" + )] + pub auth: RsaKeyPair, + pub submitted_ini: bool, + pub submitted_hia: bool, +} + +impl ClientKeys { + pub fn generate() -> anyhow::Result<Self> { + Ok(Self { + sign: RsaKeyPair::generate(KeySize::Rsa2048)?, + enc: PrivateDecryptingKey::generate(KeySize::Rsa2048)?, + auth: RsaKeyPair::generate(KeySize::Rsa2048)?, + submitted_ini: false, + submitted_hia: false, + }) + } +} + +#[derive(Debug)] +pub struct RsaPub { + pub enc: PublicEncryptingKey, + pub key: PublicKey, +} + +impl RsaPub { + pub fn from_der(der: &[u8]) -> Result<Self, KeyRejected> { + let key = PublicKey::from_der(der)?; + let component = PublicKeyComponents { + n: key.modulus().big_endian_without_leading_zero(), + e: key.exponent().big_endian_without_leading_zero(), + }; + let enc = component.try_into().map_err(|_| KeyRejected::from(()))?; + Ok(Self { enc, key }) + } +} + +impl serde::Serialize for RsaPub { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let der = self + .key + .as_der() + .map_err(|e| serde::ser::Error::custom(e.to_string()))?; + let base32 = base32::encode(der.as_ref()); + base32.serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for RsaPub { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let base32 = Cow::<str>::deserialize(deserializer)?; + let der = base32::decode(base32.as_bytes()) + .map_err(|e| serde::de::Error::custom(e.to_string()))?; + Self::from_der(&der).map_err(|e| serde::de::Error::custom(e.to_string())) + } +} + +impl PartialEq for RsaPub { + fn eq(&self, other: &Self) -> bool { + self.key.exponent().big_endian_without_leading_zero() + == other.key.exponent().big_endian_without_leading_zero() + && self.key.modulus().big_endian_without_leading_zero() + == other.key.modulus().big_endian_without_leading_zero() + } +} + +impl Eq for RsaPub {} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct BankKeys { + #[serde(rename = "bank_encryption_public_key")] + pub enc: RsaPub, + #[serde(rename = "bank_authentication_public_key")] + pub auth: RsaPub, + pub accepted: bool, +} + +fn ser_pkcs8<S, K>(key: &K, serializer: S) -> Result<S::Ok, S::Error> +where + K: AsDer<Pkcs8V1Der<'static>>, + S: Serializer, +{ + let der = key + .as_der() + .map_err(|e| serde::ser::Error::custom(e.to_string()))?; + let base32 = base32::encode(der.as_ref()); + base32.serialize(serializer) +} + +fn de_ras_priv_base32<'de, D>(deserializer: D) -> Result<PrivateDecryptingKey, D::Error> +where + D: Deserializer<'de>, +{ + let base32 = Cow::<str>::deserialize(deserializer)?; + let der = + base32::decode(base32.as_bytes()).map_err(|e| serde::de::Error::custom(e.to_string()))?; + let key = PrivateDecryptingKey::from_pkcs8(&der) + .map_err(|e| serde::de::Error::custom(e.to_string()))?; + Ok(key) +} + +fn de_ras_sign_base32<'de, D>(deserializer: D) -> Result<RsaKeyPair, D::Error> +where + D: Deserializer<'de>, +{ + let base32 = Cow::<str>::deserialize(deserializer)?; + let der = + base32::decode(base32.as_bytes()).map_err(|e| serde::de::Error::custom(e.to_string()))?; + let key = RsaKeyPair::from_pkcs8(&der).map_err(|e| serde::de::Error::custom(e.to_string()))?; + Ok(key) +} + +/// Persist the bank keys file to disk +pub fn persist_bank_keys(keys: &BankKeys, location: &Path) -> std::io::Result<()> { + json_file::persist(location, keys)?; + // TODO better error message "bank public keys" + Ok(()) +} + +pub fn persist_client_keys(keys: &ClientKeys, location: &Path) -> std::io::Result<()> { + json_file::persist(location, keys)?; + // TODO better error message "client private keys" + Ok(()) +} + +/// Load the bank keys file from disk +pub fn load_bank_keys(path: &Path) -> anyhow::Result<Option<BankKeys>> { + match json_file::load(path) { + Ok(existing) => Ok(Some(existing)), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(None), + Err(e) => anyhow::bail!( + "Could not read bank public keys at '{}': {}", + path.to_string_lossy(), + e.kind() + ), + } +} + +/// Load the client keys file from disk +pub fn load_client_keys(path: &Path) -> anyhow::Result<Option<ClientKeys>> { + match json_file::load(path) { + Ok(existing) => Ok(Some(existing)), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(None), + Err(e) => anyhow::bail!( + "Could not read client private keys at '{}': {}", + path.to_string_lossy(), + e.kind() + ), + } +} + +/// Load client and bank keys from disk and checks that the keying process has been fully completed +pub fn expect_full_keys(cfg: &EbicsKeysCfg) -> anyhow::Result<(ClientKeys, BankKeys)> { + let setup_cmd = "TODO"; + let client_keys = load_client_keys(cfg.client.as_ref())?; + let Some(client_keys) = client_keys else { + bail!( + "Missing client private keys file at '{}', run '{setup_cmd}' first", + cfg.client + ) + }; + if !client_keys.submitted_ini || !client_keys.submitted_hia { + bail!("Unsubmitted client private keys, run '{setup_cmd}' first") + } + let bank_keys = load_bank_keys(cfg.bank.as_ref())?; + let Some(bank_keys) = bank_keys else { + bail!( + "Missing bank public keys file at '{}', run '{setup_cmd}' first", + cfg.bank + ) + }; + if !bank_keys.accepted { + bail!("Unaccepted bank public keys, run '{setup_cmd}' until accepting the bank keys") + } + Ok((client_keys, bank_keys)) +} diff --git a/crates/libeufin-ebics/src/lib.rs b/crates/libeufin-ebics/src/lib.rs @@ -0,0 +1,33 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +pub mod cli; +pub mod config; +pub mod crypto; +pub mod db; +pub mod dialect; +pub mod ebics; +pub mod iso20022; +pub mod keys; +pub mod setup; +pub mod test; +pub mod utils; +pub mod ws; +pub mod xml; +pub mod xml_sign; diff --git a/crates/libeufin-ebics/src/setup.rs b/crates/libeufin-ebics/src/setup.rs @@ -0,0 +1,132 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::path::Path; + +use anyhow::bail; +use tracing::{debug, info}; + +use crate::{ + config::EbicsKeysCfg, + crypto::ebics_pub_key_hash, + ebics::{EbicsClient, administrative::VersionNumber, order::Order}, + keys::{ + BankKeys, ClientKeys, load_bank_keys, load_client_keys, persist_bank_keys, + persist_client_keys, + }, + utils::hex_chunk_by_two, +}; + +/** Load client private keys at or create new ones if missing */ +pub fn load_or_generate_client_keys(path: &Path) -> anyhow::Result<ClientKeys> { + // If exists load from disk + let current = load_client_keys(path)?; + if let Some(current) = current { + return Ok(current); + } + // Else create new keys + let new = ClientKeys::generate()?; + persist_client_keys(&new, path)?; + info!(target: "setup", + "New client private keys created at '{}'", + path.to_string_lossy() + ); + Ok(new) +} + +pub async fn ebics_setup( + ebics: &EbicsClient<'_>, + cfg: &EbicsKeysCfg<'_>, + force_keys_resubmission: bool, + generate_registration_pdf: bool, + auto_accept_keys: bool, +) -> anyhow::Result<(ClientKeys, BankKeys)> { + let mut client = load_or_generate_client_keys(cfg.client.as_ref())?; + let bank = load_bank_keys(cfg.bank.as_ref())?; + + // Check EBICS 3 support + let versions = ebics.hev().await?; + debug!(target: "setup", + "HEV: {}", + versions + .iter() + .map(|v| v.to_string()) + .collect::<Vec<_>>() + .join(", ") + ); + if !versions.contains(&VersionNumber { + number: "03.00".into(), + schema: "H005".into(), + }) && versions.contains(&VersionNumber { + number: "03.02".into(), + schema: "H005".into(), + }) { + bail!("EBICS 3 is not supported by your bank"); + } + + // Privs exist. Upload their pubs + let keys_not_sub = !client.submitted_ini; + if !client.submitted_ini || force_keys_resubmission { + ebics + .submit_client_keys(cfg, &mut client, Order::INI) + .await?; + } + // Eject PDF if the keys were submitted for the first time, or the user asked. + // TODO if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, hostCfg) + if !client.submitted_hia || force_keys_resubmission { + ebics + .submit_client_keys(cfg, &mut client, Order::HIA) + .await?; + } + + let new = ebics.hpb(&client).await?; + if let Some(current) = bank { + // Check current bank keys + if current.enc != new.enc { + bail!( + "On disk bank encryption key stored at {} doesn't match server key\nDisk: {}\nServer: {}", + cfg.bank, + hex_chunk_by_two(ebics_pub_key_hash(&current.enc.key)), + hex_chunk_by_two(ebics_pub_key_hash(&new.enc.key)) + ) + } else if current.auth != new.auth { + bail!( + "On disk bank authentication key stored at {} doesn't match server key\nDisk: {}\nServer: {}", + cfg.bank, + hex_chunk_by_two(ebics_pub_key_hash(&current.auth.key)), + hex_chunk_by_two(ebics_pub_key_hash(&new.auth.key)) + ) + } + } else { + // Accept bank keys + info!("Bank keys stored at {}", cfg.bank); + persist_bank_keys(&new, cfg.bank.as_ref())?; + }; + let mut bank = new; + if !bank.accepted { + // Finishing the setup by accepting the bank keys. + if !auto_accept_keys { + panic!("Cannot successfully finish the setup without accepting the bank keys"); + } + bank.accepted = true; + persist_bank_keys(&bank, cfg.bank.as_ref())?; + } + + Ok((client, bank)) +} diff --git a/crates/libeufin-ebics/src/test.rs b/crates/libeufin-ebics/src/test.rs @@ -0,0 +1,574 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{ + fs::Permissions, + os::unix::fs::PermissionsExt as _, + sync::{Arc, Mutex}, + time::Duration, +}; + +use aws_lc_rs::{ + encoding::AsDer, + rsa::{KeyPair, KeySize, PublicEncryptingKey, PublicKey}, +}; +use axum::{body::Bytes, response::IntoResponse as _, routing::post}; +use compact_str::CompactString; +use jiff::{ + Timestamp, Zoned, + civil::{Date, date}, + tz::TimeZone, +}; +use reqwest::StatusCode; +use taler_api::{Serve, api::TalerRouter as _}; +use taler_common::encoding::base64; +use tempfile::{TempDir, tempdir}; +use tokio::net::UnixStream; + +use crate::{ + crypto::{ebics_pub_key_hash, encrypt_ebics_e002, gen_ebics_e002_key}, + ebics::{ + key_management::{rsa_key_xml, rsa_pub_key}, + rand_ebics_id, + }, + utils::{deflate, inflate}, + xml, + xml::{Xml, XmlAccess}, + xml_sign::sign_ebics, +}; + +pub async fn wait_for_unix_socket(path: &str) { + for _ in 0..100 { + if UnixStream::connect(path).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("{path} never becomed active") +} + +pub type Sequence = fn(&mut EbicsState, body: &[u8]) -> EbicsRes; + +pub enum EbicsRes { + Ok(String), + BadRequest, + Failure, +} +pub struct EbicsState { + bank_sign: KeyPair, + bank_enc: KeyPair, + bank_auth: KeyPair, + + client_sign: Option<PublicKey>, + client_enc: Option<PublicKey>, + client_auth: Option<PublicKey>, + + tx_id: Option<CompactString>, + order_id: Option<CompactString>, +} + +impl EbicsState { + pub fn new() -> Self { + Self { + bank_sign: KeyPair::generate(KeySize::Rsa2048).unwrap(), + bank_enc: KeyPair::generate(KeySize::Rsa2048).unwrap(), + bank_auth: KeyPair::generate(KeySize::Rsa2048).unwrap(), + client_sign: None, + client_enc: None, + client_auth: None, + tx_id: None, + order_id: None, + } + } + + fn parse_unsecure_request( + body: &[u8], + order: &str, + root: &str, + parse: impl FnOnce(Xml) -> xml::Result<()>, + ) { + Xml::parse(body, "ebicsUnsecuredRequest", |n| { + let admin_order = n + .one("header") + .one("static") + .one("OrderDetails") + .one("AdminOrderType")? + .text(); + assert_eq!(admin_order, order); + let chunk = n.one("body").one("DataTransfer").one("OrderData").b64()?; + let inflated = inflate(&chunk); + Xml::parse(&inflated, root, parse) + }) + .unwrap() + } + + fn parse_download_init(body: &[u8], order: &str) { + Xml::parse(body, "ebicsRequest", |root| { + let header = root.one("header")?; + let admin_order = header + .one("static") + .one("OrderDetails") + .one("AdminOrderType")? + .text(); + assert_eq!(admin_order, order); + let phase = header.one("mutable").one("TransactionPhase")?.text(); + assert_eq!(phase, "Initialisation"); + Ok(()) + }) + .unwrap(); + } + + fn signed_response(&self, xml: String) -> EbicsRes { + EbicsRes::Ok(sign_ebics(xml, &self.bank_auth)) + } + + fn ebics_response_payload(&mut self, payload: &str, last: bool) -> EbicsRes { + let tx_id = self.tx_id.insert(rand_ebics_id()); + let deflated = deflate(payload.as_bytes()); + let client_enc = PublicEncryptingKey::from_der( + self.client_enc.as_ref().unwrap().as_der().unwrap().as_ref(), + ) + .unwrap(); + let (tx_key, encrypted_key) = gen_ebics_e002_key(client_enc); + let encrypted = encrypt_ebics_e002(&tx_key, deflated); + let xml = xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { + "header" "authenticate"="true" { + "static" { + "TransactionID": tx_id, + "NumSegments": "1" + }, + "mutable" { + "TransactionPhase": "Initialisation", + "SegmentNumber" "lastSegment"=last : 1, + "ReturnCode": "000000", + "ReportText": "[EBICS_OK] OK" + } + }, + "AuthSignature", + "body" { + "DataTransfer" { + "DataEncryptionInfo" "authenticate"="true" { + "EncryptionPubKeyDigest" "Version"="E002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256": base64::fmt(ebics_pub_key_hash(self.client_enc.as_ref().unwrap())), + "TransactionKey": base64::fmt(encrypted_key) + }, + "OrderData": base64::fmt(encrypted) + }, + "ReturnCode" "authenticate"="true": "000000" + } + }); + self.signed_response(xml) + } + + fn ebics_response_no_data(&self) -> EbicsRes { + let xml = xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { + "header" "authenticate"="true" { + "static", + "mutable" { + "TransactionPhase": "Initialisation", + "ReturnCode": "000000", + "ReportText": "[EBICS_OK] OK" + } + }, + "AuthSignature", + "body" { + "ReturnCode" "authenticate"="true": "090005" + } + }); + self.signed_response(xml) + } + + pub fn hev(&mut self, body: &[u8]) -> EbicsRes { + Xml::parse(body, "ebicsHEVRequest", |root| { + root.one("HostID")?; + Ok(()) + }) + .unwrap(); + EbicsRes::Ok( + xml!("ebicsHEVResponse" "xmlns"="http://www.ebics.org/H000" { + "SystemReturnCode" { + "ReturnCode": "000000", + "ReportText": "[EBICS_OK] OK" + }, + "VersionNumber" "ProtocolVersion"="H005" : "03.00" + }), + ) + } + + pub fn ini(&mut self, body: &[u8]) -> EbicsRes { + Self::parse_unsecure_request(body, "INI", "SignaturePubKeyOrderData", |root| { + let n = root.one("SignaturePubKeyInfo")?; + assert_eq!(n.one("SignatureVersion")?.text(), "A006"); + self.client_sign = Some(rsa_pub_key(n)?.key); + Ok(()) + }); + EbicsRes::Ok( + xml!("ebicsKeyManagementResponse" "xmlns"="http://www.ebics.org/H000" { + "header" "authenticate"="true" { + "mutable" { + "ReturnCode": "000000", + "ReportText": "[EBICS_OK] OK" + } + }, + "body" { + "ReturnCode" "authenticate"="true" : "000000" + } + }), + ) + } + + pub fn hia(&mut self, body: &[u8]) -> EbicsRes { + Self::parse_unsecure_request(body, "HIA", "HIARequestOrderData", |root| { + let n = root.one("AuthenticationPubKeyInfo")?; + assert_eq!(n.one("AuthenticationVersion")?.text(), "X002"); + self.client_auth = Some(rsa_pub_key(n)?.key); + + let n = root.one("EncryptionPubKeyInfo")?; + assert_eq!(n.one("EncryptionVersion")?.text(), "E002"); + self.client_enc = Some(rsa_pub_key(n)?.key); + Ok(()) + }); + EbicsRes::Ok( + xml!("ebicsKeyManagementResponse" "xmlns"="http://www.ebics.org/H000" { + "header" "authenticate"="true" { + "mutable" { + "ReturnCode": "000000", + "ReportText": "[EBICS_OK] OK" + } + }, + "body" { + "ReturnCode" "authenticate"="true" : "000000" + } + }), + ) + } + + pub fn hpb(&mut self, body: &[u8]) -> EbicsRes { + // Parse HPB request + Xml::parse(body, "ebicsNoPubKeyDigestsRequest", |root| { + let order = root + .one("header") + .one("static") + .one("OrderDetails") + .one("AdminOrderType")? + .text(); + assert_eq!(order, "HPB"); + Ok(()) + }) + .unwrap(); + + let payload = xml!("HPBResponseOrderData" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { + "AuthenticationPubKeyInfo" { + @ |w| rsa_key_xml(w, &self.bank_auth), + "AuthenticationVersion": "X002" + }, + "EncryptionPubKeyInfo" { + @ |w| rsa_key_xml(w, &self.bank_enc), + "EncryptionVersion": "E002" + } + }); + let deflated = deflate(payload.as_bytes()); + let client_enc = PublicEncryptingKey::from_der( + self.client_enc.as_ref().unwrap().as_der().unwrap().as_ref(), + ) + .unwrap(); + let (tx_key, encrypted_key) = gen_ebics_e002_key(client_enc); + let encrypted = encrypt_ebics_e002(&tx_key, deflated); + EbicsRes::Ok( + xml!("ebicsKeyManagementResponse" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" "xmlns"="http://www.ebics.org/H005" { + "header" "authenticate"="true"{ + "mutable" { + "ReturnCode": "000000", + "ReportText": "[EBICS_OK] OK" + } + }, + "body" { + "DataTransfer" { + "DataEncryptionInfo" "authenticate"="true" { + "EncryptionPubKeyDigest" "Version"="E002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256": base64::fmt(ebics_pub_key_hash(self.client_enc.as_ref().unwrap())), + "TransactionKey": base64::fmt(encrypted_key) + }, + "OrderData": base64::fmt(encrypted) + }, + "ReturnCode" "authenticate"="true": "000000" + } + }), + ) + } + + pub fn hkd(&mut self, body: &[u8]) -> EbicsRes { + Self::parse_download_init(body, "HKD"); + self.ebics_response_payload( + &xml!("HKDResponseOrderData" { + "PartnerInfo" { + "AddressInfo", + "OrderInfo" { + "AdminOrderType": "BTD", + "Service" { + "ServiceName": "STM", + "Scope": "CH", + "Container" "containerType"="ZIP", + "MsgName" "version"="08": "camt.052" + }, + "Description" + }, + "OrderInfo" { + "AdminOrderType": "BTU", + "Service" { + "ServiceName": "SCT", + "MsgName": "pain.001" + }, + "Description": "Direct Debit" + }, + "OrderInfo" { + "AdminOrderType": "BTU", + "Service" { + "ServiceName": "SCI", + "Scope": "DE", + "MsgName": "pain.001" + }, + "Description": "Instant Direct Debit" + } + } + }), + true, + ) + } + + pub fn haa(&mut self, body: &[u8]) -> EbicsRes { + Self::parse_download_init(body, "HAA"); + self.ebics_response_payload( + &xml!("HAAResponseOrderData" { + "Service" { + "ServiceName": "STM", + "Scope": "CH", + "Container" "containerType"="ZIP", + "MsgName" "version"="08": "camt.052" + } + }), + true, + ) + } + + fn receipt(&mut self, body: &[u8], ok: bool) -> EbicsRes { + Xml::parse(body, "ebicsRequest", |root| { + let header = root.one("header")?; + let tx_id = header.one("static").one("TransactionID")?.text(); + assert_eq!(tx_id, self.tx_id.as_deref().unwrap()); + let phase = header.one("mutable").one("TransactionPhase")?.text(); + assert_eq!(phase, "Receipt"); + let code = root + .one("body") + .one("TransferReceipt") + .one("ReceiptCode")? + .text(); + if ok { + assert_eq!(code, "0") + } else { + assert_eq!(code, "1") + } + Ok(()) + }) + .unwrap(); + let tx_id = self.tx_id.take().unwrap(); + self.signed_response(xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { + "header" "authenticate"="true" { + "static" { + "TransactionID": tx_id + }, + "mutable" { + "TransactionPhase": "Receipt", + "ReturnCode": "000000", + "ReportText": "[EBICS_OK] OK", + } + }, + "AuthSignature", + "body" { + "ReturnCode" "authenticate"="true": "000000" + } + })) + } + + pub fn receipt_ok(&mut self, body: &[u8]) -> EbicsRes { + self.receipt(body, true) + } + + pub fn receipt_err(&mut self, body: &[u8]) -> EbicsRes { + self.receipt(body, false) + } + + fn btd_date_check(&self, body: &[u8], pinned: Option<Date>) -> EbicsRes { + Xml::parse(body, "ebicsRequest", |root| { + let header = root.one("header")?; + let details = header.one("static").one("OrderDetails")?; + let admin_order = details.one("AdminOrderType")?.text(); + assert_eq!(admin_order, "BTD"); + let start = details + .one("BTDOrderParams") + .opt("DateRange") + .opt("Start") + .parse()?; + assert_eq!(start, pinned); + let phase = header.one("mutable").one("TransactionPhase")?.text(); + assert_eq!(phase, "Initialisation"); + Ok(()) + }) + .unwrap(); + self.ebics_response_no_data() + } + + pub fn btd_no_data(&mut self, body: &[u8]) -> EbicsRes { + self.btd_date_check(body, None) + } + + pub fn btd_no_data_now(&mut self, body: &[u8]) -> EbicsRes { + self.btd_date_check( + body, + Some(Zoned::new(Timestamp::now(), TimeZone::UTC).date()), + ) + } + + pub fn btd_no_data_pinned(&mut self, body: &[u8]) -> EbicsRes { + self.btd_date_check(body, Some(date(2024, 06, 05))) + } + + pub fn btu_init(&mut self, body: &[u8]) -> EbicsRes { + Self::parse_download_init(body, "BTU"); + let tx_id = self.tx_id.insert(rand_ebics_id()); + let order_id = self.order_id.insert(rand_ebics_id()); + let xml = xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { + "header" "authenticate"="true" { + "static" { + "TransactionID": tx_id + }, + "mutable" { + "TransactionPhase": "Initialisation", + "OrderID": order_id, + "ReturnCode": "000000", + "ReportText": "[EBICS_OK] OK", + } + }, + "AuthSignature", + "body" { + "ReturnCode" "authenticate"="true": "000000" + } + }); + self.signed_response(xml) + } + + pub fn btu_payload(&mut self, body: &[u8]) -> EbicsRes { + let tx_id = self.tx_id.as_ref().unwrap(); + let order_id = self.order_id.as_ref().unwrap(); + let segment_nb: CompactString = Xml::parse(body, "ebicsRequest", |root| { + let header = root.one("header")?; + let txid = header.one("static").one("TransactionID")?.text(); + assert_eq!(txid, tx_id); + let mutable = header.one("mutable")?; + let phase = mutable.one("TransactionPhase")?.text(); + assert_eq!(phase, "Transfer"); + mutable.one("SegmentNumber").parse() + }) + .unwrap(); + self.signed_response(xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { + "header" "authenticate"="true" { + "static" { + "TransactionID": tx_id + }, + "mutable" { + "TransactionPhase": "Transfer", + "SegmentNumber": segment_nb, + "OrderID": order_id, + "ReturnCode": "000000", + "ReportText": "[EBICS_OK] OK", + } + }, + "AuthSignature", + "body" { + "ReturnCode" "authenticate"="true": "000000" + } + })) + } + + pub fn init_tx(&mut self, _: &[u8]) -> EbicsRes { + self.ebics_response_payload("", false) + } + + pub fn failure(&mut self, _: &[u8]) -> EbicsRes { + EbicsRes::Failure + } + + pub fn bad_request(&mut self, _: &[u8]) -> EbicsRes { + EbicsRes::BadRequest + } +} + +pub struct TestBank { + pub dir: TempDir, + pub sock_path: String, + pub sequence: Arc<Mutex<Vec<Sequence>>>, +} + +impl TestBank { + pub async fn new() -> Self { + let dir = tempdir().unwrap(); + let sock_path = dir.path().join("bank.sock").to_str().unwrap().to_string(); + let sequence = Arc::new(Mutex::new(Vec::new())); + let server_sequence = sequence.clone(); + let bank = Arc::new(Mutex::new(EbicsState::new())); + let server = axum::Router::new() + .route( + "/", + post(async move |body: Bytes| { + let sequence: Sequence = server_sequence.lock().unwrap().pop().unwrap(); + let mut bank = bank.lock().unwrap(); + let res = sequence(&mut bank, &body); + match res { + EbicsRes::Ok(xml) => xml.into_response(), + EbicsRes::BadRequest => StatusCode::BAD_REQUEST.into_response(), + EbicsRes::Failure => StatusCode::SERVICE_UNAVAILABLE.into_response(), + } + }), + ) + .serve( + Serve::Unix { + path: sock_path.clone(), + permission: Permissions::from_mode(0o660), + }, + None, + ); + tokio::spawn(server); + wait_for_unix_socket(&sock_path).await; + Self { + dir, + sock_path, + sequence, + } + } + + pub fn sequences(&self, sequences: &[Sequence]) { + let mut state = self.sequence.lock().unwrap(); + assert_eq!(state.len(), 0); + state.extend(sequences.iter().rev()); + } +} + +impl Drop for TestBank { + fn drop(&mut self) { + assert_eq!(self.sequence.lock().unwrap().len(), 0); + } +} diff --git a/crates/libeufin-ebics/src/utils.rs b/crates/libeufin-ebics/src/utils.rs @@ -0,0 +1,46 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{fmt::Display, io::Write as _}; + +use flate2::{ + Compression, + write::{ZlibDecoder, ZlibEncoder}, +}; + +pub fn deflate(bytes: &[u8]) -> Vec<u8> { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() +} + +pub fn inflate(bytes: &[u8]) -> Vec<u8> { + let mut encoder = ZlibDecoder::new(Vec::new()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() +} + +pub fn hex_chunk_by_two<'a>(bytes: impl AsRef<[u8]> + 'a) -> impl Display + 'a { + std::fmt::from_fn(move |f| { + for b in bytes.as_ref() { + write!(f, "{b:X} ")?; + } + Ok(()) + }) +} diff --git a/crates/libeufin-ebics/src/ws.rs b/crates/libeufin-ebics/src/ws.rs @@ -0,0 +1,436 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::time::Duration; + +use compact_str::CompactString; +use futures_util::TryStreamExt as _; +use reqwest::{Client, StatusCode}; +use reqwest_websocket::{Message, Upgrade}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use taler_common::ExpoBackoffDecorr; +use thiserror::Error; +use tracing::{debug, error, info, trace}; + +use crate::{ + ebics::{ + EbicsClient, EbicsErrKind, + ebics_code::EbicsReturnCode, + order::{BTF, Order}, + }, + keys::{BankKeys, ClientKeys}, +}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub struct WssParams { + pub url: String, + pub token: String, + pub ott: String, + pub validity: String, + pub partnerid: String, + pub userid: Option<String>, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub struct WssNotificationClass { + pub name: String, + pub vers: String, + pub timestamp: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub struct WssNotificationBTF { + pub service: CompactString, + pub scope: Option<CompactString>, + pub option: Option<CompactString>, + pub conttype: Option<CompactString>, + pub msgname: CompactString, + pub variant: Option<CompactString>, + pub version: Option<CompactString>, + pub format: Option<CompactString>, +} +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub struct WssInfo { + pub lang: String, + pub free: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(untagged)] +pub enum WssNotification { + // INFO + #[serde(rename_all = "UPPERCASE")] + GeneralInfo { + mclass: Vec<WssNotificationClass>, + info: Vec<WssInfo>, + }, + #[serde(rename_all = "UPPERCASE")] + NewData { + mclass: Vec<WssNotificationClass>, + partnerid: String, + userid: Option<String>, + btf: Vec<WssNotificationBTF>, + ordertype: Vec<String>, + }, +} + +impl WssParams { + async fn connect( + &self, + client: &Client, + mut lambda: impl AsyncFnMut(WssNotification), + ) -> Result<(), WssError> { + let Self { + url, + token, + partnerid, + userid, + .. + } = self; + let username = format!( + "{partnerid}{}", + std::fmt::from_fn(|f| if let Some(userid) = userid { + write!(f, "_{userid}") + } else { + Ok(()) + }) + ); + + let mut ws = client + .get( + url.replace("https://", "wss://") + .replace("http://", "ws://"), + ) + .basic_auth(username, Some(&token)) + .upgrade() + .send() + .await? + .into_websocket() + .await?; + trace!(target: "wss", "wait for ws msg"); + while let Some(msg) = ws.try_next().await? { + match msg { + Message::Text(str) => { + // TODO handle error + let msg: WssNotification = serde_json::from_str(&str)?; + trace!(target: "wss", "received: {msg:?}"); + lambda(msg).await; + } + Message::Binary(_) => { + // TODO what should we do ? + } + Message::Ping(_) | Message::Pong(_) => { + // Handled by tungstenite + } + Message::Close { code, reason } => { + debug!(target: "wss", "closed {code} {reason}"); + break; + } + } + trace!(target: "wss", "wait for ws msg"); + } + Ok(()) + } +} + +#[derive(Error, Debug)] +pub enum WssError { + #[error("ws: {0}")] + Ws(#[from] reqwest_websocket::Error), + #[error("ws JSON msg: {0}")] + ReqJson(#[from] serde_json::Error), +} + +pub async fn listen_for_notification( + ebics: &EbicsClient<'_>, + db: &PgPool, + client: &ClientKeys, + bank: &BankKeys, + sender: tokio::sync::mpsc::Sender<Vec<Order>>, +) { + let mut backoff = ExpoBackoffDecorr::new(Duration::from_secs(30), Duration::from_mins(30), 2.5); + loop { + let res: Result<(), anyhow::Error> = async { + let res = ebics + .download( + db, + client, + bank, + &Order::WSS_PARAMS, + &None, + false, + async |content| { + serde_json::from_slice::<WssParams>(&content) + .map_err(|e| EbicsErrKind::Custom(e.to_string().into())) + }, + ) + .await; + let params = match res { + Ok(params) => params, + Err(e) => { + if matches!( + e.kind, + // Expected EBICS error + EbicsErrKind::Code { + technical: EbicsReturnCode::EBICS_INVALID_ORDER_TYPE, + .. + } | + // Netzbon HTTP error + EbicsErrKind::HTTP(StatusCode::BAD_REQUEST) + ) { + // Failure is expected if this wss is not supported + info!(target: "ws", "Real-time EBICS notifications is not supported"); + return Ok(()); + } else { + return Err(e.into()); + } + } + }; + info!(target: "ws", "Listening to real-time EBICS notifications"); + trace!(target: "ws", "{params:?}"); + + params + .connect(&ebics.http, async |msg| { + backoff.reset(); + match msg { + WssNotification::GeneralInfo { info, .. } => { + for info in info { + info!(target: "ws", "info: {}", info.free); + } + } + WssNotification::NewData { btf, .. } => { + let orders = btf + .into_iter() + .map(|it| { + Order::BTD(BTF { + service: it.service, + scope: it.scope, + option: it.option, + container: it.conttype, + msg: it.msgname, + version: it.version, + }) + }) + .collect(); + sender.send(orders).await.ok(); + } + } + }) + .await?; + Ok(()) + } + .await; + if let Err(e) = res { + error!(target: "ws", "{e}"); + tokio::time::sleep(backoff.backoff()).await; + } else { + return; + } + } +} + +#[cfg(test)] +mod test { + use std::{fmt::Debug, fs::Permissions, os::unix::fs::PermissionsExt as _}; + + use axum::{ + extract::{ + WebSocketUpgrade, + ws::{CloseFrame, Message, Utf8Bytes}, + }, + http::HeaderMap, + routing::get, + }; + use reqwest::header::AUTHORIZATION; + use serde::{Serialize, de::DeserializeOwned}; + use taler_api::api::TalerRouter as _; + + use crate::{ + test::wait_for_unix_socket, + ws::{WssNotification, WssParams}, + }; + + // WSS params example from the spec + const PARAMS_EXAMPLE: &str = r#" + { + "URL": "http://bankmitwebsocket.de", + "TOKEN": "550e8400-e29b-11d4-a716-446655440000", + "OTT": "N", + "VALIDITY": "2019-03-21T10:35:22Z", + "PARTNERID": "K1234567", + "USERID": "USER4711" + } + "#; + // Authorization header example from the spec + const AUTH_EXAMPLE: &str = + "Basic SzEyMzQ1NjdfVVNFUjQ3MTE6NTUwZTg0MDAtZTI5Yi0xMWQ0LWE3MTYtNDQ2NjU1NDQwMDAw"; + // Notifications examples from the spec + const NOTIFICATION_EXAMPLES: [&str; 3] = [ + r#" + { + "MCLASS": [ + { + "NAME": "EBICS-HAA", + "VERS": "1.0", + "TIMESTAMP": "2019-05-13T12:21:50Z" + } + ], + "PARTNERID": "K1234567", + "USERID": "USER471", + "BTF": [ + { + "SERVICE": "REP", + "SCOPE": "DE", + "CONTTYPE": "ZIP", + "MSGNAME": "camt.054" + } + ], + "ORDERTYPE": [ + "C5N" + ] + } + "#, + r#" + { + "MCLASS": [ + { + "NAME": "EBICS-HAA", + "VERS": "1.0", + "TIMESTAMP": "2019-05-13T12:21:53Z" + } + ], + "PARTNERID": "K1234567", + "USERID": "USER471", + "BTF": [ + { + "SERVICE": "REP", + "SCOPE": "DE", + "CONTTYPE": "ZIP", + "MSGNAME": "camt.052" + }, + { + "SERVICE": "REP", + "SCOPE": "DE", + "OPTION": "SCI", + "CONTTYPE": "ZIP", + "MSGNAME": "pain.002" + } + ], + "ORDERTYPE": [ + "C52", + "CIZ" + ] + } + "#, + r#" + { + "MCLASS": [ + { + "NAME": "INFO", + "VERS": "1.0", + "TIMESTAMP": "2019-03-25T12:25:34Z" + } + ], + "INFO": [ + { + "LANG": "EN", + "FREE": " The EBICS-Service is limited on 30.03.2019 from 10:00 a.m. - 11:00a.m. due to maintenance work " + } + ] + } + "#, + ]; + + #[test] + pub fn serialization() { + fn roundrip<T: Serialize + DeserializeOwned + Eq + Debug>(src: &str) { + let it: T = serde_json::from_str(src).unwrap(); + let roundrip: T = serde_json::from_str(&serde_json::to_string(&it).unwrap()).unwrap(); + assert_eq!(it, roundrip); + } + roundrip::<WssParams>(PARAMS_EXAMPLE); + for ex in NOTIFICATION_EXAMPLES { + roundrip::<WssNotification>(ex); + } + } + + #[tokio::test] + pub async fn params() { + let path = "/tmp/libeufin_nexus_wss_test.sock"; + std::fs::remove_file(&path).ok(); + let server = axum::Router::new() + .route( + "/", + get(async |headers: HeaderMap, ws: WebSocketUpgrade| { + assert_eq!( + headers.get(AUTHORIZATION).map(|it| it.as_bytes()), + Some(AUTH_EXAMPLE.as_bytes()) + ); + ws.on_upgrade(async |mut it| { + for ex in NOTIFICATION_EXAMPLES { + it.send(Message::Text(Utf8Bytes::from_static(ex))) + .await + .unwrap(); + } + it.send(Message::Close(Some(CloseFrame { + code: 1000, + reason: Utf8Bytes::from_static("Test done"), + }))) + .await + .unwrap(); + }) + }), + ) + .serve( + taler_api::Serve::Unix { + path: path.into(), + permission: Permissions::from_mode(660), + }, + None, + ); + tokio::spawn(server); + wait_for_unix_socket(path).await; + let client = reqwest::ClientBuilder::new() + .unix_socket(path) + .build() + .unwrap(); + let params: WssParams = serde_json::from_str(PARAMS_EXAMPLE).unwrap(); + let mut count = 0; + params + .connect(&client, async |msg| { + count += 1; + // Check message number and type + assert!(count <= 3); + if count == 3 { + assert!(matches!(msg, WssNotification::GeneralInfo { .. })) + } else { + assert!(matches!(msg, WssNotification::NewData { .. })) + } + }) + .await + .unwrap(); + // Check receive all messages + assert_eq!(3, count); + } +} diff --git a/crates/libeufin-ebics/src/xml.rs b/crates/libeufin-ebics/src/xml.rs @@ -0,0 +1,471 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{ + fmt::{Display, Write}, + str::{FromStr, Utf8Error}, +}; + +use roxmltree::{Document, Node}; +use taler_common::encoding::base64; + +#[macro_export] +macro_rules! xml { + // Trailing comma + ($w:ident => $(,)?) => {{}}; + // Logic escape + ($w:ident => @ $logic:expr$(, $($rest:tt)*)?) => {{ + ($logic)($w); + $($crate::xml!($w => $($rest)*);)* + }}; + // Text element + ($w:ident => $name:tt $($k:literal=$v:tt)* : $content:expr $(, $($rest:tt)*)?) => {{ + $w.text(&$name, &[$((&$k, &$v)),*], &$content); + $($crate::xml!($w => $($rest)*);)* + }}; + // Nested block + ($w:ident => $name:tt $($k:literal=$v:tt)* { $($body:tt)* }$(, $($rest:tt)*)?) => {{ + let name = &$name; + $w.open(&name, &[$((&$k, &$v)),*]); + $crate::xml!($w => $($body)*); + $w.close(&name); + $($crate::xml!($w => $($rest)*);)* + }}; + // Empty element + ($w:ident => $name:tt $($k:literal=$v:tt)* $(, $($rest:tt)*)?) => {{ + $w.empty(&$name, &[$((&$k, &$v)),*]); + $($crate::xml!($w => $($rest)*);)* + }}; + // Root builder + ($name:tt $($k:literal=$v:tt)* { $($body:tt)* }) => {{ + let mut writer = $crate::xml::XmlWriter::init(); + let w = &mut writer; + let name = &$name; + w.open(&name, &[$((&$k, &$v)),*]); + $crate::xml!(w => $($body)*); + w.close(&name); + writer.finish() + }}; +} + +pub struct XmlWriter { + xml: String, +} + +impl XmlWriter { + pub fn init() -> Self { + let mut xml = String::with_capacity(1024); + xml.push_str(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#); + Self { xml } + } + + pub fn open<N: Display>(&mut self, name: N, attrs: &[(&dyn Display, &dyn Display)]) { + self.xml.push('<'); + self.write_tag_attrs(name, attrs); + self.xml.push('>'); + } + + pub fn close<N: Display>(&mut self, name: N) { + self.xml.push_str("</"); + self.xml.write_fmt(format_args!("{name}")).unwrap(); + self.xml.push('>'); + } + + pub fn empty<N: Display>(&mut self, name: N, attrs: &[(&dyn Display, &dyn Display)]) { + self.xml.push('<'); + self.write_tag_attrs(name, attrs); + self.xml.push_str("/>"); + } + + pub fn text<N: Display, C: Display>( + &mut self, + name: N, + attrs: &[(&dyn Display, &dyn Display)], + content: C, + ) { + self.open(&name, attrs); + self.write_escaped(content); + self.close(&name); + } + + fn write_tag_attrs<N: Display>(&mut self, name: N, attrs: &[(&dyn Display, &dyn Display)]) { + self.xml.write_fmt(format_args!("{name}")).unwrap(); + + for (key, value) in attrs { + self.xml.push(' '); + self.xml.write_fmt(format_args!("{}", *key)).unwrap(); + self.xml.push_str("=\""); + self.write_escaped(*value); + self.xml.push('"'); + } + } + + fn write_escaped<D: Display>(&mut self, content: D) { + std::fmt::write(self, format_args!("{content}")).unwrap(); + } + + pub fn finish(self) -> String { + self.xml + } +} + +/// Write XML text content following XML escape rules +impl std::fmt::Write for XmlWriter { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + // Single pass over bytes. For each special character, bulk-copy + // everything before it, then push the entity. No double-scan, + // no char-at-a-time pushing for clean runs. + let mut start = 0; + for (i, &b) in s.as_bytes().iter().enumerate() { + let entity = match b { + b'<' => "&lt;", + b'>' => "&gt;", + b'&' => "&amp;", + b'\'' => "&apos;", + b'"' => "&quot;", + _ => continue, + }; + self.xml.push_str(&s[start..i]); // bulk copy of clean prefix + self.xml.push_str(entity); + start = i + 1; + } + self.xml.push_str(&s[start..]); // bulk copy of clean suffix + Ok(()) + } +} + +#[derive(Debug)] +pub enum Error { + Str(Utf8Error), + Xml(roxmltree::Error), + Root(Box<str>, Box<str>), + Parent(Box<str>), + MissingEl(Box<str>), + MissingAttr(Box<str>, Box<str>), + Duplicate(Box<str>, usize), + Parse(Box<str>, Box<str>), +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Str(e) => e.fmt(f), + Self::Xml(e) => e.fmt(f), + Self::Root(expected, got) => write!(f, "expected root '{expected}' got '{got}'"), + Self::Parent(path) => write!(f, "not parent for element '{path}'"), + Self::MissingEl(path) => write!(f, "missing element '{path}'"), + Self::MissingAttr(path, name) => write!(f, "missing attribute '{name}' on <{path}>"), + Self::Duplicate(path, nb) => write!(f, "expected one '{path}', got {nb}"), + Self::Parse(path, err) => write!(f, "malformed '{path}': {err}"), + } + } +} + +impl std::error::Error for Error {} + +pub type Result<T> = std::result::Result<T, Error>; + +#[derive(Debug, Clone, Copy)] +pub struct Xml<'xml> { + pub node: Node<'xml, 'xml>, +} + +impl<'xml> Xml<'xml> { + pub fn parse<F, R>(raw: &[u8], tag: &str, f: F) -> Result<R> + where + R: 'static, + F: for<'local> FnOnce(Xml<'local>) -> Result<R>, + { + let str = std::str::from_utf8(raw).map_err(Error::Str)?; + let xml = Document::parse(str).map_err(Error::Xml)?; + Self::doc(xml, tag, f) + } + + pub fn doc<F, R>(xml: Document, tag: &str, f: F) -> Result<R> + where + R: 'static, + F: for<'local> FnOnce(Xml<'local>) -> Result<R>, + { + let root = xml.root_element(); + if !root.has_tag_name(tag) { + return Err(Error::Root(tag.into(), root.tag_name().name().into())); + } + let node = Xml { node: root }; + let res = f(node); + drop(xml); + res + } + + fn path(self, tag: Option<&str>) -> Box<str> { + let mut ancestors = Vec::new(); + let mut cur = Some(self.node); + while let Some(n) = cur { + if n.is_element() { + ancestors.push(n); + } + cur = n.parent(); + } + let mut buf = String::new(); + for n in ancestors.into_iter().rev() { + // Add prefix if it exists + if let Some(prefix) = n.tag_name().namespace().and_then(|ns| n.lookup_prefix(ns)) { + buf.push_str(prefix); + buf.push(':'); + } + + buf.push_str(n.tag_name().name()); + buf.push('.'); + } + match tag { + Some(t) => buf.push_str(t), + None => { + buf.pop(); + } + } + buf.into() + } + + pub fn parse_err(self, err: impl Display) -> Error { + Error::Parse(self.path(None), err.to_string().into_boxed_str()) + } + + pub fn parent(self) -> Result<Xml<'xml>> { + Ok(Self { + node: self + .node + .parent() + .ok_or_else(|| Error::Parent(self.path(None)))?, + }) + } + + fn children(self, tag: &str, signed: bool) -> impl Iterator<Item = Node<'xml, 'xml>> { + self.node.children().filter(move |n| { + n.has_tag_name(tag) && (!signed || n.attribute("authenticate") == Some("true")) + }) + } + + fn opt_inner(self, tag: &str, signed: bool) -> Result<Option<Xml<'xml>>> { + let mut iter = self.children(tag, signed); + match (iter.next(), iter.next()) { + (None, _) => Ok(None), + (Some(_), Some(_)) => Err(Error::Duplicate(self.path(Some(tag)), iter.count() + 2)), + (Some(node), None) => Ok(Some(Xml { node })), + } + } + + fn one_inner(self, tag: &str, signed: bool) -> Result<Xml<'xml>> { + self.opt_inner(tag, signed) + .transpose() + .unwrap_or_else(|| Err(Error::MissingEl(self.path(Some(tag))))) + } + + pub fn many(self, tag: &str) -> impl Iterator<Item = Xml<'xml>> { + self.children(tag, false).map(|node| Xml { node }) + } + + pub fn text(self) -> &'xml str { + self.node.text().unwrap_or_default() + } + + pub fn attr(self, name: &str) -> Result<&'xml str> { + self.node + .attribute(name) + .ok_or_else(|| Error::MissingAttr(self.path(None), name.into())) + } + + pub fn opt_attr(self, name: &str) -> Option<&'xml str> { + self.node.attribute(name) + } +} + +pub trait XmlAccess<'xml>: Sized { + type Out<T>; + type Opt<T>; + + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>>; + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>>; + + fn one(self, tag: &'xml str) -> Result<Self::Out<Xml<'xml>>> { + self.lift(|n| n.one_inner(tag, false)) + } + + fn one_signed(self, tag: &'xml str) -> Result<Self::Out<Xml<'xml>>> { + self.lift(|n| n.one_inner(tag, true)) + } + + fn opt(self, tag: &'xml str) -> Result<Self::Opt<Xml<'xml>>> { + self.opt_lift(|n| n.opt_inner(tag, false)) + } + + fn opt_signed(self, tag: &'xml str) -> Result<Self::Opt<Xml<'xml>>> { + self.opt_lift(|n| n.opt_inner(tag, true)) + } + + fn parse_attr<T: FromStr>(self, name: &str) -> Result<Self::Out<T>> + where + T::Err: Display, + { + self.lift(|n| n.attr(name)?.parse().map_err(|e| n.parse_err(e))) + } + + fn parse_opt_attr<T: FromStr>(self, name: &str) -> Result<Self::Opt<T>> + where + T::Err: Display, + { + self.opt_lift(|n| { + n.opt_attr(name) + .map(|it| it.parse().map_err(|e| n.parse_err(e))) + .transpose() + }) + } + + fn decode<T, E: Display>( + self, + lambda: impl FnOnce(&str) -> std::result::Result<T, E>, + ) -> Result<Self::Out<T>> { + // TODO error not a node text ? + self.lift(|n| lambda(n.text()).map_err(|e| n.parse_err(e))) + } + + fn parse<T: FromStr>(self) -> Result<Self::Out<T>> + where + T::Err: Display, + { + self.decode(T::from_str) + } + + fn b64(self) -> Result<Self::Out<Vec<u8>>> { + self.decode(|it| base64::decode(it)) + } +} + +impl<'xml> XmlAccess<'xml> for Xml<'xml> { + type Out<T> = T; + type Opt<T> = Option<T>; + + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { + f(self) + } + + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { + self.lift(f) + } +} + +impl<'xml> XmlAccess<'xml> for Option<Xml<'xml>> { + type Out<T> = Option<T>; + type Opt<T> = Option<T>; + + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { + self.map(|it| it.lift(f)).transpose() + } + + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { + match self { + Some(xml) => xml.opt_lift(f), + None => Ok(None), + } + } +} + +impl<'xml> XmlAccess<'xml> for Result<Xml<'xml>> { + type Out<T> = T; + type Opt<T> = Option<T>; + + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { + self?.lift(f) + } + + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { + self.lift(f) + } +} + +impl<'xml> XmlAccess<'xml> for Result<Option<Xml<'xml>>> { + type Out<T> = Option<T>; + type Opt<T> = Option<T>; + + fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { + self?.map(|it| it.lift(f)).transpose() + } + + fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { + match self? { + Some(xml) => xml.opt_lift(f), + None => Ok(None), + } + } +} + +#[cfg(test)] +mod test { + use crate::xml::XmlWriter; + + #[test] + pub fn basic() { + assert_eq!( + xml!("ebicsRequest" "version"="H004" { + "a" { + "b" { + "c" "attribute-of"="c" { + "d" { + "e" { + "f" "nested"="true" { + "g" { + "h" + } + } + } + } + } + } + }, + "one_more" + }), + r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsRequest version="H004"><a><b><c attribute-of="c"><d><e><f nested="true"><g><h/></g></f></e></d></c></b></a><one_more/></ebicsRequest>"# + ) + } + + #[test] + pub fn modularity() { + fn module(w: &mut XmlWriter) { + xml!(w => "module"); + } + assert_eq!( + xml!("root" { @ module }), + r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><root><module/></root>"# + ) + } + + #[test] + pub fn iterable() { + assert_eq!( + xml!("iterable" { + "endOfDocument" { + @ |w: &mut XmlWriter| for i in 1..=10 { + xml!(w => (format_args!("e{i}")) { + (format_args!("e{i}{i}")): (format_args!("{i}{i}{i}")) + }) + } + } + }), + r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><iterable><endOfDocument><e1><e11>111</e11></e1><e2><e22>222</e22></e2><e3><e33>333</e33></e3><e4><e44>444</e44></e4><e5><e55>555</e55></e5><e6><e66>666</e66></e6><e7><e77>777</e77></e7><e8><e88>888</e88></e8><e9><e99>999</e99></e9><e10><e1010>101010</e1010></e10></endOfDocument></iterable>"# + ) + } +} diff --git a/crates/libeufin-ebics/src/xml_sign.rs b/crates/libeufin-ebics/src/xml_sign.rs @@ -0,0 +1,294 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{ + borrow::Cow, + collections::{BTreeMap, HashSet}, +}; + +use aws_lc_rs::{ + digest::Digest, + rand::SystemRandom, + signature::{RSA_PKCS1_SHA256, RsaKeyPair}, +}; +use roxmltree::{Document, Node}; +use taler_common::encoding::base64; + +fn escape<'a>(text: &'a str, replacements: &[(char, &str)]) -> Cow<'a, str> { + // Find the first character that needs escaping + let Some(first_pos) = text.find(|c| replacements.iter().any(|(r, _)| *r == c)) else { + return Cow::Borrowed(text); // No escaping needed — zero allocations + }; + + // Pre-allocate with a reasonable estimate + let mut output = String::with_capacity(text.len() + 16); + output.push_str(&text[..first_pos]); + + for ch in text[first_pos..].chars() { + match replacements.iter().find(|(r, _)| *r == ch) { + Some((_, escaped)) => output.push_str(escaped), + None => output.push(ch), + } + } + + Cow::Owned(output) +} + +// C14N requires specific escaping for Text nodes +fn escape_text(text: &str) -> Cow<'_, str> { + escape( + text, + &[ + ('&', "&amp;"), + ('<', "&lt;"), + ('>', "&gt;"), + ('\r', "&#xD;"), + ], + ) +} + +// C14N requires specific escaping for Attributes +fn escape_attr(text: &str) -> Cow<'_, str> { + escape( + text, + &[ + ('&', "&amp;"), + ('<', "&lt;"), + ('"', "&quot;"), + ('\t', "&#x9;"), + ('\n', "&#xA;"), + ('\r', "&#xD;"), + ], + ) +} + +/// Updated C14N logic to prevent redundant namespace declarations +fn c14n_inclusive<'a>( + node: Node<'a, 'a>, + mut active_namespaces: HashSet<(&'a str, &'a str)>, + out: &mut String, +) { + if node.is_text() { + out.push_str(&escape_text(node.text().unwrap_or(""))); + } else if node.is_element() { + let prefix = node + .tag_name() + .namespace() + .and_then(|uri| node.lookup_prefix(uri)); + let push_tag_name = |out: &mut String| { + if let Some(ns) = prefix { + out.push_str(ns); + out.push(':'); + }; + out.push_str(node.tag_name().name()); + }; + + // Open element + out.push('<'); + push_tag_name(out); + + // Write sorted missing namespaces + let missing: BTreeMap<&str, &str> = node + .namespaces() + .filter_map(|ns| { + let value = (ns.name().unwrap_or_default(), ns.uri()); + active_namespaces.insert(value).then_some(value) + }) + .collect(); + for (prefix, uri) in missing { + out.push(' '); + out.push_str("xmlns"); + if !prefix.is_empty() { + out.push(':'); + out.push_str(prefix); + } + out.push_str("=\""); + out.push_str(uri); + out.push('"'); + } + + // Write sorted attributes + let attributes: BTreeMap<&str, &str> = node + .attributes() + .map(|it| (it.name(), it.value())) + .collect(); + for (k, v) in attributes { + out.push(' '); + out.push_str(k); + out.push_str("=\""); + out.push_str(&escape_attr(v)); + out.push('"'); + } + + out.push('>'); + + for child in node.children() { + // Pass the cloned active_namespaces down to children + c14n_inclusive(child, active_namespaces.clone(), out); + } + + out.push_str("</"); + push_tag_name(out); + out.push('>'); + } +} + +fn digest_authenticated(doc: &Document) -> Digest { + fn find_top_level_authenticators<'a>(node: Node<'a, 'a>, results: &mut Vec<Node<'a, 'a>>) { + if node.attribute("authenticate") == Some("true") { + results.push(node); + } else { + for child in node.children().filter(|n| n.is_element()) { + find_top_level_authenticators(child, results); + } + } + } + let mut nodes = Vec::new(); + + find_top_level_authenticators(doc.root(), &mut nodes); + + let mut out = String::new(); + for node in nodes { + c14n_inclusive(node, HashSet::new(), &mut out); + } + aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, out.as_bytes()) +} + +const C14N_ALG: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"; +const SIG_ALG: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; +const DIGEST_ALG: &str = "http://www.w3.org/2001/04/xmlenc#sha256"; +const DSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; + +pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> String { + let doc = Document::parse(&xml).unwrap(); + + let digest = digest_authenticated(&doc); + let digest = base64::encode(digest.as_ref()); + + // Wrap signed info for signature in a canonical form + let default_namespace = doc + .root_element() + .default_namespace() + .expect("must be a root EBICS schema namespace"); + let signed_info = format!( + r##"<ds:SignedInfo xmlns="{default_namespace}" xmlns:ds="{DSIG_NS}"><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"></ds:CanonicalizationMethod><ds:SignatureMethod Algorithm="{SIG_ALG}"></ds:SignatureMethod><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"></ds:DigestMethod><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo>"## + ); + let mut sig = vec![0u8; key.public_modulus_len()]; + key.sign( + &RSA_PKCS1_SHA256, + &SystemRandom::new(), + signed_info.as_bytes(), + &mut sig, + ) + .unwrap(); + let sig = base64::encode(sig); + let signature = format!( + r##"<AuthSignature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"/><ds:SignatureMethod Algorithm="{SIG_ALG}"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"/></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>{sig}</ds:SignatureValue></AuthSignature>"## + ); + let pattern = "<AuthSignature/>"; + let start = xml.find(pattern).unwrap(); + xml.replace_range(start..start + pattern.len(), &signature); + xml +} + +#[cfg(test)] +mod test { + use aws_lc_rs::signature::RsaKeyPair; + use roxmltree::Document; + use taler_common::encoding::{base32, base64}; + + use crate::xml_sign::{digest_authenticated, sign_ebics}; + + #[test] + fn canonicalize() { + let xml = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsNoPubKeyDigestsRequest xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Revision="1" Version="H005"><header authenticate="true"><static><HostID>PFEBICS</HostID><Nonce>BC750C641453F93EBF236A9B25F6B70A</Nonce><Timestamp>2026-02-14T18:10:31.125926573Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><mutable/></header><AuthSignature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>RvVxaDRsgtyZITf3C/UfmWGLERFRWZFxbwb5yhoJBOu5f6KsythhNvF28gznE1VN7E+5oP+nRkba&#13; +hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA&#13; +S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i&#13; +a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC&#13; +kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><body/></ebicsNoPubKeyDigestsRequest> +"##; + + let doc = Document::parse(xml).unwrap(); + let res = digest_authenticated(&doc); + let hex = base64::encode(res); + assert_eq!(hex, "ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU="); + + let xml = r##"<?xml version="1.0" encoding="UTF-8"?> +<ebicsResponse xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" Version="H005" Revision="1" xsi:schemaLocation="urn:org:ebics:H005 ebics_response_H005.xsd"> + <header authenticate="true"> + <static> + <TransactionID>7FD993238073A6ADAE3B0E5C2A8010E6</TransactionID> + </static> + <mutable> + <TransactionPhase>Initialisation</TransactionPhase> + <OrderID>N0NU</OrderID> + <ReturnCode>000000</ReturnCode> + <ReportText>[EBICS_OK] OK</ReportText> + </mutable> + </header> + <AuthSignature> + <ds:SignedInfo> + <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> + <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/> + <ds:Reference URI="#xpointer(//*[@authenticate='true'])"> + <ds:Transforms> + <ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> + </ds:Transforms> + <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> + <ds:DigestValue>WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI=</ds:DigestValue> + </ds:Reference> + </ds:SignedInfo> + <ds:SignatureValue>Ug6LWlR5FCrOjKjqa37Y6D/vYdYxDp3FcLnj/SEJU5kCGpqd+MrEJDg0/q726ozlxkw50hEbK+Kh+MDxRPTztxOdc78V9PuAK9mzo41+G6cv26SKZqX3wtCIrcaFhsEfzIqe9m8NwlnQ3aATMxEevjVPLE+TzSd+Tb6vFybt3a6Qi3iHmjTTeNVPTcAte91A2wqI/k+aPbg2ndRio/stGjuvVYDXNy9YuXvg8XEtgkbDtkx90O5shexaUMI/W5YqY49kd7aY4gSY6jf1/rfkWHU556mtPjuYBLg0TL9nOQWIrzw3eIWpVB0xoPvdPfzRtYvT7KEuk5LtSwEfHiLqgw==</ds:SignatureValue> + </AuthSignature> + <body> + <ReturnCode authenticate="true">000000</ReturnCode> + <TimestampBankParameter authenticate="true">2020-11-25T19:03:45.693Z</TimestampBankParameter> + </body> +</ebicsResponse> +"##; + let doc = Document::parse(xml).unwrap(); + let res = digest_authenticated(&doc); + assert_eq!( + base64::encode(res), + "WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI=" + ); + } + + #[test] + fn sign() { + let key = "62109F820403038614N8CJ46YW6G20810M0090G4MWR84153080G00M2040G18GPPFA8VSJD6G0ENC3SH2ET37BXQ1RTRAX162GWVTW33BX14FBAC0N7DFJBKKNS0EJ4DY07TABNKDHGX0EX7XWV47P456NXX8DP33MVZ010X2F248GDXQK3WWYZKAYMA61KYNTJ4QD1BAZWES5GBA8F9WD9EM9WN9ZYQHFGNWVDQ2BEE54CQAGF82AFR0NJEJWFT4QKNVR8QB79VESG623W5NZPFQM1ZSJ20ZDYCJC7KJ1Q23TDJA0C1SY3KWRM97R60BZXKQ9Q9FM1AFQ8CAYDG0FJSQQM0D5W8QQENK79W8VZ0605A1FKYZYBFQX34FBFJKTCSDEZWSYDQM4HD9JF8F86HXW3F2GE9A7H7JHZG7441MJ91H24ND5M8YK4VXYAB7RX8JAXSS8K33BQXF5QBRR20C0G0082G80G0079GETRHX75MR929BDEYWFKTXE82F0QV71AHY3MKKQXNK545W4XSGHGZARZTH5TGABDTW2SJHKXQ787X2CQFY8ZDDHN5WEMXT5VMBZK5B3TJW5XM98GRREWWPA8AJPA8QZ30Q9RYX49228NHSAM8F2DEH40KAXMFT8HB1PDVCVQRQV5HKYBD1Z6Z1ZZZXXJ114X0E4X6R3FMVWQGANAKYRT2S75FKCG00C96EBM1B8RBZPBQZ7FVA9ZB8F64PYG4KRFHT4CYQZ5D6HP1K42PKYB7TA45SZKRDXE3PYTZE6XASJSP9H1EH1JM0ZPMC1Y2FBWPQ8SR2233J55HX6PXWSJ2ZJYTC8V9FM9F442SQM5EGNYK59RCSEGWMZ0WSF98WX4QVDX4CVN3E1GQPNH9K8ERG82G60G1M763Z4MZSJG1MJZQV32HYNMBEV26J8JKC6E53GWKY101RCB1JXN08H8P64P8QTDV9WRS3EQV30557E7QJAYG1K5A4D76DYTNE0GBC7KE5FD6S8ADSJV9XWVVQHVV1BRQVZ4ZWWSK5M9VEVZNRXXBJVZPKR26XEBT6ANVEBTSQ538K1BZQGBQTKWVMDFMG91A4ATXQM2B5J1MC183080RTHA7FVF7SN90B4XQ87GST3Z50H6T6CRQGR0PDDV51HGH1JE76AAWP1VCEWGASWZ2C9KVB8Z5GH71SCW0MF89A02NFNGVQ9D3QV3PMZQSGM6RC35DDBD33J8N5G2V2SN5MCNB3RA7SMJVVG5DG7QHCJ83QX11G5P8KHQ8MFEV69HGXSS7DTHBV71EWP78PPEMSXP7C7ASYZC20M1G02CCQEFM8PYF0M5DPZBEYG56RRD8JYK9PDADYXM7P1SGSZT2J07705TZNKF0Y34W9T28FZ340PRAA5HSDX8SP3EFSFMNV8RC109HKRW0ZP4WRE1E8QJVGS19CZVPAKMRQA17VF9H4HK3FVXYCA2B3FAZTMRVABA9D03P01BYNERVDEJMQ2173562N24FEBYB18EYF94WD3GVX82G60G15KVSJV527Q6723V93NANH5CYPN6H8JE8CTXXA030S1EEBEDT4KYEDZE1BVJ2A42GPCS60BA448VKT83A793QEW5A7EDKFCKWFQYPSZTSCBWRS4ZQTYG00Z5T2G4JMY6PWPS92D6YNJCYK0EGNNFF7QGDXXYWN33MM0296SQ1MK0R0F45EXAQT5S2Q39SXAA8KHR32A8BC0HG418300KMYBR5ZKNTX7SANSJB5SSYGP9RZVHN23RC1J29QRH5XFSMABMDA582GJH5JAE0D31AH5PTAHP04Y61KNCSKNC748N1PRN503VZY7EA1C4G75C0F13K04TE8RVP63K0TQ693E2XB23WSHYERKSZ6AKCTR3E15N1AF70HEKK13E4QCCY2JN896YEWWT9B8CVW50D8C87S75EK3G"; + + let key: RsaKeyPair = + RsaKeyPair::from_pkcs8(&base32::decode(key.as_bytes()).unwrap()).unwrap(); + let tmp = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsNoPubKeyDigestsRequest xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Revision="1" Version="H005"><header authenticate="true"><static><HostID>PFEBICS</HostID><Nonce>6BC48C9C2576ABD00295788E56DFCD0A</Nonce><Timestamp>2026-02-21T17:01:53.186561035Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><mutable/></header><AuthSignature/><body/></ebicsNoPubKeyDigestsRequest>"##; + let xml = tmp.to_owned(); + let signed = sign_ebics(xml, &key); + let doc = Document::parse(&signed).unwrap(); + let signature = doc + .descendants() + .find(|it| it.has_tag_name("SignatureValue")) + .unwrap() + .text() + .unwrap(); + assert_eq!( + signature, + "eYyb1v/dGVOPndpMhXZlVQM2q9H9BJP77nYOWaa7jjoeLef7/8HjKIv8oq6Kaf6Z9mAfh/Pcip3a75gkdKpz7ocl1YdsaD+CcQkO1J/n4NwY821ccSh0Ahm2PBE168hyEMzPJrDeDtJrYqs+J/+nC8ek0hbo4/WPsH4UoxVu+ANsHR+BnQFQW3k9BFv+XKZbrBltIY62SN73tYwU8QzRtINJLzjhNB3T6S101n4CYwycXpL5b/oXXOUxxfDnn9EmIFt4DIgjxxqDYdQEBytULLORdkIdf563aw2wDaN12OQV2TB9gAs4Uu203FkUbmIagarMhbKKlqa1NkOteZ13Xw==" + ); + } +} diff --git a/crates/libeufin-nexus/Cargo.toml b/crates/libeufin-nexus/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "libeufin-nexus" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true + +[dependencies] +libeufin-ebics.workspace = true +tokio.workspace = true +tracing.workspace = true +anyhow.workspace = true +jiff.workspace = true +serde_json.workspace = true +taler-common.workspace = true +taler-api.workspace = true +taler-build.workspace = true +taler-test-utils.workspace = true +clap.workspace = true +aws-lc-rs.workspace = true +serde.workspace = true +sqlx.workspace = true +compact_str.workspace = true +uuid.workspace = true +url = "2.5" +reedline = "0.47" +regex = "1.12" +const_format = { version = "0.2", features = ["rust_1_83"] } +zip = { version = "8.5", default-features = false, features = [ + "deflate-flate2-zlib-rs", +] } +tracing-subscriber = "0.3" +owo-colors = "4.3" +shlex = "1.3" diff --git a/crates/libeufin-nexus/src/api.rs b/crates/libeufin-nexus/src/api.rs @@ -0,0 +1,417 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use jiff::Timestamp; +use libeufin_ebics::{ + ebics::rand_ebics_id, + iso20022::model::{InId, InTx}, +}; +use sqlx::PgPool; +use taler_api::{ + api::{TalerApi, revenue::Revenue, transfer::PreparedTransfer, wire::WireGateway}, + error::{ApiResult, failure, failure_code}, + subject::{IncomingSubject, fmt_in_subject, subject_fmt_qr_bill}, +}; +use taler_common::{ + api_common::{SafeU64, safe_u64}, + api_params::{History, Page}, + api_revenue::RevenueIncomingHistory, + api_transfer::{ + RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, + }, + api_wire::{ + AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, + IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, + TransferState, TransferStatus, + }, + db::IncomingType, + error_code::ErrorCode, + types::{ + amount::{Amount, Currency}, + payto::{FullIbanPayto, PaytoURI}, + timestamp::TalerTimestamp, + }, +}; +use tokio::sync::watch::Sender; + +use crate::db::{ + self, + exchange::{ + TransferResult, incoming_history, outgoing_history, revenue_history, transfer, + transfer_by_id, transfer_page, + }, + payment::{IncomingRegistrationResult, register_in_talerable}, + transfer::{RegistrationResult, transfer_register, transfer_unregister}, +}; + +pub struct NexusApi { + pub pool: sqlx::PgPool, + pub currency: Currency, + pub payto: PaytoURI, + pub in_channel: Sender<i64>, + pub taler_in_channel: Sender<i64>, + pub taler_out_channel: Sender<i64>, +} + +impl NexusApi { + pub async fn start(pool: sqlx::PgPool, payto: PaytoURI, currency: Currency) -> Self { + let in_channel = Sender::new(0); + let taler_in_channel = Sender::new(0); + let taler_out_channel = Sender::new(0); + let tmp = Self { + pool: pool.clone(), + payto, + currency, + in_channel: in_channel.clone(), + taler_in_channel: taler_in_channel.clone(), + taler_out_channel: taler_out_channel.clone(), + }; + tokio::spawn(db::notification_listener( + pool, + in_channel, + taler_in_channel, + taler_out_channel, + )); + tmp + } +} + +impl TalerApi for NexusApi { + fn currency(&self) -> &str { + self.currency.as_ref() + } + + fn implementation(&self) -> &'static str { + "urn:net:taler:specs:libeufin-nexus:taler-rust" + } +} + +async fn add_incoming( + db: &PgPool, + subject: &IncomingSubject, + amount: Amount, + debit_account: PaytoURI, +) -> ApiResult<AddIncomingResponse> { + FullIbanPayto::try_from(&debit_account)?; + let now = Timestamp::now(); + match register_in_talerable( + db, + &InTx { + id: InId { + uetr: None, + tx_id: Some(rand_ebics_id()), + sref: None, + }, + amount, + credit_fee: Amount::zero(&amount.currency), + subject: Some(format!( + "Manual incoming {}", + fmt_in_subject(subject.ty(), subject.key()) + )), + execution_time: now, + debtor: Some(debit_account), + }, + subject, + ) + .await? + { + IncomingRegistrationResult::Success(in_result) => Ok(AddIncomingResponse { + row_id: safe_u64(in_result.id), + timestamp: now.into(), + }), + IncomingRegistrationResult::ReservePubReuse => { + Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + IncomingRegistrationResult::MappingReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED)) + } + IncomingRegistrationResult::UnknownMapping => { + Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN)) + } + } +} + +impl WireGateway for NexusApi { + async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> { + FullIbanPayto::try_from(&req.credit_account)?; + let result = transfer(&self.pool, &req, &rand_ebics_id(), &Timestamp::now()).await?; + match result { + TransferResult::Success { id, timestamp } => Ok(TransferResponse { + timestamp: timestamp.into(), + row_id: SafeU64::try_from(id).unwrap(), + }), + TransferResult::RequestUidReuse => { + Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) + } + TransferResult::WtidReuse => Err(failure_code(ErrorCode::BANK_TRANSFER_WTID_REUSED)), + } + } + + async fn transfer_page( + &self, + page: Page, + status: Option<TransferState>, + ) -> ApiResult<TransferList> { + Ok(TransferList { + transfers: transfer_page(&self.pool, &self.currency, &page, &status).await?, + debit_account: self.payto.clone(), + }) + } + + async fn transfer_by_id(&self, id: u64) -> ApiResult<Option<TransferStatus>> { + Ok(transfer_by_id(&self.pool, &self.currency, id).await?) + } + + async fn outgoing_history(&self, params: History) -> ApiResult<OutgoingHistory> { + Ok(OutgoingHistory { + outgoing_transactions: outgoing_history(&self.pool, &self.currency, &params, || { + self.taler_out_channel.subscribe() + }) + .await?, + debit_account: self.payto.clone(), + }) + } + + async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> { + Ok(IncomingHistory { + incoming_transactions: incoming_history(&self.pool, &self.currency, &params, || { + self.taler_in_channel.subscribe() + }) + .await?, + credit_account: self.payto.clone(), + }) + } + + async fn add_incoming_reserve( + &self, + req: AddIncomingRequest, + ) -> ApiResult<AddIncomingResponse> { + add_incoming( + &self.pool, + &IncomingSubject::Reserve(req.reserve_pub), + req.amount, + req.debit_account, + ) + .await + } + + async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> { + add_incoming( + &self.pool, + &IncomingSubject::Kyc(req.account_pub), + req.amount, + req.debit_account, + ) + .await + } + + async fn add_incoming_mapped(&self, req: AddMappedRequest) -> ApiResult<AddIncomingResponse> { + add_incoming( + &self.pool, + &IncomingSubject::Map(req.authorization_pub), + req.amount, + req.debit_account, + ) + .await + } + + fn support_account_check(&self) -> bool { + false + } +} + +impl Revenue for NexusApi { + async fn history(&self, params: History) -> ApiResult<RevenueIncomingHistory> { + Ok(RevenueIncomingHistory { + incoming_transactions: revenue_history(&self.pool, &self.currency, &params, || { + self.in_channel.subscribe() + }) + .await?, + credit_account: self.payto.clone(), + }) + } +} + +impl PreparedTransfer for NexusApi { + fn supported_formats(&self) -> &[SubjectFormat] { + &[SubjectFormat::SIMPLE] + } + + async fn registration(&self, req: RegistrationRequest) -> ApiResult<RegistrationResponse> { + let reference_number = subject_fmt_qr_bill(req.authorization_pub.as_ref()); + match transfer_register( + &self.pool, + req.r#type.into(), + &req.account_pub, + &req.authorization_pub, + &req.authorization_sig, + req.recurrent, + &reference_number, + &Timestamp::now(), + ) + .await? + { + RegistrationResult::Success => ApiResult::Ok(RegistrationResponse { + subjects: vec![ + TransferSubject::QrBill { + credit_amount: req.credit_amount, + qr_reference_number: reference_number, + }, + TransferSubject::Simple { + credit_amount: req.credit_amount, + subject: if req.authorization_pub == req.account_pub && !req.recurrent { + fmt_in_subject(req.r#type.into(), &req.account_pub) + } else { + fmt_in_subject(IncomingType::map, &req.authorization_pub) + }, + }, + ], + expiration: TalerTimestamp::Never, + }), + RegistrationResult::ReservePubReuse => { + ApiResult::Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) + } + RegistrationResult::SubjectReuse => { + ApiResult::Err(failure_code(ErrorCode::BANK_DERIVATION_REUSE)) + } + } + } + + async fn unregistration(&self, req: Unregistration) -> ApiResult<()> { + if !transfer_unregister(&self.pool, &req.authorization_pub, &Timestamp::now()).await? { + Err(failure( + ErrorCode::BANK_TRANSACTION_NOT_FOUND, + format!("Prepared transfer '{}' not found", req.authorization_pub), + )) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +pub mod test { + use std::sync::Arc; + + use sqlx::PgPool; + use taler_api::{api::TalerRouter as _, auth::AuthMethod, subject::OutgoingSubject}; + use taler_common::{ + api_revenue::RevenueConfig, + api_transfer::PreparedTransferConfig, + api_wire::{OutgoingHistory, TransferState, WireConfig}, + }; + use taler_test_utils::{ + Router, + db::db_test_setup, + routine::{ + admin_add_incoming_routine, registration_routine, revenue_routine, routine_pagination, + transfer_routine, + }, + server::TestServer as _, + }; + + use crate::{ + CONFIG_SOURCE, + api::NexusApi, + db::{payment::register_out_tx, test::check_in}, + test::{ACCOUNT, CURR, gen_out_pay}, + }; + + pub async fn api_setup() -> (Router, PgPool) { + let (_, pool) = db_test_setup(CONFIG_SOURCE).await; + let api = Arc::new(NexusApi::start(pool.clone(), ACCOUNT.clone(), CURR).await); + let server = Router::new() + .wire_gateway(api.clone(), AuthMethod::None) + .prepared_transfer(api.clone()) + .revenue(api, AuthMethod::None) + .finalize(); + + (server, pool) + } + + #[tokio::test] + async fn config() { + let (server, _) = api_setup().await; + server + .get("/taler-wire-gateway/config") + .await + .assert_ok_json::<WireConfig>(); + server + .get("/taler-prepared-transfer/config") + .await + .assert_ok_json::<PreparedTransferConfig>(); + server + .get("/taler-revenue/config") + .await + .assert_ok_json::<RevenueConfig>(); + } + + #[tokio::test] + async fn transfer() { + let (server, _) = api_setup().await; + transfer_routine(&server, TransferState::pending, &ACCOUNT).await; + // TODO + /*db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1") + db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure") + db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure") + client.getA("/taler-wire-gateway/transfers?status=transient_failure").assertOkJson<TransferList> { + assertEquals(2, it.transfers.size) + } + client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList> { + assertEquals(4, it.transfers.size) + }*/ + } + + #[tokio::test] + async fn outgoing_history() { + let (server, pool) = api_setup().await; + routine_pagination::<OutgoingHistory>( + &server, + "/taler-wire-gateway/history/outgoing", + async |_| { + register_out_tx( + &pool, + &gen_out_pay("subject"), + Some(&OutgoingSubject::rand()), + ) + .await + .unwrap(); + }, + ) + .await; + } + + #[tokio::test] + async fn admin_add_incoming() { + let (server, _) = api_setup().await; + admin_add_incoming_routine(&server, &ACCOUNT, true).await; + } + + #[tokio::test] + async fn revenue() { + let (server, _) = api_setup().await; + revenue_routine(&server, &ACCOUNT, true).await; + } + + #[tokio::test] + async fn registration() { + let (server, pool) = api_setup().await; + registration_routine(&server, &ACCOUNT, || check_in(&pool)).await; + } +} diff --git a/crates/libeufin-nexus/src/bench.rs b/crates/libeufin-nexus/src/bench.rs @@ -0,0 +1,289 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +#[cfg(test)] +mod test { + use std::fmt::Write as _; + + use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair as _}; + use compact_str::{CompactString, format_compact}; + use jiff::Timestamp; + use serde_json::json; + use taler_api::{crypto::eddsa_sign, subject::subject_fmt_qr_bill}; + use taler_common::{ + api_common::{EddsaPublicKey, HashCode, ShortHashCode}, + bench::{Bench, h32, h64}, + encoding::hex, + error_code::ErrorCode, + }; + use taler_test_utils::server::TestServer as _; + + use crate::{ + api::test::api_setup, + test::{ + ACCOUNT, incomplete_out, malformed_completeted_in, malformed_in, + malformed_incomplete_in, malformed_out, prepared_completeted_in, prepared_in, + prepared_incomplete_in, talerable_completeted_in, talerable_in, + talerable_incomplete_in, talerable_out, + }, + }; + + #[tokio::test] + pub async fn bench_db() { + let (server, db) = api_setup().await; + let amount = 10; + let iter = 10; + let amount = amount.max(10); + let accounts_pubs: Vec<_> = (0..amount * 2) + .map(|_| { + let key_pair = Ed25519KeyPair::generate().unwrap(); + let pub_key = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); + (key_pair, pub_key) + }) + .collect(); + let mut b = Bench::new(&db, iter, amount); + b.table("incoming_transactions(amount, subject, execution_time, debit_payto, uetr, tx_id, acct_svcr_ref)", |f, i| { + let subject = if i % 4 == 0 { CompactString::const_new("\\N")} else {format_compact!("subject {i}")}; + let debtor = ACCOUNT.as_ref().as_str() ; + + if i % 3 == 0 { + writeln!(f, "(20,0)\t{subject}\t0\t{debtor}\t{}\t\\N\t\\N", uuid::Uuid::new_v4())?; + writeln!(f, "(21,0)\t{subject}\t0\t{debtor}\t\\N\tTX_ID{}\t\\N", i*2)?; + writeln!(f, "(22,0)\t{subject}\t0\t{debtor}\t\\N\t\\N\tREF{}", i*2) + } else if i%3 == 1 { + writeln!(f, "(30,0)\t{subject}\t0\t{debtor}\t{}\tTX_ID{}\t\\N", uuid::Uuid::new_v4(), i*2)?; + writeln!(f, "(31,0)\t{subject}\t0\t{debtor}\t\\N\tTX_ID{}\tREF{}", i*2+1, i*2)?; + writeln!(f, "(32,0)\t{subject}\t0\t{debtor}\t{}\t\\N\tREF{}", uuid::Uuid::new_v4(), i*2+1) + } else { + writeln!(f, "(40,0)\t{subject}\t0\t{debtor}\t{}\tTX_ID{}\tREF{}", uuid::Uuid::new_v4(), i*2, i*2)?; + writeln!(f, "(40,0)\t{subject}\t0\t{debtor}\t{}\tTX_ID{}\tREF{}", uuid::Uuid::new_v4(), i*2+1, i*2+1) + } + }).await; + b.table("outgoing_transactions(amount, subject, execution_time, credit_payto, end_to_end_id, acct_svcr_ref)", |f, i| { + let subject = if i % 4 == 0 { CompactString::const_new("\\N")} else {format_compact!("subject {i}")}; + let creditor =ACCOUNT.as_ref().as_str(); + + if i % 2 == 0 { + writeln!(f, "(40,0)\t{subject}\t0\t{creditor}\t\\N\tREF{}", i*2)?; + writeln!(f, "(41,0)\t{subject}\t0\t{creditor}\tE2E_ID{}\t\\N", i*2) + } else { + writeln!(f, "(40,0)\t{subject}\t0\t{creditor}\tE2E_ID{}\tREF{}", i*2, i*2)?; + writeln!(f, "(41,0)\t{subject}\t0\t{creditor}\tE2E_ID{}\tREF{}", i*2+1, i*2+1) + } + }).await; + b.table("initiated_outgoing_transactions(amount, subject, initiation_time, credit_payto, outgoing_transaction_id, end_to_end_id)", |f, i| { + writeln!(f, "(42,0)\tsubject\t0\t{}\t{}\tE2E_ID{i}", &*ACCOUNT , i*2) + }).await; + b.table("prepared_transfers(type, account_pub, authorization_pub, authorization_sig, recurrent, reference_number, registered_at, incoming_transaction_id)", |f, i| { + let ty = if i%2==0 {"reserve"} else {"kyc"}; + let recurrent = if i%3 == 0 {"true" } else {"false"}; + let incoming_transaction_id = if i % 5 == 0 { CompactString::const_new("\\N") }else {format_compact!("{}", i*2)}; + + let reference_number = subject_fmt_qr_bill(accounts_pubs[i].1.as_ref()); + let key = hex::encode( accounts_pubs[i].1.as_ref()); + let sig = h64(); + writeln!(f, "{ty}\t\\\\x{key}\t\\\\x{key}\t\\\\x{sig}\t{recurrent}\t{reference_number}\t0\t{incoming_transaction_id}") + }).await; + b.table( + "pending_recurrent_incoming_transactions(incoming_transaction_id, authorization_pub)", + |f, i| { + let key = hex::encode(accounts_pubs[i].1.as_ref()); + writeln!(f, "{}\t\\\\x{key}", i * 2) + }, + ) + .await; + b.table( + "bounced_transactions(incoming_transaction_id, initiated_outgoing_transaction_id)", + |f, i| { + if i % 10 == 0 { + writeln!(f, "{}\t{}", i / 2, i / 2) + } else { + Ok(()) + } + }, + ) + .await; + b.table( + "talerable_incoming_transactions(type, metadata, incoming_transaction_id)", + |f, i| { + let hex = h32(); + let ty = if i % 2 == 0 { "reserve" } else { "kyc" }; + writeln!(f, "{ty}\t\\\\x{hex}\t{}", i * 2) + }, + ) + .await; + b.table( + "talerable_outgoing_transactions(wtid, exchange_base_url, outgoing_transaction_id)", + |f, i| { + let hex = h32(); + writeln!(f, "\\\\x{hex}\thttp://exchange.example.com/\t{}", i * 2 - 1) + }, + ) + .await; + b.table("transfer_operations(initiated_outgoing_transaction_id, request_uid, wtid, exchange_base_url)", |f, i| { + let h32 = h32(); + let h64 = h64(); + writeln!(f, "{i}\t\\\\x{h64}\t\\\\x{h32}\turl") + }).await; + + // Warm HTTP client + server.get("/taler-revenue/config").await.assert_ok(); + + // Register + b.measure("register_in", |_| malformed_in(&db)).await; + b.measure("register_incomplete_in", |_| malformed_incomplete_in(&db)) + .await; + b.measure("register_completed_in", |_| malformed_completeted_in(&db)) + .await; + b.measure("register_talerable_in", |_| talerable_in(&db)) + .await; + b.measure("register_talerable_incomplete_in", |_| { + talerable_incomplete_in(&db) + }) + .await; + b.measure("register_talerable_completed_in", |_| { + talerable_completeted_in(&db) + }) + .await; + b.measure("register_prepared_in", |_| prepared_in(&db)) + .await; + b.measure("register_prepared_incomplete_in", |_| { + prepared_incomplete_in(&db) + }) + .await; + b.measure("register_prepared_completed_in", |_| { + prepared_completeted_in(&db) + }) + .await; + b.measure("register_out", |_| malformed_out(&db)).await; + b.measure("register_talerable_out", |_| talerable_out(&db)) + .await; + b.measure("register_incomplete_out", |_| incomplete_out(&db)) + .await; + + // Revenue api + b.measure("transaction_revenue", async |_| { + server.get("/taler-revenue/history").await.assert_ok() + }) + .await; + + // Wire gateway + b.measure("wg_transfer", async |_| { + server + .post("/taler-wire-gateway/transfer") + .json(&json!({ + "request_uid": HashCode::rand(), + "amount": "KUDOS:0.0001", + "exchange_base_url": "http://exchange.example.com/", + "wtid": ShortHashCode::rand(), + "credit_account": &*ACCOUNT + })) + .await + .assert_ok() + }) + .await; + b.measure("wg_transfer_get", async |i| { + server + .get(&format!("/taler-wire-gateway/transfers/{}", i + 1)) + .await + .assert_ok() + }) + .await; + b.measure("wg_transfer_page", async |_| { + server + .get("/taler-wire-gateway/transfers") + .await + .assert_ok() + }) + .await; + b.measure("wg_transfer_page_filter", async |_| { + server + .get("/taler-wire-gateway/transfers?status=success") + .await + .assert_no_content() + }) + .await; + b.measure("wg_add", async |_| { + server + .post("/taler-wire-gateway/admin/add-incoming") + .json(&json!({ + "amount": "KUDOS:0.0001", + "reserve_pub": EddsaPublicKey::rand(), + "debit_account": &*ACCOUNT + })) + .await + .assert_ok() + }) + .await; + b.measure("wg_incoming", async |_| { + server + .get("/taler-wire-gateway/history/incoming") + .await + .assert_ok() + }) + .await; + b.measure("wg_outgoing", async |_| { + server + .get("/taler-wire-gateway/history/outgoing") + .await + .assert_ok() + }) + .await; + + // Wire transfer + b.measure("wt_register", async |i| { + let (pair, key) = &accounts_pubs[i]; + + server + .post("/taler-prepared-transfer/registration") + .json(&json!({ + "credit_amount": "KUDOS:55", + "type": "reserve", + "alg": "EdDSA", + "account_pub": key, + "authorization_pub": key, + "authorization_sig": eddsa_sign(&pair, key.as_ref()), + "recurrent":false + })) + .await + .assert_ok() + }) + .await; + b.measure("wt_unregister", async |i| { + let (pair, key) = &accounts_pubs[i]; + let now = Timestamp::now().to_string(); + let req = json!({ + "timestamp": &now, + "authorization_pub": key, + "authorization_sig": eddsa_sign(&pair, now.as_ref()), + }); + server + .post("/taler-prepared-transfer/unregistration") + .json(&req) + .await + .assert_no_content(); + server + .post("/taler-prepared-transfer/unregistration") + .json(&req) + .await + .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); + }) + .await; + } +} diff --git a/crates/libeufin-nexus/src/bin/testbench.rs b/crates/libeufin-nexus/src/bin/testbench.rs @@ -0,0 +1,358 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{borrow::Cow, fmt::Display, str::FromStr}; + +use anyhow::bail; +use clap::{Parser, ValueEnum}; +use jiff::Timestamp; +use libeufin_ebics::keys::{load_bank_keys, load_client_keys}; +use libeufin_nexus::{CONFIG_SOURCE, config::NexusCfg, run}; +use owo_colors::OwoColorize as _; +use reedline::{FileBackedHistory, Prompt, Reedline, Signal}; +use taler_common::{config::Config, log::taler_logger, types::payto::TransferIbanPayto}; +use tracing::Level; +use tracing_subscriber::util::SubscriberInitExt as _; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum Component { + Nexus, + Ebisync, +} + +#[derive(Parser)] +/// Run integration tests on banks provider +pub struct TestbenchCmd { + #[arg(value_enum)] + component: Component, + platform: String, +} + +#[derive(Parser)] +#[command(name = "shell", no_binary_name = true)] +/// Run integration tests on banks provider +pub enum NexusCmd { + ResetKeys, + ResetDb, + Tx, + Fetch { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + raw_args: Vec<String>, + }, + Submit { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + raw_args: Vec<String>, + }, + List { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + raw_args: Vec<String>, + }, + Wss, + TxCheck, + Exit, +} + +fn step(name: impl Display) { + println!("{}", name.magenta()) +} + +fn msg(msg: impl Display) { + println!("{}", msg.yellow()) +} + +fn err(msg: impl Display) { + println!("{}", msg.red()) +} + +fn check<R, E: Display>(res: Result<R, E>) -> bool { + match &res { + Ok(_) => println!("{}", "OK".green()), + Err(e) => { + tracing::error!(target: "testbench", "{e}"); + err("ERROR") + } + }; + res.is_ok() +} + +pub async fn nexus_cmd(cfg: &Config, cmd: &str) -> bool { + let parts = shlex::split(cmd).unwrap(); + let args = std::iter::once("libeufin_nexus").chain(parts.iter().map(|it| it.as_str())); + + match libeufin_nexus::Args::try_parse_from(args) { + Ok(cmd) => { + tokio::select! { + res = run(cfg.clone(), cmd.cmd) => check(res), + _ = tokio::signal::ctrl_c() => false + } + } + Err(e) => { + println!("Error: {}", e); + false + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + taler_logger(Some(Level::DEBUG)).init(); + let cmd = TestbenchCmd::parse(); + // List available platform + let platforms: Vec<_> = std::fs::read_dir("testbench/test/platform") + .unwrap() + .filter_map(|entry| { + let e = entry.unwrap(); + let filename = e.file_name(); + if filename == "config.json" { + None + } else { + Some( + filename + .to_string_lossy() + .strip_suffix(".conf") + .unwrap() + .to_owned(), + ) + } + }) + .collect(); + if !platforms.contains(&cmd.platform) { + bail!( + "Unknown platform '{}', expected one of {}", + cmd.platform, + platforms.join(", ") + ); + } + + // Augment config + let simple_cfg = + std::fs::read_to_string(format!("testbench/test/platform/{}.conf", cmd.platform)).unwrap(); + let cfg = format!( + r#" + {simple_cfg} + {} + [paths] + LIBEUFIN_NEXUS_HOME = testbench/test/{} + EBISYNC_HOME = testbench/test/{} + + [nexus-fetch] + FREQUENCY = 1h + CHECKPOINT_TIME_OF_DAY = 16:52 + + [ebisync-fetch] + FREQUENCY = 1h + CHECKPOINT_TIME_OF_DAY = 16:52 + DESTINATION = azure-blob-storage + AZURE_API_URL = http://localhost:10000/devstoreaccount1/ + AZURE_ACCOUNT_NAME = devstoreaccount1 + AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + AZURE_CONTAINER = test + + [ebisync-submit] + SOURCE = ebisync-api + AUTH_METHOD = none + + [libeufin-nexusdb-postgres] + CONFIG = postgres:///libeufintestbench + + [ebisyncdb-postgres] + CONFIG = postgres:///libeufintestbench + "#, + simple_cfg + .replace("[nexus-ebics]", "[ebisync]") + .replace("[nexus-setup]", "[ebisync-setup]"), + cmd.platform, + cmd.platform + ); + + let history = Box::new( + FileBackedHistory::with_file( + 1000, + match cmd.component { + Component::Ebisync => ".ebisync_history", + Component::Nexus => ".nexus_history", + } + .into(), + ) + .expect("Error configuring history with file"), + ); + let mut line_editor = Reedline::create().with_history(history); + let prompt = BenchPrompt { + prompt: format!("{:?} {}", cmd.component, cmd.platform), + }; + let cfg = Config::from_mem_with_env(CONFIG_SOURCE, &cfg).unwrap(); + let cfg = NexusCfg::parse(cfg).unwrap(); + let ebics = cfg.keys().unwrap(); + let (name, settings) = match cfg.host().unwrap().base_url.as_str() { + "https://isotest.postfinance.ch/ebicsweb/ebicsweb" => ( + "PostFinance IsoTest", + Some("https://isotest.postfinance.ch/corporates/user/settings/ebics"), + ), + "https://iso20022test.credit-suisse.com/ebicsweb/ebicsweb" => ( + "Credit Suisse isoTest", + Some("https://iso20022test.credit-suisse.com/user/settings/ebics"), + ), + "https://ebics.postfinance.ch/ebics/ebics.aspx" => ("PostFinance", None), + _ => ("Unknown", None), + }; + let test = settings.is_some(); + let payto = match cfg.currency.as_ref() { + "CHF" => { + "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff" + } + "EUR" => { + "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff" + } + _ => todo!("{}", cfg.currency), + }; + let payto = TransferIbanPayto::from_str(payto).unwrap(); + let ebics_log = format!("--debug-ebics testbench/test/{}", cmd.platform); + loop { + // Automatic setup + { + let client = load_client_keys(ebics.client.as_ref()).unwrap(); + let bank = load_bank_keys(ebics.bank.as_ref()).unwrap(); + if settings.is_none() && client.is_none() { + msg("Manual setup is required for non test environment") + } else if client + .map(|it| !it.submitted_ini || !it.submitted_hia) + .unwrap_or(true) + || bank.map(|it| !it.accepted).unwrap_or(true) + { + step("Run EBICS setup"); + if !nexus_cmd(&cfg.cfg, &format!("ebics-setup {ebics_log}")).await { + if let Some(settings) = settings { + let client = load_client_keys(ebics.client.as_ref()).unwrap(); + if client + .map(|it| !it.submitted_ini || !it.submitted_hia) + .unwrap_or(true) + { + msg(format_args!( + "Got to {settings} and click on 'Reset EBICS user'" + )) + } else { + msg(format_args!( + "Got to {settings} and click on 'Activate EBICS user'" + )) + } + } else { + msg("Activate your keys at your bank") + } + } + } + } + let Signal::Success(buf) = line_editor.read_line(&prompt).unwrap() else { + break; + }; + match NexusCmd::try_parse_from(buf.split_whitespace()) { + Ok(cmd) => match cmd { + NexusCmd::ResetDb => { + nexus_cmd(&cfg.cfg, "dbinit -r").await; + } + NexusCmd::Fetch { raw_args } => { + nexus_cmd( + &cfg.cfg, + &format!( + "ebics-fetch {ebics_log} {}", + shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap() + ), + ) + .await; + } + NexusCmd::Submit { raw_args } => { + nexus_cmd( + &cfg.cfg, + &format!( + "ebics-submit {ebics_log} {}", + shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap() + ), + ) + .await; + } + NexusCmd::Tx => { + nexus_cmd( + &cfg.cfg, + &format!( + "initiate-payment --amount={}:0.1 --subject=\"single {}\" {payto}", + cfg.currency, + Timestamp::now() + ), + ) + .await; + } + NexusCmd::List { raw_args } => { + nexus_cmd( + &cfg.cfg, + &format!( + "list {}", + shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap() + ), + ) + .await; + } + NexusCmd::ResetKeys => { + if test { + std::fs::remove_file(&ebics.client)?; + } + std::fs::remove_file(&ebics.bank)?; + } + NexusCmd::TxCheck => { + nexus_cmd(&cfg.cfg, &format!("testing tx-check {ebics_log}")).await; + } + NexusCmd::Wss => { + nexus_cmd(&cfg.cfg, &format!("testing wss {ebics_log}")).await; + } + NexusCmd::Exit => return Ok(()), + }, + Err(e) => { + println!("{e}"); + } + } + } + Ok(()) +} + +struct BenchPrompt { + prompt: String, +} + +impl Prompt for BenchPrompt { + fn render_prompt_left(&self) -> Cow<'_, str> { + Cow::Borrowed(&self.prompt) + } + + fn render_prompt_right(&self) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn render_prompt_indicator(&self, _: reedline::PromptEditMode) -> Cow<'_, str> { + Cow::Borrowed(">") + } + + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { + Cow::Borrowed(":") + } + + fn render_prompt_history_search_indicator( + &self, + _: reedline::PromptHistorySearch, + ) -> Cow<'_, str> { + Cow::Borrowed(">") + } +} diff --git a/crates/libeufin-nexus/src/config.rs b/crates/libeufin-nexus/src/config.rs @@ -0,0 +1,295 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{cell::OnceCell, time::Duration}; + +use jiff::{ + Timestamp, + civil::{Date, Time}, +}; +use libeufin_ebics::{ + config::{EbicsHostCfg, EbicsKeysCfg}, + dialect::Dialect, +}; +use regex::Regex; +use taler_api::config::DbCfg; +use taler_common::{ + config::{Config, ValueErr}, + map_config, + types::{ + amount::{Amount, Currency}, + payto::{BankID, FullIbanPayto}, + utils::date_to_utc_ts, + }, +}; + +pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { + DbCfg::parse(cfg.section("libeufin-nexusdb-postgres")) +} + +pub struct NexusKeysCfg { + pub bank: String, + pub client: String, +} + +impl NexusKeysCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let s = cfg.section("nexus-ebics"); + Ok(Self { + bank: s.path("bank_public_keys_file").require()?, + client: s.path("client_private_keys_file").require()?, + }) + } + + pub fn ebics<'a>(&'a self) -> EbicsKeysCfg<'a> { + EbicsKeysCfg { + bank: self.bank.as_str(), + client: self.client.as_str(), + } + } +} + +#[derive(Clone)] +pub struct NexusHostCfg { + pub base_url: url::Url, + pub unix_path: Option<String>, + pub host_id: String, + pub user_id: String, + pub partner_id: String, +} + +impl NexusHostCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let s = cfg.section("nexus-ebics"); + Ok(Self { + base_url: s.url("host_base_url").require()?, + unix_path: s.path("UNIXPATH").opt()?, + host_id: s.str("host_id").require()?, + user_id: s.str("user_id").require()?, + partner_id: s.str("partner_id").require()?, + }) + } + + pub fn ebics<'a>(&'a self) -> EbicsHostCfg<'a> { + EbicsHostCfg { + base_url: self.base_url.as_str(), + unix_path: self.unix_path.as_deref(), + host_id: &self.host_id, + user_id: &self.user_id, + partner_id: &self.partner_id, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum AccountType { + Exchange, + Normal, +} + +pub struct NexusIngestCfg { + pub account_type: AccountType, + pub ignore_txs_before: Timestamp, + pub ignore_bounces_before: Timestamp, + pub restriction_payto_regex: Option<Regex>, + pub bounce_deduce_fee: bool, + pub bounce_fee: Amount, + pub currency: Currency, +} + +impl NexusIngestCfg { + pub const fn simple(account_type: AccountType, currency: &Currency) -> Self { + Self { + account_type, + ignore_txs_before: Timestamp::UNIX_EPOCH, + ignore_bounces_before: Timestamp::UNIX_EPOCH, + restriction_payto_regex: None, + bounce_deduce_fee: false, + bounce_fee: Amount::zero(currency), + currency: Currency::KUDOS, + } + } +} + +pub struct NexusFetchCfg { + pub frequency: Duration, + pub frequency_raw: String, + pub checkpoint_time: Time, + pub ignore_txs_before: Timestamp, + pub ignore_bounces_before: Timestamp, + pub restriction_payto_regex: Option<Regex>, + pub bounce_deduce_fee: bool, + pub bounce_fee: Amount, +} + +impl NexusFetchCfg { + pub fn parse(cfg: &Config, currency: &Currency) -> Result<Self, ValueErr> { + let s = cfg.section("nexus-fetch"); + + Ok(Self { + frequency: s.duration("frequency").require()?, + frequency_raw: s.str("frequency").require()?, + checkpoint_time: s.time("checkpoint_time_of_day").require()?, + ignore_txs_before: date_to_utc_ts( + &s.date("ignore_transactions_before").default(Date::ZERO)?, + ), + ignore_bounces_before: date_to_utc_ts( + &s.date("ignore_bounces_before").default(Date::ZERO)?, + ), + restriction_payto_regex: s.regex("restriction_payto_regex").opt()?, + bounce_deduce_fee: s.boolean("bounce_deduce_fee").default(false)?, + bounce_fee: s + .amount("bounce_fee", currency) + .default(Amount::zero(currency))?, + }) + } +} + +pub struct NexusSubmitCfg { + pub frequency: Duration, + pub frequency_raw: String, + pub require_ack: bool, +} + +impl NexusSubmitCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let s = cfg.section("nexus-submit"); + + Ok(Self { + frequency: s.duration("frequency").require()?, + frequency_raw: s.str("frequency").require()?, + require_ack: s.boolean("manual_ack").default(false)?, + }) + } +} + +pub struct NexusEbicsConfig { + pub account: FullIbanPayto, + pub dialect: Dialect, +} + +impl NexusEbicsConfig { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let s = cfg.section("nexus-ebics"); + Ok(Self { + account: FullIbanPayto::new( + BankID { + iban: s.parse("IBAN", "iban").require()?, + bic: Some(s.parse("BIC", "bic").require()?), + }, + &s.str("name").require()?, + ), + dialect: s.parse("bank dialect", "bank_dialect").require()?, + }) + } +} + +pub struct NexusCfg { + pub cfg: Config, + pub currency: Currency, + pub account_type: AccountType, + pub keys: OnceCell<NexusKeysCfg>, + pub host: OnceCell<NexusHostCfg>, + pub fetch: OnceCell<NexusFetchCfg>, + pub submit: OnceCell<NexusSubmitCfg>, + pub ebics: OnceCell<NexusEbicsConfig>, +} + +impl NexusCfg { + pub fn parse(cfg: Config) -> Result<Self, ValueErr> { + let s = cfg.section("nexus-ebics"); + Ok(Self { + currency: s.currency("currency").require()?, + account_type: map_config!(s, "account type", "ACCOUNT_TYPE", + "exchange" => { AccountType::Exchange }, + "normal" => { AccountType::Normal } + ) + .require()?, + cfg, + keys: OnceCell::new(), + host: OnceCell::new(), + fetch: OnceCell::new(), + submit: OnceCell::new(), + ebics: OnceCell::new(), + }) + } + + pub fn keys(&self) -> Result<&NexusKeysCfg, ValueErr> { + // TODO use get_or_try_init when stable + if let Some(keys) = self.keys.get() { + return Ok(keys); + } + let keys = NexusKeysCfg::parse(&self.cfg)?; + self.keys.set(keys).ok(); + Ok(self.keys.get().unwrap()) + } + + pub fn host(&self) -> Result<&NexusHostCfg, ValueErr> { + // TODO use get_or_try_init when stable + if let Some(host) = self.host.get() { + return Ok(host); + } + let host = NexusHostCfg::parse(&self.cfg)?; + self.host.set(host).ok(); + Ok(self.host.get().unwrap()) + } + + pub fn fetch(&self) -> Result<&NexusFetchCfg, ValueErr> { + // TODO use get_or_try_init when stable + if let Some(fetch) = self.fetch.get() { + return Ok(fetch); + } + let fetch = NexusFetchCfg::parse(&self.cfg, &self.currency)?; + self.fetch.set(fetch).ok(); + Ok(self.fetch.get().unwrap()) + } + + pub fn submit(&self) -> Result<&NexusSubmitCfg, ValueErr> { + // TODO use get_or_try_init when stable + if let Some(submit) = self.submit.get() { + return Ok(submit); + } + let submit = NexusSubmitCfg::parse(&self.cfg)?; + self.submit.set(submit).ok(); + Ok(self.submit.get().unwrap()) + } + + pub fn ebics(&self) -> Result<&NexusEbicsConfig, ValueErr> { + // TODO use get_or_try_init when stable + if let Some(ebics) = self.ebics.get() { + return Ok(ebics); + } + let ebics = NexusEbicsConfig::parse(&self.cfg)?; + self.ebics.set(ebics).ok(); + Ok(self.ebics.get().unwrap()) + } + + pub fn ingest(&self) -> Result<NexusIngestCfg, ValueErr> { + let fetch = self.fetch()?; + Ok(NexusIngestCfg { + account_type: self.account_type, + ignore_txs_before: fetch.ignore_txs_before, + ignore_bounces_before: fetch.ignore_bounces_before, + restriction_payto_regex: fetch.restriction_payto_regex.clone(), + bounce_deduce_fee: fetch.bounce_deduce_fee, + bounce_fee: fetch.bounce_fee, + currency: self.currency, + }) + } +} diff --git a/src/db.rs b/crates/libeufin-nexus/src/db.rs diff --git a/crates/libeufin-nexus/src/db/exchange.rs b/crates/libeufin-nexus/src/db/exchange.rs @@ -0,0 +1,325 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use jiff::Timestamp; +use sqlx::{PgPool, QueryBuilder, Row as _, postgres::PgRow}; +use taler_api::{ + db::{BindHelper, TypeHelper as _, history, page}, + serialized, + subject::fmt_out_subject, +}; +use taler_common::{ + api_params::{History, Page}, + api_revenue::RevenueIncomingBankTransaction, + api_wire::{ + IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, + TransferState, TransferStatus, + }, + db::IncomingType, + types::amount::Currency, +}; +use tokio::sync::watch::Receiver; + +use crate::model::SubmissionState; + +pub async fn outgoing_history( + db: &PgPool, + currency: &Currency, + params: &History, + listen: impl FnOnce() -> Receiver<i64>, +) -> sqlx::Result<Vec<OutgoingBankTransaction>> { + history( + db, + "outgoing_transaction_id", + params, + listen, + || { + QueryBuilder::new( + " + SELECT + outgoing_transaction_id + ,execution_time + ,amount + ,debit_fee + ,credit_payto + ,wtid + ,exchange_base_url + ,metadata + FROM talerable_outgoing_transactions + JOIN outgoing_transactions USING(outgoing_transaction_id) + WHERE + ", + ) + }, + |r: PgRow| { + Ok(OutgoingBankTransaction { + row_id: r.try_get_safeu64("outgoing_transaction_id")?, + amount: r.try_get_amount("amount", currency)?, + debit_fee: r + .try_get_opt_amount("debit_fee", currency)? + .filter(|it| it.is_zero()), + credit_account: r.try_get_payto("credit_payto")?, + date: r.try_get_timestamp("execution_time")?.into(), + exchange_base_url: r.try_get_url("exchange_base_url")?, + wtid: r.try_get("wtid")?, + metadata: r.try_get("metadata")?, + }) + }, + ) + .await +} + +pub async fn incoming_history( + db: &PgPool, + currency: &Currency, + params: &History, + listen: impl FnOnce() -> Receiver<i64>, +) -> sqlx::Result<Vec<IncomingBankTransaction>> { + history( + db, + "incoming_transaction_id", + params, + listen, + || { + QueryBuilder::new( + " + SELECT + incoming_transaction_id + ,execution_time + ,amount + ,credit_fee + ,debit_payto + ,type::text + ,metadata + ,authorization_pub + ,authorization_sig + FROM talerable_incoming_transactions + JOIN incoming_transactions USING(incoming_transaction_id) + WHERE + ", + ) + }, + |r: PgRow| { + let credit_fee = r + .try_get_opt_amount("credit_fee", currency)? + .filter(|it| it.is_zero()); + Ok(match r.try_get("type")? { + IncomingType::reserve => IncomingBankTransaction::Reserve { + row_id: r.try_get_safeu64("incoming_transaction_id")?, + amount: r.try_get_amount("amount", currency)?, + credit_fee, + debit_account: r.try_get_payto("debit_payto")?, + date: r.try_get_timestamp("execution_time")?.into(), + reserve_pub: r.try_get("metadata")?, + authorization_pub: r.try_get("authorization_pub")?, + authorization_sig: r.try_get("authorization_sig")?, + }, + IncomingType::kyc => IncomingBankTransaction::Kyc { + row_id: r.try_get_safeu64("incoming_transaction_id")?, + amount: r.try_get_amount("amount", currency)?, + credit_fee, + debit_account: r.try_get_payto("debit_payto")?, + date: r.try_get_timestamp("execution_time")?.into(), + account_pub: r.try_get("metadata")?, + authorization_pub: r.try_get("authorization_pub")?, + authorization_sig: r.try_get("authorization_sig")?, + }, + IncomingType::map => unimplemented!("MAP are never listed in the history"), + }) + }, + ) + .await +} + +pub async fn revenue_history( + db: &PgPool, + currency: &Currency, + params: &History, + listen: impl FnOnce() -> Receiver<i64>, +) -> sqlx::Result<Vec<RevenueIncomingBankTransaction>> { + history( + db, + "incoming_transaction_id", + params, + listen, + || { + QueryBuilder::new( + " + SELECT + incoming_transaction_id + ,execution_time + ,amount + ,credit_fee + ,debit_payto + ,subject + FROM incoming_transactions + WHERE debit_payto IS NOT NULL AND subject IS NOT NULL AND + ", + ) + }, + |r: PgRow| { + Ok(RevenueIncomingBankTransaction { + row_id: r.try_get_safeu64("incoming_transaction_id")?, + amount: r.try_get_amount("amount", currency)?, + credit_fee: r + .try_get_opt_amount("credit_fee", currency)? + .filter(|it| it.is_zero()), + debit_account: r.try_get_payto("debit_payto")?, + date: r.try_get_timestamp("execution_time")?.into(), + subject: r.try_get("subject")?, + }) + }, + ) + .await +} + +pub enum TransferResult { + Success { id: u64, timestamp: Timestamp }, + RequestUidReuse, + WtidReuse, +} + +pub async fn transfer( + db: &PgPool, + req: &TransferRequest, + e2e_id: &str, + timestamp: &Timestamp, +) -> sqlx::Result<TransferResult> { + let subject = fmt_out_subject(&req.wtid, &req.exchange_base_url, req.metadata.as_deref()); + serialized!( + sqlx::query( + " + SELECT + out_request_uid_reuse + ,out_wtid_reuse + ,out_tx_row_id + ,out_timestamp + FROM taler_transfer($1,$2,$3,$4,$5,$6,$7,$8,$9) + ", + ) + .bind(&req.request_uid) + .bind(&req.wtid) + .bind(&subject) + .bind(req.amount) + .bind(req.exchange_base_url.as_str()) + .bind(&req.metadata) + .bind(req.credit_account.as_ref().as_str()) + .bind(e2e_id) + .bind_timestamp(timestamp) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_request_uid_reuse")? { + TransferResult::RequestUidReuse + } else if r.try_get_flag("out_wtid_reuse")? { + TransferResult::WtidReuse + } else { + TransferResult::Success { + id: r.try_get_u64("out_tx_row_id")?, + timestamp: r.try_get_timestamp("out_timestamp")?, + } + }) + }) + .fetch_one(db) + ) +} + +pub async fn transfer_by_id( + db: &PgPool, + currency: &Currency, + id: u64, +) -> sqlx::Result<Option<TransferStatus>> { + serialized!( + sqlx::query( + " + SELECT + wtid + ,exchange_base_url + ,metadata + ,amount + ,credit_payto + ,initiation_time + ,status + ,status_msg + FROM transfer_operations + JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) + WHERE initiated_outgoing_transaction_id=$1 + ", + ) + .bind(id as i64) + .try_map(|r: PgRow| { + Ok(TransferStatus { + status: r + .try_get::<SubmissionState, _>("status")? + .to_transfer_status(), + status_msg: r.try_get("status_msg")?, + amount: r.try_get_amount("amount", currency)?, + origin_exchange_url: r.try_get("exchange_base_url")?, + metadata: r.try_get("metadata")?, + wtid: r.try_get("wtid")?, + credit_account: r.try_get_payto("credit_payto")?, + timestamp: r.try_get_timestamp("initiation_time")?.into(), + }) + }) + .fetch_optional(db) + ) +} + +pub async fn transfer_page( + db: &PgPool, + currency: &Currency, + params: &Page, + status: &Option<TransferState>, +) -> sqlx::Result<Vec<TransferListStatus>> { + page( + db, + params, + "initiated_outgoing_transaction_id", + || { + let mut builder = QueryBuilder::new( + " + SELECT + initiated_outgoing_transaction_id + ,amount + ,status + ,credit_payto + ,initiation_time + FROM transfer_operations + JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) + WHERE + ", + ); + if let Some(status) = status { + match status { + TransferState::pending => { + builder.push("( status = ").push_bind(SubmissionState::pending).push(" OR ").push(" status = ").push_bind(SubmissionState::unsubmitted).push(") AND "); + } + status => { + builder.push(" status = ").push_bind(SubmissionState::from(*status)).push(" AND ");} + } + } + builder + }, + |r: PgRow| { + Ok(TransferListStatus { + row_id: r.try_get_safeu64("initiated_outgoing_transaction_id")?, + status: r.try_get::<SubmissionState, _>("status")?.to_transfer_status(), + amount: r.try_get_amount("amount", currency)?, + credit_account: r.try_get_payto("credit_payto")?, + timestamp: r.try_get_timestamp("initiation_time")?.into(), + }) + }, + ) + .await +} diff --git a/crates/libeufin-nexus/src/db/initiated.rs b/crates/libeufin-nexus/src/db/initiated.rs @@ -0,0 +1,894 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::collections::BTreeMap; + +use const_format::formatcp; +use jiff::Timestamp; +use libeufin_ebics::iso20022::model::{OutId, OutTx}; +use sqlx::{PgPool, Row as _, postgres::PgRow}; +use taler_api::db::{BindHelper as _, TypeHelper as _}; +use taler_common::types::{ + amount::{Amount, Currency}, + payto::PaytoURI, +}; + +use crate::{ + db::{PENDING, UNSETTLED}, + model::{Initiated, PaymentBatch, SubmissionState}, +}; + +/// Outgoing payments initiation result +#[derive(Debug, PartialEq, Eq)] +pub enum PaymentInitiationResult { + Success(u64), + RequestUidReuse, +} + +/// Initiate a new payment +pub async fn initiate( + pool: &PgPool, + amount: &Amount, + subject: &str, + creditor: &PaytoURI, + initiation_time: &Timestamp, + e2e_id: &str, +) -> sqlx::Result<PaymentInitiationResult> { + let res = sqlx::query( + " + INSERT INTO initiated_outgoing_transactions ( + amount, + subject, + credit_payto, + initiation_time, + end_to_end_id + ) VALUES ($1,$2,$3,$4,$5) + RETURNING initiated_outgoing_transaction_id + ", + ) + .bind(amount) + .bind(subject) + .bind(creditor.as_ref().as_str()) + .bind_timestamp(initiation_time) + .bind(e2e_id) + .try_map(|r: PgRow| Ok(PaymentInitiationResult::Success(r.try_get_u64(0)?))) + .fetch_one(pool) + .await; + if let Err(e) = &res + && let Some(db_err) = e.as_database_error() + && db_err.code() == Some(std::borrow::Cow::Borrowed("23505")) + { + Ok(PaymentInitiationResult::RequestUidReuse) + } else { + res + } +} + +/// Group unbatched transaction into a single batch +pub async fn batch_initiated( + pool: &PgPool, + timestamp: &Timestamp, + ebics_id: &str, + require_ack: bool, +) -> sqlx::Result<()> { + sqlx::query("SELECT batch_outgoing_transactions($1, $2, $3)") + .bind_timestamp(timestamp) + .bind(ebics_id) + .bind(require_ack) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn initiated_ack(db: &PgPool, id: u64) -> sqlx::Result<()> { + sqlx::query("UPDATE initiated_outgoing_transactions SET awaiting_ack=false WHERE initiated_outgoing_transaction_id=$1") + .bind(id as i64) + .execute(db) + .await?; + Ok(()) +} + +pub async fn initiated_submittable( + db: &PgPool, + currency: &Currency, +) -> sqlx::Result<Vec<PaymentBatch>> { + const SELECT_PART: &str = " + SELECT initiated_outgoing_batch_id, message_id, creation_date, sum + FROM initiated_outgoing_batches + "; + let mut tx = db.begin().await?; + // We want to maximize the number of successfully submitted batches in the event + // of a malformed transaction or a persistent error classified as transient. We send + // the unsubmitted batches first, starting with the oldest by creation time. + // This is the happy path, giving every batch a chance while being fair on the + // basis of creation date. + // Then we retry the failed batches, starting with the oldest by submission time. + // This the bad path retrying each failed batch applying a rotation based on + // resubmission time. + let mut batches = sqlx::query(formatcp!( + " + ({SELECT_PART} WHERE status='unsubmitted' ORDER BY creation_date ASC) + UNION ALL + ({SELECT_PART} WHERE status='transient_failure' ORDER BY submission_date) + " + )) + .try_map(|r: PgRow| { + Ok(PaymentBatch { + id: r.try_get_u64("initiated_outgoing_batch_id")?, + msg_id: r.try_get("message_id")?, + creation_date: r.try_get_timestamp("creation_date")?, + sum: r.try_get_amount("sum", currency)?, + payments: Vec::new(), + }) + }) + .fetch_all(&mut *tx) + .await?; + let mut batch_map: BTreeMap<_, _> = batches.iter_mut().map(|it| (it.id, it)).collect(); + // Then load transactions + sqlx::query( + " + SELECT + initiated_outgoing_transaction_id + ,amount + ,subject + ,credit_payto + ,initiated_outgoing_transactions.initiation_time + ,end_to_end_id + ,initiated_outgoing_batch_id + FROM initiated_outgoing_transactions + JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) + WHERE initiated_outgoing_batches.status IN ('unsubmitted', 'transient_failure') + ", + ) + .try_map(|r: PgRow| { + let payment = Initiated { + id: r.try_get_u64("initiated_outgoing_transaction_id")?, + amount: r.try_get_amount("amount", currency)?, + creditor: r.try_get_parse("credit_payto")?, + subject: r.try_get("subject")?, + initiation_time: r.try_get_timestamp("initiation_time")?, + e2e_id: r.try_get("end_to_end_id")?, + }; + let batch_id = r.try_get_u64("initiated_outgoing_batch_id")?; + batch_map.get_mut(&batch_id).unwrap().payments.push(payment); + Ok(()) + }) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(batches) +} + +pub async fn unsettled_tx_in_batch( + db: &PgPool, + currency: &Currency, + msg_id: &str, + execution_time: &Timestamp, +) -> sqlx::Result<Vec<OutTx>> { + sqlx::query(formatcp!( + " + SELECT + end_to_end_id, + amount, + subject, + credit_payto + FROM initiated_outgoing_transactions + JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) + WHERE message_id = $1 + AND initiated_outgoing_transactions.{UNSETTLED} + " + )) + .bind(msg_id) + .try_map(|r: PgRow| { + Ok(OutTx { + id: OutId { + msg_id: Some(msg_id.into()), + e2e_id: r.try_get("end_to_end_id")?, + sref: None, + }, + amount: r.try_get_amount("amount", currency)?, + debit_fee: Amount::zero(currency), + subject: r.try_get("subject")?, + execution_time: *execution_time, + creditor: r.try_get_opt_payto("credit_payto")?, + }) + }) + .fetch_all(db) + .await +} + +/** Register submission success of order [orderId] for batch [id] at [timestamp] */ +pub async fn batch_sub_success( + db: &PgPool, + batch_id: u64, + timestamp: &Timestamp, + order_id: &str, +) -> sqlx::Result<()> { + let mut tx = db.begin().await?; + // Update batch status + let updated = sqlx::query( + " + UPDATE initiated_outgoing_batches + SET status = 'pending' + ,submission_date = $1 + ,status_msg = NULL + ,order_id = $2 + ,submission_counter = submission_counter + 1 + WHERE initiated_outgoing_batch_id = $3 AND order_id IS NULL + ", + ) + .bind_timestamp(timestamp) + .bind(order_id) + .bind(batch_id as i64) + .execute(&mut *tx) + .await?; + if updated.rows_affected() > 0 { + // Update unsettled batch's transaction status + sqlx::query(formatcp!( + " + UPDATE initiated_outgoing_transactions + SET status = 'pending', status_msg = NULL + WHERE initiated_outgoing_batch_id = $1 AND {UNSETTLED} + " + )) + .bind(batch_id as i64) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +/** Register submission failure with [msg] for batch [id] at [timestamp]*/ +pub async fn batch_sub_failure( + db: &PgPool, + batch_id: u64, + timestamp: &Timestamp, + msg: &str, +) -> sqlx::Result<()> { + let permanent = false; + let mut tx = db.begin().await?; + // Update batch status + sqlx::query( + " + UPDATE initiated_outgoing_batches + SET status = $1 + ,submission_date = $2 + ,status_msg = $3 + ,submission_counter = submission_counter + 1 + WHERE initiated_outgoing_batch_id = $4 + ", + ) + .bind(if permanent { + SubmissionState::permanent_failure + } else { + SubmissionState::transient_failure + }) + .bind_timestamp(timestamp) + .bind(msg) + .bind(batch_id as i64) + .execute(&mut *tx) + .await?; + // Update unsettled batch's transaction status + sqlx::query(formatcp!( + " + UPDATE initiated_outgoing_transactions + SET status = $1, status_msg = $2 + WHERE initiated_outgoing_batch_id = $3 AND {UNSETTLED} + " + )) + .bind(if permanent { + SubmissionState::permanent_failure + } else { + SubmissionState::transient_failure + }) + .bind(msg) + .bind(batch_id as i64) + .execute(&mut *tx) + .await?; + tx.commit().await +} + +/** Register order step [msg] for [orderId] */ +pub async fn order_step(db: &PgPool, order_id: &str, msg: &str) -> sqlx::Result<()> { + let mut tx = db.begin().await?; + // Update batch status + let batch_id = sqlx::query(formatcp!( + " + UPDATE initiated_outgoing_batches + SET status = 'pending', status_msg = $1 + WHERE order_id = $2 AND {PENDING} + RETURNING initiated_outgoing_batch_id + " + )) + .bind(msg) + .bind(order_id) + .try_map(|r: PgRow| r.try_get_u64(0)) + .fetch_optional(&mut *tx) + .await?; + if let Some(batch_id) = batch_id { + // Update unsettled batch's transaction status + sqlx::query(formatcp!( + " + UPDATE initiated_outgoing_transactions + SET status = 'pending', status_msg = $1 + WHERE initiated_outgoing_batch_id = $2 AND {PENDING} + " + )) + .bind(msg) + .bind(batch_id as i64) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +/** Register order success for [orderId] and return message_id if found */ +pub async fn order_success(db: &PgPool, order_id: &str) -> sqlx::Result<Option<String>> { + let mut tx = db.begin().await?; + // Update batch status + let res = sqlx::query(formatcp!( + " + UPDATE initiated_outgoing_batches + SET status = 'success' + WHERE order_id = $1 + RETURNING initiated_outgoing_batch_id, message_id + " + )) + .bind(order_id) + .try_map(|r: PgRow| Ok((r.try_get_u64(0)?, r.try_get(1)?))) + .fetch_optional(&mut *tx) + .await?; + if let Some((batch_id, _)) = &res { + // Update unsettled batch's transaction status + sqlx::query(formatcp!( + " + UPDATE initiated_outgoing_transactions + SET status = 'pending' + WHERE initiated_outgoing_batch_id = $1 AND {UNSETTLED} + " + )) + .bind(*batch_id as i64) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(res.map(|(_, msg_id)| msg_id)) +} + +/** Register order failure for [orderId] and return message_id and previous status_msg if found */ +pub async fn order_failure( + db: &PgPool, + order_id: &str, +) -> sqlx::Result<Option<(String, Option<String>)>> { + let mut tx = db.begin().await?; + // Update batch status + let res = sqlx::query(formatcp!( + " + UPDATE initiated_outgoing_batches + SET status = 'permanent_failure' + WHERE order_id = $1 + RETURNING initiated_outgoing_batch_id, message_id, status_msg + " + )) + .bind(order_id) + .try_map(|r: PgRow| Ok((r.try_get_u64(0)?, r.try_get(1)?, r.try_get(2)?))) + .fetch_optional(&mut *tx) + .await?; + if let Some((batch_id, _, _)) = &res { + // Update unsettled batch's transaction status + sqlx::query(formatcp!( + " + UPDATE initiated_outgoing_transactions + SET status = 'permanent_failure' + WHERE initiated_outgoing_batch_id = $1 + " + )) + .bind(*batch_id as i64) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(res.map(|(_, msg_id, status_msg)| (msg_id, status_msg))) +} + +/** Register payment status [state] with [msg] for batch [msgId] */ +pub async fn batch_status_update( + db: &PgPool, + msg_id: &str, + state: SubmissionState, + msg: &str, +) -> sqlx::Result<bool> { + sqlx::query(formatcp!( + "SELECT out_ok FROM batch_status_update($1,$2,$3)" + )) + .bind(msg_id) + .bind(state) + .bind(msg) + .try_map(|r: PgRow| r.try_get(0)) + .fetch_one(db) + .await +} + +/** Register payment status [state] with [msg] for transaction [endToEndId] in batch [msgId] */ +pub async fn tx_status_update( + db: &PgPool, + end_to_end_id: &str, + msg_id: &str, + state: SubmissionState, + msg: &str, +) -> sqlx::Result<bool> { + sqlx::query(formatcp!( + "SELECT out_ok FROM tx_status_update($1,$2,$3,$4)" + )) + .bind(end_to_end_id) + .bind(msg_id) + .bind(state) + .bind(msg) + .try_map(|r: PgRow| r.try_get(0)) + .fetch_one(db) + .await +} + +#[cfg(test)] +mod test { + use std::str::FromStr as _; + + use jiff::{Span, Timestamp, civil::Date}; + use libeufin_ebics::{ebics::rand_ebics_id, iso20022::model::Tx}; + use sqlx::{PgPool, Row as _, postgres::PgRow}; + use taler_api::db::TypeHelper as _; + use taler_common::{config::Config, types::utils::date_to_utc_ts}; + + use crate::{ + CONFIG_SOURCE, + config::{NexusCfg, NexusIngestCfg}, + db::{ + initiated::{ + PaymentInitiationResult, batch_initiated, batch_status_update, batch_sub_failure, + batch_sub_success, initiated_submittable, order_failure, order_step, order_success, + tx_status_update, + }, + test::{check_count, db_setup}, + }, + fetch::{register_outgoing, register_tx}, + model::SubmissionState, + test::{CURR, gen_in_pay, gen_initiate, gen_out_pay}, + }; + + #[tokio::test] + pub async fn initiated_skip() { + let (_, db) = db_setup().await; + let cfg = + Config::from_file(CONFIG_SOURCE, Some("../../libeufin-nexus/conf/skip.conf")).unwrap(); + let cfg = NexusCfg::parse(cfg).unwrap(); + let cfg = cfg.ingest().unwrap(); + let millis = Span::new().milliseconds(10); + + async fn ingest(db: &PgPool, cfg: &NexusIngestCfg, execution_time: Timestamp) { + for tx in [ + Tx::In( + gen_in_pay(format!("test at {execution_time}")) + .with_execution_time(execution_time), + ), + Tx::Out( + gen_out_pay(format!("test at {execution_time}")) + .with_execution_time(execution_time), + ), + ] { + register_tx(db, cfg, &tx).await.unwrap() + } + } + + assert_eq!( + cfg.ignore_txs_before, + date_to_utc_ts(&Date::from_str("2024-04-04").unwrap()) + ); + assert_eq!( + cfg.ignore_bounces_before, + date_to_utc_ts(&Date::from_str("2024-06-12").unwrap()) + ); + + // No transaction at the beginning + check_count(&db, 0, 0).await; + + // Skipped transactions + ingest(&db, &cfg, cfg.ignore_txs_before - millis).await; + check_count(&db, 0, 0).await; + + // Skipped bounces + ingest(&db, &cfg, cfg.ignore_txs_before).await; + ingest(&db, &cfg, cfg.ignore_txs_before + millis).await; + ingest(&db, &cfg, cfg.ignore_bounces_before - millis).await; + check_count(&db, 6, 0).await; + + // Bounces + ingest(&db, &cfg, cfg.ignore_bounces_before).await; + ingest(&db, &cfg, cfg.ignore_bounces_before + millis).await; + check_count(&db, 10, 2).await; + } + + #[tokio::test] + pub async fn initiated_status() { + use SubmissionState::*; + + let (_, db) = db_setup().await; + + let check_parts = async |batch_id: u64, + batch_status: SubmissionState, + batch_msg: &str, + tx_status: SubmissionState, + tx_msg: &str, + settled_status: SubmissionState, + settled_msg: &str| { + // Check batch status + let msg_id: String = sqlx::query( + " + SELECT message_id, status, status_msg FROM initiated_outgoing_batches WHERE initiated_outgoing_batch_id=$1 + " + ).bind(batch_id as i64) + .try_map(|r: PgRow| { + let msg_id: String = r.try_get("message_id")?; + assert_eq!((batch_status, Some(batch_msg).filter(|it| !it.is_empty())), (r.try_get("status")?, r.try_get("status_msg")?), "{msg_id}"); + Ok(msg_id) + }).fetch_one(&db).await.unwrap(); + // Check tx status + sqlx::query( + " + SELECT end_to_end_id, status, status_msg FROM initiated_outgoing_transactions WHERE initiated_outgoing_batch_id=$1 + " + ).bind(batch_id as i64).try_map(|r: PgRow| { + let end_to_end_id: &str = r.try_get("end_to_end_id")?; + let expected = match end_to_end_id { + "TX" => (tx_status, Some(tx_msg).filter(|it| !it.is_empty())), + "TX_SETTLED" => (settled_status, Some(settled_msg).filter(|it| !it.is_empty())), + _ =>panic!("Unexpected tx $endToEndId") + }; + assert_eq!(expected, + (r.try_get("status")?, r.try_get("status_msg")?), + "{msg_id},{end_to_end_id}" + ); + Ok(()) + }).fetch_all(&db).await.unwrap(); + }; + + let check_batch_tx = async |batch_id: u64, + status: SubmissionState, + msg: &str, + tx_status: SubmissionState| { + check_parts(batch_id, status, msg, tx_status, msg, tx_status, msg).await; + }; + let check_batch = async |batch_id: u64, status: SubmissionState, msg: &str| { + check_batch_tx(batch_id, status, msg, status).await; + }; + let check_order_tx = async |order_id: &str, + status: SubmissionState, + msg: &str, + tx_status: SubmissionState| { + let batch_id = sqlx::query( + "SELECT initiated_outgoing_batch_id FROM initiated_outgoing_batches WHERE order_id=$1" + ).bind(order_id) + .try_map(|r: PgRow| { + r.try_get_u64(0) + }).fetch_one(&db).await.unwrap(); + check_batch_tx(batch_id, status, msg, tx_status).await; + }; + let check_order = async |order_id: &str, status: SubmissionState, msg: &str| { + check_order_tx(order_id, status, msg, status).await; + }; + + async fn test(db: &PgPool, lambda: impl AsyncFnOnce(u64)) { + // Reset DB + sqlx::query("DELETE FROM initiated_outgoing_transactions") + .execute(db) + .await + .unwrap(); + sqlx::query("DELETE FROM initiated_outgoing_batches") + .execute(db) + .await + .unwrap(); + // Create a test batch with three transactions + for id in ["TX", "TX_SETTLED"] { + assert!(matches!( + gen_initiate(db, id, "lol").await, + PaymentInitiationResult::Success(_) + )); + } + batch_initiated(db, &Timestamp::now(), "BATCH", false) + .await + .unwrap(); + + // Create witness transactions and batch + for id in ["WITNESS_1", "WITNESS_2"] { + assert!(matches!( + gen_initiate(db, id, "lol").await, + PaymentInitiationResult::Success(_) + )); + } + batch_initiated(db, &Timestamp::now(), "BATCH_WITNESS", false) + .await + .unwrap(); + for id in ["WITNESS_3", "WITNESS_4"] { + assert!(matches!( + gen_initiate(db, id, "lol").await, + PaymentInitiationResult::Success(_) + )); + } + // Check everything is unsubmitted + sqlx::query( + " + SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches) + AND (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_transactions) + " + ).try_map(|r: PgRow| { + assert!(r.try_get_flag(0).unwrap()); + Ok(()) + }).fetch_one(db).await.unwrap(); + let submitibale = initiated_submittable(db, &CURR).await.unwrap(); + lambda( + submitibale + .iter() + .find(|it| it.msg_id == "BATCH") + .unwrap() + .id, + ) + .await; + // Check witness status is unaltered + sqlx::query( + " + SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches WHERE message_id != 'BATCH') + AND (SELECT bool_and(initiated_outgoing_transactions.status = 'unsubmitted') + FROM initiated_outgoing_transactions JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) + WHERE message_id != 'BATCH') + " + ).try_map(|r: PgRow| { + assert!(r.try_get(0)?); + Ok(()) + }).fetch_one(db).await.unwrap(); + } + + let now = Timestamp::now(); + + // Submission retry status + test(&db, async |batch_id| { + batch_sub_failure(&db, batch_id, &now, "First failure") + .await + .unwrap(); + check_batch(batch_id, transient_failure, "First failure").await; + batch_sub_failure(&db, batch_id, &now, "Second failure") + .await + .unwrap(); + check_batch(batch_id, transient_failure, "Second failure").await; + batch_sub_success(&db, batch_id, &now, "ORDER") + .await + .unwrap(); + check_order("ORDER", pending, "").await; + batch_sub_success(&db, batch_id, &now, "ORDER") + .await + .unwrap(); + check_order("ORDER", pending, "").await; + order_step(&db, "ORDER", "step msg").await.unwrap(); + check_order("ORDER", pending, "step msg").await; + order_step(&db, "ORDER", "success msg").await.unwrap(); + check_order("ORDER", pending, "success msg").await; + order_success(&db, "ORDER").await.unwrap(); + check_order_tx("ORDER", success, "success msg", pending).await; + order_step(&db, "ORDER", "late msg").await.unwrap(); + check_order_tx("ORDER", success, "success msg", pending).await; + }) + .await; + + // Order step message on failure + test(&db, async |batch_id| { + batch_sub_success(&db, batch_id, &now, "ORDER") + .await + .unwrap(); + check_order("ORDER", pending, "").await; + order_step(&db, "ORDER", "step msg").await.unwrap(); + check_order("ORDER", pending, "step msg").await; + order_step(&db, "ORDER", "failure msg").await.unwrap(); + check_order("ORDER", pending, "failure msg").await; + assert_eq!( + Some("failure msg"), + order_failure(&db, "ORDER") + .await + .unwrap() + .unwrap() + .1 + .as_deref() + ); + check_order("ORDER", permanent_failure, "failure msg").await; + order_step(&db, "ORDER", "late msg").await.unwrap(); + check_order("ORDER", permanent_failure, "failure msg").await; + }) + .await; + + // Payment & batch status + test(&db, async |batch_id| { + check_batch(batch_id, unsubmitted, "").await; + batch_status_update(&db, "BATCH", pending, "progress") + .await + .unwrap(); + check_batch(batch_id, pending, "progress").await; + tx_status_update(&db, "TX_SETTLED", "", success, "success") + .await + .unwrap(); + check_parts( + batch_id, pending, "progress", pending, "progress", success, "success", + ) + .await; + batch_status_update(&db, "BATCH", transient_failure, "waiting") + .await + .unwrap(); + check_parts( + batch_id, + transient_failure, + "waiting", + transient_failure, + "waiting", + success, + "success", + ) + .await; + tx_status_update(&db, "TX", "BATCH", permanent_failure, "failure") + .await + .unwrap(); + check_parts( + batch_id, + success, + "", + permanent_failure, + "failure", + success, + "success", + ) + .await; + tx_status_update(&db, "TX_SETTLED", "BATCH", permanent_failure, "late") + .await + .unwrap(); + check_parts( + batch_id, + success, + "", + permanent_failure, + "failure", + late_failure, + "late", + ) + .await; + }) + .await; + + // Registration + test(&db, async |batch_id| { + check_batch(batch_id, unsubmitted, "").await; + register_outgoing(&db, &gen_out_pay("").with_e2e_id("TX_SETTLED")) + .await + .unwrap(); + check_parts(batch_id, unsubmitted, "", unsubmitted, "", success, "").await; + register_outgoing(&db, &gen_out_pay("").with_e2e_id("TX").with_msg_id("BATCH")) + .await + .unwrap(); + check_parts(batch_id, success, "", success, "", success, "").await; + }) + .await; + + // Transaction failure take over batch failures + test(&db, async |batch_id| { + check_batch(batch_id, unsubmitted, "").await; + batch_status_update(&db, "BATCH", permanent_failure, "batch") + .await + .unwrap(); + check_parts( + batch_id, + permanent_failure, + "batch", + permanent_failure, + "batch", + permanent_failure, + "batch", + ) + .await; + tx_status_update(&db, "TX", "BATCH", permanent_failure, "tx") + .await + .unwrap(); + batch_status_update(&db, "BATCH", permanent_failure, "batch2") + .await + .unwrap(); + check_parts( + batch_id, + permanent_failure, + "batch", + permanent_failure, + "tx", + permanent_failure, + "batch", + ) + .await; + }) + .await; + + // Unknown order and batch + batch_sub_success(&db, 42, &now, "ORDER_X").await.unwrap(); + batch_sub_failure(&db, 42, &now, "").await.unwrap(); + order_step(&db, "ORDER_X", "msg").await.unwrap(); + batch_status_update(&db, "BATCH_X", success, "") + .await + .unwrap(); + tx_status_update(&db, "TX_X", "BATCH_X", success, "msg") + .await + .unwrap(); + assert!(order_success(&db, "ORDER_X").await.unwrap().is_none()); + assert!(order_failure(&db, "ORDER_X").await.unwrap().is_none()); + } + + #[tokio::test] + pub async fn initiated_submittables() { + let (_, db) = db_setup().await; + let now = Timestamp::now(); + for i in 0..6 { + assert!(matches!( + gen_initiate(&db, format!("PAY{i}"), "").await, + PaymentInitiationResult::Success(_) + )); + batch_initiated(&db, &now, &rand_ebics_id(), false) + .await + .unwrap(); + } + + let check_ids = async |ids: &[&str]| { + assert_eq!( + ids, + initiated_submittable(&db, &CURR) + .await + .unwrap() + .iter() + .flat_map(|it| it.payments.iter().map(|it| it.e2e_id.as_str())) + .collect::<Vec<_>>() + ); + }; + check_ids(&["PAY0", "PAY1", "PAY2", "PAY3", "PAY4", "PAY5"]).await; + + // Check submitted not submitable + batch_sub_success(&db, 1, &now, "ORDER1").await.unwrap(); + check_ids(&["PAY1", "PAY2", "PAY3", "PAY4", "PAY5"]).await; + + // Check transient failure submitable last + batch_sub_failure(&db, 2, &now, "Failure").await.unwrap(); + check_ids(&["PAY2", "PAY3", "PAY4", "PAY5", "PAY1"]).await; + + // Check persistent failure not submitable + batch_sub_success(&db, 4, &now, "ORDER3").await.unwrap(); + order_failure(&db, "ORDER3").await.unwrap(); + check_ids(&["PAY2", "PAY4", "PAY5", "PAY1"]).await; + batch_sub_success(&db, 5, &now, "ORDER4").await.unwrap(); + order_failure(&db, "ORDER4").await.unwrap(); + check_ids(&["PAY2", "PAY5", "PAY1"]).await; + + // Check rotation + batch_sub_failure(&db, 3, &Timestamp::now(), "FAILURE") + .await + .unwrap(); + check_ids(&["PAY5", "PAY1", "PAY2"]).await; + batch_sub_failure(&db, 6, &Timestamp::now(), "FAILURE") + .await + .unwrap(); + check_ids(&["PAY1", "PAY2", "PAY5"]).await; + batch_sub_failure(&db, 2, &Timestamp::now(), "FAILURE") + .await + .unwrap(); + check_ids(&["PAY2", "PAY5", "PAY1"]).await; + } +} diff --git a/crates/libeufin-nexus/src/db/list.rs b/crates/libeufin-nexus/src/db/list.rs @@ -0,0 +1,268 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use compact_str::CompactString; +use jiff::Timestamp; +use libeufin_ebics::iso20022::model::{InId, OutId}; +use sqlx::{PgPool, Row as _, postgres::PgRow}; +use taler_api::db::TypeHelper as _; +use taler_common::{ + api_common::{EddsaPublicKey, ShortHashCode}, + types::amount::{Amount, Currency, Decimal}, +}; + +/** Incoming transaction metadata for debugging */ +pub struct InMetadata { + pub id: InId, + pub date: Timestamp, + pub amount: Amount, + pub credit_fee: Option<Decimal>, + pub subject: Option<String>, + pub debtor: Option<String>, + pub talerable: Option<String>, + pub bounced: Option<String>, +} + +/** Outgoing transaction metadata for debugging */ +pub struct OutMetadata { + pub id: OutId, + pub date: Timestamp, + pub amount: Amount, + pub subject: Option<String>, + pub creditor: Option<String>, + pub wtid: Option<ShortHashCode>, + pub exchange_base_url: Option<String>, +} + +/** Initiated metadata for debugging */ +pub struct InitMetadata { + pub date: Timestamp, + pub amount: Amount, + pub subject: String, + pub creditor: String, + pub id: String, + pub batch: Option<String>, + pub batch_order: Option<String>, + pub status: String, + pub msg: Option<String>, + pub submission_time: Option<Timestamp>, + pub submission_counter: u32, +} + +/** Initiated metadata for debugging */ +pub struct InitMetadataAck { + pub date: Timestamp, + pub amount: Amount, + pub subject: String, + pub creditor: String, + pub db_id: u64, + pub id: String, +} + +/** List incoming transaction metadata for debugging */ +pub async fn incoming( + db: &PgPool, + incomplete: bool, + currency: &Currency, +) -> sqlx::Result<Vec<InMetadata>> { + let query = if incomplete { + " + SELECT + incoming.amount AS amount + ,credit_fee + ,incoming.subject + ,end_to_end_id AS bounced + ,execution_time + ,debit_payto + ,type::text + ,metadata + ,uetr + ,tx_id + ,acct_svcr_ref + ,talerable_incoming_transactions.authorization_pub as auth_pub + ,pending_recurrent_incoming_transactions.authorization_pub as pending_pub + FROM incoming_transactions AS incoming + LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) + LEFT JOIN bounced_transactions USING (incoming_transaction_id) + LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) + LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) + WHERE debit_payto IS NULL OR incoming.subject IS NULL + ORDER BY execution_time + " + } else { + " + SELECT + incoming.amount AS amount + ,credit_fee + ,incoming.subject + ,end_to_end_id AS bounced + ,execution_time + ,debit_payto + ,type::text + ,metadata + ,uetr + ,tx_id + ,acct_svcr_ref + ,talerable_incoming_transactions.authorization_pub as auth_pub + ,pending_recurrent_incoming_transactions.authorization_pub as pending_pub + FROM incoming_transactions AS incoming + LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) + LEFT JOIN bounced_transactions USING (incoming_transaction_id) + LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) + LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) + ORDER BY execution_time + " + }; + sqlx::query(query) + .try_map(|r: PgRow| { + let auth_pub: Option<EddsaPublicKey> = r.try_get("auth_pub")?; + let pending_pub: Option<EddsaPublicKey> = r.try_get("pending_pub")?; + let map = if let Some(auth_pub) = auth_pub { + format!(" mapped by {auth_pub}") + } else { + String::new() + }; + Ok(InMetadata { + id: InId { + uetr: r.try_get("uetr")?, + tx_id: r.try_get("tx_id")?, + sref: r.try_get("acct_svcr_ref")?, + }, + date: r.try_get_timestamp("execution_time")?, + amount: r.try_get_amount("amount", currency)?, + credit_fee: r.try_get("credit_fee")?, + subject: r.try_get("subject")?, + debtor: r.try_get("debit_payto")?, + bounced: r.try_get("bounced")?, + talerable: match r.try_get::<Option<CompactString>, _>("type")? { + None => pending_pub.map(|pending| format!("pending mapped by {pending}")), + Some(ty) => Some(format!( + "{ty} {}{map}", + r.try_get::<EddsaPublicKey, _>("metadata")? + )), + }, + }) + }) + .fetch_all(db) + .await +} + +/** List outgoing transaction metadata for debugging */ +pub async fn outgoing(db: &PgPool, currency: &Currency) -> sqlx::Result<Vec<OutMetadata>> { + sqlx::query( + " + SELECT + amount + ,subject + ,execution_time + ,credit_payto + ,end_to_end_id + ,acct_svcr_ref + ,wtid + ,exchange_base_url + FROM outgoing_transactions + LEFT JOIN talerable_outgoing_transactions using (outgoing_transaction_id) + ORDER BY execution_time + ", + ) + .try_map(|r: PgRow| { + Ok(OutMetadata { + id: OutId { + msg_id: None, + e2e_id: r.try_get("end_to_end_id")?, + sref: r.try_get("acct_svcr_ref")?, + }, + date: r.try_get_timestamp("execution_time")?, + amount: r.try_get_amount("amount", currency)?, + subject: r.try_get("subject")?, + creditor: r.try_get("credit_payto")?, + wtid: r.try_get("wtid")?, + exchange_base_url: r.try_get("exchange_base_url")?, + }) + }) + .fetch_all(db) + .await +} + +/** List initiated transaction metadata for debugging */ +pub async fn initiated(db: &PgPool, currency: &Currency) -> sqlx::Result<Vec<InitMetadata>> { + sqlx::query( + " + SELECT + amount + ,subject + ,initiation_time + ,submission_date + ,submission_counter + ,credit_payto + ,end_to_end_id + ,message_id + ,order_id + ,initiated_outgoing_transactions.status::text + ,initiated_outgoing_transactions.status_msg + FROM initiated_outgoing_transactions + LEFT JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) + ORDER BY initiation_time + ", + ) + .try_map(|r: PgRow| { + Ok(InitMetadata { + date: r.try_get_timestamp("initiation_time")?, + amount: r.try_get_amount("amount", currency)?, + subject: r.try_get("subject")?, + creditor: r.try_get("credit_payto")?, + id: r.try_get("end_to_end_id")?, + batch: r.try_get("message_id")?, + batch_order: r.try_get("order_id")?, + status: r.try_get("status")?, + msg: r.try_get("status_msg")?, + submission_time: r.try_get_opt_timestamp("submission_date")?, + submission_counter: r.try_get_opt_u32("submission_counter")?.unwrap_or_default(), + }) + }) + .fetch_all(db) + .await +} + +/** List initiated transaction metadata pending acknowledgment for debugging */ +pub async fn initiated_ack(db: &PgPool, currency: &Currency) -> sqlx::Result<Vec<InitMetadataAck>> { + sqlx::query( + " + SELECT + amount + ,subject + ,initiation_time + ,credit_payto + ,end_to_end_id + ,initiated_outgoing_transaction_id + FROM initiated_outgoing_transactions + WHERE initiated_outgoing_batch_id IS NULL AND NOT awaiting_ack + ORDER BY initiation_time + ", + ) + .try_map(|r: PgRow| { + Ok(InitMetadataAck { + date: r.try_get_timestamp("initiation_time")?, + amount: r.try_get_amount("amount", currency)?, + subject: r.try_get("subject")?, + creditor: r.try_get("credit_payto")?, + id: r.try_get("end_to_end_id")?, + db_id: r.try_get_u64("initiated_outgoing_transaction_id")?, + }) + }) + .fetch_all(db) + .await +} diff --git a/crates/libeufin-nexus/src/db/payment.rs b/crates/libeufin-nexus/src/db/payment.rs @@ -0,0 +1,1131 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use compact_str::CompactString; +use jiff::Timestamp; +use libeufin_ebics::iso20022::model::{InTx, OutTx}; +use sqlx::{PgPool, Row as _, postgres::PgRow}; +use taler_api::{ + db::{BindHelper as _, TypeHelper as _}, + subject::{IncomingSubject, OutgoingSubject}, +}; +use taler_common::types::amount::Amount; + +#[derive(Debug, PartialEq, Eq)] +pub struct OutgoingRegistrationResult { + pub id: u64, + pub initiated: bool, + pub new: bool, +} + +/** Register an outgoing payment reconciling it with its initiated payment counterpart if present */ +pub async fn register_out_tx( + pool: &PgPool, + payment: &OutTx, + subject: Option<&OutgoingSubject>, +) -> sqlx::Result<OutgoingRegistrationResult> { + sqlx::query( + " + SELECT out_tx_id, out_initiated, out_found + FROM register_outgoing($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ", + ) + .bind(payment.amount) + .bind(payment.debit_fee) + .bind(&payment.subject) + .bind_timestamp(&payment.execution_time) + .bind(payment.creditor.as_ref().map(|it| it.as_ref().as_str())) + .bind(&payment.id.e2e_id) + .bind(&payment.id.msg_id) + .bind(&payment.id.sref) + .bind(subject.as_ref().map(|s| &s.wtid)) + .bind(subject.as_ref().map(|s| s.exchange_base_url.as_str())) + .bind(subject.as_ref().map(|s| &s.metadata)) + .try_map(|r: PgRow| { + Ok(OutgoingRegistrationResult { + id: r.try_get_u64(0)?, + initiated: r.try_get_flag(1)?, + new: !r.try_get_flag(2)?, + }) + }) + .fetch_one(pool) + .await +} + +/// Register an outgoing batch +pub async fn register_out_batch( + pool: &PgPool, + payment: &OutTx, + subject: Option<&OutgoingSubject>, +) -> sqlx::Result<OutgoingRegistrationResult> { + sqlx::query( + " + SELECT out_tx_id, out_initiated, out_found + FROM register_outgoing($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ", + ) + .bind(payment.amount) + .bind(payment.debit_fee) + .bind(&payment.subject) + .bind_timestamp(&payment.execution_time) + .bind(payment.creditor.as_ref().map(|it| it.as_ref().as_str())) + .bind(&payment.id.e2e_id) + .bind(&payment.id.msg_id) + .bind(&payment.id.sref) + .bind(subject.as_ref().map(|s| &s.wtid)) + .bind(subject.as_ref().map(|s| s.exchange_base_url.as_str())) + .bind(subject.as_ref().map(|s| &s.metadata)) + .try_map(|r: PgRow| { + Ok(OutgoingRegistrationResult { + id: r.try_get_u64(0)?, + initiated: r.try_get_flag(1)?, + new: !r.try_get_flag(2)?, + }) + }) + .fetch_one(pool) + .await +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InResult { + pub id: u64, + pub new: bool, + pub completed: bool, + pub pending: bool, + pub bounce_id: Option<CompactString>, +} + +/** Incoming payments registration result */ +#[derive(Debug, PartialEq, Eq)] +pub enum IncomingRegistrationResult { + Success(InResult), + ReservePubReuse, + MappingReuse, + UnknownMapping, +} + +/** Register an incoming payment */ +pub async fn register_in(pool: &PgPool, payment: &InTx) -> sqlx::Result<InResult> { + sqlx::query( + " + SELECT out_found, out_completed, out_tx_id, out_bounce_id + FROM register_incoming($1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,NULL) + ", + ) + .bind(payment.amount) + .bind(payment.credit_fee) + .bind(&payment.subject) + .bind_timestamp(&payment.execution_time) + .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) + .bind(payment.id.uetr) + .bind(&payment.id.tx_id) + .bind(&payment.id.sref) + .try_map(|r: PgRow| { + Ok(InResult { + id: r.try_get_u64("out_tx_id")?, + new: !r.try_get_flag("out_found")?, + completed: r.try_get_flag("out_completed")?, + bounce_id: r.try_get("out_bounce_id")?, + pending: false, + }) + }) + .fetch_one(pool) + .await +} + +/** Register an talerable incoming payment */ +pub async fn register_in_talerable( + pool: &PgPool, + payment: &InTx, + subject: &IncomingSubject, +) -> sqlx::Result<IncomingRegistrationResult> { + sqlx::query( + " + SELECT + out_reserve_pub_reuse, + out_mapping_reuse, + out_unknown_mapping, + out_found, + out_completed, + out_pending, + out_tx_id, + out_bounce_id + FROM register_incoming($1,$2,$3,$4,$5,$6,$7,$8,$9::taler_incoming_type,$10,NULL) + ", + ) + .bind(payment.amount) + .bind(payment.credit_fee) + .bind(&payment.subject) + .bind_timestamp(&payment.execution_time) + .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) + .bind(payment.id.uetr) + .bind(&payment.id.tx_id) + .bind(&payment.id.sref) + .bind(subject.ty()) + .bind(subject.key()) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_reserve_pub_reuse")? { + IncomingRegistrationResult::ReservePubReuse + } else if r.try_get_flag("out_mapping_reuse")? { + IncomingRegistrationResult::MappingReuse + } else if r.try_get_flag("out_unknown_mapping")? { + IncomingRegistrationResult::UnknownMapping + } else { + IncomingRegistrationResult::Success(InResult { + id: r.try_get_u64("out_tx_id")?, + new: !r.try_get_flag("out_found")?, + completed: r.try_get_flag("out_completed")?, + bounce_id: r.try_get("out_bounce_id")?, + pending: r.try_get("out_pending")?, + }) + }) + }) + .fetch_one(pool) + .await +} + +/** Register an talerable incoming payment */ +pub async fn register_in_qr_bill( + pool: &PgPool, + payment: &InTx, + reference: &str, +) -> sqlx::Result<IncomingRegistrationResult> { + sqlx::query( + " + SELECT + out_reserve_pub_reuse, + out_mapping_reuse, + out_unknown_mapping, + out_found, + out_completed, + out_pending, + out_tx_id, + out_bounce_id + FROM register_incoming($1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9) + ", + ) + .bind(payment.amount) + .bind(payment.credit_fee) + .bind(&payment.subject) + .bind_timestamp(&payment.execution_time) + .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) + .bind(payment.id.uetr) + .bind(&payment.id.tx_id) + .bind(&payment.id.sref) + .bind(reference) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_reserve_pub_reuse")? { + IncomingRegistrationResult::ReservePubReuse + } else if r.try_get_flag("out_mapping_reuse")? { + IncomingRegistrationResult::MappingReuse + } else if r.try_get_flag("out_unknown_mapping")? { + IncomingRegistrationResult::UnknownMapping + } else { + IncomingRegistrationResult::Success(InResult { + id: r.try_get_u64("out_tx_id")?, + new: !r.try_get_flag("out_found")?, + completed: r.try_get_flag("out_completed")?, + bounce_id: r.try_get("out_bounce_id")?, + pending: r.try_get("out_pending")?, + }) + }) + }) + .fetch_one(pool) + .await +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/** Incoming payments bounce registration result */ +pub enum IncomingBounceRegistrationResult { + Success(InResult), + Talerable, +} + +/** Register an incoming payment and bounce it */ +pub async fn register_in_malformed( + pool: &PgPool, + payment: &InTx, + bounce_amount: &Amount, + bounce_end_to_end_id: &str, + timestamp: &Timestamp, + cause: &str, +) -> sqlx::Result<IncomingBounceRegistrationResult> { + sqlx::query( + " + SELECT out_found, out_tx_id, out_completed, out_bounce_id, out_talerable + FROM register_and_bounce_incoming($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + ", + ) + .bind(payment.amount) + .bind(payment.credit_fee) + .bind(&payment.subject) + .bind_timestamp(&payment.execution_time) + .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) + .bind(payment.id.uetr) + .bind(&payment.id.tx_id) + .bind(&payment.id.sref) + .bind(bounce_amount) + .bind_timestamp(timestamp) + .bind(bounce_end_to_end_id) + .bind(cause) + .try_map(|r: PgRow| { + Ok(if r.try_get_flag("out_talerable")? { + IncomingBounceRegistrationResult::Talerable + } else { + IncomingBounceRegistrationResult::Success(InResult { + id: r.try_get_u64("out_tx_id")?, + new: !r.try_get_flag("out_found")?, + completed: r.try_get_flag("out_completed")?, + bounce_id: r.try_get("out_bounce_id")?, + pending: false, + }) + }) + }) + .fetch_one(pool) + .await +} + +#[cfg(test)] +mod test { + + use jiff::Timestamp; + use libeufin_ebics::{ + ebics::rand_ebics_id, + iso20022::model::{InId, InTx, OutBatch, OutId, OutTx}, + }; + use sqlx::{PgPool, postgres::PgRow}; + use taler_api::{db::TypeHelper as _, subject::subject_fmt_qr_bill}; + use taler_common::{ + api_common::{EddsaPublicKey, EddsaSignature, ShortHashCode}, + db::IncomingType, + types::amount::amount, + }; + use taler_test_utils::routine::Status::*; + use uuid::Uuid; + + use crate::{ + config::{AccountType, NexusIngestCfg}, + db::{ + initiated::{PaymentInitiationResult, batch_initiated, initiated_ack}, + payment::{ + InResult, IncomingBounceRegistrationResult, OutgoingRegistrationResult, + register_in_malformed, + }, + test::{check_in_count, check_in_state, check_out_count, db_setup}, + transfer::{RegistrationResult, transfer_register}, + }, + fetch::{register_incoming, register_outgoing, register_outgoing_batch}, + test::{CURR, gen_in_pay, gen_initiate, gen_out_pay}, + }; + + #[tokio::test] + async fn out_tx() { + let (_, db) = db_setup().await; + // Register initiated transactions + for subject in [ + "initiated by nexus".to_owned(), + format!("{} https://exchange.com/", ShortHashCode::rand()), + ] { + let payment = gen_out_pay(subject.clone()); + assert!(matches!( + gen_initiate(&db, payment.id.e2e_id.clone().unwrap(), subject).await, + PaymentInitiationResult::Success(_) + )); + let first = register_outgoing(&db, &payment).await.unwrap(); + assert_eq!( + first, + OutgoingRegistrationResult { + id: first.id, + initiated: true, + new: true + } + ); + assert_eq!( + register_outgoing(&db, &payment).await.unwrap(), + OutgoingRegistrationResult { + id: first.id, + initiated: true, + new: false + } + ); + let payment = OutTx { + id: OutId { + msg_id: None, + e2e_id: None, + sref: payment.id.e2e_id, + }, + ..payment + }; + let second = register_outgoing(&db, &payment).await.unwrap(); + assert_eq!( + second, + OutgoingRegistrationResult { + id: first.id + 1, + initiated: false, + new: true + } + ); + assert_eq!( + register_outgoing(&db, &payment).await.unwrap(), + OutgoingRegistrationResult { + id: second.id, + initiated: false, + new: false + } + ); + } + check_out_count(&db, 4, 1).await; + + // Register unknown + for subject in [ + "initiated by nexus".to_owned(), + format!("{} https://exchange.com/", ShortHashCode::rand()), + ] { + let payment = gen_out_pay(subject.clone()); + let res = register_outgoing(&db, &payment).await.unwrap(); + assert_eq!( + res, + OutgoingRegistrationResult { + id: res.id, + initiated: false, + new: true + } + ); + assert_eq!( + register_outgoing(&db, &payment).await.unwrap(), + OutgoingRegistrationResult { + id: res.id, + initiated: false, + new: false + } + ); + } + check_out_count(&db, 6, 2).await; + + // Register wtid reuse + let wtid = ShortHashCode::rand(); + for subject in [ + format!("{wtid} https://exchange.com/"), + format!("{wtid} https://exchange.com/"), + ] { + let payment = gen_out_pay(subject.clone()); + let res = register_outgoing(&db, &payment).await.unwrap(); + assert_eq!( + res, + OutgoingRegistrationResult { + id: res.id, + initiated: false, + new: true + } + ); + assert_eq!( + register_outgoing(&db, &payment).await.unwrap(), + OutgoingRegistrationResult { + id: res.id, + initiated: false, + new: false + } + ); + } + check_out_count(&db, 8, 3).await + } + + #[tokio::test] + async fn out_batch() { + let (_, db) = db_setup().await; + // Init batch + let wtid = ShortHashCode::rand(); + for subject in [ + "initiated by nexus".to_string(), + format!("{} https://exchange.com/", ShortHashCode::rand()), + format!("{wtid} https://exchange.com/"), + format!("{wtid} https://exchange.com/"), + ] { + assert!(matches!( + gen_initiate(&db, rand_ebics_id(), subject).await, + PaymentInitiationResult::Success(_) + )); + } + batch_initiated(&db, &Timestamp::now(), "BATCH", false) + .await + .unwrap(); + + // Register batch + register_outgoing_batch( + &db, + &CURR, + &OutBatch { + msg_id: "BATCH".into(), + execution_time: Timestamp::now(), + }, + ) + .await + .unwrap(); + check_out_count(&db, 4, 2).await; + + // Test manual ack + let mut txs = Vec::new(); + for nb in 0..3 { + let res = gen_initiate(&db, rand_ebics_id(), format!("tx {nb}")).await; + if let PaymentInitiationResult::Success(id) = &res { + txs.push(*id); + } else { + panic!("Expected success got {res:?}"); + } + } + + // Check not sent without ack + batch_initiated(&db, &Timestamp::now(), "BATCH_MANUAL", true) + .await + .unwrap(); + register_outgoing_batch( + &db, + &CURR, + &OutBatch { + msg_id: "BATCH_MANUAL".into(), + execution_time: Timestamp::now(), + }, + ) + .await + .unwrap(); + check_out_count(&db, 4, 2).await; + + // Check sent with ack + for tx in txs { + initiated_ack(&db, tx).await.unwrap(); + } + batch_initiated(&db, &Timestamp::now(), "BATCH_MANUAL", true) + .await + .unwrap(); + register_outgoing_batch( + &db, + &CURR, + &OutBatch { + msg_id: "BATCH_MANUAL".into(), + execution_time: Timestamp::now(), + }, + ) + .await + .unwrap(); + check_out_count(&db, 7, 2).await; + } + + #[tokio::test] + async fn in_bounce() { + let (_, db) = db_setup().await; + + // Creating and bouncing one incoming transaction + let payment = gen_in_pay("incoming and bounce"); + let id = rand_ebics_id(); + + let bounce_amount = amount("KUDOS:2.53"); + let res = register_in_malformed( + &db, + &payment, + &bounce_amount, + &id, + &Timestamp::now(), + "manual bounce", + ) + .await + .unwrap(); + assert!( + matches!( + res, + IncomingBounceRegistrationResult::Success(InResult { + new: true, + id: _, + completed: false, + pending: false, + ref bounce_id + }) if bounce_id.as_ref() == Some(&id) + ), + "{res:?}" + ); + // Idempotent + let res = register_in_malformed( + &db, + &payment, + &amount("KUDOS:2.5"), + &rand_ebics_id(), + &Timestamp::now(), + "other reason to bounce", + ) + .await + .unwrap(); + assert!( + matches!( + res, + IncomingBounceRegistrationResult::Success(InResult { + new: false, + id: _, + completed: false, + pending: false, + ref bounce_id + }) if bounce_id.as_ref() == Some(&id) + ), + "{res:?}" + ); + + // Checking one incoming got created and bounced + sqlx::query( + " + SELECT + incoming_transactions.amount as in_amount, + initiated_outgoing_transactions.amount as bounce_amount + FROM incoming_transactions + JOIN bounced_transactions USING (incoming_transaction_id) + JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) + ", + ) + .try_map(|r: PgRow| { + assert_eq!(r.try_get_amount("in_amount", &CURR)?, payment.amount); + assert_eq!(r.try_get_amount("bounce_amount", &CURR)?, bounce_amount); + Ok(()) + }) + .fetch_one(&db) + .await + .unwrap(); + } + + #[tokio::test] + async fn in_simple() { + let (_, db) = db_setup().await; + + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + + // Register + let incoming = gen_in_pay("test".to_owned()); + register_incoming(&db, &cfg, &incoming).await.unwrap(); + check_in_state(&db, &[Bounced]).await; + + // Idempotent + register_incoming(&db, &cfg, &incoming).await.unwrap(); + check_in_state(&db, &[Bounced]).await; + + // Many + register_incoming(&db, &cfg, &gen_in_pay("another subject".to_owned())) + .await + .unwrap(); + check_in_state(&db, &[Bounced, Bounced]).await; + + // Admin balance adjust is ignored + register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) + .await + .unwrap(); + + check_in_state(&db, &[Bounced, Bounced, Simple]).await; + + let original = gen_in_pay("test 2".to_owned()); + let incomplete = InTx { + subject: None, + debtor: None, + ..original.clone() + }; + + // Register incomplete transaction + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + check_in_state(&db, &[Bounced, Bounced, Simple, Incomplete]).await; + // Idempotent + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + check_in_state(&db, &[Bounced, Bounced, Simple, Incomplete]).await; + // Recover info when completed + register_incoming(&db, &cfg, &original).await.unwrap(); + check_in_state(&db, &[Bounced, Bounced, Simple, Bounced]).await; + } + + #[tokio::test] + async fn in_talerable() { + let (_, db) = db_setup().await; + + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + let key = EddsaPublicKey::rand(); + let subject = format!("test with {key} reserve pub"); + + // Register + let incoming = gen_in_pay(subject.clone()); + register_incoming(&db, &cfg, &incoming).await.unwrap(); + check_in_state(&db, &[Reserve(key.clone())]).await; + + // Idempotent + register_incoming(&db, &cfg, &incoming).await.unwrap(); + check_in_state(&db, &[Reserve(key.clone())]).await; + + // Key reuse is bounced + register_incoming(&db, &cfg, &gen_in_pay(subject.clone())) + .await + .unwrap(); + register_incoming(&db, &cfg, &gen_in_pay(format!("another {subject}"))) + .await + .unwrap(); + check_in_state(&db, &[Reserve(key.clone()), Bounced, Bounced]).await; + + // Admin balance adjust is ignored + register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) + .await + .unwrap(); + check_in_state(&db, &[Reserve(key.clone()), Bounced, Bounced, Simple]).await; + + let new = EddsaPublicKey::rand(); + let original = gen_in_pay(format!("test 2 with {new} reserve pub")); + let incomplete = InTx { + subject: None, + debtor: None, + ..original.clone() + }; + + // Register incomplete transaction + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + check_in_state( + &db, + &[Reserve(key.clone()), Bounced, Bounced, Simple, Incomplete], + ) + .await; + // Idempotent + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + check_in_state( + &db, + &[Reserve(key.clone()), Bounced, Bounced, Simple, Incomplete], + ) + .await; + // Recover info when completed + register_incoming(&db, &cfg, &original).await.unwrap(); + check_in_state( + &db, + &[Reserve(key.clone()), Bounced, Bounced, Simple, Reserve(new)], + ) + .await; + } + + #[tokio::test] + async fn in_mapping() { + let (_, db) = db_setup().await; + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + let first = EddsaPublicKey::rand(); + let auth_pub = EddsaPublicKey::rand(); + let auth_sig = EddsaSignature::rand(); + let reference_number = subject_fmt_qr_bill(auth_pub.as_slice()); + let subject = format!("test with MAP:{auth_pub} auth pub"); + + assert_eq!( + transfer_register( + &db, + IncomingType::reserve, + &first, + &auth_pub, + &auth_sig, + false, + &reference_number, + &Timestamp::now() + ) + .await + .unwrap(), + RegistrationResult::Success + ); + + // Register + let incoming = gen_in_pay(subject.clone()); + register_incoming(&db, &cfg, &incoming).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone())]).await; + + // Idempotent + register_incoming(&db, &cfg, &incoming).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone())]).await; + + // Admin balance adjust is ignored + register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) + .await + .unwrap(); + check_in_state(&db, &[Reserve(first.clone()), Simple]).await; + + let original = gen_in_pay(format!("test 2 for {subject}")); + let incomplete = InTx { + subject: None, + debtor: None, + ..original.clone() + }; + // Register incomplete transaction + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone()), Simple, Incomplete]).await; + // Idempotent + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone()), Simple, Incomplete]).await; + // Recover info when completed + register_incoming(&db, &cfg, &original).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone()), Simple, Bounced]).await; + + let second = EddsaPublicKey::rand(); + assert_eq!( + transfer_register( + &db, + IncomingType::reserve, + &second, + &auth_pub, + &auth_sig, + true, + &reference_number, + &Timestamp::now() + ) + .await + .unwrap(), + RegistrationResult::Success + ); + check_in_state(&db, &[Reserve(first.clone()), Simple, Bounced]).await; + + // Key reuse is pending + for _ in 0..3 { + register_incoming(&db, &cfg, &gen_in_pay(subject.clone())) + .await + .unwrap(); + } + check_in_state( + &db, + &[ + Reserve(first.clone()), + Simple, + Bounced, + Reserve(second.clone()), + Pending, + Pending, + ], + ) + .await; + + // Finish pending + let third = EddsaPublicKey::rand(); + assert_eq!( + transfer_register( + &db, + IncomingType::reserve, + &third, + &auth_pub, + &auth_sig, + true, + &reference_number, + &Timestamp::now() + ) + .await + .unwrap(), + RegistrationResult::Success + ); + check_in_state( + &db, + &[ + Reserve(first.clone()), + Simple, + Bounced, + Reserve(second.clone()), + Reserve(third.clone()), + Pending, + ], + ) + .await; + } + + #[tokio::test] + async fn in_reference() { + let (_, db) = db_setup().await; + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + let first = EddsaPublicKey::rand(); + let auth_pub = EddsaPublicKey::rand(); + let auth_sig = EddsaSignature::rand(); + let reference_number = subject_fmt_qr_bill(auth_pub.as_slice()); + + assert_eq!( + transfer_register( + &db, + IncomingType::reserve, + &first, + &auth_pub, + &auth_sig, + false, + &reference_number, + &Timestamp::now() + ) + .await + .unwrap(), + RegistrationResult::Success + ); + + // Register + let incoming = gen_in_pay(reference_number.clone()); + register_incoming(&db, &cfg, &incoming).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone())]).await; + + // Idempotent + register_incoming(&db, &cfg, &incoming).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone())]).await; + + // Admin balance adjust is ignored + register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) + .await + .unwrap(); + check_in_state(&db, &[Reserve(first.clone()), Simple]).await; + + let original = gen_in_pay(reference_number.clone()); + let incomplete = InTx { + subject: None, + debtor: None, + ..original.clone() + }; + // Register incomplete transaction + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone()), Simple, Incomplete]).await; + // Idempotent + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone()), Simple, Incomplete]).await; + // Recover info when completed + register_incoming(&db, &cfg, &original).await.unwrap(); + check_in_state(&db, &[Reserve(first.clone()), Simple, Bounced]).await; + + let second = EddsaPublicKey::rand(); + assert_eq!( + transfer_register( + &db, + IncomingType::reserve, + &second, + &auth_pub, + &auth_sig, + true, + &reference_number, + &Timestamp::now() + ) + .await + .unwrap(), + RegistrationResult::Success + ); + check_in_state(&db, &[Reserve(first.clone()), Simple, Bounced]).await; + + // Key reuse is pending + for _ in 0..3 { + register_incoming(&db, &cfg, &gen_in_pay(reference_number.clone())) + .await + .unwrap(); + } + check_in_state( + &db, + &[ + Reserve(first.clone()), + Simple, + Bounced, + Reserve(second.clone()), + Pending, + Pending, + ], + ) + .await; + + // Finish pending + let third = EddsaPublicKey::rand(); + assert_eq!( + transfer_register( + &db, + IncomingType::reserve, + &third, + &auth_pub, + &auth_sig, + true, + &reference_number, + &Timestamp::now() + ) + .await + .unwrap(), + RegistrationResult::Success + ); + check_in_state( + &db, + &[ + Reserve(first.clone()), + Simple, + Bounced, + Reserve(second.clone()), + Reserve(third.clone()), + Pending, + ], + ) + .await; + } + + #[tokio::test] + async fn in_recover_info() { + let (_, db) = db_setup().await; + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + + async fn check_content(db: &PgPool, p: &InTx) { + sqlx::query( + " + SELECT + uetr IS NOT DISTINCT FROM $1 AND + tx_id IS NOT DISTINCT FROM $2 AND + acct_svcr_ref IS NOT DISTINCT FROM $3 AND + subject IS NOT DISTINCT FROM $4 AND + debit_payto IS NOT DISTINCT FROM $5 + FROM incoming_transactions ORDER BY incoming_transaction_id DESC LIMIT 1 + ", + ) + .bind(p.id.uetr) + .bind(&p.id.tx_id) + .bind(&p.id.sref) + .bind(&p.subject) + .bind(p.debtor.as_ref().map(|it| it.as_ref().as_str())) + .try_map(|r: PgRow| { + assert!(r.try_get_flag(0)?); + Ok(()) + }) + .fetch_one(db) + .await + .unwrap(); + } + + // Non talerable + for (i, id) in [ + InId::new(Some(Uuid::new_v4()), None, None), + InId::new(None, Some(rand_ebics_id()), None), + InId::new(None, None, Some(rand_ebics_id())), + ] + .iter() + .enumerate() + { + let payment = gen_in_pay("subject".to_owned()); + + // Register minimal + let partial = InTx { + id: id.clone(), + subject: None, + debtor: None, + ..payment.clone() + }; + register_incoming(&db, &cfg, &partial).await.unwrap(); + check_content(&db, &partial).await; + check_in_count(&db, i + 1, i, 0).await; + + // Recover ID + let full_id = InId::new( + Some(id.uetr.unwrap_or_else(Uuid::new_v4)), + Some(id.tx_id.clone().unwrap_or_else(rand_ebics_id)), + Some(id.sref.clone().unwrap_or_else(rand_ebics_id)), + ); + let full = InTx { + id: full_id.clone(), + ..partial.clone() + }; + register_incoming(&db, &cfg, &full).await.unwrap(); + check_content(&db, &full).await; + check_in_count(&db, i + 1, i, 0).await; + + // Recover subject & debtor + let full = InTx { + id: full_id, + ..payment.clone() + }; + register_incoming(&db, &cfg, &full).await.unwrap(); + check_content(&db, &full).await; + check_in_count(&db, i + 1, i + 1, 0).await; + } + + // Talerable + for (i, id) in [ + InId::new(Some(Uuid::new_v4()), None, None), + InId::new(None, Some(rand_ebics_id()), None), + InId::new(None, None, Some(rand_ebics_id())), + ] + .iter() + .enumerate() + { + let key = EddsaPublicKey::rand(); + let payment = gen_in_pay(format!("test with {key} reserve pub")); + + // Register minimal + let partial = InTx { + id: id.clone(), + subject: None, + debtor: None, + ..payment.clone() + }; + register_incoming(&db, &cfg, &partial).await.unwrap(); + check_content(&db, &partial).await; + check_in_count(&db, i + 4, 3, i).await; + + // Recover ID + let full_id = InId::new( + Some(id.uetr.unwrap_or_else(Uuid::new_v4)), + Some(id.tx_id.clone().unwrap_or_else(rand_ebics_id)), + Some(id.sref.clone().unwrap_or_else(rand_ebics_id)), + ); + let full = InTx { + id: full_id.clone(), + ..partial.clone() + }; + register_incoming(&db, &cfg, &full).await.unwrap(); + check_content(&db, &full).await; + check_in_count(&db, i + 4, 3, i).await; + + // Recover subject & debtor + let full = InTx { + id: full_id, + ..payment.clone() + }; + register_incoming(&db, &cfg, &full).await.unwrap(); + check_content(&db, &full).await; + check_in_count(&db, i + 4, 3, i + 1).await; + } + } + + #[tokio::test] + pub async fn in_horror() { + let (_, db) = db_setup().await; + let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + + // Check we do not bounce already registered talerable transaction + let key = EddsaPublicKey::rand(); + let payment = gen_in_pay(format!("test with {key} reserve pub")); + register_incoming(&db, &cfg, &payment).await.unwrap(); + assert_eq!( + register_in_malformed( + &db, + &payment, + &amount("KUDOS:2.53"), + &rand_ebics_id(), + &Timestamp::now(), + "manual bounce", + ) + .await + .unwrap(), + IncomingBounceRegistrationResult::Talerable + ); + let incomplete = InTx { + subject: None, + ..payment.clone() + }; + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + register_incoming(&db, &cfg, &payment).await.unwrap(); + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + check_in_state(&db, &[Reserve(key.clone())]).await; + + // Check we do not register as talerable bounced transaction + let new_key = EddsaPublicKey::rand(); + let payment = gen_in_pay(format!("bounced {new_key}")); + let incomplete = InTx { + subject: None, + ..payment.clone() + }; + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + register_incoming(&db, &cfg, &payment).await.unwrap(); + register_incoming(&db, &cfg, &incomplete).await.unwrap(); + register_incoming(&db, &cfg, &payment).await.unwrap(); + check_in_state(&db, &[Reserve(key.clone()), Bounced]).await; + } +} diff --git a/src/db/transfer.rs b/crates/libeufin-nexus/src/db/transfer.rs diff --git a/crates/libeufin-nexus/src/fetch.rs b/crates/libeufin-nexus/src/fetch.rs @@ -0,0 +1,658 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{ + collections::BTreeMap, + io::{Cursor, Read as _}, + time::Duration, +}; + +use anyhow::{anyhow, bail}; +use jiff::{Timestamp, Zoned, tz::TimeZone}; +use libeufin_ebics::{ + ebics::{ + EbicsClient, EbicsErrKind, + ebics_code::EbicsReturnCode, + order::{Order, OrderDoc}, + }, + iso20022::{ + HacAction, + camt::{AccountId, parse_camt}, + hac::parse_hac, + model::{InTx, OutBatch, OutTx, Tx}, + pain002::parse_pain002, + status_code::{PaymentGroupStatus, PaymentTransactionStatus}, + }, + keys::{BankKeys, ClientKeys}, + ws::listen_for_notification, +}; +use sqlx::PgPool; +use taler_api::subject::{ + IncomingSubject, parse_incoming_unstructured, parse_outgoing, subject_is_qr_bill, +}; +use taler_common::types::amount::Currency; +use tokio::{time::timeout, try_join}; +use tracing::{debug, error, info, trace, warn}; + +use crate::{ + CHECKPOINT_KEY, FETCH_TASK_KEY, + config::{AccountType, NexusCfg, NexusIngestCfg}, + db::{ + get_task_status, + initiated::{ + batch_status_update, order_failure, order_step, order_success, tx_status_update, + unsettled_tx_in_batch, + }, + payment::{ + InResult, IncomingBounceRegistrationResult, IncomingRegistrationResult, + OutgoingRegistrationResult, register_in, register_in_malformed, register_in_qr_bill, + register_in_talerable, register_out_tx, + }, + update_task_status, + }, + model::SubmissionState, + rand_ebics_id, +}; + +pub async fn ebics_fetch( + ebics: &EbicsClient<'_>, + cfg: &NexusCfg, + client: &ClientKeys, + bank: &BankKeys, + db: &PgPool, + documents: Option<&[OrderDoc]>, + pinned_start: &Option<Timestamp>, + peek: bool, + transient: bool, + transient_checkpoint: bool, +) -> anyhow::Result<()> { + let ebics_cfg = cfg.ebics()?; + + let register_file = async |doc: &OrderDoc, xml: Vec<u8>| -> anyhow::Result<()> { + match doc { + OrderDoc::acknowledgement => { + for ack in parse_hac(&xml)? { + debug!(target: "fetch", "{ack}"); + if let Some(order_id) = &ack.order_id { + match ack.action { + HacAction::ORDER_HAC_FINAL_POS => { + if let Some(msg_id) = order_success(db, order_id).await? { + info!(target: "fetch", "Batch {msg_id} order {order_id} accepted at {}", ack.timestamp); + } + } + HacAction::ORDER_HAC_FINAL_NEG => { + if let Some((msg_id, msg)) = order_failure(db, order_id).await? { + info!(target: "fetch", "Batch {msg_id} order {order_id} refused at {}{}", ack.timestamp, std::fmt::from_fn( |f| if let Some(msg) = &msg { + write!(f, ": {msg}") + } else { + Ok(()) + })); + } + } + _ => { + order_step(db, order_id, &ack.to_string()).await?; + } + } + } + } + } + OrderDoc::status => { + let msg_status = parse_pain002(&xml)?; + debug!(target: "ebics-fetch", "{msg_status}"); + if let Some(code) = msg_status.status { + let msg = msg_status.msg(); + batch_status_update( + db, + &msg_status.id, + match code { + PaymentGroupStatus::AcceptedSettlementCompletedDebitorAccount => { + SubmissionState::success + } + PaymentGroupStatus::Rejected => { + error!(target: "fetch", "Batch {} failed: {msg}", msg_status.id); + SubmissionState::success + } + _ => SubmissionState::pending, + }, + &msg, + ) + .await?; + } + for p_status in msg_status.payments { + if p_status.id != "NOTPROVIDED" { + warn!(target: "fetch", "Unexpected payment status for {}.{}", msg_status.id, p_status.id); + } else if let Some(code) = p_status.status { + let msg = p_status.msg(); + batch_status_update( + db, + &msg_status.id, + match code { + PaymentGroupStatus::AcceptedSettlementCompletedDebitorAccount => { + SubmissionState::success + } + PaymentGroupStatus::Rejected => { + error!(target: "fetch", "Batch {} failed: {msg}", msg_status.id); + SubmissionState::success + } + _ => SubmissionState::pending + }, + &msg, + ) + .await?; + } + for tx_status in p_status.txs { + let msg = tx_status.msg(); + tx_status_update( + db, + &tx_status.e2e_id, + &msg_status.id, + match tx_status.status { + PaymentTransactionStatus::Rejected | PaymentTransactionStatus::Blocked => { + error!(target: "fetch", "Transaction {} failed: {msg}", tx_status.e2e_id); + SubmissionState::permanent_failure + } + _ => SubmissionState::pending + }, + &msg, + ) + .await?; + } + } + } + OrderDoc::report | OrderDoc::statement | OrderDoc::notification => { + register_camt(db, cfg, &xml).await?; + } + } + Ok(()) + }; + let register_payload = async |doc: &OrderDoc, content: Vec<u8>| -> anyhow::Result<()> { + // Unzip payload if necessary + match doc { + OrderDoc::acknowledgement => register_file(doc, content).await, + OrderDoc::status | OrderDoc::report | OrderDoc::statement | OrderDoc::notification => { + let mut z = zip::ZipArchive::new(Cursor::new(content))?; + for i in 0..z.len() { + let mut file = z.by_index(i)?; + trace!(target: "fetch", "parse {}", file.name()); + let mut buf = Vec::new(); + file.read_to_end(&mut buf)?; + register_file(doc, buf).await?; + } + Ok(()) + } + } + }; + let fetch = async |orders: &[Order], since: Option<Timestamp>| -> anyhow::Result<bool> { + let mut grouped_orders = BTreeMap::new(); + + for order in orders { + grouped_orders + .entry(order.doc()) + .or_insert_with(Vec::new) + .push(order); + } + + let mut success = true; + for (doc, orders) in grouped_orders { + if let Some(doc) = doc { + for order in orders { + if let Err(e) = ebics + .download( + db, + client, + bank, + order, + &since.map(|it| (it, Timestamp::now())), + transient && peek, + async |content| { + register_payload(&doc, content) + .await + .map_err(|e| EbicsErrKind::Custom(e.to_string().into())) + }, + ) + .await + { + if let EbicsErrKind::Code { bank, .. } = e.kind { + match bank { + EbicsReturnCode::EBICS_NO_DOWNLOAD_DATA_AVAILABLE => continue, + EbicsReturnCode::EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED => { + error!(target: "ebics-fetch", "{e}"); + success = false; + continue; + } + _ => {} + } + } + return Err(e.into()); + } + } + } else { + debug!(target: "fetch", "Skip unsupported orders {orders:?}") + } + } + Ok(success) + }; + + // EBICS order than should be fetched + let orders: Vec<_> = documents + .unwrap_or(OrderDoc::entries) + .iter() + .flat_map(|it| ebics_cfg.dialect.standard().downloads(it)) + .collect(); + + let fetch_cfg = cfg.fetch()?; + + let (sender, mut receiver) = tokio::sync::mpsc::channel::<Vec<Order>>(10); + + let fetch = async { + if transient { + info!(target: "fetch", "Transient mode: fetching once and returning"); + } else { + info!(target: "fetch", "Running with a frequency of {}", fetch_cfg.frequency_raw); + } + + // TODO loop + + let mut last_fetch = Timestamp::UNIX_EPOCH; + loop { + let now = Timestamp::now(); + let checkpoint = get_task_status(db, CHECKPOINT_KEY) + .await? + .unwrap_or_default(); + let next_fetch = last_fetch + fetch_cfg.frequency; + let next_checkpoint = { + if let Some(last_trial) = checkpoint.last_trial { + // We run today at checkpoint_time + let checkpoint_date = Zoned::new(now, TimeZone::UTC) + .with() + .time(fetch_cfg.checkpoint_time) + .build() + .unwrap(); + // If we already ran today we ran tomorrow + if last_trial > checkpoint_date.timestamp() { + checkpoint_date.tomorrow().unwrap().timestamp() + } else { + checkpoint_date.timestamp() + } + } else { + // We never ran, we must checkpoint now + now + } + }; + + let mut success = true; + if + // Run transient checkpoint at request + (transient && transient_checkpoint) + // Or run recurrent checkpoint + || (!transient && now > next_checkpoint) + { + info!(target: "fetch", "Running checkpoint"); + + let since = if let Some(pinned_start) = pinned_start + && transient + && checkpoint + .last_successfull + .map(|it| *pinned_start <= it) + .unwrap_or(true) + { + Some(*pinned_start) + } else { + checkpoint.last_successfull + }; + let res = async { + // We fetch HKD to only fetch supported EBICS orders and get the document versions + let hkd = ebics.hkd(db, client, bank, false).await?; + let mut supported_orders = hkd + .partner + .orders + .into_iter() + .map(|it| it.order) + .collect::<Vec<_>>(); + debug!( + "HKD: {}", + std::fmt::from_fn(|f| f.write_str( + &supported_orders + .iter() + .map(|it| it.to_string()) + .collect::<Vec<_>>() + .join(",") + )) + ); + supported_orders + .retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); + fetch(&supported_orders, since).await + } + .await; + if let Err(e) = res { + success = false; + error!(target: "fetch", "{e}"); + } + try_join!( + update_task_status(db, CHECKPOINT_KEY, &now, success), + update_task_status(db, FETCH_TASK_KEY, &now, success) + )?; + last_fetch = now; + } else if transient || now > next_fetch { + if !transient { + info!(target: "fetch", "Running at frequency"); + } + let res = async { + // We fetch HAA to only fetch pending & supported EBICS orders and get the document versions + let mut haa = ebics.haa(db, client, bank, false).await?; + debug!(target: "fetch", + "HAA: {}", + std::fmt::from_fn(|f| f.write_str( + &haa.orders + .iter() + .map(|it| it.to_string()) + .collect::<Vec<_>>() + .join(",") + )) + ); + haa.orders + .retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); + fetch(&haa.orders, *pinned_start).await + } + .await; + if let Err(e) = res { + success = false; + error!(target: "fetch", "{e}"); + } + update_task_status(db, FETCH_TASK_KEY, &now, success).await?; + last_fetch = now; + } + + if transient { + if success { + return anyhow::Ok(()); + } else { + return Err(anyhow!("fetch failed")); + } + } + + let delay = now.duration_until(next_fetch.min(next_checkpoint)); + let tx = timeout( + Duration::from_millis(delay.abs().as_millis() as u64), + receiver.recv(), + ) + .await; + if let Ok(Some(mut notification)) = tx { + notification.retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); + if !notification.is_empty() { + info!(target: "fetch", "Running at real-time notifications reception"); + fetch(&notification, None).await?; + } + } + } + }; + + if transient { + fetch.await?; + } else { + tokio::try_join!(fetch, async { + listen_for_notification(ebics, db, client, bank, sender).await; + Ok(()) + })?; + } + + Ok(()) +} + +async fn register_camt(db: &PgPool, cfg: &NexusCfg, xml: &[u8]) -> anyhow::Result<usize> { + let account = &cfg.ebics()?.account; + let ingest_cfg = cfg.ingest()?; + let mut nb_tx = 0; + for actx in parse_camt(xml)? { + if let AccountId::Iban(iban) = &actx.id + && iban == &account.iban + { + if let Some(currency) = actx.currency + && currency != cfg.currency + { + bail!( + "Expected transactions of currency {} got {currency}", + cfg.currency + ) + } + for tx in actx.txs { + match tx { + Tx::In(InTx { amount, .. }) | Tx::Out(OutTx { amount, .. }) => { + if amount.currency != cfg.currency { + bail!( + "Expected transactions of currency {} got {}", + cfg.currency, + amount.currency + ) + } + } + Tx::Batch(_) | Tx::Reversal(_) => {} + } + register_tx(db, &ingest_cfg, &tx).await?; + nb_tx += 1; + } + } else { + warn!(target: "fetch", "Skip transaction for unknown account {}", actx.id); + } + } + Ok(nb_tx) +} + +pub async fn register_incoming( + db: &PgPool, + cfg: &NexusIngestCfg, + payment: &InTx, +) -> sqlx::Result<()> { + let log_res = |res: InResult, kind: &str, suffix: &str| { + let fmt = std::fmt::from_fn(|f| { + write!(f, "{payment}")?; + if kind.is_empty() { + write!(f, " {kind}")?; + } + if res.new { + if let Some(id) = &res.bounce_id { + write!(f, " bounced in {id}")?; + } + } else { + if res.completed { + f.write_str(" completed")?; + if let Some(id) = &res.bounce_id { + write!(f, " bounced in {id}")?; + } + } else { + if let Some(id) = &res.bounce_id { + write!(f, " already bounced in {id}")?; + } + } + } + if suffix.is_empty() { + write!(f, " {suffix}")?; + } + Ok(()) + }); + + if res.completed || res.new { + info!(target: "fetch", "{fmt}") + } else { + debug!(target: "fetch", "{fmt}") + } + }; + let bounce = async |cause: &str| { + match cfg.account_type { + AccountType::Exchange => { + if payment.execution_time < cfg.ignore_bounces_before { + let res = register_in(db, payment).await?; + log_res(res, "", &format!("ignored bounce: {cause}")); + } else { + let mut bounce_amount = payment.amount; + if !payment.credit_fee.is_zero() && cfg.bounce_deduce_fee { + if let Some(res) = bounce_amount.try_sub(&payment.credit_fee) { + bounce_amount = res + } else { + let res = register_in(db, payment).await?; + log_res( + res, + "", + &format!("skip bounce (transfer fee higher than amount): {cause}"), + ); + return Ok(()); + } + } + if let Some(res) = bounce_amount.try_sub(&cfg.bounce_fee) { + bounce_amount = res + } else { + let res = register_in(db, payment).await?; + log_res( + res, + "", + &format!("skip bounce (bounce fee higher than amount): {cause}"), + ); + return Ok(()); + } + let res = register_in_malformed( + db, + payment, + &bounce_amount, + &rand_ebics_id(), + &Timestamp::now(), + cause, + ) + .await?; + match res { + IncomingBounceRegistrationResult::Talerable => { + warn!(target: "fetch", "{payment} tried to bounce a talerable transaction"); + } + IncomingBounceRegistrationResult::Success(res) => { + log_res(res, "", &format!(": {cause}")); + } + } + } + } + AccountType::Normal => { + let res = register_in(db, payment).await?; + log_res(res, "", ""); + } + } + sqlx::Result::<_, sqlx::Error>::Ok(()) + }; + + // Check we have enough info to handle this transaction + if payment.debtor.is_none() { + // TODO payment.debtor.receiverName == null + let res = register_in(db, payment).await?; + log_res(res, "incomplete", ""); + return Ok(()); + } + // TODO if payment.debtor.is_none() && payment.debtor.map(|it| it.rec) + if let Some(regex) = &cfg.restriction_payto_regex + && let Some(debtor) = &payment.debtor + && !regex.is_match(debtor.as_ref().as_str()) + { + bounce("restricted account").await?; + return Ok(()); + } + + if let Some(subject) = &payment.subject + && subject_is_qr_bill(subject) + { + match register_in_qr_bill(db, payment, subject).await? { + IncomingRegistrationResult::ReservePubReuse => bounce("reverse pub reuse").await?, + IncomingRegistrationResult::MappingReuse => bounce("mapping reuse").await?, + IncomingRegistrationResult::UnknownMapping => bounce("unknown mapping").await?, + IncomingRegistrationResult::Success(res) => { + log_res(res, "", ""); + } + } + } else { + match parse_incoming_unstructured(payment.subject.as_deref().unwrap_or_default()) { + Ok(None) => bounce("missing public key").await?, + Ok(Some(IncomingSubject::AdminBalanceAdjust)) => { + let res = register_in(db, payment).await?; + log_res(res, "admin balance adjust", ""); + } + Ok(Some(subject)) => match register_in_talerable(db, payment, &subject).await? { + IncomingRegistrationResult::ReservePubReuse => bounce("reverse pub reuse").await?, + IncomingRegistrationResult::MappingReuse => bounce("mapping reuse").await?, + IncomingRegistrationResult::UnknownMapping => bounce("unknown mapping").await?, + IncomingRegistrationResult::Success(res) => { + log_res(res, "", ""); + } + }, + Err(e) => { + bounce(&e.to_string()).await?; + } + } + } + + Ok(()) +} + +pub async fn register_outgoing( + db: &PgPool, + payment: &OutTx, +) -> sqlx::Result<OutgoingRegistrationResult> { + let metadata = payment + .subject + .as_ref() + .and_then(|s| parse_outgoing(s).ok()); + let res = register_out_tx(db, payment, metadata.as_ref()).await?; + if res.new { + if res.initiated { + info!(target: "fetch", "{payment}"); + } else { + warn!(target: "fetch", "{payment} recovered"); + } + } else { + debug!(target: "fetch", "{payment} already seen"); + } + Ok(res) +} + +pub async fn register_outgoing_batch( + db: &PgPool, + currency: &Currency, + batch: &OutBatch, +) -> sqlx::Result<()> { + info!(target: "fetch", "{batch}"); + let txs = unsettled_tx_in_batch(db, currency, &batch.msg_id, &batch.execution_time).await?; + for tx in txs { + register_outgoing(db, &tx).await?; + } + Ok(()) +} + +pub async fn register_tx(db: &PgPool, cfg: &NexusIngestCfg, tx: &Tx) -> sqlx::Result<()> { + if tx.execution_time() < &cfg.ignore_txs_before { + debug!(target: "fetch", "IGNORE {tx}"); + } else { + match tx { + Tx::In(payment) => { + register_incoming(db, cfg, payment).await?; + } + Tx::Out(payment) => { + register_outgoing(db, payment).await?; + } + Tx::Batch(batch) => { + register_outgoing_batch(db, &cfg.currency, batch).await?; + } + Tx::Reversal(_) => todo!(), + } + } + Ok(()) +} diff --git a/crates/libeufin-nexus/src/lib.rs b/crates/libeufin-nexus/src/lib.rs @@ -0,0 +1,455 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{str::FromStr, time::Duration}; + +use anyhow::{anyhow, bail}; +use compact_str::{CompactString, CompactStringExt}; +use jiff::{Timestamp, civil::Date}; +use libeufin_ebics::{ + cli::EbicsLogs, + ebics::{ + EbicsClient, EbicsCtx, EbicsErrKind, EbicsError, EbicsErrorHelper as _, order::Order, + rand_ebics_id, + }, + iso20022::pain001::{Pain001Msg, Pain001Tx, create_pain001}, + keys::{BankKeys, ClientKeys, expect_full_keys}, +}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sqlx::PgPool; +use taler_build::long_version; +use taler_common::{ + CommonArgs, + cli::ConfigCmd, + config::{Config, parser::ConfigSource}, + types::{ + amount::Amount, + payto::{FullIbanPayto, TransferIbanPayto}, + utils::date_to_utc_ts, + }, +}; +use tracing::{debug, error, info, warn}; + +use crate::{ + config::{NexusCfg, NexusKeysCfg}, + db::{ + dbinit, + initiated::{ + batch_initiated, batch_sub_failure, batch_sub_success, initiate, initiated_submittable, + }, + pool, update_task_status, + }, + fetch::ebics_fetch, + list::ListCmd, + model::PaymentBatch, + testing::TestingCmd, +}; + +pub mod api; +pub mod bench; +pub mod config; +pub mod db; +pub mod fetch; +pub mod list; +pub mod model; +#[cfg(test)] +pub mod test; +pub mod testing; + +// KV +const CHECKPOINT_KEY: &str = "checkpoint"; +const SUBMIT_TASK_KEY: &str = "submit_task"; +const FETCH_TASK_KEY: &str = "fetch_task"; + +pub const CONFIG_SOURCE: ConfigSource = + ConfigSource::new("libeufin", "libeufin-nexus", "libeufin-nexus"); + +#[derive(clap::Parser, Debug, Clone)] +pub struct EbicsArgs { + #[command(flatten)] + logs: EbicsLogs, + + /// Execute once and return, ignoring the 'FREQUENCY' configuration value + #[clap(long)] + transient: bool, +} + +#[derive(clap::Subcommand, Debug)] +pub enum Cmd { + /// Initialize libeufin-nexus database + Dbinit { + /// Reset database (DANGEROUS: All existing data is lost) + #[clap(long, short)] + reset: bool, + }, + /// Set up the EBICS subscriber + EbicsSetup { + #[command(flatten)] + ebics_logs: EbicsLogs, + + /// Resubmits all the keys to the bank + #[clap(long)] + force_keys_resubmission: bool, + + /// Accepts the bank keys without interactively asking the user + #[clap(long)] + auto_accept_keys: bool, + + /// Generates the PDF with the client public keys to send to the bank + #[clap(long)] + generate_registration_pdf: bool, + }, + /// Submits pending initiated payments found in the database + EbicsSubmit { + #[clap(flatten)] + ebics: EbicsArgs, + }, + /// Downloads and parse EBICS files from the bank and register them into the database + EbicsFetch { + #[clap(flatten)] + ebics: EbicsArgs, + + /// Only supported in --transient mode, this option lets specify the earliest timestamp of the downloaded documents + #[clap(long, value_name = "YYYY-MM-DD")] + pinned_start: Option<Date>, + + /// Only supported in --transient mode, do not consume fetched documents + #[clap(long, requires = "transient")] + peek: bool, + + /// Only supported in --transient mode, run a checkpoint + #[clap(long, requires = "transient")] + checkpoint: bool, + }, + Serve {}, + /// Initiate an outgoing payment + InitiatePayment { + /// The amount to transfer, payto 'amount' parameter takes the precedence + #[clap(long)] + amount: Option<Amount>, + + /// The payment subject, payto 'message' parameter takes the precedence + #[clap(long)] + subject: Option<CompactString>, + + /// The payment end-to-end UID + #[clap(long, alias = "request-uid")] + end_to_end_id: Option<CompactString>, + + /// The credited account IBAN payto UR + payto: TransferIbanPayto, + }, + Manual {}, + #[command(subcommand)] + List(ListCmd), + #[command(subcommand)] + Config(ConfigCmd), + #[command(subcommand)] + Testing(TestingCmd), +} + +#[derive(clap::Parser, Debug)] +#[command(long_version = long_version(), about, long_about = None)] +pub struct Args { + #[clap(flatten)] + pub common: CommonArgs, + + #[command(subcommand)] + pub cmd: Cmd, +} + +pub async fn ebics_submit( + ebics: &EbicsClient<'_>, + cfg: &NexusCfg, + client: &ClientKeys, + bank: &BankKeys, + db: &PgPool, + transient: bool, +) -> anyhow::Result<()> { + let ebics_cfg = cfg.ebics()?; + let submit_cfg = cfg.submit()?; + + let submit_batch = async |order: &Order, + batch: &PaymentBatch, + instant: bool| + -> Result<CompactString, EbicsError> { + let ctx = EbicsCtx::new(order); + let msg = Pain001Msg { + msg_id: &batch.msg_id, + timestamp: &Timestamp::now(), + debtor: &ebics_cfg.account, + sum: batch.sum, + txs: batch + .payments + .iter() + .map(|tx| { + let creditor = FullIbanPayto::from_str(tx.creditor.as_ref().as_str()).unwrap(); + // TODO handle missing name ? + Pain001Tx { + creditor, + amount: tx.amount, + subject: &tx.subject, + e2e_id: &tx.e2e_id, + } + }) + .collect(), + }; + let xml = create_pain001(&msg, &ebics_cfg.dialect, instant).ctx(&ctx)?; + ebics.upload(client, bank, order, &xml).await + }; + + let submit_all = async || -> anyhow::Result<()> { + let standard = cfg.ebics()?.dialect.standard(); + + // Find a supported debit order + let mut instant_order = standard.instant_direct_debit(); + let debit_order = standard.direct_debit(); + + // Create batch if necessary + batch_initiated( + db, + &Timestamp::now(), + &rand_ebics_id(), + submit_cfg.require_ack, + ) + .await?; + + // Send submittable batches + for batch in initiated_submittable(db, &cfg.currency).await? { + debug!(target: "ebics-submit", "Submitting batch {}", batch.msg_id); + let res = async { + if let Some(instant) = standard.instant_direct_debit() { + match submit_batch(&instant, &batch, true).await { + Ok(id) => return Ok(id), + Err(e) => if let EbicsErrKind::Code { .. } = e.kind { + // No longer try to submit using the instant method for now + debug!(target: "ebics-submit", "Failed to submit using instant credit order {e}"); + instant_order = None; + } else { + return Err(e) + }, + } + } + submit_batch(&debit_order, &batch, false).await + }.await; + match res { + Ok(order_id) => { + batch_sub_success(db, batch.id, &Timestamp::now(), &order_id).await?; + let txs = batch + .payments + .iter() + .map(|it| &it.e2e_id) + .collect::<Vec<_>>() + .join_compact(","); + if instant_order.is_some() { + info!(target: "ebics-submit", "Instant batch {} submitted as order {order_id}: {txs}", batch.msg_id); + } else { + info!(target: "ebics-submit", "Batch {} submitted as order {order_id}: {txs}", batch.msg_id); + } + } + Err(e) => { + batch_sub_failure(db, batch.id, &Timestamp::now(), &e.to_string()).await?; + error!(target: "ebics-submit", "Batch {} submission failure: {e}", batch.msg_id); + return Err(e.into()); + } + } + } + + Ok(()) + }; + if transient { + debug!(target: "ebics-submit", "Transient mode: submitting what found and returning"); + submit_all().await + } else { + debug!(target: "ebics-submit", "Running with a frequency of {}", submit_cfg.frequency_raw); + loop { + let now = Timestamp::now(); + let success = match submit_all().await { + Ok(_) => true, + Err(e) => { + error!(target: "ebics-submit", "{e}"); + false + } + }; + if let Err(e) = update_task_status(db, SUBMIT_TASK_KEY, &now, success).await { + warn!(target: "ebics-submit", "{e}"); + } + tokio::time::sleep(Duration::from_millis( + Timestamp::now() + .duration_until(now + submit_cfg.frequency) + .abs() + .as_millis() as u64, + )) + .await; + } + } +} + +pub async fn ebics_setup( + ebics: &EbicsClient<'_>, + cfg: &NexusKeysCfg, + force_keys_resubmission: bool, + generate_registration_pdf: bool, + auto_accept_keys: bool, +) -> anyhow::Result<()> { + let (client, bank) = libeufin_ebics::setup::ebics_setup( + ebics, + &cfg.ebics(), + force_keys_resubmission, + generate_registration_pdf, + auto_accept_keys, + ) + .await?; + + // Check account information + info!(target: "setup", "Doing administrative request HKD"); + // TODO HKD + + eprintln!("setup ready"); + Ok(()) +} + +pub async fn run(cfg: Config, cmd: Cmd) -> anyhow::Result<()> { + match cmd { + Cmd::Dbinit { reset } => { + dbinit(&cfg, reset).await?; + } + Cmd::EbicsSetup { + ebics_logs, + force_keys_resubmission, + auto_accept_keys, + generate_registration_pdf, + } => { + let cfg = NexusCfg::parse(cfg)?; + let ebics = EbicsClient::new(cfg.host()?.ebics(), ebics_logs)?; + ebics_setup( + &ebics, + cfg.keys()?, + force_keys_resubmission, + generate_registration_pdf, + auto_accept_keys, + ) + .await?; + } + Cmd::EbicsFetch { + pinned_start, + peek, + checkpoint, + ebics: EbicsArgs { logs, transient }, + } => { + let pool = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + let key_cfg = cfg.keys()?; + let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?; + let (client, bank) = expect_full_keys(&key_cfg.ebics())?; + ebics_fetch( + &ebics, + &cfg, + &client, + &bank, + &pool, + None, + &pinned_start.map(|it| date_to_utc_ts(&it)), + peek, + transient, + transient && checkpoint, + ) + .await? + } + Cmd::EbicsSubmit { + ebics: EbicsArgs { logs, transient }, + } => { + let pool = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?; + let key_cfg = cfg.keys()?; + let (client, bank) = expect_full_keys(&key_cfg.ebics())?; + ebics_submit(&ebics, &cfg, &client, &bank, &pool, transient).await? + } + Cmd::InitiatePayment { + amount, + subject, + end_to_end_id, + payto, + } => { + let pool = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + + let subject = payto + .subject + .as_ref() + .or(subject.as_ref()) + .ok_or(anyhow!("Mising subject"))?; + let amount = payto + .amount + .as_ref() + .or(amount.as_ref()) + .ok_or(anyhow!("Mising amount"))?; + + if cfg.currency != amount.currency { + bail!( + "Wrong currency: expected {} got {}", + cfg.currency, + amount.currency + ); + } + initiate( + &pool, + amount, + subject, + &payto.as_payto(), + &Timestamp::now(), + &end_to_end_id + .as_ref() + .cloned() + .unwrap_or_else(rand_ebics_id), + ) + .await?; + } + Cmd::Serve {} => todo!(), + Cmd::Manual {} => todo!(), + Cmd::List(cmd) => { + let pool = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + cmd.run(&pool, &cfg.currency).await?; + } + Cmd::Config(cmd) => cmd.run(&cfg)?, + Cmd::Testing(cmd) => cmd.run(cfg).await?, + } + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct TaskStatus { + #[serde(serialize_with = "ser_micros", deserialize_with = "de_micros", default)] + pub last_successfull: Option<Timestamp>, + #[serde(serialize_with = "ser_micros", deserialize_with = "de_micros", default)] + pub last_trial: Option<Timestamp>, +} + +fn ser_micros<S: Serializer>(key: &Option<Timestamp>, serializer: S) -> Result<S::Ok, S::Error> { + key.map(|it| it.as_microsecond()).serialize(serializer) +} + +fn de_micros<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Timestamp>, D::Error> { + Option::<i64>::deserialize(deserializer)? + .map(Timestamp::from_microsecond) + .transpose() + .map_err(|e| serde::de::Error::custom(e.to_string())) +} diff --git a/src/list.rs b/crates/libeufin-nexus/src/list.rs diff --git a/crates/libeufin-nexus/src/main.rs b/crates/libeufin-nexus/src/main.rs @@ -0,0 +1,27 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use clap::Parser as _; +use libeufin_nexus::{Args, CONFIG_SOURCE, run}; +use taler_common::taler_main; + +fn main() { + let args = Args::parse(); + taler_main(CONFIG_SOURCE, args.common, |cfg| run(cfg, args.cmd)) +} diff --git a/crates/libeufin-nexus/src/model.rs b/crates/libeufin-nexus/src/model.rs @@ -0,0 +1,88 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use compact_str::CompactString; +use jiff::Timestamp; +use taler_common::{ + api_wire::TransferState, + types::{amount::Amount, payto::PaytoURI}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[allow(non_camel_case_types)] +#[sqlx(type_name = "submission_state")] +/** Outgoing transactions and batches submission status */ +pub enum SubmissionState { + // Initiated but not yet submitted + unsubmitted, + // Submission failed, retry possible + transient_failure, + // Submission succeed, pending settltment + pending, + // Definitive failure, will never succeed + permanent_failure, + // Definitive success, booked and settled + success, + // Late failure after a success, happens when a payment is returned + late_failure, +} + +impl SubmissionState { + pub fn to_transfer_status(self) -> TransferState { + match self { + SubmissionState::unsubmitted | SubmissionState::pending => TransferState::pending, + SubmissionState::transient_failure => TransferState::transient_failure, + SubmissionState::permanent_failure => TransferState::permanent_failure, + SubmissionState::success | SubmissionState::late_failure => TransferState::success, + } + } +} + +impl From<TransferState> for SubmissionState { + fn from(value: TransferState) -> Self { + match value { + TransferState::pending => SubmissionState::pending, + TransferState::transient_failure => SubmissionState::transient_failure, + TransferState::permanent_failure => SubmissionState::permanent_failure, + TransferState::late_failure => SubmissionState::late_failure, + TransferState::success => SubmissionState::success, + } + } +} + +/** Batch of initiated outgoing payment to sent together */ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaymentBatch { + pub id: u64, + pub msg_id: CompactString, + pub creation_date: Timestamp, + pub sum: Amount, + pub payments: Vec<Initiated>, +} + +/** Initiated outgoing transaction */ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Initiated { + pub id: u64, + pub amount: Amount, + pub subject: String, + pub creditor: PaytoURI, + pub initiation_time: Timestamp, + pub e2e_id: CompactString, +} diff --git a/crates/libeufin-nexus/src/test.rs b/crates/libeufin-nexus/src/test.rs @@ -0,0 +1,490 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use std::{str::FromStr as _, sync::LazyLock}; + +use compact_str::CompactString; +use jiff::Timestamp; +use libeufin_ebics::iso20022::model::{InId, InTx, OutId, OutTx}; +use sqlx::PgPool; +use taler_api::subject::{fmt_in_subject, fmt_out_subject, subject_fmt_qr_bill}; +use taler_common::{ + api_common::{EddsaPublicKey, EddsaSignature}, + db::IncomingType, + types::{ + amount::{Amount, Currency}, + base32::Base32, + payto::{IbanPayto, PaytoURI, payto}, + }, +}; +use url::Url; + +use crate::{ + config::{AccountType, NexusIngestCfg}, + db::{ + initiated::{PaymentInitiationResult, initiate}, + transfer::{RegistrationResult, transfer_register}, + }, + fetch::{register_incoming, register_outgoing}, + model::Initiated, + rand_ebics_id, +}; + +pub const CURR: Currency = Currency::KUDOS; +pub static ACCOUNT: LazyLock<PaytoURI> = + LazyLock::new(|| payto("payto://iban/CH4189144589712575493?receiver-name=Test")); + +/** Generates an outgoing payment, given its subject */ +pub fn gen_out_pay(subject: impl Into<String>) -> OutTx { + OutTx { + id: OutId { + msg_id: None, + e2e_id: Some(rand_ebics_id()), + sref: None, + }, + amount: Amount::new(&CURR, 44, 0), + debit_fee: Amount::zero(&CURR), + creditor: Some( + IbanPayto::from_str("payto://iban/CH4189144589712575493?receiver-name=Test") + .unwrap() + .as_payto(), + ), + subject: Some(subject.into()), + execution_time: Timestamp::now(), + } +} + +/** Generates a payment initiation, given its subject and end-to-end ID */ +pub fn gen_init_pay( + end_to_end_id: impl Into<CompactString>, + subject: impl Into<String>, +) -> Initiated { + Initiated { + id: 0, + amount: Amount::new(&CURR, 44, 0), + creditor: IbanPayto::from_str("payto://iban/CH4189144589712575493?receiver-name=Test") + .unwrap() + .as_payto(), + subject: subject.into(), + initiation_time: Timestamp::now(), + e2e_id: end_to_end_id.into(), + } +} + +/** Generates an incoming payment, given its subject */ +pub fn gen_in_pay(subject: impl Into<String>) -> InTx { + InTx { + id: InId::new(None, Some(rand_ebics_id()), None), + amount: Amount::new(&CURR, 44, 0), + credit_fee: Amount::zero(&CURR), + debtor: Some( + IbanPayto::from_str("payto://iban/DE84500105177118117964?receiver-name=John+Smith") + .unwrap() + .as_payto(), + ), + subject: Some(subject.into()), + execution_time: Timestamp::now(), + } +} + +pub async fn gen_initiate( + db: &PgPool, + end_to_end_id: impl Into<CompactString>, + subject: impl Into<String>, +) -> PaymentInitiationResult { + let init = gen_init_pay(end_to_end_id, subject); + initiate( + &db, + &init.amount, + &init.subject, + &init.creditor, + &init.initiation_time, + &init.e2e_id, + ) + .await + .unwrap() +} + +const CFG: NexusIngestCfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); + +async fn prepare(db: &PgPool) -> String { + let key = EddsaPublicKey::rand(); + let sig = EddsaSignature::rand(); + let reference_number = subject_fmt_qr_bill(key.as_ref()); + assert_eq!( + RegistrationResult::Success, + transfer_register( + db, + IncomingType::reserve, + &key, + &key, + &sig, + false, + &reference_number, + &Timestamp::now() + ) + .await + .unwrap() + ); + return reference_number; +} + +/// Register a talerable reserve prepared incoming transaction +pub async fn prepared_in(db: &PgPool) { + let ref_nb = prepare(db).await; + register_incoming(db, &CFG, &gen_in_pay(ref_nb)) + .await + .unwrap(); +} + +/// Register an incomplete talerable reserve prepared incoming transaction +pub async fn prepared_incomplete_in(db: &PgPool) { + let ref_nb = prepare(db).await; + let incomplete = InTx { + subject: None, + debtor: None, + ..gen_in_pay(ref_nb) + }; + register_incoming(db, &CFG, &incomplete).await.unwrap(); +} + +/// Register a completed talerable reserve prepared incoming transaction +pub async fn prepared_completeted_in(db: &PgPool) { + let ref_nb = prepare(db).await; + let original = gen_in_pay(ref_nb); + let incomplete = InTx { + subject: None, + debtor: None, + ..original.clone() + }; + register_incoming(db, &CFG, &incomplete).await.unwrap(); + register_incoming(db, &CFG, &original).await.unwrap(); +} + +/// Register a talerable reserve incoming transaction +pub async fn talerable_in(db: &PgPool) { + register_incoming( + db, + &CFG, + &gen_in_pay(fmt_in_subject( + IncomingType::reserve, + &EddsaPublicKey::rand(), + )), + ) + .await + .unwrap(); +} + +/// Register a talerable kyc incoming transaction +pub async fn talerable_kyc_in(db: &PgPool) { + register_incoming( + db, + &CFG, + &gen_in_pay(fmt_in_subject(IncomingType::kyc, &EddsaPublicKey::rand())), + ) + .await + .unwrap(); +} + +/// Register an incomplete talerable reserve incoming transaction +pub async fn talerable_incomplete_in(db: &PgPool) { + let incomplete = InTx { + subject: None, + debtor: None, + ..gen_in_pay(fmt_in_subject( + IncomingType::reserve, + &EddsaPublicKey::rand(), + )) + }; + register_incoming(db, &CFG, &incomplete).await.unwrap(); +} + +/// Register a completed talerable reserve incoming transaction +pub async fn talerable_completeted_in(db: &PgPool) { + let original = gen_in_pay(fmt_in_subject( + IncomingType::reserve, + &EddsaPublicKey::rand(), + )); + let incomplete = InTx { + subject: None, + debtor: None, + ..original.clone() + }; + register_incoming(db, &CFG, &incomplete).await.unwrap(); + register_incoming(db, &CFG, &original).await.unwrap(); +} + +/// Register incoming malformed transaction +pub async fn malformed_in(db: &PgPool) { + register_incoming(db, &CFG, &gen_in_pay("ignored")) + .await + .unwrap(); +} + +/// Register incoming incomplete malformed incoming transaction +pub async fn malformed_incomplete_in(db: &PgPool) { + let incomplete = InTx { + subject: None, + debtor: None, + ..gen_in_pay("ignored") + }; + register_incoming(db, &CFG, &incomplete).await.unwrap(); +} + +/// Register incoming completed malformed transaction +pub async fn malformed_completeted_in(db: &PgPool) { + let original = gen_in_pay("ignored"); + let incomplete = InTx { + subject: None, + debtor: None, + ..original.clone() + }; + register_incoming(db, &CFG, &incomplete).await.unwrap(); + register_incoming(db, &CFG, &original).await.unwrap(); +} + +/** Register an outgoing transaction */ +pub async fn malformed_out(db: &PgPool) { + register_outgoing(db, &gen_out_pay("ignored")) + .await + .unwrap(); +} + +/** Register an incomplete outgoing transaction */ +pub async fn incomplete_out(db: &PgPool) { + let incomplete = OutTx { + subject: None, + creditor: None, + ..gen_out_pay("ignored") + }; + register_outgoing(db, &incomplete).await.unwrap(); +} + +/// Register outgoing talerable transaction +pub async fn talerable_out(db: &PgPool) { + register_outgoing( + db, + &gen_out_pay(fmt_out_subject( + &Base32::rand(), + &Url::from_str("https://exchange.test.com").unwrap(), + None, + )), + ) + .await + .unwrap(); +} + +#[cfg(test)] +mod ebics { + use clap::Parser as _; + use libeufin_ebics::test::{EbicsState, TestBank}; + use sqlx::PgPool; + use taler_common::config::Config; + + use crate::{Args, CHECKPOINT_KEY, CONFIG_SOURCE, db::test::db_setup, run}; + + pub async fn nexus_cmd(cfg: &Config, cmd: &str) -> anyhow::Result<()> { + let parts = shlex::split(cmd).unwrap(); + let args = std::iter::once("libeufin_nexus").chain(parts.iter().map(|it| it.as_str())); + + let cmd = Args::try_parse_from(args).unwrap(); + run(cfg.clone(), cmd.cmd).await + } + + async fn test_setup() -> (TestBank, Config, PgPool) { + let (_, db) = db_setup().await; + let test = TestBank::new().await; + let cfg = Config::from_mem_with_env( + CONFIG_SOURCE, + &format!( + " + [paths] + LIBEUFIN_NEXUS_HOME = {:?} + + {} + + [nexus-ebics] + UNIXPATH = {} + + [libeufin-nexusdb-postgres] + CONFIG = postgresql:///{} + ", + test.dir.path(), + include_str!("../../../testbench/conf/mini.conf"), + test.sock_path, + db.connect_options().get_database().unwrap() + ), + ) + .unwrap(); + test.sequences(&[ + EbicsState::hev, + EbicsState::ini, + EbicsState::hia, + EbicsState::hpb, + ]); + nexus_cmd(&cfg, "ebics-setup --auto-accept-keys") + .await + .unwrap(); + + (test, cfg, db) + } + + #[tokio::test] + async fn setup() { + test_setup().await; + } + + #[tokio::test] + async fn fetch_pinned_date() { + let (test, cfg, db) = test_setup().await; + + let reset_checkpoint = async || { + let res = sqlx::query("DELETE FROM kv WHERE key=$1") + .bind(CHECKPOINT_KEY) + .execute(&db) + .await + .unwrap(); + assert_eq!(res.rows_affected(), 1); + }; + + // Default transient + test.sequences(&[ + EbicsState::haa, + EbicsState::receipt_ok, + EbicsState::btd_no_data, + ]); + nexus_cmd(&cfg, "ebics-fetch --transient").await.unwrap(); + + // Pinned transient + test.sequences(&[ + EbicsState::haa, + EbicsState::receipt_ok, + EbicsState::btd_no_data_pinned, + ]); + nexus_cmd(&cfg, "ebics-fetch --transient --pinned-start 2024-06-05") + .await + .unwrap(); + + // Init checkpoint + test.sequences(&[ + EbicsState::hkd, + EbicsState::receipt_ok, + EbicsState::btd_no_data, + ]); + nexus_cmd(&cfg, "ebics-fetch --transient --checkpoint") + .await + .unwrap(); + + // Default checkpoint + test.sequences(&[ + EbicsState::hkd, + EbicsState::receipt_ok, + EbicsState::btd_no_data_now, + ]); + nexus_cmd(&cfg, "ebics-fetch --transient --checkpoint") + .await + .unwrap(); + + // Pinned checkpoint + test.sequences(&[ + EbicsState::hkd, + EbicsState::receipt_ok, + EbicsState::btd_no_data_pinned, + ]); + nexus_cmd( + &cfg, + "ebics-fetch --transient --checkpoint --pinned-start 2024-06-05", + ) + .await + .unwrap(); + + // Reset checkpoint + reset_checkpoint().await; + test.sequences(&[ + EbicsState::hkd, + EbicsState::receipt_ok, + EbicsState::btd_no_data, + ]); + nexus_cmd(&cfg, "ebics-fetch --transient --checkpoint") + .await + .unwrap(); + + // Reset pinned checkpoint + reset_checkpoint().await; + test.sequences(&[ + EbicsState::hkd, + EbicsState::receipt_ok, + EbicsState::btd_no_data_pinned, + ]); + nexus_cmd( + &cfg, + "ebics-fetch --transient --checkpoint --pinned-start 2024-06-05", + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn close_pending_transaction() { + let (test, cfg, _) = test_setup().await; + + // Failure before first segment + test.sequences(&[ + // Failure to perform download + EbicsState::failure, + // Then continue + EbicsState::haa, + EbicsState::receipt_ok, + EbicsState::btd_no_data, + ]); + nexus_cmd(&cfg, "ebics-fetch --transient") + .await + .unwrap_err(); + nexus_cmd(&cfg, "ebics-fetch --transient").await.unwrap(); + + // Compliant server + test.sequences(&[ + EbicsState::haa, + EbicsState::receipt_ok, + // Failure to perform download + EbicsState::init_tx, + EbicsState::failure, + // Retry fail once + EbicsState::failure, + // Retry fail twice + EbicsState::failure, + // Retry succeed + EbicsState::bad_request, + // Then continue + EbicsState::haa, + EbicsState::receipt_ok, + EbicsState::btd_no_data, + ]); + nexus_cmd(&cfg, "ebics-fetch --transient") + .await + .unwrap_err(); + nexus_cmd(&cfg, "ebics-fetch --transient") + .await + .unwrap_err(); + nexus_cmd(&cfg, "ebics-fetch --transient") + .await + .unwrap_err(); + nexus_cmd(&cfg, "ebics-fetch --transient").await.unwrap(); + } +} diff --git a/crates/libeufin-nexus/src/testing.rs b/crates/libeufin-nexus/src/testing.rs @@ -0,0 +1,260 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 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/> +*/ + +use anyhow::{anyhow, bail}; +use compact_str::CompactString; +use jiff::{Timestamp, civil::Date}; +use libeufin_ebics::{ + ebics::{ + EbicsErrKind, + order::{BTF, Order, OrderDoc}, + tx_check, + }, + iso20022::model::{InId, InTx}, + keys::expect_full_keys, + ws::listen_for_notification, +}; +use taler_common::{ + config::Config, + types::{ + amount::Amount, + iban::{Country, IBAN}, + payto::TransferIbanPayto, + }, +}; +use tracing::debug; + +use crate::{ + EbicsClient, EbicsLogs, config::NexusCfg, db::pool, fetch::register_incoming, list::ListCmd, + rand_ebics_id, +}; + +#[derive(clap::Subcommand, Debug)] +pub enum IbanCmd { + /// Generate fake IBANs for testing + Gen { country: Country }, +} + +impl IbanCmd { + pub fn run(self) -> anyhow::Result<()> { + match self { + IbanCmd::Gen { country } => { + println!("{}", IBAN::random(country)) + } + } + Ok(()) + } +} + +/// Testing helper commands +#[derive(clap::Subcommand, Debug)] +pub enum TestingCmd { + /// List incoming transactions + #[clap(subcommand)] + Iban(IbanCmd), + /// Genere a fake incoming payment + FakeIncoming { + /// The amount to transfer, payto 'amount' parameter takes the precedence + #[clap(long)] + amount: Option<Amount>, + + /// The payment credit fee + #[clap(long)] + credit_fee: Option<Amount>, + + /// The payment subject, payto 'message' parameter takes the precedence + #[clap(long)] + subject: Option<CompactString>, + + /// The debited account IBAN payto URI + payto: TransferIbanPayto, + }, + #[clap(subcommand)] + List(ListCmd), + /// Perform EBICS requests + EbicsBtd { + #[clap(long = "type", default_value_t = CompactString::const_new("BTD"))] + ty: CompactString, + #[clap(long)] + name: CompactString, + #[clap(long)] + scope: Option<CompactString>, + #[clap(long)] + message_name: CompactString, + #[clap(long)] + message_version: Option<CompactString>, + #[clap(long)] + container: Option<CompactString>, + #[clap(long)] + option: Option<CompactString>, + #[clap(flatten)] + logs: EbicsLogs, + /// Erliest timestamp of the downloaded documents + #[clap(long, value_name = "YYYY-MM-DD")] + pinned_start: Option<Date>, + /// Do not consume fetched documents + #[clap(long)] + peek: bool, + #[clap(long)] + dry_run: bool, + }, + /// Check transaction semantic + TxCheck { + #[clap(flatten)] + logs: EbicsLogs, + }, + /// Listen to EBICS instant notification over websocket + Wss { + #[clap(flatten)] + logs: EbicsLogs, + }, +} + +impl TestingCmd { + pub async fn run(self, cfg: Config) -> anyhow::Result<()> { + match self { + TestingCmd::Iban(cmd) => cmd.run()?, + TestingCmd::FakeIncoming { + amount, + credit_fee, + subject, + payto, + } => { + let db = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + let subject = payto + .subject + .as_ref() + .or(subject.as_ref()) + .ok_or(anyhow!("Mising subject"))?; + let amount = payto + .amount + .as_ref() + .or(amount.as_ref()) + .ok_or(anyhow!("Mising amount"))?; + + if cfg.currency != amount.currency { + bail!( + "Wrong currency: expected {} got {}", + cfg.currency, + amount.currency + ); + } + register_incoming( + &db, + &cfg.ingest()?, + &InTx { + id: InId::new(None, Some(rand_ebics_id()), None), + amount: *amount, + credit_fee: credit_fee.unwrap_or(Amount::zero(&cfg.currency)), + subject: Some(subject.clone().into_string()), + execution_time: Timestamp::now(), + debtor: Some(payto.as_payto()), + }, + ) + .await?; + } + TestingCmd::List(list_cmd) => { + let db = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + list_cmd.run(&db, &cfg.currency).await?; + } + TestingCmd::EbicsBtd { + ty, + name, + scope, + message_name, + message_version, + container, + option, + logs, + pinned_start, + peek, + dry_run, + } => { + let db = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + let order = Order::from_parts( + &ty, + Some(BTF { + service: name, + scope, + option, + container, + msg: message_name, + version: message_version, + }), + ) + .ok_or(anyhow!("Unknown ebics order"))?; + let (client, bank) = expect_full_keys(&cfg.keys()?.ebics())?; + let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?; + ebics + .download( + &db, + &client, + &bank, + &order, + &None, // TODO + peek, + async |_| { + if dry_run { + Err(EbicsErrKind::Custom("dry run".into())) + } else { + Ok(()) + } + }, + ) + .await?; + } + TestingCmd::TxCheck { logs } => { + let db = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + let (client, bank) = expect_full_keys(&cfg.keys()?.ebics())?; + let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?; + let dialect = cfg.ebics()?.dialect.standard(); + let res = tx_check( + &ebics, + &db, + &client, + &bank, + &dialect.downloads(&OrderDoc::acknowledgement)[0], + &dialect.direct_debit(), + ) + .await?; + println!("{res:?}") + } + TestingCmd::Wss { logs } => { + let db = pool(&cfg).await?; + let cfg = NexusCfg::parse(cfg)?; + let (client, bank) = expect_full_keys(&cfg.keys()?.ebics())?; + let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?; + let (sender, mut receiver) = tokio::sync::mpsc::channel(10); + tokio::join!( + listen_for_notification(&ebics, &db, &client, &bank, sender), + async move { + while let Some(orders) = receiver.recv().await { + debug!(target: "testing", "{orders:?}") + } + } + ); + } + } + Ok(()) + } +} diff --git a/rustfmt.toml b/rustfmt.toml @@ -0,0 +1,2 @@ +imports_granularity = "Crate" +group_imports = "StdExternalCrate" diff --git a/src/api.rs b/src/api.rs @@ -1,417 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use jiff::Timestamp; -use sqlx::PgPool; -use taler_api::{ - api::{TalerApi, revenue::Revenue, transfer::PreparedTransfer, wire::WireGateway}, - error::{ApiResult, failure, failure_code}, - subject::{IncomingSubject, fmt_in_subject, subject_fmt_qr_bill}, -}; -use taler_common::{ - api_common::{SafeU64, safe_u64}, - api_params::{History, Page}, - api_revenue::RevenueIncomingHistory, - api_transfer::{ - RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject, Unregistration, - }, - api_wire::{ - AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest, - IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse, - TransferState, TransferStatus, - }, - db::IncomingType, - error_code::ErrorCode, - types::{ - amount::{Amount, Currency}, - payto::{FullIbanPayto, PaytoURI}, - timestamp::TalerTimestamp, - }, -}; -use tokio::sync::watch::Sender; - -use crate::{ - db::{ - self, - exchange::{ - TransferResult, incoming_history, outgoing_history, revenue_history, transfer, - transfer_by_id, transfer_page, - }, - payment::{IncomingRegistrationResult, register_in_talerable}, - transfer::{RegistrationResult, transfer_register, transfer_unregister}, - }, - model::{InId, InTx}, - rand_ebics_id, -}; - -pub struct NexusApi { - pub pool: sqlx::PgPool, - pub currency: Currency, - pub payto: PaytoURI, - pub in_channel: Sender<i64>, - pub taler_in_channel: Sender<i64>, - pub taler_out_channel: Sender<i64>, -} - -impl NexusApi { - pub async fn start(pool: sqlx::PgPool, payto: PaytoURI, currency: Currency) -> Self { - let in_channel = Sender::new(0); - let taler_in_channel = Sender::new(0); - let taler_out_channel = Sender::new(0); - let tmp = Self { - pool: pool.clone(), - payto, - currency, - in_channel: in_channel.clone(), - taler_in_channel: taler_in_channel.clone(), - taler_out_channel: taler_out_channel.clone(), - }; - tokio::spawn(db::notification_listener( - pool, - in_channel, - taler_in_channel, - taler_out_channel, - )); - tmp - } -} - -impl TalerApi for NexusApi { - fn currency(&self) -> &str { - self.currency.as_ref() - } - - fn implementation(&self) -> &'static str { - "urn:net:taler:specs:libeufin-nexus:taler-rust" - } -} - -async fn add_incoming( - db: &PgPool, - subject: &IncomingSubject, - amount: Amount, - debit_account: PaytoURI, -) -> ApiResult<AddIncomingResponse> { - FullIbanPayto::try_from(&debit_account)?; - let now = Timestamp::now(); - match register_in_talerable( - db, - &InTx { - id: InId { - uetr: None, - tx_id: Some(rand_ebics_id()), - sref: None, - }, - amount, - credit_fee: Amount::zero(&amount.currency), - subject: Some(format!( - "Manual incoming {}", - fmt_in_subject(subject.ty(), subject.key()) - )), - execution_time: now, - debtor: Some(debit_account), - }, - subject, - ) - .await? - { - IncomingRegistrationResult::Success(in_result) => Ok(AddIncomingResponse { - row_id: safe_u64(in_result.id), - timestamp: now.into(), - }), - IncomingRegistrationResult::ReservePubReuse => { - Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) - } - IncomingRegistrationResult::MappingReuse => { - Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED)) - } - IncomingRegistrationResult::UnknownMapping => { - Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN)) - } - } -} - -impl WireGateway for NexusApi { - async fn transfer(&self, req: TransferRequest) -> ApiResult<TransferResponse> { - FullIbanPayto::try_from(&req.credit_account)?; - let result = transfer(&self.pool, &req, &rand_ebics_id(), &Timestamp::now()).await?; - match result { - TransferResult::Success { id, timestamp } => Ok(TransferResponse { - timestamp: timestamp.into(), - row_id: SafeU64::try_from(id).unwrap(), - }), - TransferResult::RequestUidReuse => { - Err(failure_code(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED)) - } - TransferResult::WtidReuse => Err(failure_code(ErrorCode::BANK_TRANSFER_WTID_REUSED)), - } - } - - async fn transfer_page( - &self, - page: Page, - status: Option<TransferState>, - ) -> ApiResult<TransferList> { - Ok(TransferList { - transfers: transfer_page(&self.pool, &self.currency, &page, &status).await?, - debit_account: self.payto.clone(), - }) - } - - async fn transfer_by_id(&self, id: u64) -> ApiResult<Option<TransferStatus>> { - Ok(transfer_by_id(&self.pool, &self.currency, id).await?) - } - - async fn outgoing_history(&self, params: History) -> ApiResult<OutgoingHistory> { - Ok(OutgoingHistory { - outgoing_transactions: outgoing_history(&self.pool, &self.currency, &params, || { - self.taler_out_channel.subscribe() - }) - .await?, - debit_account: self.payto.clone(), - }) - } - - async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> { - Ok(IncomingHistory { - incoming_transactions: incoming_history(&self.pool, &self.currency, &params, || { - self.taler_in_channel.subscribe() - }) - .await?, - credit_account: self.payto.clone(), - }) - } - - async fn add_incoming_reserve( - &self, - req: AddIncomingRequest, - ) -> ApiResult<AddIncomingResponse> { - add_incoming( - &self.pool, - &IncomingSubject::Reserve(req.reserve_pub), - req.amount, - req.debit_account, - ) - .await - } - - async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> { - add_incoming( - &self.pool, - &IncomingSubject::Kyc(req.account_pub), - req.amount, - req.debit_account, - ) - .await - } - - async fn add_incoming_mapped(&self, req: AddMappedRequest) -> ApiResult<AddIncomingResponse> { - add_incoming( - &self.pool, - &IncomingSubject::Map(req.authorization_pub), - req.amount, - req.debit_account, - ) - .await - } - - fn support_account_check(&self) -> bool { - false - } -} - -impl Revenue for NexusApi { - async fn history(&self, params: History) -> ApiResult<RevenueIncomingHistory> { - Ok(RevenueIncomingHistory { - incoming_transactions: revenue_history(&self.pool, &self.currency, &params, || { - self.in_channel.subscribe() - }) - .await?, - credit_account: self.payto.clone(), - }) - } -} - -impl PreparedTransfer for NexusApi { - fn supported_formats(&self) -> &[SubjectFormat] { - &[SubjectFormat::SIMPLE] - } - - async fn registration(&self, req: RegistrationRequest) -> ApiResult<RegistrationResponse> { - let reference_number = subject_fmt_qr_bill(req.authorization_pub.as_ref()); - match transfer_register( - &self.pool, - req.r#type.into(), - &req.account_pub, - &req.authorization_pub, - &req.authorization_sig, - req.recurrent, - &reference_number, - &Timestamp::now(), - ) - .await? - { - RegistrationResult::Success => ApiResult::Ok(RegistrationResponse { - subjects: vec![ - TransferSubject::QrBill { - credit_amount: req.credit_amount, - qr_reference_number: reference_number, - }, - TransferSubject::Simple { - credit_amount: req.credit_amount, - subject: if req.authorization_pub == req.account_pub && !req.recurrent { - fmt_in_subject(req.r#type.into(), &req.account_pub) - } else { - fmt_in_subject(IncomingType::map, &req.authorization_pub) - }, - }, - ], - expiration: TalerTimestamp::Never, - }), - RegistrationResult::ReservePubReuse => { - ApiResult::Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)) - } - RegistrationResult::SubjectReuse => { - ApiResult::Err(failure_code(ErrorCode::BANK_DERIVATION_REUSE)) - } - } - } - - async fn unregistration(&self, req: Unregistration) -> ApiResult<()> { - if !transfer_unregister(&self.pool, &req.authorization_pub, &Timestamp::now()).await? { - Err(failure( - ErrorCode::BANK_TRANSACTION_NOT_FOUND, - format!("Prepared transfer '{}' not found", req.authorization_pub), - )) - } else { - Ok(()) - } - } -} - -#[cfg(test)] -pub mod test { - use std::sync::Arc; - - use sqlx::PgPool; - use taler_api::{api::TalerRouter as _, auth::AuthMethod, subject::OutgoingSubject}; - use taler_common::{ - api_revenue::RevenueConfig, - api_transfer::PreparedTransferConfig, - api_wire::{OutgoingHistory, TransferState, WireConfig}, - }; - use taler_test_utils::{ - Router, - db::db_test_setup, - routine::{ - admin_add_incoming_routine, registration_routine, revenue_routine, routine_pagination, - transfer_routine, - }, - server::TestServer as _, - }; - - use crate::{ - CONFIG_SOURCE, - api::NexusApi, - db::{payment::register_out_tx, test::check_in}, - test::{ACCOUNT, CURR, gen_out_pay}, - }; - - pub async fn api_setup() -> (Router, PgPool) { - let (_, pool) = db_test_setup(CONFIG_SOURCE).await; - let api = Arc::new(NexusApi::start(pool.clone(), ACCOUNT.clone(), CURR).await); - let server = Router::new() - .wire_gateway(api.clone(), AuthMethod::None) - .prepared_transfer(api.clone()) - .revenue(api, AuthMethod::None) - .finalize(); - - (server, pool) - } - - #[tokio::test] - async fn config() { - let (server, _) = api_setup().await; - server - .get("/taler-wire-gateway/config") - .await - .assert_ok_json::<WireConfig>(); - server - .get("/taler-prepared-transfer/config") - .await - .assert_ok_json::<PreparedTransferConfig>(); - server - .get("/taler-revenue/config") - .await - .assert_ok_json::<RevenueConfig>(); - } - - #[tokio::test] - async fn transfer() { - let (server, _) = api_setup().await; - transfer_routine(&server, TransferState::pending, &ACCOUNT).await; - // TODO - /*db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1") - db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure") - db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure") - client.getA("/taler-wire-gateway/transfers?status=transient_failure").assertOkJson<TransferList> { - assertEquals(2, it.transfers.size) - } - client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList> { - assertEquals(4, it.transfers.size) - }*/ - } - - #[tokio::test] - async fn outgoing_history() { - let (server, pool) = api_setup().await; - routine_pagination::<OutgoingHistory>( - &server, - "/taler-wire-gateway/history/outgoing", - async |_| { - register_out_tx( - &pool, - &gen_out_pay("subject"), - Some(&OutgoingSubject::rand()), - ) - .await - .unwrap(); - }, - ) - .await; - } - - #[tokio::test] - async fn admin_add_incoming() { - let (server, _) = api_setup().await; - admin_add_incoming_routine(&server, &ACCOUNT, true).await; - } - - #[tokio::test] - async fn revenue() { - let (server, _) = api_setup().await; - revenue_routine(&server, &ACCOUNT, true).await; - } - - #[tokio::test] - async fn registration() { - let (server, pool) = api_setup().await; - registration_routine(&server, &ACCOUNT, || check_in(&pool)).await; - } -} diff --git a/src/bench.rs b/src/bench.rs @@ -1,288 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -#[cfg(test)] -mod test { - use std::fmt::Write as _; - - use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair as _}; - use compact_str::{CompactString, format_compact}; - use jiff::Timestamp; - use serde_json::json; - use taler_api::{crypto::eddsa_sign, subject::subject_fmt_qr_bill}; - use taler_common::{ - api_common::{EddsaPublicKey, HashCode, ShortHashCode}, - bench::{Bench, h32, h64}, - error_code::ErrorCode, - }; - use taler_test_utils::server::TestServer as _; - - use crate::{ - api::test::api_setup, - test::{ - ACCOUNT, incomplete_out, malformed_completeted_in, malformed_in, - malformed_incomplete_in, malformed_out, prepared_completeted_in, prepared_in, - prepared_incomplete_in, talerable_completeted_in, talerable_in, - talerable_incomplete_in, talerable_out, - }, - }; - - #[tokio::test] - pub async fn bench_db() { - let (server, db) = api_setup().await; - let amount = 10; - let iter = 10; - let amount = amount.max(10); - let accounts_pubs: Vec<_> = (0..amount * 2) - .map(|_| { - let key_pair = Ed25519KeyPair::generate().unwrap(); - let pub_key = EddsaPublicKey::try_from(key_pair.public_key().as_ref()).unwrap(); - (key_pair, pub_key) - }) - .collect(); - let mut b = Bench::new(&db, iter, amount); - b.table("incoming_transactions(amount, subject, execution_time, debit_payto, uetr, tx_id, acct_svcr_ref)", |f, i| { - let subject = if i % 4 == 0 { CompactString::const_new("\\N")} else {format_compact!("subject {i}")}; - let debtor = ACCOUNT.as_ref().as_str() ; - - if i % 3 == 0 { - writeln!(f, "(20,0)\t{subject}\t0\t{debtor}\t{}\t\\N\t\\N", uuid::Uuid::new_v4())?; - writeln!(f, "(21,0)\t{subject}\t0\t{debtor}\t\\N\tTX_ID{}\t\\N", i*2)?; - writeln!(f, "(22,0)\t{subject}\t0\t{debtor}\t\\N\t\\N\tREF{}", i*2) - } else if i%3 == 1 { - writeln!(f, "(30,0)\t{subject}\t0\t{debtor}\t{}\tTX_ID{}\t\\N", uuid::Uuid::new_v4(), i*2)?; - writeln!(f, "(31,0)\t{subject}\t0\t{debtor}\t\\N\tTX_ID{}\tREF{}", i*2+1, i*2)?; - writeln!(f, "(32,0)\t{subject}\t0\t{debtor}\t{}\t\\N\tREF{}", uuid::Uuid::new_v4(), i*2+1) - } else { - writeln!(f, "(40,0)\t{subject}\t0\t{debtor}\t{}\tTX_ID{}\tREF{}", uuid::Uuid::new_v4(), i*2, i*2)?; - writeln!(f, "(40,0)\t{subject}\t0\t{debtor}\t{}\tTX_ID{}\tREF{}", uuid::Uuid::new_v4(), i*2+1, i*2+1) - } - }).await; - b.table("outgoing_transactions(amount, subject, execution_time, credit_payto, end_to_end_id, acct_svcr_ref)", |f, i| { - let subject = if i % 4 == 0 { CompactString::const_new("\\N")} else {format_compact!("subject {i}")}; - let creditor =ACCOUNT.as_ref().as_str(); - - if i % 2 == 0 { - writeln!(f, "(40,0)\t{subject}\t0\t{creditor}\t\\N\tREF{}", i*2)?; - writeln!(f, "(41,0)\t{subject}\t0\t{creditor}\tE2E_ID{}\t\\N", i*2) - } else { - writeln!(f, "(40,0)\t{subject}\t0\t{creditor}\tE2E_ID{}\tREF{}", i*2, i*2)?; - writeln!(f, "(41,0)\t{subject}\t0\t{creditor}\tE2E_ID{}\tREF{}", i*2+1, i*2+1) - } - }).await; - b.table("initiated_outgoing_transactions(amount, subject, initiation_time, credit_payto, outgoing_transaction_id, end_to_end_id)", |f, i| { - writeln!(f, "(42,0)\tsubject\t0\t{}\t{}\tE2E_ID{i}", &*ACCOUNT , i*2) - }).await; - b.table("prepared_transfers(type, account_pub, authorization_pub, authorization_sig, recurrent, reference_number, registered_at, incoming_transaction_id)", |f, i| { - let ty = if i%2==0 {"reserve"} else {"kyc"}; - let recurrent = if i%3 == 0 {"true" } else {"false"}; - let incoming_transaction_id = if i % 5 == 0 { CompactString::const_new("\\N") }else {format_compact!("{}", i*2)}; - - let reference_number = subject_fmt_qr_bill(accounts_pubs[i].1.as_ref()); - let key = hex::encode( accounts_pubs[i].1.as_ref()); - let sig = h64(); - writeln!(f, "{ty}\t\\\\x{key}\t\\\\x{key}\t\\\\x{sig}\t{recurrent}\t{reference_number}\t0\t{incoming_transaction_id}") - }).await; - b.table( - "pending_recurrent_incoming_transactions(incoming_transaction_id, authorization_pub)", - |f, i| { - let key = hex::encode(accounts_pubs[i].1.as_ref()); - writeln!(f, "{}\t\\\\x{key}", i * 2) - }, - ) - .await; - b.table( - "bounced_transactions(incoming_transaction_id, initiated_outgoing_transaction_id)", - |f, i| { - if i % 10 == 0 { - writeln!(f, "{}\t{}", i / 2, i / 2) - } else { - Ok(()) - } - }, - ) - .await; - b.table( - "talerable_incoming_transactions(type, metadata, incoming_transaction_id)", - |f, i| { - let hex = h32(); - let ty = if i % 2 == 0 { "reserve" } else { "kyc" }; - writeln!(f, "{ty}\t\\\\x{hex}\t{}", i * 2) - }, - ) - .await; - b.table( - "talerable_outgoing_transactions(wtid, exchange_base_url, outgoing_transaction_id)", - |f, i| { - let hex = h32(); - writeln!(f, "\\\\x{hex}\thttp://exchange.example.com/\t{}", i * 2 - 1) - }, - ) - .await; - b.table("transfer_operations(initiated_outgoing_transaction_id, request_uid, wtid, exchange_base_url)", |f, i| { - let h32 = h32(); - let h64 = h64(); - writeln!(f, "{i}\t\\\\x{h64}\t\\\\x{h32}\turl") - }).await; - - // Warm HTTP client - server.get("/taler-revenue/config").await.assert_ok(); - - // Register - b.measure("register_in", |_| malformed_in(&db)).await; - b.measure("register_incomplete_in", |_| malformed_incomplete_in(&db)) - .await; - b.measure("register_completed_in", |_| malformed_completeted_in(&db)) - .await; - b.measure("register_talerable_in", |_| talerable_in(&db)) - .await; - b.measure("register_talerable_incomplete_in", |_| { - talerable_incomplete_in(&db) - }) - .await; - b.measure("register_talerable_completed_in", |_| { - talerable_completeted_in(&db) - }) - .await; - b.measure("register_prepared_in", |_| prepared_in(&db)) - .await; - b.measure("register_prepared_incomplete_in", |_| { - prepared_incomplete_in(&db) - }) - .await; - b.measure("register_prepared_completed_in", |_| { - prepared_completeted_in(&db) - }) - .await; - b.measure("register_out", |_| malformed_out(&db)).await; - b.measure("register_talerable_out", |_| talerable_out(&db)) - .await; - b.measure("register_incomplete_out", |_| incomplete_out(&db)) - .await; - - // Revenue api - b.measure("transaction_revenue", async |_| { - server.get("/taler-revenue/history").await.assert_ok() - }) - .await; - - // Wire gateway - b.measure("wg_transfer", async |_| { - server - .post("/taler-wire-gateway/transfer") - .json(&json!({ - "request_uid": HashCode::rand(), - "amount": "KUDOS:0.0001", - "exchange_base_url": "http://exchange.example.com/", - "wtid": ShortHashCode::rand(), - "credit_account": &*ACCOUNT - })) - .await - .assert_ok() - }) - .await; - b.measure("wg_transfer_get", async |i| { - server - .get(&format!("/taler-wire-gateway/transfers/{}", i + 1)) - .await - .assert_ok() - }) - .await; - b.measure("wg_transfer_page", async |_| { - server - .get("/taler-wire-gateway/transfers") - .await - .assert_ok() - }) - .await; - b.measure("wg_transfer_page_filter", async |_| { - server - .get("/taler-wire-gateway/transfers?status=success") - .await - .assert_no_content() - }) - .await; - b.measure("wg_add", async |_| { - server - .post("/taler-wire-gateway/admin/add-incoming") - .json(&json!({ - "amount": "KUDOS:0.0001", - "reserve_pub": EddsaPublicKey::rand(), - "debit_account": &*ACCOUNT - })) - .await - .assert_ok() - }) - .await; - b.measure("wg_incoming", async |_| { - server - .get("/taler-wire-gateway/history/incoming") - .await - .assert_ok() - }) - .await; - b.measure("wg_outgoing", async |_| { - server - .get("/taler-wire-gateway/history/outgoing") - .await - .assert_ok() - }) - .await; - - // Wire transfer - b.measure("wt_register", async |i| { - let (pair, key) = &accounts_pubs[i]; - - server - .post("/taler-prepared-transfer/registration") - .json(&json!({ - "credit_amount": "KUDOS:55", - "type": "reserve", - "alg": "EdDSA", - "account_pub": key, - "authorization_pub": key, - "authorization_sig": eddsa_sign(&pair, key.as_ref()), - "recurrent":false - })) - .await - .assert_ok() - }) - .await; - b.measure("wt_unregister", async |i| { - let (pair, key) = &accounts_pubs[i]; - let now = Timestamp::now().to_string(); - let req = json!({ - "timestamp": &now, - "authorization_pub": key, - "authorization_sig": eddsa_sign(&pair, now.as_ref()), - }); - server - .post("/taler-prepared-transfer/unregistration") - .json(&req) - .await - .assert_no_content(); - server - .post("/taler-prepared-transfer/unregistration") - .json(&req) - .await - .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND); - }) - .await; - } -} diff --git a/src/bin/iso20022-codegen.rs b/src/bin/iso20022-codegen.rs @@ -1,206 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{ - collections::BTreeMap, - fmt::Write as _, - io::{Cursor, Read as _}, -}; - -use calamine::{DataType, Reader as _, Xlsx}; -use reqwest::StatusCode; -use tokio::join; -use zip::ZipArchive; - -pub async fn iso20022codegen_external_code_set() { - let res = reqwest::get( - "https://www.iso20022.org/sites/default/files/media/file/ExternalCodeSets_XLSX.zip", - ) - .await - .unwrap(); - - assert_eq!(res.status(), StatusCode::OK); - let zipped = res.bytes().await.unwrap(); - let mut zip = ZipArchive::new(Cursor::new(&zipped)).unwrap(); - assert_eq!(zip.len(), 1); - - let mut bytes = Vec::new(); - zip.by_index(0).unwrap().read_to_end(&mut bytes).unwrap(); - let mut excel: Xlsx<_> = calamine::open_workbook_from_rs(Cursor::new(&bytes)).unwrap(); - - let mut code_sets: BTreeMap<_, Vec<_>> = BTreeMap::new(); - - let range = excel.worksheet_range("AllCodeSets").unwrap(); - for row in range.rows() { - let set = row[0].as_string().unwrap(); - let code = row[1].as_string().unwrap(); - let name = row[2].as_string().unwrap().replace('-', ""); - let definition = row[3] - .as_string() - .unwrap() - .split(['.', '\n']) - .next() - .unwrap() - .trim() - .replace("_x000D_", ""); - let vec = code_sets.entry(set).or_default(); - vec.push((code, name, definition)) - } - - let mut out = " -/* - * This file is part of LibEuFin. - * Copyright (C) 2026 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 - -use taler_enum_meta::EnumMeta; - " - .to_string(); - - for (set, enum_name) in [ - ("ExternalStatusReason1Code", "StatusReason"), - ("ExternalPaymentGroupStatus1Code", "PaymentGroupStatus"), - ( - "ExternalPaymentTransactionStatus1Code", - "PaymentTransactionStatus", - ), - ("ExternalReturnReason1Code", "ReturnReason"), - ] { - let set = code_sets.get_mut(set).unwrap(); - set.sort_unstable_by_key(|(code, _, _)| code.clone()); - writeln!( - &mut out, - " - #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] - #[enum_meta(DomainCode, Description, Str)] - pub enum {enum_name} {{ - " - ) - .unwrap(); - for (code, name, description) in set.iter() { - writeln!(&mut out, "/// {description}").unwrap(); - writeln!(&mut out, "#[code = \"{code}\"]").unwrap(); - writeln!(&mut out, "{name},").unwrap(); - } - writeln!(&mut out, "}}").unwrap(); - } - std::fs::write("src/iso20022/status_code.rs", out).unwrap(); -} - -pub async fn iso20022codegen_bank_transaction_code() { - let res = reqwest::get( - "https://www.iso20022.org/sites/default/files/media/file/BTC_Codification_21March2024.xlsx", - ) - .await - .unwrap(); - - assert_eq!(res.status(), StatusCode::OK); - let bytes = res.bytes().await.unwrap(); - let mut excel: Xlsx<_> = calamine::open_workbook_from_rs(Cursor::new(&bytes)).unwrap(); - - let mut domain = BTreeMap::new(); - let mut family = BTreeMap::new(); - let mut subfamily = BTreeMap::new(); - - let range = excel.worksheet_range("BTC_Codification").unwrap(); - - for row in range.rows().skip(3) { - for (i, set) in [&mut domain, &mut family, &mut subfamily] - .into_iter() - .enumerate() - { - let name = row[i].as_string().unwrap(); - let code = row[i + 3].as_string().unwrap(); - let code = code.trim().to_string(); - set.insert(code, name); - } - } - - let mut out = " -/* - * This file is part of LibEuFin. - * Copyright (C) 2026 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 - -use taler_enum_meta::EnumMeta; - " - .to_string(); - - for (set, enum_name) in [ - (domain, "BankTxDomainCode"), - (family, "BankTxFamilyCode"), - (subfamily, "BankTxSubFamilyCode"), - ] { - writeln!( - &mut out, - " - #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] - #[enum_meta(Description, Str)] - pub enum {enum_name} {{ - " - ) - .unwrap(); - for (code, description) in set.iter() { - writeln!(&mut out, "/// {description}").unwrap(); - writeln!(&mut out, "{code},").unwrap(); - } - writeln!(&mut out, "}}").unwrap(); - } - std::fs::write("src/iso20022/bank_tx_code.rs", out).unwrap(); -} - -#[tokio::main] -pub async fn main() { - join!( - iso20022codegen_external_code_set(), - iso20022codegen_bank_transaction_code() - ); -} diff --git a/src/bin/testbench.rs b/src/bin/testbench.rs @@ -1,363 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{borrow::Cow, fmt::Display, str::FromStr}; - -use anyhow::bail; -use clap::{Parser, ValueEnum}; -use jiff::Timestamp; -use libeufin::{ - CONFIG_SOURCE, - config::NexusCfg, - keys::{load_bank_keys, load_client_keys}, - run, -}; -use owo_colors::OwoColorize as _; -use reedline::{FileBackedHistory, Prompt, Reedline, Signal}; -use taler_common::{config::Config, log::taler_logger, types::payto::TransferIbanPayto}; -use tracing::Level; -use tracing_subscriber::util::SubscriberInitExt as _; - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -enum Component { - Nexus, - Ebisync, -} - -#[derive(Parser)] -/// Run integration tests on banks provider -pub struct TestbenchCmd { - #[arg(value_enum)] - component: Component, - platform: String, -} - -#[derive(Parser)] -#[command(name = "shell", no_binary_name = true)] -/// Run integration tests on banks provider -pub enum NexusCmd { - ResetKeys, - ResetDb, - Tx, - Fetch { - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - raw_args: Vec<String>, - }, - Submit { - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - raw_args: Vec<String>, - }, - List { - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - raw_args: Vec<String>, - }, - Wss, - TxCheck, - Exit, -} - -fn step(name: impl Display) { - println!("{}", name.magenta()) -} - -fn msg(msg: impl Display) { - println!("{}", msg.yellow()) -} - -fn err(msg: impl Display) { - println!("{}", msg.red()) -} - -fn check<R, E: Display>(res: Result<R, E>) -> bool { - match &res { - Ok(_) => println!("{}", "OK".green()), - Err(e) => { - tracing::error!(target: "testbench", "{e}"); - err("ERROR") - } - }; - res.is_ok() -} - -pub async fn nexus_cmd(cfg: &Config, cmd: &str) -> bool { - let parts = shlex::split(cmd).unwrap(); - let args = std::iter::once("libeufin_nexus").chain(parts.iter().map(|it| it.as_str())); - - match libeufin::Args::try_parse_from(args) { - Ok(cmd) => { - tokio::select! { - res = run(cfg.clone(), cmd.cmd) => check(res), - _ = tokio::signal::ctrl_c() => false - } - } - Err(e) => { - println!("Error: {}", e); - false - } - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - taler_logger(Some(Level::DEBUG)).init(); - let cmd = TestbenchCmd::parse(); - // List available platform - let platforms: Vec<_> = std::fs::read_dir("testbench/test/platform") - .unwrap() - .filter_map(|entry| { - let e = entry.unwrap(); - let filename = e.file_name(); - if filename == "config.json" { - None - } else { - Some( - filename - .to_string_lossy() - .strip_suffix(".conf") - .unwrap() - .to_owned(), - ) - } - }) - .collect(); - if !platforms.contains(&cmd.platform) { - bail!( - "Unknown platform '{}', expected one of {}", - cmd.platform, - platforms.join(", ") - ); - } - - // Augment config - let simple_cfg = - std::fs::read_to_string(format!("testbench/test/platform/{}.conf", cmd.platform)).unwrap(); - let cfg = format!( - r#" - {simple_cfg} - {} - [paths] - LIBEUFIN_NEXUS_HOME = testbench/test/{} - EBISYNC_HOME = testbench/test/{} - - [nexus-fetch] - FREQUENCY = 1h - CHECKPOINT_TIME_OF_DAY = 16:52 - - [ebisync-fetch] - FREQUENCY = 1h - CHECKPOINT_TIME_OF_DAY = 16:52 - DESTINATION = azure-blob-storage - AZURE_API_URL = http://localhost:10000/devstoreaccount1/ - AZURE_ACCOUNT_NAME = devstoreaccount1 - AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== - AZURE_CONTAINER = test - - [ebisync-submit] - SOURCE = ebisync-api - AUTH_METHOD = none - - [libeufin-nexusdb-postgres] - CONFIG = postgres:///libeufintestbench - - [ebisyncdb-postgres] - CONFIG = postgres:///libeufintestbench - "#, - simple_cfg - .replace("[nexus-ebics]", "[ebisync]") - .replace("[nexus-setup]", "[ebisync-setup]"), - cmd.platform, - cmd.platform - ); - - let history = Box::new( - FileBackedHistory::with_file( - 1000, - match cmd.component { - Component::Ebisync => ".ebisync_history", - Component::Nexus => ".nexus_history", - } - .into(), - ) - .expect("Error configuring history with file"), - ); - let mut line_editor = Reedline::create().with_history(history); - let prompt = BenchPrompt { - prompt: format!("{:?} {}", cmd.component, cmd.platform), - }; - let cfg = Config::from_mem_with_env(CONFIG_SOURCE, &cfg).unwrap(); - let cfg = NexusCfg::parse(cfg).unwrap(); - let ebics = cfg.keys().unwrap(); - let (name, settings) = match cfg.host().unwrap().base_url.as_str() { - "https://isotest.postfinance.ch/ebicsweb/ebicsweb" => ( - "PostFinance IsoTest", - Some("https://isotest.postfinance.ch/corporates/user/settings/ebics"), - ), - "https://iso20022test.credit-suisse.com/ebicsweb/ebicsweb" => ( - "Credit Suisse isoTest", - Some("https://iso20022test.credit-suisse.com/user/settings/ebics"), - ), - "https://ebics.postfinance.ch/ebics/ebics.aspx" => ("PostFinance", None), - _ => ("Unknown", None), - }; - let test = settings.is_some(); - let payto = match cfg.currency.as_ref() { - "CHF" => { - "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff" - } - "EUR" => { - "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff" - } - _ => todo!("{}", cfg.currency), - }; - let payto = TransferIbanPayto::from_str(payto).unwrap(); - let ebics_log = format!("--debug-ebics testbench/test/{}", cmd.platform); - loop { - // Automatic setup - { - let client = load_client_keys(ebics.client_priv_keys_path.as_ref()).unwrap(); - let bank = load_bank_keys(ebics.bank_pub_keys_path.as_ref()).unwrap(); - if settings.is_none() && client.is_none() { - msg("Manual setup is required for non test environment") - } else if client - .map(|it| !it.submitted_ini || !it.submitted_hia) - .unwrap_or(true) - || bank.map(|it| !it.accepted).unwrap_or(true) - { - step("Run EBICS setup"); - if !nexus_cmd(&cfg.cfg, &format!("ebics-setup {ebics_log}")).await { - if let Some(settings) = settings { - let client = - load_client_keys(ebics.client_priv_keys_path.as_ref()).unwrap(); - if client - .map(|it| !it.submitted_ini || !it.submitted_hia) - .unwrap_or(true) - { - msg(format_args!( - "Got to {settings} and click on 'Reset EBICS user'" - )) - } else { - msg(format_args!( - "Got to {settings} and click on 'Activate EBICS user'" - )) - } - } else { - msg("Activate your keys at your bank") - } - } - } - } - let Signal::Success(buf) = line_editor.read_line(&prompt).unwrap() else { - break; - }; - match NexusCmd::try_parse_from(buf.split_whitespace()) { - Ok(cmd) => match cmd { - NexusCmd::ResetDb => { - nexus_cmd(&cfg.cfg, "dbinit -r").await; - } - NexusCmd::Fetch { raw_args } => { - nexus_cmd( - &cfg.cfg, - &format!( - "ebics-fetch {ebics_log} {}", - shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap() - ), - ) - .await; - } - NexusCmd::Submit { raw_args } => { - nexus_cmd( - &cfg.cfg, - &format!( - "ebics-submit {ebics_log} {}", - shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap() - ), - ) - .await; - } - NexusCmd::Tx => { - nexus_cmd( - &cfg.cfg, - &format!( - "initiate-payment --amount={}:0.1 --subject=\"single {}\" {payto}", - cfg.currency, - Timestamp::now() - ), - ) - .await; - } - NexusCmd::List { raw_args } => { - nexus_cmd( - &cfg.cfg, - &format!( - "list {}", - shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap() - ), - ) - .await; - } - NexusCmd::ResetKeys => { - if test { - std::fs::remove_file(&ebics.client_priv_keys_path)?; - } - std::fs::remove_file(&ebics.bank_pub_keys_path)?; - } - NexusCmd::TxCheck => { - nexus_cmd(&cfg.cfg, &format!("testing tx-check {ebics_log}")).await; - } - NexusCmd::Wss => { - nexus_cmd(&cfg.cfg, &format!("testing wss {ebics_log}")).await; - } - NexusCmd::Exit => return Ok(()), - }, - Err(e) => { - println!("{e}"); - } - } - } - Ok(()) -} - -struct BenchPrompt { - prompt: String, -} - -impl Prompt for BenchPrompt { - fn render_prompt_left(&self) -> Cow<'_, str> { - Cow::Borrowed(&self.prompt) - } - - fn render_prompt_right(&self) -> Cow<'_, str> { - Cow::Borrowed("") - } - - fn render_prompt_indicator(&self, _: reedline::PromptEditMode) -> Cow<'_, str> { - Cow::Borrowed(">") - } - - fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { - Cow::Borrowed(":") - } - - fn render_prompt_history_search_indicator( - &self, - _: reedline::PromptHistorySearch, - ) -> Cow<'_, str> { - Cow::Borrowed(">") - } -} diff --git a/src/config.rs b/src/config.rs @@ -1,276 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{cell::OnceCell, time::Duration}; - -use jiff::{ - Timestamp, - civil::{Date, Time}, -}; -use regex::Regex; -use taler_api::config::DbCfg; -use taler_common::{ - config::{Config, ValueErr}, - map_config, - types::{ - amount::{Amount, Currency}, - payto::{BankID, FullIbanPayto}, - utils::date_to_utc_ts, - }, -}; - -use crate::dialect::Dialect; - -pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { - DbCfg::parse(cfg.section("libeufin-nexusdb-postgres")) -} - -pub struct EbicsKeysCfg { - pub bank_pub_keys_path: String, - pub client_priv_keys_path: String, -} - -impl EbicsKeysCfg { - pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { - let s = cfg.section("nexus-ebics"); - Ok(Self { - bank_pub_keys_path: s.path("bank_public_keys_file").require()?, - client_priv_keys_path: s.path("client_private_keys_file").require()?, - }) - } -} - -#[derive(Clone)] -pub struct EbicsHostCfg { - pub base_url: url::Url, - pub unix_path: Option<String>, - pub host_id: String, - pub user_id: String, - pub partner_id: String, -} - -impl EbicsHostCfg { - pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { - let s = cfg.section("nexus-ebics"); - Ok(Self { - base_url: s.url("host_base_url").require()?, - unix_path: s.path("UNIXPATH").opt()?, - host_id: s.str("host_id").require()?, - user_id: s.str("user_id").require()?, - partner_id: s.str("partner_id").require()?, - }) - } -} - -#[derive(Debug, Clone, Copy)] -pub enum AccountType { - Exchange, - Normal, -} - -pub struct NexusIngestCfg { - pub account_type: AccountType, - pub ignore_txs_before: Timestamp, - pub ignore_bounces_before: Timestamp, - pub restriction_payto_regex: Option<Regex>, - pub bounce_deduce_fee: bool, - pub bounce_fee: Amount, - pub currency: Currency, -} - -impl NexusIngestCfg { - pub const fn simple(account_type: AccountType, currency: &Currency) -> Self { - Self { - account_type, - ignore_txs_before: Timestamp::UNIX_EPOCH, - ignore_bounces_before: Timestamp::UNIX_EPOCH, - restriction_payto_regex: None, - bounce_deduce_fee: false, - bounce_fee: Amount::zero(currency), - currency: Currency::KUDOS, - } - } -} - -pub struct NexusFetchCfg { - pub frequency: Duration, - pub frequency_raw: String, - pub checkpoint_time: Time, - pub ignore_txs_before: Timestamp, - pub ignore_bounces_before: Timestamp, - pub restriction_payto_regex: Option<Regex>, - pub bounce_deduce_fee: bool, - pub bounce_fee: Amount, -} - -impl NexusFetchCfg { - pub fn parse(cfg: &Config, currency: &Currency) -> Result<Self, ValueErr> { - let s = cfg.section("nexus-fetch"); - - Ok(Self { - frequency: s.duration("frequency").require()?, - frequency_raw: s.str("frequency").require()?, - checkpoint_time: s.time("checkpoint_time_of_day").require()?, - ignore_txs_before: date_to_utc_ts( - &s.date("ignore_transactions_before").default(Date::ZERO)?, - ), - ignore_bounces_before: date_to_utc_ts( - &s.date("ignore_bounces_before").default(Date::ZERO)?, - ), - restriction_payto_regex: s.regex("restriction_payto_regex").opt()?, - bounce_deduce_fee: s.boolean("bounce_deduce_fee").default(false)?, - bounce_fee: s - .amount("bounce_fee", currency) - .default(Amount::zero(currency))?, - }) - } -} - -pub struct NexusSubmitCfg { - pub frequency: Duration, - pub frequency_raw: String, - pub require_ack: bool, -} - -impl NexusSubmitCfg { - pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { - let s = cfg.section("nexus-submit"); - - Ok(Self { - frequency: s.duration("frequency").require()?, - frequency_raw: s.str("frequency").require()?, - require_ack: s.boolean("manual_ack").default(false)?, - }) - } -} - -pub struct NexusEbicsConfig { - pub account: FullIbanPayto, - pub dialect: Dialect, -} - -impl NexusEbicsConfig { - pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { - let s = cfg.section("nexus-ebics"); - Ok(Self { - account: FullIbanPayto::new( - BankID { - iban: s.parse("IBAN", "iban").require()?, - bic: Some(s.parse("BIC", "bic").require()?), - }, - &s.str("name").require()?, - ), - dialect: s.parse("bank dialect", "bank_dialect").require()?, - }) - } -} - -pub struct NexusCfg { - pub cfg: Config, - pub currency: Currency, - pub account_type: AccountType, - pub keys: OnceCell<EbicsKeysCfg>, - pub host: OnceCell<EbicsHostCfg>, - pub fetch: OnceCell<NexusFetchCfg>, - pub submit: OnceCell<NexusSubmitCfg>, - pub ebics: OnceCell<NexusEbicsConfig>, -} - -impl NexusCfg { - pub fn parse(cfg: Config) -> Result<Self, ValueErr> { - let s = cfg.section("nexus-ebics"); - Ok(Self { - currency: s.currency("currency").require()?, - account_type: map_config!(s, "account type", "ACCOUNT_TYPE", - "exchange" => { Ok(AccountType::Exchange) }, - "normal" => { Ok(AccountType::Normal) } - ) - .require()?, - cfg, - keys: OnceCell::new(), - host: OnceCell::new(), - fetch: OnceCell::new(), - submit: OnceCell::new(), - ebics: OnceCell::new(), - }) - } - - pub fn keys(&self) -> Result<&EbicsKeysCfg, ValueErr> { - // TODO use get_or_try_init when stable - if let Some(keys) = self.keys.get() { - return Ok(keys); - } - let keys = EbicsKeysCfg::parse(&self.cfg)?; - self.keys.set(keys).ok(); - Ok(self.keys.get().unwrap()) - } - - pub fn host(&self) -> Result<&EbicsHostCfg, ValueErr> { - // TODO use get_or_try_init when stable - if let Some(host) = self.host.get() { - return Ok(host); - } - let host = EbicsHostCfg::parse(&self.cfg)?; - self.host.set(host).ok(); - Ok(self.host.get().unwrap()) - } - - pub fn fetch(&self) -> Result<&NexusFetchCfg, ValueErr> { - // TODO use get_or_try_init when stable - if let Some(fetch) = self.fetch.get() { - return Ok(fetch); - } - let fetch = NexusFetchCfg::parse(&self.cfg, &self.currency)?; - self.fetch.set(fetch).ok(); - Ok(self.fetch.get().unwrap()) - } - - pub fn submit(&self) -> Result<&NexusSubmitCfg, ValueErr> { - // TODO use get_or_try_init when stable - if let Some(submit) = self.submit.get() { - return Ok(submit); - } - let submit = NexusSubmitCfg::parse(&self.cfg)?; - self.submit.set(submit).ok(); - Ok(self.submit.get().unwrap()) - } - - pub fn ebics(&self) -> Result<&NexusEbicsConfig, ValueErr> { - // TODO use get_or_try_init when stable - if let Some(ebics) = self.ebics.get() { - return Ok(ebics); - } - let ebics = NexusEbicsConfig::parse(&self.cfg)?; - self.ebics.set(ebics).ok(); - Ok(self.ebics.get().unwrap()) - } - - pub fn ingest(&self) -> Result<NexusIngestCfg, ValueErr> { - let fetch = self.fetch()?; - Ok(NexusIngestCfg { - account_type: self.account_type, - ignore_txs_before: fetch.ignore_txs_before, - ignore_bounces_before: fetch.ignore_bounces_before, - restriction_payto_regex: fetch.restriction_payto_regex.clone(), - bounce_deduce_fee: fetch.bounce_deduce_fee, - bounce_fee: fetch.bounce_fee, - currency: self.currency, - }) - } -} diff --git a/src/crypto.rs b/src/crypto.rs @@ -1,277 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use aws_lc_rs::{ - cipher::{ - AES_128, DecryptingKey, DecryptionContext, EncryptingKey, EncryptionContext, - UnboundCipherKey, - }, - digest::{Context, Digest, SHA256}, - encoding::AsDer, - iv::FixedLength, - rand::SystemRandom, - rsa::{ - KeyPair, Pkcs1PrivateDecryptingKey, Pkcs1PublicEncryptingKey, PrivateDecryptingKey, - PublicEncryptingKey, PublicKey, - }, - signature::{RSA_PSS_2048_8192_SHA256, RSA_PSS_SHA256, UnparsedPublicKey}, -}; -use base64::{Engine as _, prelude::BASE64_STANDARD}; -use jiff::{Timestamp, Zoned, tz::TimeZone}; -use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyUsagePurpose}; -use x509_parser::prelude::{FromDer as _, X509Certificate}; - -use crate::keys::RsaPub; - -/// Generate a self-signed X.509 certificate from an RSA private key (PEM or DER) -pub fn x509_certificate_from_rsa_private( - pem: &str, - name: &str, -) -> Result<rcgen::Certificate, rcgen::Error> { - let keys = rcgen::KeyPair::from_pem(pem).unwrap(); - let mut params = CertificateParams::new(vec![])?; - - // Set subject/issuer CN - params.distinguished_name.push(DnType::CommonName, name); - - let now = Zoned::new(Timestamp::now(), TimeZone::UTC).date(); - - // 1000-year validity - params.not_before = rcgen::date_time_ymd(now.year() as i32, now.month() as u8, now.day() as u8); - params.not_after = - rcgen::date_time_ymd(now.year() as i32 + 1000, now.month() as u8, now.day() as u8); - - // CA: true (basicConstraints) - params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); - - // Key usage flags - params.key_usages = vec![ - KeyUsagePurpose::DigitalSignature, - KeyUsagePurpose::ContentCommitment, // NonRepudiation - KeyUsagePurpose::KeyEncipherment, - KeyUsagePurpose::DataEncipherment, - KeyUsagePurpose::KeyAgreement, - KeyUsagePurpose::KeyCertSign, - KeyUsagePurpose::CrlSign, - KeyUsagePurpose::EncipherOnly, - KeyUsagePurpose::DecipherOnly, - ]; - - let cert = params.self_signed(&keys)?; - Ok(cert) -} - -/** Create an RSA public key from its components: [modulus] and [exponent] */ -pub fn rsa_pub_from_component(modulus: &[u8], exponent: &[u8]) -> anyhow::Result<RsaPub> { - let key: PublicEncryptingKey = aws_lc_rs::rsa::PublicKeyComponents { - n: modulus, - e: exponent, - } - .try_into()?; - Ok(RsaPub::from_der(key.as_der()?.as_ref())?) -} - -/// Extract an RSA public key from a X.509 certificate -pub fn rsa_private_from_b64_x509_certificate(encoded: &str) -> anyhow::Result<RsaPub> { - let der = BASE64_STANDARD.decode(encoded)?; - let (_, cert) = X509Certificate::from_der(&der)?; - let issuer_public_key = cert.public_key(); - cert.verify_signature(Some(issuer_public_key))?; - Ok(RsaPub::from_der(issuer_public_key.raw)?) -} - -/// Hash an RSA public key according to the EBICS standard (EBICS 2.5: 4.4.1.2.3). -pub fn ebics_pub_key_hash(public_key: &PublicKey) -> Digest { - let mut ctx = Context::new(&SHA256); - let hex_encoded = |input: &[u8], ctx: &mut Context| { - let encoded = hex::encode(input); - if encoded.starts_with('0') { - ctx.update(&encoded.as_bytes()[1..]); - } else { - ctx.update(encoded.as_bytes()); - } - }; - - hex_encoded( - public_key.exponent().big_endian_without_leading_zero(), - &mut ctx, - ); - ctx.update(b" "); - hex_encoded( - public_key.modulus().big_endian_without_leading_zero(), - &mut ctx, - ); - ctx.finish() -} - -pub fn gen_ebics_e002_key(pub_key: PublicEncryptingKey) -> ([u8; 16], Vec<u8>) { - let mut transaction_key = [0u8; 16]; - getrandom::fill(&mut transaction_key).unwrap(); - - let key = Pkcs1PublicEncryptingKey::new(pub_key).unwrap(); - let mut encrypted_key = vec![0; key.ciphertext_size()]; - key.encrypt(&transaction_key, &mut encrypted_key).unwrap(); - - (transaction_key, encrypted_key) -} - -pub fn encrypt_ebics_e002(transaction_key: &[u8; 16], mut data: Vec<u8>) -> Vec<u8> { - let block_size = 16; - let padding_len = block_size - (data.len() % block_size); - - // Add padding - for i in 0..padding_len { - if i == padding_len - 1 { - data.push(padding_len as u8); - } else { - data.push(0); - } - } - - let iv = FixedLength::from([0u8; 16]); - let enc_key = - EncryptingKey::cbc(UnboundCipherKey::new(&AES_128, transaction_key).unwrap()).unwrap(); - enc_key - .less_safe_encrypt(&mut data, EncryptionContext::Iv128(iv)) - .unwrap(); - - data -} - -pub fn decrypt_ebics_e002(transaction_key: &DecryptingKey, mut encrypted_data: Vec<u8>) -> Vec<u8> { - let iv = FixedLength::from([0u8; 16]); - - let plaintext = transaction_key - .decrypt(&mut encrypted_data, DecryptionContext::Iv128(iv)) - .unwrap(); - - // Strip X9.23 / ANSI X9.23 padding: - // The last byte holds the number of padding bytes to remove. - let pad_len = *plaintext.last().unwrap() as usize; - if pad_len == 0 || pad_len > 16 || pad_len > plaintext.len() { - panic!("WTF"); - } - let decoded = plaintext.len() - pad_len; - encrypted_data.truncate(decoded); - encrypted_data -} - -pub fn decrypt_ebics_e002_key( - private_key: PrivateDecryptingKey, - encrypted_transaction_key: &[u8], -) -> DecryptingKey { - let private_key = Pkcs1PrivateDecryptingKey::new(private_key).unwrap(); - let mut plaintext = vec![0u8; private_key.min_output_size()]; - let cipher = private_key - .decrypt(encrypted_transaction_key, &mut plaintext) - .unwrap(); - let cipher_key = UnboundCipherKey::new(&AES_128, cipher).unwrap(); - DecryptingKey::cbc(cipher_key).unwrap() -} - -pub fn digest_ebics_order_a006(order_data: &[u8]) -> Digest { - let mut digest = Context::new(&SHA256); - for chunk in order_data.split(|b| matches!(b, b'\r' | b'\n' | b'\x1a')) { - digest.update(chunk); - } - digest.finish() -} - -pub fn sign_ebics_a006(data: &[u8], key_pair: &KeyPair) -> Vec<u8> { - let mut sig = vec![0; key_pair.public_modulus_len()]; - key_pair - .sign(&RSA_PSS_SHA256, &SystemRandom::new(), data, &mut sig) - .unwrap(); - sig -} - -pub fn verify_ebics_a006(sig: &[u8], data: &[u8], public_key_der: &PublicKey) -> bool { - UnparsedPublicKey::new(&RSA_PSS_2048_8192_SHA256, public_key_der.as_ref()) - .verify(data, sig) - .is_ok() -} - -#[cfg(test)] -mod test { - use aws_lc_rs::{ - rsa::{KeyPair, KeySize, PrivateDecryptingKey}, - signature::KeyPair as _, - }; - - use crate::crypto::{ - decrypt_ebics_e002, decrypt_ebics_e002_key, ebics_pub_key_hash, encrypt_ebics_e002, - gen_ebics_e002_key, rsa_pub_from_component, sign_ebics_a006, verify_ebics_a006, - }; - - #[test] - fn e002() { - let data = b"Hello, World!"; - let key = PrivateDecryptingKey::generate(KeySize::Rsa2048).unwrap(); - - let (tx_key, encrypted_key) = gen_ebics_e002_key(key.public_key()); - let enc = encrypt_ebics_e002(&tx_key, data.to_vec()); - let key = decrypt_ebics_e002_key(key, &encrypted_key); - let dec = decrypt_ebics_e002(&key, enc); - assert_eq!(&data, &dec.as_slice()); - } - - #[test] - fn a006() { - let data = b"Hello, World!"; - let key_pair = KeyPair::generate(KeySize::Rsa2048).unwrap(); - let sig = sign_ebics_a006(data, &key_pair); - assert!(verify_ebics_a006(&sig, data, key_pair.public_key())); - } - - #[test] - fn public_key_hash() { - let exponent = "01 00 01".replace(|it: char| it.is_whitespace(), ""); - let modulus = " - EB BD B8 E3 73 45 60 06 44 A1 AD 6A 25 33 65 F5 - 9C EB E5 93 E0 51 72 77 90 6B F0 58 A8 89 EB 00 - C6 0B 37 38 F3 3C 55 F2 4D 83 D0 33 C3 A8 F0 3C - 82 4E AF 78 51 D6 F4 71 6A CC 9C 10 2A 58 C9 5F - 3D 30 B4 31 D7 1B 79 6D 43 AA F9 75 B5 7E 0B 4A - 55 52 1D 7C AC 8F 92 B0 AE 9F CF 5F 16 5C 6A D1 - 88 DB E2 48 E7 78 43 F9 18 63 29 45 ED 6C 08 6C - 16 1C DE F3 02 01 23 8A 58 35 43 2B 2E C5 3F 6F - 33 B7 A3 46 E1 75 BD 98 7C 6D 55 DE 71 11 56 3D - 7A 2C 85 42 98 42 DF 94 BF E8 8B 76 84 13 3E CA - 0E 8D 12 57 D6 8A CF 82 DE B7 D7 BB BC 45 AE 25 - 95 76 00 19 08 AA D2 C8 A7 D8 10 37 88 96 B9 98 - 14 B4 B0 65 F3 36 CE 93 F7 46 12 58 9F E7 79 33 - D5 BE 0D 0E F8 E7 E0 A9 C3 10 51 A1 3E A4 4F 67 - 5E 75 8C 9D E6 FE 27 B6 3C CF 61 9B 31 D4 D0 22 - B9 2E 4C AF 5F D6 4B 1F F0 4D 06 5F 68 EB 0B 71 - " - .replace(|it: char| it.is_whitespace(), ""); - let expected = " - 72 71 D5 83 B4 24 A6 DA 0B 7B 22 24 3B E2 B8 8C - 6E A6 0F 9F 76 11 FD 18 BE 2C E8 8B 21 03 A9 41 - " - .replace(|it: char| it.is_whitespace(), ""); - let key = rsa_pub_from_component( - &hex::decode(modulus).unwrap(), - &hex::decode(exponent).unwrap(), - ) - .unwrap(); - let hash = ebics_pub_key_hash(&key.key); - assert_eq!(&hex::decode(expected).unwrap(), hash.as_ref()); - } -} diff --git a/src/db/exchange.rs b/src/db/exchange.rs @@ -1,325 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use jiff::Timestamp; -use sqlx::{PgPool, QueryBuilder, Row as _, postgres::PgRow}; -use taler_api::{ - db::{BindHelper, TypeHelper as _, history, page}, - serialized, - subject::fmt_out_subject, -}; -use taler_common::{ - api_params::{History, Page}, - api_revenue::RevenueIncomingBankTransaction, - api_wire::{ - IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferRequest, - TransferState, TransferStatus, - }, - db::IncomingType, - types::amount::Currency, -}; -use tokio::sync::watch::Receiver; - -use crate::model::SubmissionState; - -pub async fn outgoing_history( - db: &PgPool, - currency: &Currency, - params: &History, - listen: impl FnOnce() -> Receiver<i64>, -) -> sqlx::Result<Vec<OutgoingBankTransaction>> { - history( - db, - "outgoing_transaction_id", - params, - listen, - || { - QueryBuilder::new( - " - SELECT - outgoing_transaction_id - ,execution_time - ,amount - ,debit_fee - ,credit_payto - ,wtid - ,exchange_base_url - ,metadata - FROM talerable_outgoing_transactions - JOIN outgoing_transactions USING(outgoing_transaction_id) - WHERE - ", - ) - }, - |r: PgRow| { - Ok(OutgoingBankTransaction { - row_id: r.try_get_safeu64("outgoing_transaction_id")?, - amount: r.try_get_amount("amount", currency)?, - debit_fee: r - .try_get_opt_amount("debit_fee", currency)? - .filter(|it| it.is_zero()), - credit_account: r.try_get_payto("credit_payto")?, - date: r.try_get_timestamp("execution_time")?.into(), - exchange_base_url: r.try_get_url("exchange_base_url")?, - wtid: r.try_get("wtid")?, - metadata: r.try_get("metadata")?, - }) - }, - ) - .await -} - -pub async fn incoming_history( - db: &PgPool, - currency: &Currency, - params: &History, - listen: impl FnOnce() -> Receiver<i64>, -) -> sqlx::Result<Vec<IncomingBankTransaction>> { - history( - db, - "incoming_transaction_id", - params, - listen, - || { - QueryBuilder::new( - " - SELECT - incoming_transaction_id - ,execution_time - ,amount - ,credit_fee - ,debit_payto - ,type::text - ,metadata - ,authorization_pub - ,authorization_sig - FROM talerable_incoming_transactions - JOIN incoming_transactions USING(incoming_transaction_id) - WHERE - ", - ) - }, - |r: PgRow| { - let credit_fee = r - .try_get_opt_amount("credit_fee", currency)? - .filter(|it| it.is_zero()); - Ok(match r.try_get("type")? { - IncomingType::reserve => IncomingBankTransaction::Reserve { - row_id: r.try_get_safeu64("incoming_transaction_id")?, - amount: r.try_get_amount("amount", currency)?, - credit_fee, - debit_account: r.try_get_payto("debit_payto")?, - date: r.try_get_timestamp("execution_time")?.into(), - reserve_pub: r.try_get("metadata")?, - authorization_pub: r.try_get("authorization_pub")?, - authorization_sig: r.try_get("authorization_sig")?, - }, - IncomingType::kyc => IncomingBankTransaction::Kyc { - row_id: r.try_get_safeu64("incoming_transaction_id")?, - amount: r.try_get_amount("amount", currency)?, - credit_fee, - debit_account: r.try_get_payto("debit_payto")?, - date: r.try_get_timestamp("execution_time")?.into(), - account_pub: r.try_get("metadata")?, - authorization_pub: r.try_get("authorization_pub")?, - authorization_sig: r.try_get("authorization_sig")?, - }, - IncomingType::map => unimplemented!("MAP are never listed in the history"), - }) - }, - ) - .await -} - -pub async fn revenue_history( - db: &PgPool, - currency: &Currency, - params: &History, - listen: impl FnOnce() -> Receiver<i64>, -) -> sqlx::Result<Vec<RevenueIncomingBankTransaction>> { - history( - db, - "incoming_transaction_id", - params, - listen, - || { - QueryBuilder::new( - " - SELECT - incoming_transaction_id - ,execution_time - ,amount - ,credit_fee - ,debit_payto - ,subject - FROM incoming_transactions - WHERE debit_payto IS NOT NULL AND subject IS NOT NULL AND - ", - ) - }, - |r: PgRow| { - Ok(RevenueIncomingBankTransaction { - row_id: r.try_get_safeu64("incoming_transaction_id")?, - amount: r.try_get_amount("amount", currency)?, - credit_fee: r - .try_get_opt_amount("credit_fee", currency)? - .filter(|it| it.is_zero()), - debit_account: r.try_get_payto("debit_payto")?, - date: r.try_get_timestamp("execution_time")?.into(), - subject: r.try_get("subject")?, - }) - }, - ) - .await -} - -pub enum TransferResult { - Success { id: u64, timestamp: Timestamp }, - RequestUidReuse, - WtidReuse, -} - -pub async fn transfer( - db: &PgPool, - req: &TransferRequest, - e2e_id: &str, - timestamp: &Timestamp, -) -> sqlx::Result<TransferResult> { - let subject = fmt_out_subject(&req.wtid, &req.exchange_base_url, req.metadata.as_deref()); - serialized!( - sqlx::query( - " - SELECT - out_request_uid_reuse - ,out_wtid_reuse - ,out_tx_row_id - ,out_timestamp - FROM taler_transfer($1,$2,$3,$4,$5,$6,$7,$8,$9) - ", - ) - .bind(&req.request_uid) - .bind(&req.wtid) - .bind(&subject) - .bind(req.amount) - .bind(req.exchange_base_url.as_str()) - .bind(&req.metadata) - .bind(req.credit_account.as_ref().as_str()) - .bind(e2e_id) - .bind_timestamp(timestamp) - .try_map(|r: PgRow| { - Ok(if r.try_get_flag("out_request_uid_reuse")? { - TransferResult::RequestUidReuse - } else if r.try_get_flag("out_wtid_reuse")? { - TransferResult::WtidReuse - } else { - TransferResult::Success { - id: r.try_get_u64("out_tx_row_id")?, - timestamp: r.try_get_timestamp("out_timestamp")?, - } - }) - }) - .fetch_one(db) - ) -} - -pub async fn transfer_by_id( - db: &PgPool, - currency: &Currency, - id: u64, -) -> sqlx::Result<Option<TransferStatus>> { - serialized!( - sqlx::query( - " - SELECT - wtid - ,exchange_base_url - ,metadata - ,amount - ,credit_payto - ,initiation_time - ,status - ,status_msg - FROM transfer_operations - JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) - WHERE initiated_outgoing_transaction_id=$1 - ", - ) - .bind(id as i64) - .try_map(|r: PgRow| { - Ok(TransferStatus { - status: r - .try_get::<SubmissionState, _>("status")? - .to_transfer_status(), - status_msg: r.try_get("status_msg")?, - amount: r.try_get_amount("amount", currency)?, - origin_exchange_url: r.try_get("exchange_base_url")?, - metadata: r.try_get("metadata")?, - wtid: r.try_get("wtid")?, - credit_account: r.try_get_payto("credit_payto")?, - timestamp: r.try_get_timestamp("initiation_time")?.into(), - }) - }) - .fetch_optional(db) - ) -} - -pub async fn transfer_page( - db: &PgPool, - currency: &Currency, - params: &Page, - status: &Option<TransferState>, -) -> sqlx::Result<Vec<TransferListStatus>> { - page( - db, - "initiated_outgoing_transaction_id", - params, - || { - let mut builder = QueryBuilder::new( - " - SELECT - initiated_outgoing_transaction_id - ,amount - ,status - ,credit_payto - ,initiation_time - FROM transfer_operations - JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) - WHERE - ", - ); - if let Some(status) = status { - match status { - TransferState::pending => { - builder.push("( status = ").push_bind(SubmissionState::pending).push(" OR ").push(" status = ").push_bind(SubmissionState::unsubmitted).push(") AND "); - } - status => { - builder.push(" status = ").push_bind(SubmissionState::from(*status)).push(" AND ");} - } - } - builder - }, - |r: PgRow| { - Ok(TransferListStatus { - row_id: r.try_get_safeu64("initiated_outgoing_transaction_id")?, - status: r.try_get::<SubmissionState, _>("status")?.to_transfer_status(), - amount: r.try_get_amount("amount", currency)?, - credit_account: r.try_get_payto("credit_payto")?, - timestamp: r.try_get_timestamp("initiation_time")?.into(), - }) - }, - ) - .await -} diff --git a/src/db/initiated.rs b/src/db/initiated.rs @@ -1,892 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use std::collections::BTreeMap; - -use const_format::formatcp; -use jiff::Timestamp; -use sqlx::{PgPool, Row as _, postgres::PgRow}; -use taler_api::db::{BindHelper as _, TypeHelper as _}; -use taler_common::types::{ - amount::{Amount, Currency}, - payto::PaytoURI, -}; - -use crate::{ - db::{PENDING, UNSETTLED}, - model::{Initiated, OutId, OutTx, PaymentBatch, SubmissionState}, -}; - -/// Outgoing payments initiation result -#[derive(Debug, PartialEq, Eq)] -pub enum PaymentInitiationResult { - Success(u64), - RequestUidReuse, -} - -/// Initiate a new payment -pub async fn initiate( - pool: &PgPool, - amount: &Amount, - subject: &str, - creditor: &PaytoURI, - initiation_time: &Timestamp, - e2e_id: &str, -) -> sqlx::Result<PaymentInitiationResult> { - let res = sqlx::query( - " - INSERT INTO initiated_outgoing_transactions ( - amount, - subject, - credit_payto, - initiation_time, - end_to_end_id - ) VALUES ($1,$2,$3,$4,$5) - RETURNING initiated_outgoing_transaction_id - ", - ) - .bind(amount) - .bind(subject) - .bind(creditor.as_ref().as_str()) - .bind_timestamp(initiation_time) - .bind(e2e_id) - .try_map(|r: PgRow| Ok(PaymentInitiationResult::Success(r.try_get_u64(0)?))) - .fetch_one(pool) - .await; - if let Err(e) = &res - && let Some(db_err) = e.as_database_error() - && db_err.code() == Some(std::borrow::Cow::Borrowed("23505")) - { - Ok(PaymentInitiationResult::RequestUidReuse) - } else { - res - } -} - -/// Group unbatched transaction into a single batch -pub async fn batch_initiated( - pool: &PgPool, - timestamp: &Timestamp, - ebics_id: &str, - require_ack: bool, -) -> sqlx::Result<()> { - sqlx::query("SELECT batch_outgoing_transactions($1, $2, $3)") - .bind_timestamp(timestamp) - .bind(ebics_id) - .bind(require_ack) - .execute(pool) - .await?; - Ok(()) -} - -pub async fn initiated_ack(db: &PgPool, id: u64) -> sqlx::Result<()> { - sqlx::query("UPDATE initiated_outgoing_transactions SET awaiting_ack=false WHERE initiated_outgoing_transaction_id=$1") - .bind(id as i64) - .execute(db) - .await?; - Ok(()) -} - -pub async fn initiated_submittable( - db: &PgPool, - currency: &Currency, -) -> sqlx::Result<Vec<PaymentBatch>> { - const SELECT_PART: &str = " - SELECT initiated_outgoing_batch_id, message_id, creation_date, sum - FROM initiated_outgoing_batches - "; - let mut tx = db.begin().await?; - // We want to maximize the number of successfully submitted batches in the event - // of a malformed transaction or a persistent error classified as transient. We send - // the unsubmitted batches first, starting with the oldest by creation time. - // This is the happy path, giving every batch a chance while being fair on the - // basis of creation date. - // Then we retry the failed batches, starting with the oldest by submission time. - // This the bad path retrying each failed batch applying a rotation based on - // resubmission time. - let mut batches = sqlx::query(formatcp!( - " - ({SELECT_PART} WHERE status='unsubmitted' ORDER BY creation_date ASC) - UNION ALL - ({SELECT_PART} WHERE status='transient_failure' ORDER BY submission_date) - " - )) - .try_map(|r: PgRow| { - Ok(PaymentBatch { - id: r.try_get_u64("initiated_outgoing_batch_id")?, - msg_id: r.try_get("message_id")?, - creation_date: r.try_get_timestamp("creation_date")?, - sum: r.try_get_amount("sum", currency)?, - payments: Vec::new(), - }) - }) - .fetch_all(&mut *tx) - .await?; - let mut batch_map: BTreeMap<_, _> = batches.iter_mut().map(|it| (it.id, it)).collect(); - // Then load transactions - sqlx::query( - " - SELECT - initiated_outgoing_transaction_id - ,amount - ,subject - ,credit_payto - ,initiated_outgoing_transactions.initiation_time - ,end_to_end_id - ,initiated_outgoing_batch_id - FROM initiated_outgoing_transactions - JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) - WHERE initiated_outgoing_batches.status IN ('unsubmitted', 'transient_failure') - ", - ) - .try_map(|r: PgRow| { - let payment = Initiated { - id: r.try_get_u64("initiated_outgoing_transaction_id")?, - amount: r.try_get_amount("amount", currency)?, - creditor: r.try_get_parse("credit_payto")?, - subject: r.try_get("subject")?, - initiation_time: r.try_get_timestamp("initiation_time")?, - e2e_id: r.try_get("end_to_end_id")?, - }; - let batch_id = r.try_get_u64("initiated_outgoing_batch_id")?; - batch_map.get_mut(&batch_id).unwrap().payments.push(payment); - Ok(()) - }) - .fetch_all(&mut *tx) - .await?; - tx.commit().await?; - Ok(batches) -} - -pub async fn unsettled_tx_in_batch( - db: &PgPool, - currency: &Currency, - msg_id: &str, - execution_time: &Timestamp, -) -> sqlx::Result<Vec<OutTx>> { - sqlx::query(formatcp!( - " - SELECT - end_to_end_id, - amount, - subject, - credit_payto - FROM initiated_outgoing_transactions - JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) - WHERE message_id = $1 - AND initiated_outgoing_transactions.{UNSETTLED} - " - )) - .bind(msg_id) - .try_map(|r: PgRow| { - Ok(OutTx { - id: OutId { - msg_id: Some(msg_id.into()), - e2e_id: r.try_get("end_to_end_id")?, - sref: None, - }, - amount: r.try_get_amount("amount", currency)?, - debit_fee: Amount::zero(currency), - subject: r.try_get("subject")?, - execution_time: *execution_time, - creditor: r.try_get_opt_payto("credit_payto")?, - }) - }) - .fetch_all(db) - .await -} - -/** Register submission success of order [orderId] for batch [id] at [timestamp] */ -pub async fn batch_sub_success( - db: &PgPool, - batch_id: u64, - timestamp: &Timestamp, - order_id: &str, -) -> sqlx::Result<()> { - let mut tx = db.begin().await?; - // Update batch status - let updated = sqlx::query( - " - UPDATE initiated_outgoing_batches - SET status = 'pending' - ,submission_date = $1 - ,status_msg = NULL - ,order_id = $2 - ,submission_counter = submission_counter + 1 - WHERE initiated_outgoing_batch_id = $3 AND order_id IS NULL - ", - ) - .bind_timestamp(timestamp) - .bind(order_id) - .bind(batch_id as i64) - .execute(&mut *tx) - .await?; - if updated.rows_affected() > 0 { - // Update unsettled batch's transaction status - sqlx::query(formatcp!( - " - UPDATE initiated_outgoing_transactions - SET status = 'pending', status_msg = NULL - WHERE initiated_outgoing_batch_id = $1 AND {UNSETTLED} - " - )) - .bind(batch_id as i64) - .execute(&mut *tx) - .await?; - } - tx.commit().await -} - -/** Register submission failure with [msg] for batch [id] at [timestamp]*/ -pub async fn batch_sub_failure( - db: &PgPool, - batch_id: u64, - timestamp: &Timestamp, - msg: &str, -) -> sqlx::Result<()> { - let permanent = false; - let mut tx = db.begin().await?; - // Update batch status - sqlx::query( - " - UPDATE initiated_outgoing_batches - SET status = $1 - ,submission_date = $2 - ,status_msg = $3 - ,submission_counter = submission_counter + 1 - WHERE initiated_outgoing_batch_id = $4 - ", - ) - .bind(if permanent { - SubmissionState::permanent_failure - } else { - SubmissionState::transient_failure - }) - .bind_timestamp(timestamp) - .bind(msg) - .bind(batch_id as i64) - .execute(&mut *tx) - .await?; - // Update unsettled batch's transaction status - sqlx::query(formatcp!( - " - UPDATE initiated_outgoing_transactions - SET status = $1, status_msg = $2 - WHERE initiated_outgoing_batch_id = $3 AND {UNSETTLED} - " - )) - .bind(if permanent { - SubmissionState::permanent_failure - } else { - SubmissionState::transient_failure - }) - .bind(msg) - .bind(batch_id as i64) - .execute(&mut *tx) - .await?; - tx.commit().await -} - -/** Register order step [msg] for [orderId] */ -pub async fn order_step(db: &PgPool, order_id: &str, msg: &str) -> sqlx::Result<()> { - let mut tx = db.begin().await?; - // Update batch status - let batch_id = sqlx::query(formatcp!( - " - UPDATE initiated_outgoing_batches - SET status = 'pending', status_msg = $1 - WHERE order_id = $2 AND {PENDING} - RETURNING initiated_outgoing_batch_id - " - )) - .bind(msg) - .bind(order_id) - .try_map(|r: PgRow| r.try_get_u64(0)) - .fetch_optional(&mut *tx) - .await?; - if let Some(batch_id) = batch_id { - // Update unsettled batch's transaction status - sqlx::query(formatcp!( - " - UPDATE initiated_outgoing_transactions - SET status = 'pending', status_msg = $1 - WHERE initiated_outgoing_batch_id = $2 AND {PENDING} - " - )) - .bind(msg) - .bind(batch_id as i64) - .execute(&mut *tx) - .await?; - } - tx.commit().await -} - -/** Register order success for [orderId] and return message_id if found */ -pub async fn order_success(db: &PgPool, order_id: &str) -> sqlx::Result<Option<String>> { - let mut tx = db.begin().await?; - // Update batch status - let res = sqlx::query(formatcp!( - " - UPDATE initiated_outgoing_batches - SET status = 'success' - WHERE order_id = $1 - RETURNING initiated_outgoing_batch_id, message_id - " - )) - .bind(order_id) - .try_map(|r: PgRow| Ok((r.try_get_u64(0)?, r.try_get(1)?))) - .fetch_optional(&mut *tx) - .await?; - if let Some((batch_id, _)) = &res { - // Update unsettled batch's transaction status - sqlx::query(formatcp!( - " - UPDATE initiated_outgoing_transactions - SET status = 'pending' - WHERE initiated_outgoing_batch_id = $1 AND {UNSETTLED} - " - )) - .bind(*batch_id as i64) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - Ok(res.map(|(_, msg_id)| msg_id)) -} - -/** Register order failure for [orderId] and return message_id and previous status_msg if found */ -pub async fn order_failure( - db: &PgPool, - order_id: &str, -) -> sqlx::Result<Option<(String, Option<String>)>> { - let mut tx = db.begin().await?; - // Update batch status - let res = sqlx::query(formatcp!( - " - UPDATE initiated_outgoing_batches - SET status = 'permanent_failure' - WHERE order_id = $1 - RETURNING initiated_outgoing_batch_id, message_id, status_msg - " - )) - .bind(order_id) - .try_map(|r: PgRow| Ok((r.try_get_u64(0)?, r.try_get(1)?, r.try_get(2)?))) - .fetch_optional(&mut *tx) - .await?; - if let Some((batch_id, _, _)) = &res { - // Update unsettled batch's transaction status - sqlx::query(formatcp!( - " - UPDATE initiated_outgoing_transactions - SET status = 'permanent_failure' - WHERE initiated_outgoing_batch_id = $1 - " - )) - .bind(*batch_id as i64) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - Ok(res.map(|(_, msg_id, status_msg)| (msg_id, status_msg))) -} - -/** Register payment status [state] with [msg] for batch [msgId] */ -pub async fn batch_status_update( - db: &PgPool, - msg_id: &str, - state: SubmissionState, - msg: &str, -) -> sqlx::Result<bool> { - sqlx::query(formatcp!( - "SELECT out_ok FROM batch_status_update($1,$2,$3)" - )) - .bind(msg_id) - .bind(state) - .bind(msg) - .try_map(|r: PgRow| r.try_get(0)) - .fetch_one(db) - .await -} - -/** Register payment status [state] with [msg] for transaction [endToEndId] in batch [msgId] */ -pub async fn tx_status_update( - db: &PgPool, - end_to_end_id: &str, - msg_id: &str, - state: SubmissionState, - msg: &str, -) -> sqlx::Result<bool> { - sqlx::query(formatcp!( - "SELECT out_ok FROM tx_status_update($1,$2,$3,$4)" - )) - .bind(end_to_end_id) - .bind(msg_id) - .bind(state) - .bind(msg) - .try_map(|r: PgRow| r.try_get(0)) - .fetch_one(db) - .await -} - -#[cfg(test)] -mod test { - use std::str::FromStr as _; - - use jiff::{Span, Timestamp, civil::Date}; - use sqlx::{PgPool, Row as _, postgres::PgRow}; - use taler_api::db::TypeHelper as _; - use taler_common::{config::Config, types::utils::date_to_utc_ts}; - - use crate::{ - CONFIG_SOURCE, - config::{NexusCfg, NexusIngestCfg}, - db::{ - initiated::{ - PaymentInitiationResult, batch_initiated, batch_status_update, batch_sub_failure, - batch_sub_success, initiated_submittable, order_failure, order_step, order_success, - tx_status_update, - }, - test::{check_count, db_setup}, - }, - model::{SubmissionState, Tx}, - rand_ebics_id, - test::{CURR, gen_in_pay, gen_initiate, gen_out_pay}, - worker::{register_outgoing, register_tx}, - }; - - #[tokio::test] - pub async fn initiated_skip() { - let (_, db) = db_setup().await; - let cfg = Config::from_file(CONFIG_SOURCE, Some("libeufin-nexus/conf/skip.conf")).unwrap(); - let cfg = NexusCfg::parse(cfg).unwrap(); - let cfg = cfg.ingest().unwrap(); - let millis = Span::new().milliseconds(10); - - async fn ingest(db: &PgPool, cfg: &NexusIngestCfg, execution_time: Timestamp) { - for tx in [ - Tx::In( - gen_in_pay(format!("test at {execution_time}")) - .with_execution_time(execution_time), - ), - Tx::Out( - gen_out_pay(format!("test at {execution_time}")) - .with_execution_time(execution_time), - ), - ] { - register_tx(db, cfg, &tx).await.unwrap() - } - } - - assert_eq!( - cfg.ignore_txs_before, - date_to_utc_ts(&Date::from_str("2024-04-04").unwrap()) - ); - assert_eq!( - cfg.ignore_bounces_before, - date_to_utc_ts(&Date::from_str("2024-06-12").unwrap()) - ); - - // No transaction at the beginning - check_count(&db, 0, 0).await; - - // Skipped transactions - ingest(&db, &cfg, cfg.ignore_txs_before - millis).await; - check_count(&db, 0, 0).await; - - // Skipped bounces - ingest(&db, &cfg, cfg.ignore_txs_before).await; - ingest(&db, &cfg, cfg.ignore_txs_before + millis).await; - ingest(&db, &cfg, cfg.ignore_bounces_before - millis).await; - check_count(&db, 6, 0).await; - - // Bounces - ingest(&db, &cfg, cfg.ignore_bounces_before).await; - ingest(&db, &cfg, cfg.ignore_bounces_before + millis).await; - check_count(&db, 10, 2).await; - } - - #[tokio::test] - pub async fn initiated_status() { - use SubmissionState::*; - - let (_, db) = db_setup().await; - - let check_parts = async |batch_id: u64, - batch_status: SubmissionState, - batch_msg: &str, - tx_status: SubmissionState, - tx_msg: &str, - settled_status: SubmissionState, - settled_msg: &str| { - // Check batch status - let msg_id: String = sqlx::query( - " - SELECT message_id, status, status_msg FROM initiated_outgoing_batches WHERE initiated_outgoing_batch_id=$1 - " - ).bind(batch_id as i64) - .try_map(|r: PgRow| { - let msg_id: String = r.try_get("message_id")?; - assert_eq!((batch_status, Some(batch_msg).filter(|it| !it.is_empty())), (r.try_get("status")?, r.try_get("status_msg")?), "{msg_id}"); - Ok(msg_id) - }).fetch_one(&db).await.unwrap(); - // Check tx status - sqlx::query( - " - SELECT end_to_end_id, status, status_msg FROM initiated_outgoing_transactions WHERE initiated_outgoing_batch_id=$1 - " - ).bind(batch_id as i64).try_map(|r: PgRow| { - let end_to_end_id: &str = r.try_get("end_to_end_id")?; - let expected = match end_to_end_id { - "TX" => (tx_status, Some(tx_msg).filter(|it| !it.is_empty())), - "TX_SETTLED" => (settled_status, Some(settled_msg).filter(|it| !it.is_empty())), - _ =>panic!("Unexpected tx $endToEndId") - }; - assert_eq!(expected, - (r.try_get("status")?, r.try_get("status_msg")?), - "{msg_id},{end_to_end_id}" - ); - Ok(()) - }).fetch_all(&db).await.unwrap(); - }; - - let check_batch_tx = async |batch_id: u64, - status: SubmissionState, - msg: &str, - tx_status: SubmissionState| { - check_parts(batch_id, status, msg, tx_status, msg, tx_status, msg).await; - }; - let check_batch = async |batch_id: u64, status: SubmissionState, msg: &str| { - check_batch_tx(batch_id, status, msg, status).await; - }; - let check_order_tx = async |order_id: &str, - status: SubmissionState, - msg: &str, - tx_status: SubmissionState| { - let batch_id = sqlx::query( - "SELECT initiated_outgoing_batch_id FROM initiated_outgoing_batches WHERE order_id=$1" - ).bind(order_id) - .try_map(|r: PgRow| { - r.try_get_u64(0) - }).fetch_one(&db).await.unwrap(); - check_batch_tx(batch_id, status, msg, tx_status).await; - }; - let check_order = async |order_id: &str, status: SubmissionState, msg: &str| { - check_order_tx(order_id, status, msg, status).await; - }; - - async fn test(db: &PgPool, lambda: impl AsyncFnOnce(u64)) { - // Reset DB - sqlx::query("DELETE FROM initiated_outgoing_transactions") - .execute(db) - .await - .unwrap(); - sqlx::query("DELETE FROM initiated_outgoing_batches") - .execute(db) - .await - .unwrap(); - // Create a test batch with three transactions - for id in ["TX", "TX_SETTLED"] { - assert!(matches!( - gen_initiate(db, id, "lol").await, - PaymentInitiationResult::Success(_) - )); - } - batch_initiated(db, &Timestamp::now(), "BATCH", false) - .await - .unwrap(); - - // Create witness transactions and batch - for id in ["WITNESS_1", "WITNESS_2"] { - assert!(matches!( - gen_initiate(db, id, "lol").await, - PaymentInitiationResult::Success(_) - )); - } - batch_initiated(db, &Timestamp::now(), "BATCH_WITNESS", false) - .await - .unwrap(); - for id in ["WITNESS_3", "WITNESS_4"] { - assert!(matches!( - gen_initiate(db, id, "lol").await, - PaymentInitiationResult::Success(_) - )); - } - // Check everything is unsubmitted - sqlx::query( - " - SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches) - AND (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_transactions) - " - ).try_map(|r: PgRow| { - assert!(r.try_get_flag(0).unwrap()); - Ok(()) - }).fetch_one(db).await.unwrap(); - let submitibale = initiated_submittable(db, &CURR).await.unwrap(); - lambda( - submitibale - .iter() - .find(|it| it.msg_id == "BATCH") - .unwrap() - .id, - ) - .await; - // Check witness status is unaltered - sqlx::query( - " - SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches WHERE message_id != 'BATCH') - AND (SELECT bool_and(initiated_outgoing_transactions.status = 'unsubmitted') - FROM initiated_outgoing_transactions JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) - WHERE message_id != 'BATCH') - " - ).try_map(|r: PgRow| { - assert!(r.try_get(0)?); - Ok(()) - }).fetch_one(db).await.unwrap(); - } - - let now = Timestamp::now(); - - // Submission retry status - test(&db, async |batch_id| { - batch_sub_failure(&db, batch_id, &now, "First failure") - .await - .unwrap(); - check_batch(batch_id, transient_failure, "First failure").await; - batch_sub_failure(&db, batch_id, &now, "Second failure") - .await - .unwrap(); - check_batch(batch_id, transient_failure, "Second failure").await; - batch_sub_success(&db, batch_id, &now, "ORDER") - .await - .unwrap(); - check_order("ORDER", pending, "").await; - batch_sub_success(&db, batch_id, &now, "ORDER") - .await - .unwrap(); - check_order("ORDER", pending, "").await; - order_step(&db, "ORDER", "step msg").await.unwrap(); - check_order("ORDER", pending, "step msg").await; - order_step(&db, "ORDER", "success msg").await.unwrap(); - check_order("ORDER", pending, "success msg").await; - order_success(&db, "ORDER").await.unwrap(); - check_order_tx("ORDER", success, "success msg", pending).await; - order_step(&db, "ORDER", "late msg").await.unwrap(); - check_order_tx("ORDER", success, "success msg", pending).await; - }) - .await; - - // Order step message on failure - test(&db, async |batch_id| { - batch_sub_success(&db, batch_id, &now, "ORDER") - .await - .unwrap(); - check_order("ORDER", pending, "").await; - order_step(&db, "ORDER", "step msg").await.unwrap(); - check_order("ORDER", pending, "step msg").await; - order_step(&db, "ORDER", "failure msg").await.unwrap(); - check_order("ORDER", pending, "failure msg").await; - assert_eq!( - Some("failure msg"), - order_failure(&db, "ORDER") - .await - .unwrap() - .unwrap() - .1 - .as_deref() - ); - check_order("ORDER", permanent_failure, "failure msg").await; - order_step(&db, "ORDER", "late msg").await.unwrap(); - check_order("ORDER", permanent_failure, "failure msg").await; - }) - .await; - - // Payment & batch status - test(&db, async |batch_id| { - check_batch(batch_id, unsubmitted, "").await; - batch_status_update(&db, "BATCH", pending, "progress") - .await - .unwrap(); - check_batch(batch_id, pending, "progress").await; - tx_status_update(&db, "TX_SETTLED", "", success, "success") - .await - .unwrap(); - check_parts( - batch_id, pending, "progress", pending, "progress", success, "success", - ) - .await; - batch_status_update(&db, "BATCH", transient_failure, "waiting") - .await - .unwrap(); - check_parts( - batch_id, - transient_failure, - "waiting", - transient_failure, - "waiting", - success, - "success", - ) - .await; - tx_status_update(&db, "TX", "BATCH", permanent_failure, "failure") - .await - .unwrap(); - check_parts( - batch_id, - success, - "", - permanent_failure, - "failure", - success, - "success", - ) - .await; - tx_status_update(&db, "TX_SETTLED", "BATCH", permanent_failure, "late") - .await - .unwrap(); - check_parts( - batch_id, - success, - "", - permanent_failure, - "failure", - late_failure, - "late", - ) - .await; - }) - .await; - - // Registration - test(&db, async |batch_id| { - check_batch(batch_id, unsubmitted, "").await; - register_outgoing(&db, &gen_out_pay("").with_e2e_id("TX_SETTLED")) - .await - .unwrap(); - check_parts(batch_id, unsubmitted, "", unsubmitted, "", success, "").await; - register_outgoing(&db, &gen_out_pay("").with_e2e_id("TX").with_msg_id("BATCH")) - .await - .unwrap(); - check_parts(batch_id, success, "", success, "", success, "").await; - }) - .await; - - // Transaction failure take over batch failures - test(&db, async |batch_id| { - check_batch(batch_id, unsubmitted, "").await; - batch_status_update(&db, "BATCH", permanent_failure, "batch") - .await - .unwrap(); - check_parts( - batch_id, - permanent_failure, - "batch", - permanent_failure, - "batch", - permanent_failure, - "batch", - ) - .await; - tx_status_update(&db, "TX", "BATCH", permanent_failure, "tx") - .await - .unwrap(); - batch_status_update(&db, "BATCH", permanent_failure, "batch2") - .await - .unwrap(); - check_parts( - batch_id, - permanent_failure, - "batch", - permanent_failure, - "tx", - permanent_failure, - "batch", - ) - .await; - }) - .await; - - // Unknown order and batch - batch_sub_success(&db, 42, &now, "ORDER_X").await.unwrap(); - batch_sub_failure(&db, 42, &now, "").await.unwrap(); - order_step(&db, "ORDER_X", "msg").await.unwrap(); - batch_status_update(&db, "BATCH_X", success, "") - .await - .unwrap(); - tx_status_update(&db, "TX_X", "BATCH_X", success, "msg") - .await - .unwrap(); - assert!(order_success(&db, "ORDER_X").await.unwrap().is_none()); - assert!(order_failure(&db, "ORDER_X").await.unwrap().is_none()); - } - - #[tokio::test] - pub async fn initiated_submittables() { - let (_, db) = db_setup().await; - let now = Timestamp::now(); - for i in 0..6 { - assert!(matches!( - gen_initiate(&db, format!("PAY{i}"), "").await, - PaymentInitiationResult::Success(_) - )); - batch_initiated(&db, &now, &rand_ebics_id(), false) - .await - .unwrap(); - } - - let check_ids = async |ids: &[&str]| { - assert_eq!( - ids, - initiated_submittable(&db, &CURR) - .await - .unwrap() - .iter() - .flat_map(|it| it.payments.iter().map(|it| it.e2e_id.as_str())) - .collect::<Vec<_>>() - ); - }; - check_ids(&["PAY0", "PAY1", "PAY2", "PAY3", "PAY4", "PAY5"]).await; - - // Check submitted not submitable - batch_sub_success(&db, 1, &now, "ORDER1").await.unwrap(); - check_ids(&["PAY1", "PAY2", "PAY3", "PAY4", "PAY5"]).await; - - // Check transient failure submitable last - batch_sub_failure(&db, 2, &now, "Failure").await.unwrap(); - check_ids(&["PAY2", "PAY3", "PAY4", "PAY5", "PAY1"]).await; - - // Check persistent failure not submitable - batch_sub_success(&db, 4, &now, "ORDER3").await.unwrap(); - order_failure(&db, "ORDER3").await.unwrap(); - check_ids(&["PAY2", "PAY4", "PAY5", "PAY1"]).await; - batch_sub_success(&db, 5, &now, "ORDER4").await.unwrap(); - order_failure(&db, "ORDER4").await.unwrap(); - check_ids(&["PAY2", "PAY5", "PAY1"]).await; - - // Check rotation - batch_sub_failure(&db, 3, &Timestamp::now(), "FAILURE") - .await - .unwrap(); - check_ids(&["PAY5", "PAY1", "PAY2"]).await; - batch_sub_failure(&db, 6, &Timestamp::now(), "FAILURE") - .await - .unwrap(); - check_ids(&["PAY1", "PAY2", "PAY5"]).await; - batch_sub_failure(&db, 2, &Timestamp::now(), "FAILURE") - .await - .unwrap(); - check_ids(&["PAY2", "PAY5", "PAY1"]).await; - } -} diff --git a/src/db/list.rs b/src/db/list.rs @@ -1,269 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use compact_str::CompactString; -use jiff::Timestamp; -use sqlx::{PgPool, Row as _, postgres::PgRow}; -use taler_api::db::TypeHelper as _; -use taler_common::{ - api_common::{EddsaPublicKey, ShortHashCode}, - types::amount::{Amount, Currency, Decimal}, -}; - -use crate::model::{InId, OutId}; - -/** Incoming transaction metadata for debugging */ -pub struct InMetadata { - pub id: InId, - pub date: Timestamp, - pub amount: Amount, - pub credit_fee: Option<Decimal>, - pub subject: Option<String>, - pub debtor: Option<String>, - pub talerable: Option<String>, - pub bounced: Option<String>, -} - -/** Outgoing transaction metadata for debugging */ -pub struct OutMetadata { - pub id: OutId, - pub date: Timestamp, - pub amount: Amount, - pub subject: Option<String>, - pub creditor: Option<String>, - pub wtid: Option<ShortHashCode>, - pub exchange_base_url: Option<String>, -} - -/** Initiated metadata for debugging */ -pub struct InitMetadata { - pub date: Timestamp, - pub amount: Amount, - pub subject: String, - pub creditor: String, - pub id: String, - pub batch: Option<String>, - pub batch_order: Option<String>, - pub status: String, - pub msg: Option<String>, - pub submission_time: Option<Timestamp>, - pub submission_counter: u32, -} - -/** Initiated metadata for debugging */ -pub struct InitMetadataAck { - pub date: Timestamp, - pub amount: Amount, - pub subject: String, - pub creditor: String, - pub db_id: u64, - pub id: String, -} - -/** List incoming transaction metadata for debugging */ -pub async fn incoming( - db: &PgPool, - incomplete: bool, - currency: &Currency, -) -> sqlx::Result<Vec<InMetadata>> { - let query = if incomplete { - " - SELECT - incoming.amount AS amount - ,credit_fee - ,incoming.subject - ,end_to_end_id AS bounced - ,execution_time - ,debit_payto - ,type::text - ,metadata - ,uetr - ,tx_id - ,acct_svcr_ref - ,talerable_incoming_transactions.authorization_pub as auth_pub - ,pending_recurrent_incoming_transactions.authorization_pub as pending_pub - FROM incoming_transactions AS incoming - LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) - LEFT JOIN bounced_transactions USING (incoming_transaction_id) - LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) - LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) - WHERE debit_payto IS NULL OR incoming.subject IS NULL - ORDER BY execution_time - " - } else { - " - SELECT - incoming.amount AS amount - ,credit_fee - ,incoming.subject - ,end_to_end_id AS bounced - ,execution_time - ,debit_payto - ,type::text - ,metadata - ,uetr - ,tx_id - ,acct_svcr_ref - ,talerable_incoming_transactions.authorization_pub as auth_pub - ,pending_recurrent_incoming_transactions.authorization_pub as pending_pub - FROM incoming_transactions AS incoming - LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) - LEFT JOIN bounced_transactions USING (incoming_transaction_id) - LEFT JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) - LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) - ORDER BY execution_time - " - }; - sqlx::query(query) - .try_map(|r: PgRow| { - let auth_pub: Option<EddsaPublicKey> = r.try_get("auth_pub")?; - let pending_pub: Option<EddsaPublicKey> = r.try_get("pending_pub")?; - let map = if let Some(auth_pub) = auth_pub { - format!(" mapped by {auth_pub}") - } else { - String::new() - }; - Ok(InMetadata { - id: InId { - uetr: r.try_get("uetr")?, - tx_id: r.try_get("tx_id")?, - sref: r.try_get("acct_svcr_ref")?, - }, - date: r.try_get_timestamp("execution_time")?, - amount: r.try_get_amount("amount", currency)?, - credit_fee: r.try_get("credit_fee")?, - subject: r.try_get("subject")?, - debtor: r.try_get("debit_payto")?, - bounced: r.try_get("bounced")?, - talerable: match r.try_get::<Option<CompactString>, _>("type")? { - None => pending_pub.map(|pending| format!("pending mapped by {pending}")), - Some(ty) => Some(format!( - "{ty} {}{map}", - r.try_get::<EddsaPublicKey, _>("metadata")? - )), - }, - }) - }) - .fetch_all(db) - .await -} - -/** List outgoing transaction metadata for debugging */ -pub async fn outgoing(db: &PgPool, currency: &Currency) -> sqlx::Result<Vec<OutMetadata>> { - sqlx::query( - " - SELECT - amount - ,subject - ,execution_time - ,credit_payto - ,end_to_end_id - ,acct_svcr_ref - ,wtid - ,exchange_base_url - FROM outgoing_transactions - LEFT JOIN talerable_outgoing_transactions using (outgoing_transaction_id) - ORDER BY execution_time - ", - ) - .try_map(|r: PgRow| { - Ok(OutMetadata { - id: OutId { - msg_id: None, - e2e_id: r.try_get("end_to_end_id")?, - sref: r.try_get("acct_svcr_ref")?, - }, - date: r.try_get_timestamp("execution_time")?, - amount: r.try_get_amount("amount", currency)?, - subject: r.try_get("subject")?, - creditor: r.try_get("credit_payto")?, - wtid: r.try_get("wtid")?, - exchange_base_url: r.try_get("exchange_base_url")?, - }) - }) - .fetch_all(db) - .await -} - -/** List initiated transaction metadata for debugging */ -pub async fn initiated(db: &PgPool, currency: &Currency) -> sqlx::Result<Vec<InitMetadata>> { - sqlx::query( - " - SELECT - amount - ,subject - ,initiation_time - ,submission_date - ,submission_counter - ,credit_payto - ,end_to_end_id - ,message_id - ,order_id - ,initiated_outgoing_transactions.status::text - ,initiated_outgoing_transactions.status_msg - FROM initiated_outgoing_transactions - LEFT JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id) - ORDER BY initiation_time - ", - ) - .try_map(|r: PgRow| { - Ok(InitMetadata { - date: r.try_get_timestamp("initiation_time")?, - amount: r.try_get_amount("amount", currency)?, - subject: r.try_get("subject")?, - creditor: r.try_get("credit_payto")?, - id: r.try_get("end_to_end_id")?, - batch: r.try_get("message_id")?, - batch_order: r.try_get("order_id")?, - status: r.try_get("status")?, - msg: r.try_get("status_msg")?, - submission_time: r.try_get_opt_timestamp("submission_date")?, - submission_counter: r.try_get_opt_u32("submission_counter")?.unwrap_or_default(), - }) - }) - .fetch_all(db) - .await -} - -/** List initiated transaction metadata pending acknowledgment for debugging */ -pub async fn initiated_ack(db: &PgPool, currency: &Currency) -> sqlx::Result<Vec<InitMetadataAck>> { - sqlx::query( - " - SELECT - amount - ,subject - ,initiation_time - ,credit_payto - ,end_to_end_id - ,initiated_outgoing_transaction_id - FROM initiated_outgoing_transactions - WHERE initiated_outgoing_batch_id IS NULL AND NOT awaiting_ack - ORDER BY initiation_time - ", - ) - .try_map(|r: PgRow| { - Ok(InitMetadataAck { - date: r.try_get_timestamp("initiation_time")?, - amount: r.try_get_amount("amount", currency)?, - subject: r.try_get("subject")?, - creditor: r.try_get("credit_payto")?, - id: r.try_get("end_to_end_id")?, - db_id: r.try_get_u64("initiated_outgoing_transaction_id")?, - }) - }) - .fetch_all(db) - .await -} diff --git a/src/db/payment.rs b/src/db/payment.rs @@ -1,1130 +0,0 @@ -/* - This file is part of TALER - Copyright (C) 2026 Taler Systems SA - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU Affero General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> -*/ - -use compact_str::CompactString; -use jiff::Timestamp; -use sqlx::{PgPool, Row as _, postgres::PgRow}; -use taler_api::{ - db::{BindHelper as _, TypeHelper as _}, - subject::{IncomingSubject, OutgoingSubject}, -}; -use taler_common::types::amount::Amount; - -use crate::model::{InTx, OutTx}; - -#[derive(Debug, PartialEq, Eq)] -pub struct OutgoingRegistrationResult { - pub id: u64, - pub initiated: bool, - pub new: bool, -} - -/** Register an outgoing payment reconciling it with its initiated payment counterpart if present */ -pub async fn register_out_tx( - pool: &PgPool, - payment: &OutTx, - subject: Option<&OutgoingSubject>, -) -> sqlx::Result<OutgoingRegistrationResult> { - sqlx::query( - " - SELECT out_tx_id, out_initiated, out_found - FROM register_outgoing($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) - ", - ) - .bind(payment.amount) - .bind(payment.debit_fee) - .bind(&payment.subject) - .bind_timestamp(&payment.execution_time) - .bind(payment.creditor.as_ref().map(|it| it.as_ref().as_str())) - .bind(&payment.id.e2e_id) - .bind(&payment.id.msg_id) - .bind(&payment.id.sref) - .bind(subject.as_ref().map(|s| &s.wtid)) - .bind(subject.as_ref().map(|s| s.exchange_base_url.as_str())) - .bind(subject.as_ref().map(|s| &s.metadata)) - .try_map(|r: PgRow| { - Ok(OutgoingRegistrationResult { - id: r.try_get_u64(0)?, - initiated: r.try_get_flag(1)?, - new: !r.try_get_flag(2)?, - }) - }) - .fetch_one(pool) - .await -} - -/// Register an outgoing batch -pub async fn register_out_batch( - pool: &PgPool, - payment: &OutTx, - subject: Option<&OutgoingSubject>, -) -> sqlx::Result<OutgoingRegistrationResult> { - sqlx::query( - " - SELECT out_tx_id, out_initiated, out_found - FROM register_outgoing($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) - ", - ) - .bind(payment.amount) - .bind(payment.debit_fee) - .bind(&payment.subject) - .bind_timestamp(&payment.execution_time) - .bind(payment.creditor.as_ref().map(|it| it.as_ref().as_str())) - .bind(&payment.id.e2e_id) - .bind(&payment.id.msg_id) - .bind(&payment.id.sref) - .bind(subject.as_ref().map(|s| &s.wtid)) - .bind(subject.as_ref().map(|s| s.exchange_base_url.as_str())) - .bind(subject.as_ref().map(|s| &s.metadata)) - .try_map(|r: PgRow| { - Ok(OutgoingRegistrationResult { - id: r.try_get_u64(0)?, - initiated: r.try_get_flag(1)?, - new: !r.try_get_flag(2)?, - }) - }) - .fetch_one(pool) - .await -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InResult { - pub id: u64, - pub new: bool, - pub completed: bool, - pub pending: bool, - pub bounce_id: Option<CompactString>, -} - -/** Incoming payments registration result */ -#[derive(Debug, PartialEq, Eq)] -pub enum IncomingRegistrationResult { - Success(InResult), - ReservePubReuse, - MappingReuse, - UnknownMapping, -} - -/** Register an incoming payment */ -pub async fn register_in(pool: &PgPool, payment: &InTx) -> sqlx::Result<InResult> { - sqlx::query( - " - SELECT out_found, out_completed, out_tx_id, out_bounce_id - FROM register_incoming($1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,NULL) - ", - ) - .bind(payment.amount) - .bind(payment.credit_fee) - .bind(&payment.subject) - .bind_timestamp(&payment.execution_time) - .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) - .bind(payment.id.uetr) - .bind(&payment.id.tx_id) - .bind(&payment.id.sref) - .try_map(|r: PgRow| { - Ok(InResult { - id: r.try_get_u64("out_tx_id")?, - new: !r.try_get_flag("out_found")?, - completed: r.try_get_flag("out_completed")?, - bounce_id: r.try_get("out_bounce_id")?, - pending: false, - }) - }) - .fetch_one(pool) - .await -} - -/** Register an talerable incoming payment */ -pub async fn register_in_talerable( - pool: &PgPool, - payment: &InTx, - subject: &IncomingSubject, -) -> sqlx::Result<IncomingRegistrationResult> { - sqlx::query( - " - SELECT - out_reserve_pub_reuse, - out_mapping_reuse, - out_unknown_mapping, - out_found, - out_completed, - out_pending, - out_tx_id, - out_bounce_id - FROM register_incoming($1,$2,$3,$4,$5,$6,$7,$8,$9::taler_incoming_type,$10,NULL) - ", - ) - .bind(payment.amount) - .bind(payment.credit_fee) - .bind(&payment.subject) - .bind_timestamp(&payment.execution_time) - .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) - .bind(payment.id.uetr) - .bind(&payment.id.tx_id) - .bind(&payment.id.sref) - .bind(subject.ty()) - .bind(subject.key()) - .try_map(|r: PgRow| { - Ok(if r.try_get_flag("out_reserve_pub_reuse")? { - IncomingRegistrationResult::ReservePubReuse - } else if r.try_get_flag("out_mapping_reuse")? { - IncomingRegistrationResult::MappingReuse - } else if r.try_get_flag("out_unknown_mapping")? { - IncomingRegistrationResult::UnknownMapping - } else { - IncomingRegistrationResult::Success(InResult { - id: r.try_get_u64("out_tx_id")?, - new: !r.try_get_flag("out_found")?, - completed: r.try_get_flag("out_completed")?, - bounce_id: r.try_get("out_bounce_id")?, - pending: r.try_get("out_pending")?, - }) - }) - }) - .fetch_one(pool) - .await -} - -/** Register an talerable incoming payment */ -pub async fn register_in_qr_bill( - pool: &PgPool, - payment: &InTx, - reference: &str, -) -> sqlx::Result<IncomingRegistrationResult> { - sqlx::query( - " - SELECT - out_reserve_pub_reuse, - out_mapping_reuse, - out_unknown_mapping, - out_found, - out_completed, - out_pending, - out_tx_id, - out_bounce_id - FROM register_incoming($1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9) - ", - ) - .bind(payment.amount) - .bind(payment.credit_fee) - .bind(&payment.subject) - .bind_timestamp(&payment.execution_time) - .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) - .bind(payment.id.uetr) - .bind(&payment.id.tx_id) - .bind(&payment.id.sref) - .bind(reference) - .try_map(|r: PgRow| { - Ok(if r.try_get_flag("out_reserve_pub_reuse")? { - IncomingRegistrationResult::ReservePubReuse - } else if r.try_get_flag("out_mapping_reuse")? { - IncomingRegistrationResult::MappingReuse - } else if r.try_get_flag("out_unknown_mapping")? { - IncomingRegistrationResult::UnknownMapping - } else { - IncomingRegistrationResult::Success(InResult { - id: r.try_get_u64("out_tx_id")?, - new: !r.try_get_flag("out_found")?, - completed: r.try_get_flag("out_completed")?, - bounce_id: r.try_get("out_bounce_id")?, - pending: r.try_get("out_pending")?, - }) - }) - }) - .fetch_one(pool) - .await -} - -#[derive(Debug, Clone, PartialEq, Eq)] -/** Incoming payments bounce registration result */ -pub enum IncomingBounceRegistrationResult { - Success(InResult), - Talerable, -} - -/** Register an incoming payment and bounce it */ -pub async fn register_in_malformed( - pool: &PgPool, - payment: &InTx, - bounce_amount: &Amount, - bounce_end_to_end_id: &str, - timestamp: &Timestamp, - cause: &str, -) -> sqlx::Result<IncomingBounceRegistrationResult> { - sqlx::query( - " - SELECT out_found, out_tx_id, out_completed, out_bounce_id, out_talerable - FROM register_and_bounce_incoming($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) - ", - ) - .bind(payment.amount) - .bind(payment.credit_fee) - .bind(&payment.subject) - .bind_timestamp(&payment.execution_time) - .bind(payment.debtor.as_ref().map(|it| it.as_ref().as_str())) - .bind(payment.id.uetr) - .bind(&payment.id.tx_id) - .bind(&payment.id.sref) - .bind(bounce_amount) - .bind_timestamp(timestamp) - .bind(bounce_end_to_end_id) - .bind(cause) - .try_map(|r: PgRow| { - Ok(if r.try_get_flag("out_talerable")? { - IncomingBounceRegistrationResult::Talerable - } else { - IncomingBounceRegistrationResult::Success(InResult { - id: r.try_get_u64("out_tx_id")?, - new: !r.try_get_flag("out_found")?, - completed: r.try_get_flag("out_completed")?, - bounce_id: r.try_get("out_bounce_id")?, - pending: false, - }) - }) - }) - .fetch_one(pool) - .await -} - -#[cfg(test)] -mod test { - - use jiff::Timestamp; - use sqlx::{PgPool, postgres::PgRow}; - use taler_api::{db::TypeHelper as _, subject::subject_fmt_qr_bill}; - use taler_common::{ - api_common::{EddsaPublicKey, EddsaSignature, ShortHashCode}, - db::IncomingType, - types::amount::amount, - }; - use taler_test_utils::routine::Status::*; - use uuid::Uuid; - - use crate::{ - config::{AccountType, NexusIngestCfg}, - db::{ - initiated::{PaymentInitiationResult, batch_initiated, initiated_ack}, - payment::{ - InResult, IncomingBounceRegistrationResult, OutgoingRegistrationResult, - register_in_malformed, - }, - test::{check_in_count, check_in_state, check_out_count, db_setup}, - transfer::{RegistrationResult, transfer_register}, - }, - model::{InId, InTx, OutBatch, OutId, OutTx}, - rand_ebics_id, - test::{CURR, gen_in_pay, gen_initiate, gen_out_pay}, - worker::{register_incoming, register_outgoing, register_outgoing_batch}, - }; - - #[tokio::test] - async fn out_tx() { - let (_, db) = db_setup().await; - // Register initiated transactions - for subject in [ - "initiated by nexus".to_owned(), - format!("{} https://exchange.com/", ShortHashCode::rand()), - ] { - let payment = gen_out_pay(subject.clone()); - assert!(matches!( - gen_initiate(&db, payment.id.e2e_id.clone().unwrap(), subject).await, - PaymentInitiationResult::Success(_) - )); - let first = register_outgoing(&db, &payment).await.unwrap(); - assert_eq!( - first, - OutgoingRegistrationResult { - id: first.id, - initiated: true, - new: true - } - ); - assert_eq!( - register_outgoing(&db, &payment).await.unwrap(), - OutgoingRegistrationResult { - id: first.id, - initiated: true, - new: false - } - ); - let payment = OutTx { - id: OutId { - msg_id: None, - e2e_id: None, - sref: payment.id.e2e_id, - }, - ..payment - }; - let second = register_outgoing(&db, &payment).await.unwrap(); - assert_eq!( - second, - OutgoingRegistrationResult { - id: first.id + 1, - initiated: false, - new: true - } - ); - assert_eq!( - register_outgoing(&db, &payment).await.unwrap(), - OutgoingRegistrationResult { - id: second.id, - initiated: false, - new: false - } - ); - } - check_out_count(&db, 4, 1).await; - - // Register unknown - for subject in [ - "initiated by nexus".to_owned(), - format!("{} https://exchange.com/", ShortHashCode::rand()), - ] { - let payment = gen_out_pay(subject.clone()); - let res = register_outgoing(&db, &payment).await.unwrap(); - assert_eq!( - res, - OutgoingRegistrationResult { - id: res.id, - initiated: false, - new: true - } - ); - assert_eq!( - register_outgoing(&db, &payment).await.unwrap(), - OutgoingRegistrationResult { - id: res.id, - initiated: false, - new: false - } - ); - } - check_out_count(&db, 6, 2).await; - - // Register wtid reuse - let wtid = ShortHashCode::rand(); - for subject in [ - format!("{wtid} https://exchange.com/"), - format!("{wtid} https://exchange.com/"), - ] { - let payment = gen_out_pay(subject.clone()); - let res = register_outgoing(&db, &payment).await.unwrap(); - assert_eq!( - res, - OutgoingRegistrationResult { - id: res.id, - initiated: false, - new: true - } - ); - assert_eq!( - register_outgoing(&db, &payment).await.unwrap(), - OutgoingRegistrationResult { - id: res.id, - initiated: false, - new: false - } - ); - } - check_out_count(&db, 8, 3).await - } - - #[tokio::test] - async fn out_batch() { - let (_, db) = db_setup().await; - // Init batch - let wtid = ShortHashCode::rand(); - for subject in [ - "initiated by nexus".to_string(), - format!("{} https://exchange.com/", ShortHashCode::rand()), - format!("{wtid} https://exchange.com/"), - format!("{wtid} https://exchange.com/"), - ] { - assert!(matches!( - gen_initiate(&db, rand_ebics_id(), subject).await, - PaymentInitiationResult::Success(_) - )); - } - batch_initiated(&db, &Timestamp::now(), "BATCH", false) - .await - .unwrap(); - - // Register batch - register_outgoing_batch( - &db, - &CURR, - &OutBatch { - msg_id: "BATCH".into(), - execution_time: Timestamp::now(), - }, - ) - .await - .unwrap(); - check_out_count(&db, 4, 2).await; - - // Test manual ack - let mut txs = Vec::new(); - for nb in 0..3 { - let res = gen_initiate(&db, rand_ebics_id(), format!("tx {nb}")).await; - if let PaymentInitiationResult::Success(id) = &res { - txs.push(*id); - } else { - panic!("Expected success got {res:?}"); - } - } - - // Check not sent without ack - batch_initiated(&db, &Timestamp::now(), "BATCH_MANUAL", true) - .await - .unwrap(); - register_outgoing_batch( - &db, - &CURR, - &OutBatch { - msg_id: "BATCH_MANUAL".into(), - execution_time: Timestamp::now(), - }, - ) - .await - .unwrap(); - check_out_count(&db, 4, 2).await; - - // Check sent with ack - for tx in txs { - initiated_ack(&db, tx).await.unwrap(); - } - batch_initiated(&db, &Timestamp::now(), "BATCH_MANUAL", true) - .await - .unwrap(); - register_outgoing_batch( - &db, - &CURR, - &OutBatch { - msg_id: "BATCH_MANUAL".into(), - execution_time: Timestamp::now(), - }, - ) - .await - .unwrap(); - check_out_count(&db, 7, 2).await; - } - - #[tokio::test] - async fn in_bounce() { - let (_, db) = db_setup().await; - - // Creating and bouncing one incoming transaction - let payment = gen_in_pay("incoming and bounce"); - let id = rand_ebics_id(); - - let bounce_amount = amount("KUDOS:2.53"); - let res = register_in_malformed( - &db, - &payment, - &bounce_amount, - &id, - &Timestamp::now(), - "manual bounce", - ) - .await - .unwrap(); - assert!( - matches!( - res, - IncomingBounceRegistrationResult::Success(InResult { - new: true, - id: _, - completed: false, - pending: false, - ref bounce_id - }) if bounce_id.as_ref() == Some(&id) - ), - "{res:?}" - ); - // Idempotent - let res = register_in_malformed( - &db, - &payment, - &amount("KUDOS:2.5"), - &rand_ebics_id(), - &Timestamp::now(), - "other reason to bounce", - ) - .await - .unwrap(); - assert!( - matches!( - res, - IncomingBounceRegistrationResult::Success(InResult { - new: false, - id: _, - completed: false, - pending: false, - ref bounce_id - }) if bounce_id.as_ref() == Some(&id) - ), - "{res:?}" - ); - - // Checking one incoming got created and bounced - sqlx::query( - " - SELECT - incoming_transactions.amount as in_amount, - initiated_outgoing_transactions.amount as bounce_amount - FROM incoming_transactions - JOIN bounced_transactions USING (incoming_transaction_id) - JOIN initiated_outgoing_transactions USING (initiated_outgoing_transaction_id) - ", - ) - .try_map(|r: PgRow| { - assert_eq!(r.try_get_amount("in_amount", &CURR)?, payment.amount); - assert_eq!(r.try_get_amount("bounce_amount", &CURR)?, bounce_amount); - Ok(()) - }) - .fetch_one(&db) - .await - .unwrap(); - } - - #[tokio::test] - async fn in_simple() { - let (_, db) = db_setup().await; - - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); - - // Register - let incoming = gen_in_pay("test".to_owned()); - register_incoming(&db, &cfg, &incoming).await.unwrap(); - check_in_state(&db, &[Bounced]).await; - - // Idempotent - register_incoming(&db, &cfg, &incoming).await.unwrap(); - check_in_state(&db, &[Bounced]).await; - - // Many - register_incoming(&db, &cfg, &gen_in_pay("another subject".to_owned())) - .await - .unwrap(); - check_in_state(&db, &[Bounced, Bounced]).await; - - // Admin balance adjust is ignored - register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) - .await - .unwrap(); - - check_in_state(&db, &[Bounced, Bounced, Simple]).await; - - let original = gen_in_pay("test 2".to_owned()); - let incomplete = InTx { - subject: None, - debtor: None, - ..original.clone() - }; - - // Register incomplete transaction - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - check_in_state(&db, &[Bounced, Bounced, Simple, Incomplete]).await; - // Idempotent - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - check_in_state(&db, &[Bounced, Bounced, Simple, Incomplete]).await; - // Recover info when completed - register_incoming(&db, &cfg, &original).await.unwrap(); - check_in_state(&db, &[Bounced, Bounced, Simple, Bounced]).await; - } - - #[tokio::test] - async fn in_talerable() { - let (_, db) = db_setup().await; - - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); - let key = EddsaPublicKey::rand(); - let subject = format!("test with {key} reserve pub"); - - // Register - let incoming = gen_in_pay(subject.clone()); - register_incoming(&db, &cfg, &incoming).await.unwrap(); - check_in_state(&db, &[Reserve(key.clone())]).await; - - // Idempotent - register_incoming(&db, &cfg, &incoming).await.unwrap(); - check_in_state(&db, &[Reserve(key.clone())]).await; - - // Key reuse is bounced - register_incoming(&db, &cfg, &gen_in_pay(subject.clone())) - .await - .unwrap(); - register_incoming(&db, &cfg, &gen_in_pay(format!("another {subject}"))) - .await - .unwrap(); - check_in_state(&db, &[Reserve(key.clone()), Bounced, Bounced]).await; - - // Admin balance adjust is ignored - register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) - .await - .unwrap(); - check_in_state(&db, &[Reserve(key.clone()), Bounced, Bounced, Simple]).await; - - let new = EddsaPublicKey::rand(); - let original = gen_in_pay(format!("test 2 with {new} reserve pub")); - let incomplete = InTx { - subject: None, - debtor: None, - ..original.clone() - }; - - // Register incomplete transaction - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - check_in_state( - &db, - &[Reserve(key.clone()), Bounced, Bounced, Simple, Incomplete], - ) - .await; - // Idempotent - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - check_in_state( - &db, - &[Reserve(key.clone()), Bounced, Bounced, Simple, Incomplete], - ) - .await; - // Recover info when completed - register_incoming(&db, &cfg, &original).await.unwrap(); - check_in_state( - &db, - &[Reserve(key.clone()), Bounced, Bounced, Simple, Reserve(new)], - ) - .await; - } - - #[tokio::test] - async fn in_mapping() { - let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); - let first = EddsaPublicKey::rand(); - let auth_pub = EddsaPublicKey::rand(); - let auth_sig = EddsaSignature::rand(); - let reference_number = subject_fmt_qr_bill(auth_pub.as_slice()); - let subject = format!("test with MAP:{auth_pub} auth pub"); - - assert_eq!( - transfer_register( - &db, - IncomingType::reserve, - &first, - &auth_pub, - &auth_sig, - false, - &reference_number, - &Timestamp::now() - ) - .await - .unwrap(), - RegistrationResult::Success - ); - - // Register - let incoming = gen_in_pay(subject.clone()); - register_incoming(&db, &cfg, &incoming).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone())]).await; - - // Idempotent - register_incoming(&db, &cfg, &incoming).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone())]).await; - - // Admin balance adjust is ignored - register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) - .await - .unwrap(); - check_in_state(&db, &[Reserve(first.clone()), Simple]).await; - - let original = gen_in_pay(format!("test 2 for {subject}")); - let incomplete = InTx { - subject: None, - debtor: None, - ..original.clone() - }; - // Register incomplete transaction - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone()), Simple, Incomplete]).await; - // Idempotent - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone()), Simple, Incomplete]).await; - // Recover info when completed - register_incoming(&db, &cfg, &original).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone()), Simple, Bounced]).await; - - let second = EddsaPublicKey::rand(); - assert_eq!( - transfer_register( - &db, - IncomingType::reserve, - &second, - &auth_pub, - &auth_sig, - true, - &reference_number, - &Timestamp::now() - ) - .await - .unwrap(), - RegistrationResult::Success - ); - check_in_state(&db, &[Reserve(first.clone()), Simple, Bounced]).await; - - // Key reuse is pending - for _ in 0..3 { - register_incoming(&db, &cfg, &gen_in_pay(subject.clone())) - .await - .unwrap(); - } - check_in_state( - &db, - &[ - Reserve(first.clone()), - Simple, - Bounced, - Reserve(second.clone()), - Pending, - Pending, - ], - ) - .await; - - // Finish pending - let third = EddsaPublicKey::rand(); - assert_eq!( - transfer_register( - &db, - IncomingType::reserve, - &third, - &auth_pub, - &auth_sig, - true, - &reference_number, - &Timestamp::now() - ) - .await - .unwrap(), - RegistrationResult::Success - ); - check_in_state( - &db, - &[ - Reserve(first.clone()), - Simple, - Bounced, - Reserve(second.clone()), - Reserve(third.clone()), - Pending, - ], - ) - .await; - } - - #[tokio::test] - async fn in_reference() { - let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); - let first = EddsaPublicKey::rand(); - let auth_pub = EddsaPublicKey::rand(); - let auth_sig = EddsaSignature::rand(); - let reference_number = subject_fmt_qr_bill(auth_pub.as_slice()); - - assert_eq!( - transfer_register( - &db, - IncomingType::reserve, - &first, - &auth_pub, - &auth_sig, - false, - &reference_number, - &Timestamp::now() - ) - .await - .unwrap(), - RegistrationResult::Success - ); - - // Register - let incoming = gen_in_pay(reference_number.clone()); - register_incoming(&db, &cfg, &incoming).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone())]).await; - - // Idempotent - register_incoming(&db, &cfg, &incoming).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone())]).await; - - // Admin balance adjust is ignored - register_incoming(&db, &cfg, &gen_in_pay("ADMIN BALANCE ADJUST".to_owned())) - .await - .unwrap(); - check_in_state(&db, &[Reserve(first.clone()), Simple]).await; - - let original = gen_in_pay(reference_number.clone()); - let incomplete = InTx { - subject: None, - debtor: None, - ..original.clone() - }; - // Register incomplete transaction - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone()), Simple, Incomplete]).await; - // Idempotent - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone()), Simple, Incomplete]).await; - // Recover info when completed - register_incoming(&db, &cfg, &original).await.unwrap(); - check_in_state(&db, &[Reserve(first.clone()), Simple, Bounced]).await; - - let second = EddsaPublicKey::rand(); - assert_eq!( - transfer_register( - &db, - IncomingType::reserve, - &second, - &auth_pub, - &auth_sig, - true, - &reference_number, - &Timestamp::now() - ) - .await - .unwrap(), - RegistrationResult::Success - ); - check_in_state(&db, &[Reserve(first.clone()), Simple, Bounced]).await; - - // Key reuse is pending - for _ in 0..3 { - register_incoming(&db, &cfg, &gen_in_pay(reference_number.clone())) - .await - .unwrap(); - } - check_in_state( - &db, - &[ - Reserve(first.clone()), - Simple, - Bounced, - Reserve(second.clone()), - Pending, - Pending, - ], - ) - .await; - - // Finish pending - let third = EddsaPublicKey::rand(); - assert_eq!( - transfer_register( - &db, - IncomingType::reserve, - &third, - &auth_pub, - &auth_sig, - true, - &reference_number, - &Timestamp::now() - ) - .await - .unwrap(), - RegistrationResult::Success - ); - check_in_state( - &db, - &[ - Reserve(first.clone()), - Simple, - Bounced, - Reserve(second.clone()), - Reserve(third.clone()), - Pending, - ], - ) - .await; - } - - #[tokio::test] - async fn in_recover_info() { - let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); - - async fn check_content(db: &PgPool, p: &InTx) { - sqlx::query( - " - SELECT - uetr IS NOT DISTINCT FROM $1 AND - tx_id IS NOT DISTINCT FROM $2 AND - acct_svcr_ref IS NOT DISTINCT FROM $3 AND - subject IS NOT DISTINCT FROM $4 AND - debit_payto IS NOT DISTINCT FROM $5 - FROM incoming_transactions ORDER BY incoming_transaction_id DESC LIMIT 1 - ", - ) - .bind(p.id.uetr) - .bind(&p.id.tx_id) - .bind(&p.id.sref) - .bind(&p.subject) - .bind(p.debtor.as_ref().map(|it| it.as_ref().as_str())) - .try_map(|r: PgRow| { - assert!(r.try_get_flag(0)?); - Ok(()) - }) - .fetch_one(db) - .await - .unwrap(); - } - - // Non talerable - for (i, id) in [ - InId::new(Some(Uuid::new_v4()), None, None), - InId::new(None, Some(rand_ebics_id()), None), - InId::new(None, None, Some(rand_ebics_id())), - ] - .iter() - .enumerate() - { - let payment = gen_in_pay("subject".to_owned()); - - // Register minimal - let partial = InTx { - id: id.clone(), - subject: None, - debtor: None, - ..payment.clone() - }; - register_incoming(&db, &cfg, &partial).await.unwrap(); - check_content(&db, &partial).await; - check_in_count(&db, i + 1, i, 0).await; - - // Recover ID - let full_id = InId::new( - Some(id.uetr.unwrap_or_else(Uuid::new_v4)), - Some(id.tx_id.clone().unwrap_or_else(rand_ebics_id)), - Some(id.sref.clone().unwrap_or_else(rand_ebics_id)), - ); - let full = InTx { - id: full_id.clone(), - ..partial.clone() - }; - register_incoming(&db, &cfg, &full).await.unwrap(); - check_content(&db, &full).await; - check_in_count(&db, i + 1, i, 0).await; - - // Recover subject & debtor - let full = InTx { - id: full_id, - ..payment.clone() - }; - register_incoming(&db, &cfg, &full).await.unwrap(); - check_content(&db, &full).await; - check_in_count(&db, i + 1, i + 1, 0).await; - } - - // Talerable - for (i, id) in [ - InId::new(Some(Uuid::new_v4()), None, None), - InId::new(None, Some(rand_ebics_id()), None), - InId::new(None, None, Some(rand_ebics_id())), - ] - .iter() - .enumerate() - { - let key = EddsaPublicKey::rand(); - let payment = gen_in_pay(format!("test with {key} reserve pub")); - - // Register minimal - let partial = InTx { - id: id.clone(), - subject: None, - debtor: None, - ..payment.clone() - }; - register_incoming(&db, &cfg, &partial).await.unwrap(); - check_content(&db, &partial).await; - check_in_count(&db, i + 4, 3, i).await; - - // Recover ID - let full_id = InId::new( - Some(id.uetr.unwrap_or_else(Uuid::new_v4)), - Some(id.tx_id.clone().unwrap_or_else(rand_ebics_id)), - Some(id.sref.clone().unwrap_or_else(rand_ebics_id)), - ); - let full = InTx { - id: full_id.clone(), - ..partial.clone() - }; - register_incoming(&db, &cfg, &full).await.unwrap(); - check_content(&db, &full).await; - check_in_count(&db, i + 4, 3, i).await; - - // Recover subject & debtor - let full = InTx { - id: full_id, - ..payment.clone() - }; - register_incoming(&db, &cfg, &full).await.unwrap(); - check_content(&db, &full).await; - check_in_count(&db, i + 4, 3, i + 1).await; - } - } - - #[tokio::test] - pub async fn in_horror() { - let (_, db) = db_setup().await; - let cfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); - - // Check we do not bounce already registered talerable transaction - let key = EddsaPublicKey::rand(); - let payment = gen_in_pay(format!("test with {key} reserve pub")); - register_incoming(&db, &cfg, &payment).await.unwrap(); - assert_eq!( - register_in_malformed( - &db, - &payment, - &amount("KUDOS:2.53"), - &rand_ebics_id(), - &Timestamp::now(), - "manual bounce", - ) - .await - .unwrap(), - IncomingBounceRegistrationResult::Talerable - ); - let incomplete = InTx { - subject: None, - ..payment.clone() - }; - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - register_incoming(&db, &cfg, &payment).await.unwrap(); - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - check_in_state(&db, &[Reserve(key.clone())]).await; - - // Check we do not register as talerable bounced transaction - let new_key = EddsaPublicKey::rand(); - let payment = gen_in_pay(format!("bounced {new_key}")); - let incomplete = InTx { - subject: None, - ..payment.clone() - }; - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - register_incoming(&db, &cfg, &payment).await.unwrap(); - register_incoming(&db, &cfg, &incomplete).await.unwrap(); - register_incoming(&db, &cfg, &payment).await.unwrap(); - check_in_state(&db, &[Reserve(key.clone()), Bounced]).await; - } -} diff --git a/src/dialect.rs b/src/dialect.rs @@ -1,196 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use taler_enum_meta::EnumMeta; - -use crate::ebics::order::{BTF, Order, OrderDoc}; - -/** Supported EBICS standard */ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Standard { - /// Swiss Payment Standards - SIX, - /// German Banking Industry Committee - GBIC, -} - -impl Standard { - pub fn downloads(&self, doc: &OrderDoc) -> Vec<Order> { - match self { - Standard::SIX => match doc { - OrderDoc::acknowledgement => vec![Order::HAC], - OrderDoc::status => vec![Order::BTD(BTF { - service: "PSR".into(), - scope: Some("CH".into()), - option: None, - container: Some("ZIP".into()), - msg: "pain.002".into(), - version: Some("10".into()), - })], - OrderDoc::report => vec![Order::BTD(BTF { - service: "STM".into(), - scope: Some("CH".into()), - option: None, - container: Some("ZIP".into()), - msg: "camt.052".into(), - version: Some("08".into()), - })], - OrderDoc::statement => vec![Order::BTD(BTF { - service: "EOP".into(), - scope: Some("CH".into()), - option: None, - container: Some("ZIP".into()), - msg: "camt.053".into(), - version: Some("08".into()), - })], - OrderDoc::notification => vec![Order::BTD(BTF { - service: "REP".into(), - scope: Some("CH".into()), - option: None, - container: Some("ZIP".into()), - msg: "camt.054".into(), - version: Some("08".into()), - })], - }, - Standard::GBIC => match doc { - OrderDoc::acknowledgement => vec![Order::HAC], - OrderDoc::status => vec![ - Order::BTD(BTF { - service: "REP".into(), - scope: Some("DE".into()), - option: Some("SCI".into()), - container: Some("ZIP".into()), - msg: "pain.002".into(), - version: None, - }), - Order::BTD(BTF { - service: "REP".into(), - scope: Some("DE".into()), - option: Some("SCT".into()), - container: Some("ZIP".into()), - msg: "pain.002".into(), - version: None, - }), - ], - OrderDoc::report => vec![Order::BTD(BTF { - service: "STM".into(), - scope: Some("DE".into()), - option: None, - container: Some("ZIP".into()), - msg: "camt.052".into(), - version: None, - })], - OrderDoc::statement => vec![Order::BTD(BTF { - service: "EOP".into(), - scope: Some("DE".into()), - option: None, - container: Some("ZIP".into()), - msg: "camt.053".into(), - version: None, - })], - OrderDoc::notification => vec![ - Order::BTD(BTF { - service: "STM".into(), - scope: Some("DE".into()), - option: None, - container: Some("ZIP".into()), - msg: "camt.054".into(), - version: None, - }), - Order::BTD(BTF { - service: "STM".into(), - scope: Some("DE".into()), - option: Some("SCI".into()), - container: Some("ZIP".into()), - msg: "camt.054".into(), - version: None, - }), - ], - }, - } - } - - pub fn direct_debit(&self) -> Order { - match self { - Standard::SIX => Order::BTU(BTF { - service: "MCT".into(), - scope: Some("CH".into()), - option: None, - container: None, - msg: "pain.001".into(), - version: Some("09".into()), - }), - Standard::GBIC => Order::BTU(BTF { - service: "SCT".into(), - scope: None, - option: None, - container: None, - msg: "pain.001".into(), - version: None, - }), - } - } - - pub fn instant_direct_debit(&self) -> Option<Order> { - match self { - Standard::SIX => None, - Standard::GBIC => Some(Order::BTU(BTF { - service: "SCI".into(), - scope: Some("DE".into()), - option: None, - container: None, - msg: "pain.001".into(), - version: None, - })), - } - } - - /* - - /** All orders required for a dialect implementation to work */ - fun downloadOrders(): Set<EbicsOrder> = ( - // Administrative orders - sequenceOf(EbicsOrder.V3.HAA, EbicsOrder.V3.HKD) - // and documents orders - + OrderDoc.entries.flatMap { downloadDoc(it) } - ).toSet() */ -} - -/** Supported bank dialects */ -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(Str)] -#[allow(non_camel_case_types)] -pub enum Dialect { - valiant, - raiffeisen, - postfinance, - gls, - maerki_baumann, -} - -impl Dialect { - pub fn standard(&self) -> Standard { - match self { - Self::valiant | Self::raiffeisen | Self::postfinance | Self::maerki_baumann => { - Standard::SIX - } - Self::gls => Standard::GBIC, - } - } -} diff --git a/src/ebics/administrative.rs b/src/ebics/administrative.rs @@ -1,224 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::fmt::Display; - -use compact_str::CompactString; -use taler_common::types::{ - amount::Currency, - iban::{BIC, IBAN}, -}; -use taler_enum_meta::EnumMeta; - -use crate::{ - config::EbicsHostCfg, - ebics::{ - EbicsResponse, - ebics_code::EbicsReturnCode, - order::{BTF, Order}, - }, - xml, - xml::{Xml, XmlAccess as _}, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VersionNumber { - pub number: CompactString, - pub schema: CompactString, -} - -impl Display for VersionNumber { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { number, schema } = self; - write!(f, "{number}:{schema}") - } -} - -pub struct HKD { - pub partner: PartnerInfo, - pub users: Box<[UserInfo]>, -} -pub struct PartnerInfo { - pub name: Option<CompactString>, - pub accounts: Box<[AccountInfo]>, - pub orders: Box<[OrderInfo]>, -} -pub struct OrderInfo { - pub order: Order, - pub description: String, -} -pub struct AccountInfo { - pub currency: Currency, - pub iban: IBAN, - pub bic: BIC, -} -pub struct UserInfo { - pub id: CompactString, - pub status: UserStatus, - pub permissions: Box<[Order]>, -} - -pub struct HAA { - pub orders: Vec<Order>, -} - -#[derive(Debug, Clone, PartialEq, Eq, EnumMeta)] -#[enum_meta(Description)] -pub enum UserStatus { - /// "Subscriber is permitted access" - Ready, - /// "Subscriber is established, pending access permission" - New, - /// "Subscriber has sent INI file, but no HIA file yet" - INI, - /// "Subscriber has sent HIA order, but no INI file yet" - HIA, - /// "Subscriber has sent both HIA order and INI file" - Initialised, - /// "Suspended after several failed attempts, new initialisation via INI and HIA possible" - SuspendedFailedAttempts, - /// "Suspended after SPR order, new initialisation via INI and HIA possible" - SuspendedSPR, - /// "Suspended by bank, new initialisation via INI and HIA is not possible, suspension can only be revoked by the bank" - SuspendedBank, -} - -pub fn hev_msg(cfg: &EbicsHostCfg) -> String { - xml!( - "ebicsHEVRequest" "xmlns"="http://www.ebics.org/H000" { - "HostID": &cfg.host_id - } - ) -} - -pub fn parse_hev(xml: &[u8]) -> xml::Result<EbicsResponse<Box<[VersionNumber]>>> { - Xml::parse(xml, "ebicsHEVResponse", |root| { - let s = root.one("SystemReturnCode")?; - Ok(EbicsResponse { - technical_code: s.one("ReturnCode").parse()?, - technical_text: s.one("ReportText").parse()?, - bank_code: EbicsReturnCode::EBICS_OK, - content: Some( - root.many("VersionNumber") - .map(|n| { - Ok(VersionNumber { - number: n.parse()?, - schema: n.attr("ProtocolVersion")?.into(), - }) - }) - .collect::<xml::Result<_>>()?, - ), - }) - }) -} - -fn service(n: Xml) -> xml::Result<BTF> { - let msg = n.one("MsgName")?; - Ok(BTF { - service: n.one("ServiceName").parse()?, - scope: n.opt("Scope").parse()?, - option: n.opt("ServiceOption").parse()?, - container: n.opt("Container").parse_attr("containerType")?, - msg: msg.parse()?, - version: msg.parse_opt_attr("version")?, - }) -} - -pub fn parse_hkd(xml: &[u8]) -> xml::Result<HKD> { - fn order(n: Xml) -> xml::Result<Order> { - let ty = n.one("AdminOrderType")?.text(); - Order::from_parts(ty, n.opt("Service")?.map(service).transpose()?) - .ok_or_else(|| n.parse_err(format_args!("Unknown order type {ty}"))) - } - Xml::parse(xml, "HKDResponseOrderData", |root| { - let partner = root.one("PartnerInfo")?; - - Ok(HKD { - partner: PartnerInfo { - name: partner.one("AddressInfo").opt("Name").parse()?, - accounts: partner - .many("AccountInfo") - .map(|account| { - let currency = account.parse_attr("Currency")?; - let iban = account - .many("AccountNumber") - .find(|nb| nb.opt_attr("international") == Some("true")) - .unwrap() - .parse()?; - let bic = account - .many("BankCode") - .find(|nb| nb.opt_attr("international") == Some("true")) - .unwrap() - .parse()?; - Ok(AccountInfo { - currency, - iban, - bic, - }) - }) - .collect::<xml::Result<_>>()?, - orders: partner - .many("OrderInfo") - .map(|n| { - Ok(OrderInfo { - order: order(n)?, - description: n.one("Description").parse()?, - }) - }) - .collect::<xml::Result<_>>()?, - }, - users: root - .many("UserInfo") - .map(|n| { - let id = n.one("UserID")?; - Ok(UserInfo { - id: id.parse()?, - status: match id.attr("Status")? { - "1" => UserStatus::Ready, - "2" => UserStatus::New, - "3" => UserStatus::INI, - "4" => UserStatus::HIA, - "5" => UserStatus::Initialised, - "6" => UserStatus::SuspendedFailedAttempts, - // 7 is not applicable per spec - "8" => UserStatus::SuspendedSPR, - "9" => UserStatus::SuspendedBank, - s => return Err(id.parse_err(format_args!("Unknown user status {s}"))), - }, - permissions: n - .many("Permission") - .map(|p| order(p)) - .collect::<xml::Result<_>>()?, - }) - }) - .collect::<xml::Result<_>>()?, - }) - }) -} - -pub fn parse_haa(xml: &[u8]) -> xml::Result<HAA> { - Xml::parse(xml, "HAAResponseOrderData", |root| { - Ok(HAA { - orders: root - .many("Service") - .map(|n| Ok(Order::BTD(service(n)?))) - .collect::<xml::Result<_>>()?, - }) - }) -} diff --git a/src/ebics/bts.rs b/src/ebics/bts.rs @@ -1,496 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -/*! EBICS protocol for business transactions */ - -use compact_str::CompactString; -use jiff::{Timestamp, Zoned, tz::TimeZone}; - -use crate::{ - config::EbicsHostCfg, - crypto::ebics_pub_key_hash, - ebics::{ - EbicsResponse, PreparedUploadData, - ebics_code::EbicsReturnCode, - order::{BTF, Order}, - }, - keys::{BankKeys, ClientKeys}, - utils::b64, - xml, - xml::{Xml, XmlAccess, XmlWriter}, - xml_sign::sign_ebics, -}; - -fn signed_request( - order: &Order, - client: &ClientKeys, - lambda: impl FnOnce(&mut XmlWriter), -) -> String { - let schema = order.schema(); - let doc = xml!( - "ebicsRequest" - "xmlns"=(format_args!("urn:org:ebics:{schema}")) - "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" - "Version"=schema - "Revision"="1" - { - @ lambda - } - ); - sign_ebics(doc, &client.auth) -} - -fn bank_digest(w: &mut XmlWriter, bank: &BankKeys) { - xml!(w => - "BankPubKeyDigests" { - "Authentication" "Version"="X002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256" : b64(ebics_pub_key_hash(&bank.auth.key)), - "Encryption" "Version"="E002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256" : b64(ebics_pub_key_hash(&bank.enc.key)) - }, - "SecurityMedium": "0000" - ) -} - -fn service(w: &mut XmlWriter, service: &BTF) { - let BTF { - service: name, - scope, - msg, - version, - container, - option, - } = service; - xml!(w => - "Service" { - "ServiceName": name, - @ |w: &mut XmlWriter| { - if let Some(scope) = scope { - xml!(w => "Scope": scope) - } - if let Some(option) = option { - xml!(w => "ServiceOption": option) - } - if let Some(container) = container { - xml!(w => "Container" "containerType"=container) - } - - if let Some(version) = version { - xml!(w => "MsgName" "version"=version : msg) - } else { - xml!(w => "MsgName": msg) - } - } - } - ) -} - -pub fn d_init( - cfg: &EbicsHostCfg, - bank: &BankKeys, - client: &ClientKeys, - order: &Order, - range: &Option<(Timestamp, Timestamp)>, -) -> String { - let nonce: u128 = rand::random(); - signed_request(order, client, |w| { - xml!(w => - "header" "authenticate"="true" { - "static" { - "HostID": cfg.host_id, - "Nonce": format_args!("{:032x}", nonce), - "Timestamp": jiff::Timestamp::now(), - "PartnerID": cfg.partner_id, - "UserID": cfg.user_id, - "OrderDetails" { - "AdminOrderType": order.ty(), - @ |w: &mut XmlWriter| if let Order::BTD(s) = order { - xml!(w => "BTDOrderParams" { - @ |w: &mut XmlWriter| { - service(w, s); - if let Some((start, end)) = range { - xml!(w => - "DateRange" { - "Start": Zoned::new(*start, TimeZone::UTC).date(), - "End": Zoned::new(*end, TimeZone::UTC).date() - } - ) - } - } - }) - } else { - xml!(w => "StandardOrderParams") - } - }, - @ |w: &mut XmlWriter| bank_digest(w, bank) - }, - "mutable" { - "TransactionPhase": "Initialisation" - } - }, - "AuthSignature", - "body" - ) - }) -} - -pub fn d_transfer( - cfg: &EbicsHostCfg, - client: &ClientKeys, - order: &Order, - nb_segment: usize, - segment_nb: usize, - tx_id: &str, -) -> String { - signed_request(order, client, |w| { - xml!(w => - "header" "authenticate"="true" { - "static" { - "HostID": cfg.host_id, - "TransactionID": tx_id - }, - "mutable" { - "TransactionPhase": "Transfer", - "SegmentNumber" "lastSegment"=(nb_segment == segment_nb) : segment_nb - } - }, - "AuthSignature", - "body" - ) - }) -} - -pub fn receipt( - cfg: &EbicsHostCfg, - client: &ClientKeys, - order: &Order, - tx_id: &str, - success: bool, -) -> String { - signed_request(order, client, |w| { - xml!(w => - "header" "authenticate"="true" { - "static" { - "HostID": cfg.host_id, - "TransactionID": tx_id - }, - "mutable" { - "TransactionPhase": "Receipt" - } - }, - "AuthSignature", - "body" { - "TransferReceipt" "authenticate"="true" { - "ReceiptCode": (if success { "0" } else { "1"}) - } - } - ) - }) -} - -pub fn u_init( - cfg: &EbicsHostCfg, - bank: &BankKeys, - client: &ClientKeys, - order: &Order, - data: &PreparedUploadData, -) -> String { - let nonce: u128 = rand::random(); - signed_request(order, client, |w| { - xml!(w => - "header" "authenticate"="true" { - "static" { - "HostID": cfg.host_id, - "Nonce": format_args!("{:032x}", nonce), - "Timestamp": jiff::Timestamp::now(), - "PartnerID": cfg.partner_id, - "UserID": cfg.user_id, - "OrderDetails" { - "AdminOrderType": order.ty(), - @ |w: &mut XmlWriter| if let Order::BTU(s) = order { - xml!(w => "BTUOrderParams" { - @ |w: &mut XmlWriter| service(w, s), - "SignatureFlag" - }) - } else { - xml!(w => "StandardOrderParams") - } - }, - @ |w: &mut XmlWriter| bank_digest(w, bank), - "NumSegments": data.nb_segments() - }, - "mutable" { - "TransactionPhase": "Initialisation" - } - }, - "AuthSignature", - "body" { - "DataTransfer" { - "DataEncryptionInfo" "authenticate"="true" { - "EncryptionPubKeyDigest" "Version"="E002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256": b64(ebics_pub_key_hash(&bank.enc.key)), - "TransactionKey": b64(&data.encrypted_key) - }, - "SignatureData" "authenticate"="true" : data.signature_data, - "DataDigest" "SignatureVersion"="A006" : b64(data.digest) - } - } - ) - }) -} - -pub fn u_transfer( - cfg: &EbicsHostCfg, - client: &ClientKeys, - order: &Order, - tx_id: &str, - data: &PreparedUploadData, - segment_nb: usize, -) -> String { - signed_request(order, client, |w| { - xml!(w => - "header" "authenticate"="true" { - "static" { - "HostID": cfg.host_id, - "TransactionID": tx_id - }, - "mutable" { - "TransactionPhase": "Transfer", - "SegmentNumber" "lastSegment"=(data.nb_segments() == segment_nb) : segment_nb - } - }, - "AuthSignature", - "body" { - "DataTransfer" { - "OrderData": data.segment(segment_nb) - } - } - ) - }) -} - -pub struct DataEncryptionInfo { - pub tx_key: Vec<u8>, - pub bank_pub_digest: Vec<u8>, -} - -fn expect_phase(n: Xml<'_>, phase: &str) -> xml::Result<()> { - let n = n.one("TransactionPhase")?; - if n.text() != phase { - Err(n.parse_err(format_args!("Expected phase '{phase}' got '{}'", n.text()))) - } else { - Ok(()) - } -} - -pub struct DInit { - pub tx_id: CompactString, - pub data_encryption_info: DataEncryptionInfo, - pub segment: Vec<u8>, - pub nb_segments: usize, -} - -pub fn parse_d_init(xml: &[u8]) -> xml::Result<EbicsResponse<DInit>> { - Xml::parse(xml, "ebicsResponse", |root| { - let header = root.one_signed("header")?; - let st = header.one("static")?; - let mutable = header.one("mutable")?; - let body = root.one("body")?; - - let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; - let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; - let technical_text = mutable.one("ReportText").parse()?; - - if technical_code.is_error() || bank_code.is_error() { - return Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: None, - }); - } - - expect_phase(mutable, "Initialisation")?; - - let data: Xml<'_> = body.one("DataTransfer")?; - let enc_info = data.one_signed("DataEncryptionInfo")?; - Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: Some(DInit { - tx_id: st.one("TransactionID").parse()?, - data_encryption_info: DataEncryptionInfo { - tx_key: enc_info.one("TransactionKey").b64()?, - bank_pub_digest: enc_info.one("EncryptionPubKeyDigest").b64()?, - }, - segment: data.one("OrderData").b64()?, - nb_segments: st.one("NumSegments").parse()?, - }), - }) - }) -} - -pub struct DTransfer { - pub tx_id: CompactString, - pub segment: Vec<u8>, - pub nb_segments: usize, -} - -pub fn parse_d_transfer(xml: &[u8]) -> xml::Result<EbicsResponse<DTransfer>> { - Xml::parse(xml, "ebicsResponse", |root| { - let header = root.one_signed("header")?; - let st = header.one("static")?; - let mutable = header.one("mutable")?; - let body = root.one("body")?; - - let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; - let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; - let technical_text = mutable.one("ReportText").parse()?; - - if technical_code.is_error() || bank_code.is_error() { - return Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: None, - }); - } - - expect_phase(mutable, "Transfer")?; - - Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: Some(DTransfer { - tx_id: st.one("TransactionID").parse()?, - segment: body.one("DataTransfer").one("OrderData").b64()?, - nb_segments: st.one("NumSegments").parse()?, - }), - }) - }) -} - -pub struct Receipt { - pub tx_id: CompactString, -} - -pub fn parse_receipt(xml: &[u8]) -> xml::Result<EbicsResponse<Receipt>> { - Xml::parse(xml, "ebicsResponse", |root| { - let header = root.one_signed("header")?; - let st = header.one("static")?; - let mutable = header.one("mutable")?; - let body = root.one("body")?; - - let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; - let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; - let technical_text = mutable.one("ReportText").parse()?; - - if technical_code.is_error() || bank_code.is_error() { - return Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: None, - }); - } - - expect_phase(mutable, "Receipt")?; - - Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: Some(Receipt { - tx_id: st.one("TransactionID").parse()?, - }), - }) - }) -} - -pub struct U { - pub tx_id: CompactString, - pub order_id: CompactString, -} - -pub fn parse_u_init(xml: &[u8]) -> xml::Result<EbicsResponse<U>> { - Xml::parse(xml, "ebicsResponse", |root| { - let header = root.one_signed("header")?; - let st = header.one("static")?; - let mutable = header.one("mutable")?; - let body = root.one("body")?; - - let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; - let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; - let technical_text = mutable.one("ReportText").parse()?; - - if technical_code.is_error() || bank_code.is_error() { - return Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: None, - }); - } - - expect_phase(mutable, "Initialisation")?; - - Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: Some(U { - order_id: mutable.one("OrderID").parse()?, - tx_id: st.one("TransactionID").parse()?, - }), - }) - }) -} - -pub fn parse_u_transfer(xml: &[u8]) -> xml::Result<EbicsResponse<U>> { - Xml::parse(xml, "ebicsResponse", |root| { - let header = root.one_signed("header")?; - let st = header.one("static")?; - let mutable = header.one("mutable")?; - let body = root.one("body")?; - - let bank_code: EbicsReturnCode = body.one_signed("ReturnCode").parse()?; - let technical_code: EbicsReturnCode = mutable.one("ReturnCode").parse()?; - let technical_text = mutable.one("ReportText").parse()?; - - if technical_code.is_error() || bank_code.is_error() { - return Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: None, - }); - } - - expect_phase(mutable, "Transfer")?; - - Ok(EbicsResponse { - technical_code, - bank_code, - technical_text, - content: Some(U { - order_id: mutable.one("OrderID").parse()?, - tx_id: st.one("TransactionID").parse()?, - }), - }) - }) -} diff --git a/src/ebics/ebics_code.rs b/src/ebics/ebics_code.rs @@ -1,210 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use taler_enum_meta::EnumMeta; - -/// EBICS Error Class (First two digits of the return code) -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EbicsKind { - /// 00 - Success / General Information - Information, - /// 01 - Positive notification, but action might be required - Note, - /// 03 - Warning - Warning, - /// 06 - Recoverable Error - RecoverableError, - /// 09 - Non-recoverable Error - NonRecoverableError, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(DomainCode, Str)] -#[allow(non_camel_case_types)] -pub enum EbicsReturnCode { - // --- 00: Information --- - #[code = "000000"] - EBICS_OK, - - // --- 01: Notes --- - #[code = "011000"] - EBICS_DOWNLOAD_POSTPROCESS_DONE, - #[code = "011001"] - EBICS_DOWNLOAD_POSTPROCESS_SKIPPED, - #[code = "011101"] - EBICS_TX_SEGMENT_NUMBER_UNDERRUN, - #[code = "011301"] - EBICS_NO_ONLINE_CHECKS, - - // --- 03: Warnings --- - #[code = "031001"] - EBICS_ORDER_PARAMS_IGNORED, - - // --- 06: Technical Errors (Recoverable) --- - #[code = "061001"] - EBICS_AUTHENTICATION_FAILED, - #[code = "061002"] - EBICS_INVALID_REQUEST, - #[code = "061099"] - EBICS_INTERNAL_ERROR, - #[code = "061101"] - EBICS_TX_RECOVERY_SYNC, - - // --- 09: Business Errors (Non-Recoverable) --- - #[code = "090003"] - EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED, - #[code = "090004"] - EBICS_INVALID_ORDER_DATA_FORMAT, - #[code = "090005"] - EBICS_NO_DOWNLOAD_DATA_AVAILABLE, - #[code = "090006"] - EBICS_UNSUPPORTED_REQUEST_FOR_ORDER_INSTANCE, - - // --- 09: Transaction Administration --- - #[code = "091002"] - EBICS_INVALID_USER_OR_USER_STATE, - #[code = "091003"] - EBICS_USER_UNKNOWN, - #[code = "091004"] - EBICS_INVALID_USER_STATE, - #[code = "091005"] - EBICS_INVALID_ORDER_TYPE, - #[code = "091006"] - EBICS_UNSUPPORTED_ORDER_TYPE, - #[code = "091007"] - EBICS_DISTRIBUTED_SIGNATURE_AUTHORISATION_FAILED, - #[code = "091008"] - EBICS_BANK_PUBKEY_UPDATE_REQUIRED, - #[code = "091009"] - EBICS_SEGMENT_SIZE_EXCEEDED, - #[code = "091010"] - EBICS_INVALID_XML, - #[code = "091011"] - EBICS_INVALID_HOST_ID, - - // --- 09: Transaction Processing --- - #[code = "091101"] - EBICS_TX_UNKNOWN_TXID, - #[code = "091102"] - EBICS_TX_ABORT, - #[code = "091103"] - EBICS_TX_MESSAGE_REPLAY, - #[code = "091104"] - EBICS_TX_SEGMENT_NUMBER_EXCEEDED, - #[code = "091105"] - EBICS_RECOVERY_NOT_SUPPORTED, - #[code = "091111"] - EBICS_INVALID_SIGNATURE_FILE_FORMAT, - #[code = "091112"] - EBICS_INVALID_ORDER_PARAMS, - #[code = "091113"] - EBICS_INVALID_REQUEST_CONTENT, - #[code = "091114"] - EBICS_ORDERID_UNKNOWN, - #[code = "091115"] - EBICS_ORDERID_ALREADY_FINAL, - #[code = "091116"] - EBICS_PROCESSING_ERROR, - #[code = "091117"] - EBICS_MAX_ORDER_DATA_SIZE_EXCEEDED, - #[code = "091118"] - EBICS_MAX_SEGMENTS_EXCEEDED, - #[code = "091119"] - EBICS_MAX_TRANSACTIONS_EXCEEDED, - #[code = "091120"] - EBICS_PARTNER_ID_MISMATCH, - #[code = "091121"] - EBICS_INCOMPATIBLE_ORDER_ATTRIBUTE, - #[code = "091122"] - EBICS_ORDER_ALREADY_EXISTS, - - // --- 09: Key Management (X.509 & Keys) --- - #[code = "091201"] - EBICS_KEYMGMT_UNSUPPORTED_VERSION_SIGNATURE, - #[code = "091202"] - EBICS_KEYMGMT_UNSUPPORTED_VERSION_AUTHENTICATION, - #[code = "091203"] - EBICS_KEYMGMT_UNSUPPORTED_VERSION_ENCRYPTION, - #[code = "091204"] - EBICS_KEYMGMT_KEYLENGTH_ERROR_SIGNATURE, - #[code = "091205"] - EBICS_KEYMGMT_KEYLENGTH_ERROR_AUTHENTICATION, - #[code = "091206"] - EBICS_KEYMGMT_KEYLENGTH_ERROR_ENCRYPTION, - #[code = "091207"] - EBICS_KEYMGMT_NO_X509_SUPPORT, - #[code = "091208"] - EBICS_X509_CERTIFICATE_EXPIRED, - #[code = "091209"] - EBICS_X509_CERTIFICATE_NOT_VALID_YET, - #[code = "091210"] - EBICS_X509_WRONG_KEY_USAGE, - #[code = "091211"] - EBICS_X509_WRONG_ALGORITHM, - #[code = "091212"] - EBICS_X509_INVALID_THUMBPRINT, - #[code = "091213"] - EBICS_X509_CTL_INVALID, - #[code = "091214"] - EBICS_X509_UNKNOWN_CERTIFICATE_AUTHORITY, - #[code = "091215"] - EBICS_X509_INVALID_POLICY, - #[code = "091216"] - EBICS_X509_INVALID_BASIC_CONSTRAINTS, - #[code = "091217"] - EBICS_ONLY_X509_SUPPORT, - #[code = "091218"] - EBICS_KEYMGMT_DUPLICATE_KEY, - #[code = "091219"] - EBICS_CERTIFICATES_VALIDATION_ERROR, - - // --- 09: Pre-verification / Signature Logic --- - #[code = "091301"] - EBICS_SIGNATURE_VERIFICATION_FAILED, - #[code = "091302"] - EBICS_ACCOUNT_AUTHORISATION_FAILED, - #[code = "091303"] - EBICS_AMOUNT_CHECK_FAILED, - #[code = "091304"] - EBICS_SIGNER_UNKNOWN, - #[code = "091305"] - EBICS_INVALID_SIGNER_STATE, - #[code = "091306"] - EBICS_DUPLICATE_SIGNATURE, -} - -impl EbicsReturnCode { - /// Automatically classifies the severity/kind based on standard EBICS prefixes. - pub fn kind(&self) -> EbicsKind { - match &self.code()[..2] { - "00" => EbicsKind::Information, - "01" => EbicsKind::Note, - "03" => EbicsKind::Warning, - "06" => EbicsKind::RecoverableError, - "09" => EbicsKind::NonRecoverableError, - prefix => unreachable!("Internal parser mapping error {prefix}"), - } - } - - pub fn is_error(&self) -> bool { - matches!( - self.kind(), - EbicsKind::RecoverableError | EbicsKind::NonRecoverableError - ) - } -} diff --git a/src/ebics/key_management.rs b/src/ebics/key_management.rs @@ -1,278 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{borrow::Cow, io::Write as _}; - -use anyhow::bail; -use aws_lc_rs::encoding::{AsDer, Pkcs8V1Der}; -use base64::{Engine as _, prelude::BASE64_STANDARD}; -use flate2::{Compression, write::ZlibEncoder}; -use tracing::info; - -use crate::{ - config::{EbicsHostCfg, EbicsKeysCfg}, - crypto::{rsa_private_from_b64_x509_certificate, x509_certificate_from_rsa_private}, - ebics::{ - EbicsClient, EbicsCtx, EbicsErrKind, EbicsError, EbicsErrorHelper, EbicsResponse, - bts::DataEncryptionInfo, decrypt_and_decompress_payload, ebics_code::EbicsReturnCode, - order::Order, - }, - keys::{self, BankKeys, ClientKeys, RsaPub}, - xml, - xml::{Xml, XmlAccess as _, XmlWriter}, - xml_sign::sign_ebics, -}; - -impl EbicsClient { - /** Perform an EBICS public key management [order] using [client] and update on disk state */ - pub async fn submit_client_keys( - &self, - cfg: &EbicsKeysCfg, - client: &mut ClientKeys, - order: Order, - ) -> Result<(), EbicsError> { - let ctx = EbicsCtx::new(&order); - if !matches!(order, Order::INI | Order::HIA) { - unreachable!("Only INI & HIA are supported for client keys"); - } - let res = self.key_management(client, &order).await?; - - if res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_STATE - || res.technical_code == EbicsReturnCode::EBICS_INVALID_USER_OR_USER_STATE - { - return Err(EbicsErrKind::Custom(Cow::Owned(format!( - "status code {}: either your IDs are incorrect, or you already have keys registered with this bank", - res.technical_code - ))).ctx(&ctx)); - } - res.ok_or_fail().ctx(&ctx)?; - match order { - Order::INI => client.submitted_ini = true, - Order::HIA => client.submitted_hia = true, - _ => unreachable!("Only INI & HIA are supported for client keys"), - } - keys::persist_client_keys(client, cfg.client_priv_keys_path.as_ref()).ctx(&ctx)?; - // TODO better error: Could not update the $order state on disk - Ok(()) - } - - /** Perform an EBICS private key management HPB using [client] */ - pub async fn hpb(&self, client: &ClientKeys) -> anyhow::Result<BankKeys> { - let order = Order::HPB; - let res = self.key_management(client, &order).await?; - if res.technical_code == EbicsReturnCode::EBICS_AUTHENTICATION_FAILED { - bail!( - "{order} status code {}: could not download bank keys, send client keys (and/or related PDF document with --generate-registration-pdf) to the bank", - res.technical_code - ) - } - let order_data = res.ok_or_fail()?.expect("{order}: missing order data"); - - Ok(Xml::parse(&order_data, "HPBResponseOrderData", |root| { - let auth_pub = root.one("AuthenticationPubKeyInfo")?; - let version = auth_pub.one("AuthenticationVersion")?.text(); - assert_eq!( - version, "X002", - "Expected authentication version X002 got unsupported {version}" - ); - let auth_pub = rsa_pub_key(auth_pub)?; - - let enc_pub = root.one("EncryptionPubKeyInfo")?; - let version = enc_pub.one("EncryptionVersion")?.text(); - assert_eq!( - version, "E002", - "Expected encryption version E002 got unsupported {version}" - ); - let enc_pub = rsa_pub_key(enc_pub)?; - - Ok(BankKeys { - auth: auth_pub, - enc: enc_pub, - accepted: false, - }) - })?) - } - - async fn key_management( - &self, - client: &ClientKeys, - order: &Order, - ) -> Result<EbicsResponse<Option<Vec<u8>>>, EbicsError> { - let EbicsHostCfg { - host_id, - user_id, - partner_id, - .. - } = &self.cfg; - let ctx = EbicsCtx::new(order); - info!("Doing key request {order}"); - - let (name, security_medium) = match order { - Order::INI | Order::HIA => ("ebicsUnsecuredRequest", "0200"), - Order::HPB => ("ebicsNoPubKeyDigestsRequest", "0000"), - _ => unreachable!(), - }; - - fn xml_order_data( - cfg: &EbicsHostCfg, - name: &str, - schema: &str, - build: impl FnOnce(&mut XmlWriter), - ) -> String { - let xml = xml!(name "xmlns"=schema "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { - @ build, - "PartnerID": &cfg.partner_id, - "UserID": &cfg.user_id - }); - // Deflate TODO write inside the compressor directly - let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(xml.as_bytes()).unwrap(); - let compressed = encoder.finish().unwrap(); - BASE64_STANDARD.encode(&compressed) - } - - let data = match order { - Order::INI => Some(xml_order_data( - &self.cfg, - "SignaturePubKeyOrderData", - "http://www.ebics.org/S002", - |w| { - xml!(w => "SignaturePubKeyInfo" { - @ |w| rsa_key_xml(w, &client.sign), - "SignatureVersion": "A006" - }) - }, - )), - Order::HIA => Some(xml_order_data( - &self.cfg, - "HIARequestOrderData", - "urn:org:ebics:H005", - |w| { - xml!(w => - "AuthenticationPubKeyInfo" { - @ |w| rsa_key_xml(w, &client.auth), - "AuthenticationVersion": "X002" - }, - "EncryptionPubKeyInfo" { - @ |w| rsa_key_xml(w, &client.enc), - "EncryptionVersion": "E002" - } - ) - }, - )), - Order::HPB => None, - _ => unreachable!(), - }; - let sign = matches!(order, Order::HPB); - let msg = xml!( - name - "xmlns"="urn:org:ebics:H005" - "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" - "Version"="H005" - "Revision"="1" - { - "header" "authenticate"="true" { - "static" { - "HostID": host_id, - @ |w: &mut XmlWriter| if *order == Order::HPB { - let nonce: u128 = rand::random(); - xml!(w => - "Nonce": format_args!("{:032x}", nonce), - "Timestamp": jiff::Timestamp::now() - ) - }, - "PartnerID": partner_id, - "UserID": user_id, - "OrderDetails" { - "AdminOrderType": order - }, - "SecurityMedium": security_medium - }, - "mutable" - }, - @ |w: &mut XmlWriter| if sign { - xml!(w => "AuthSignature") - }, - "body" { - @ |w: &mut XmlWriter| if let Some(data) = data { - xml!(w => "DataTransfer" { - "OrderData": data - }) - } - } - } - ); - let signed = if sign { - sign_ebics(msg, &client.auth) - } else { - msg - }; - let res = self.post_to_bank(signed, &ctx).await?; - Xml::parse(&res, "ebicsKeyManagementResponse", |root| { - let body = root.one("body")?; - let mutable = root.one_signed("header").one("mutable")?; - Ok(EbicsResponse { - technical_code: mutable.one("ReturnCode").parse()?, - technical_text: mutable.one("ReportText").parse()?, - bank_code: body.one_signed("ReturnCode").parse()?, - content: Some(if let Some(data) = body.opt("DataTransfer")? { - let info = data.one_signed("DataEncryptionInfo")?; - let info = DataEncryptionInfo { - tx_key: info.one("TransactionKey").b64()?, - bank_pub_digest: info.one("EncryptionPubKeyDigest").b64()?, - }; - let chunk = data.one("OrderData").b64()?; - let decoded = decrypt_and_decompress_payload(&client.enc, info, vec![chunk]); - Some(decoded) - } else { - None - }), - }) - }) - .ctx(&ctx) - } -} - -pub fn rsa_pub_key(xml: Xml) -> xml::Result<RsaPub> { - xml.one("X509Data") - .one("X509Certificate") - .decode(rsa_private_from_b64_x509_certificate) -} - -pub fn rsa_key_xml<K>(w: &mut XmlWriter, key: &K) -where - K: AsDer<Pkcs8V1Der<'static>>, -{ - let der = key.as_der().unwrap(); - let b64 = BASE64_STANDARD.encode(der.as_ref()); - let lines = b64 - .as_bytes() - .chunks(64) - .map(|c| std::str::from_utf8(c).unwrap()) - .collect::<Vec<_>>() - .join("\n"); - let pem = format!("-----BEGIN RSA PRIVATE KEY-----\n{lines}\n-----END RSA PRIVATE KEY-----\n"); - let cert = x509_certificate_from_rsa_private(&pem, "LibEuFin EBICS").unwrap(); - let der = cert.der(); - let b64 = BASE64_STANDARD.encode(der.as_ref()); - - xml!(w => "ds:X509Data" { - "ds:X509Certificate": b64 - }) -} diff --git a/src/ebics/mod.rs b/src/ebics/mod.rs @@ -1,1227 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{borrow::Cow, io::Write as _}; - -use aws_lc_rs::{digest::Digest, rsa::PrivateDecryptingKey}; -use base64::{Engine, prelude::BASE64_STANDARD}; -use compact_str::CompactString; -use flate2::write::ZlibDecoder; -use jiff::Timestamp; -use rand::{RngExt as _, distr::Alphanumeric}; -use reqwest::{ - Client, ClientBuilder, StatusCode, - header::{CONTENT_TYPE, HeaderValue}, -}; -use sqlx::PgPool; -use tracing::{debug, info, trace, warn}; - -use crate::{ - EbicsLogs, - config::{EbicsHostCfg, NexusCfg}, - crypto::{ - decrypt_ebics_e002, decrypt_ebics_e002_key, digest_ebics_order_a006, encrypt_ebics_e002, - gen_ebics_e002_key, sign_ebics_a006, - }, - db::{ebics_first, ebics_register, ebics_remove}, - ebics::{ - administrative::{HAA, HKD, VersionNumber, hev_msg, parse_haa, parse_hev, parse_hkd}, - bts::{ - DInit, DTransfer, DataEncryptionInfo, U, d_init, d_transfer, parse_d_init, - parse_d_transfer, parse_receipt, parse_u_init, parse_u_transfer, receipt, u_init, - u_transfer, - }, - ebics_code::EbicsReturnCode, - logger::EbicsLogger, - order::Order, - }, - keys::{BankKeys, ClientKeys}, - utils::{b64, deflate}, - xml, -}; - -pub mod administrative; -pub mod bts; -pub mod ebics_code; -pub mod key_management; -pub mod logger; -pub mod order; - -#[derive(Debug, Clone, Copy)] -pub enum Phase { - Interrupt, - Init, - Transfer(usize), - Process, - Receipt, -} - -impl std::fmt::Display for Phase { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Phase::Interrupt => f.write_str("interrupt"), - Phase::Init => f.write_str("init"), - Phase::Transfer(i) => write!(f, "transfer{i}"), - Phase::Process => f.write_str("process"), - Phase::Receipt => f.write_str("receipt"), - } - } -} - -#[derive(Debug)] -pub struct EbicsCtx<'a> { - pub now: Timestamp, - pub order: Cow<'a, Order>, - pub phase: Option<Phase>, - pub tx_id: Option<CompactString>, -} - -impl<'a> EbicsCtx<'a> { - pub fn new(order: &'a Order) -> Self { - Self { - now: Timestamp::now(), - order: Cow::Borrowed(order), - phase: None, - tx_id: None, - } - } - - pub fn init(self) -> Self { - Self { - phase: Some(Phase::Init), - tx_id: None, - ..self - } - } - - pub fn interrupt(self, id: &str) -> Self { - Self { - phase: Some(Phase::Interrupt), - tx_id: Some( - self.tx_id - .filter(|it| it != id) - .unwrap_or_else(|| id.into()), - ), - ..self - } - } - - pub fn transfer(self, id: &str, segment: usize) -> Self { - Self { - phase: Some(Phase::Transfer(segment)), - tx_id: Some( - self.tx_id - .filter(|it| it != id) - .unwrap_or_else(|| id.into()), - ), - ..self - } - } - - pub fn process(self, id: &str) -> Self { - Self { - phase: Some(Phase::Process), - tx_id: Some( - self.tx_id - .filter(|it| it != id) - .unwrap_or_else(|| id.into()), - ), - ..self - } - } - - pub fn receipt(self, id: &str) -> Self { - Self { - phase: Some(Phase::Receipt), - tx_id: Some( - self.tx_id - .filter(|it| it != id) - .unwrap_or_else(|| id.into()), - ), - ..self - } - } -} - -impl std::fmt::Display for EbicsCtx<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { - order, - phase, - tx_id, - .. - } = self; - write!(f, "{order}")?; - if let Some(phase) = phase { - write!(f, " {phase}")?; - } - if let Some(tx_id) = tx_id { - write!(f, " {tx_id}")?; - } - Ok(()) - } -} - -#[derive(Debug, thiserror::Error)] -pub struct EbicsError { - pub ctx: Box<EbicsCtx<'static>>, - pub kind: EbicsErrKind, -} - -impl std::fmt::Display for EbicsError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { ctx, kind } = self; - write!(f, "{ctx}: {kind}") - } -} - -fn fmt_code( - f: &mut std::fmt::Formatter<'_>, - technical: &EbicsReturnCode, - bank: &EbicsReturnCode, -) -> std::fmt::Result { - if technical.is_error() { - write!(f, "technical error: {technical}") - } else { - write!(f, "technical error: {bank}") - } -} - -pub trait EbicsErrorHelper<T> { - fn ctx(self, ctx: &EbicsCtx<'_>) -> Result<T, EbicsError>; -} - -impl<T, E: Into<EbicsErrKind>> EbicsErrorHelper<T> for Result<T, E> { - fn ctx(self, ctx: &EbicsCtx<'_>) -> Result<T, EbicsError> { - self.map_err(|e| e.into().ctx(ctx)) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum EbicsErrKind { - #[error(transparent)] - Network(#[from] reqwest::Error), - - #[error(transparent)] - IO(#[from] std::io::Error), - - #[error("ebics HTTP error {0}")] - HTTP(StatusCode), - - #[error(transparent)] - XML(#[from] xml::Error), - - #[error("{}", std::fmt::from_fn(|f| fmt_code(f, technical, bank)))] - Code { - technical: EbicsReturnCode, - bank: EbicsReturnCode, - }, - - #[error(transparent)] - Db(#[from] sqlx::Error), - - #[error(transparent)] - Zip(#[from] zip::result::ZipError), - - #[error("{0}")] - Custom(Cow<'static, str>), -} - -impl EbicsErrKind { - pub fn ctx(self, ctx: &EbicsCtx<'_>) -> EbicsError { - EbicsError { - ctx: Box::new(EbicsCtx { - now: ctx.now, - phase: ctx.phase, - tx_id: ctx.tx_id.clone(), - order: Cow::Owned(ctx.order.as_ref().clone()), - }), - kind: self, - } - } -} -pub struct EbicsResponse<T> { - pub technical_code: EbicsReturnCode, - pub bank_code: EbicsReturnCode, - pub technical_text: String, - pub content: Option<T>, -} - -impl<T> EbicsResponse<T> { - fn ok_or_fail(self) -> Result<T, EbicsErrKind> { - if let Some(content) = self.content - && !self.technical_code.is_error() - && !self.bank_code.is_error() - { - Ok(content) - } else { - Err(EbicsErrKind::Code { - technical: self.technical_code, - bank: self.bank_code, - }) - } - } -} - -pub struct EbicsClient { - cfg: EbicsHostCfg, - pub http: Client, - logger: EbicsLogger, -} - -impl EbicsClient { - pub fn new(cfg: &NexusCfg, log: EbicsLogs) -> anyhow::Result<Self> { - let cfg = cfg.host()?.clone(); - let mut builder = ClientBuilder::new(); - if let Some(unix_path) = &cfg.unix_path { - builder = builder.unix_socket(unix_path.as_str()); - } - Ok(Self { - cfg, - http: builder.build()?, - logger: EbicsLogger::new(log.dir)?, - }) - } - - async fn post_to_bank(&self, xml: String, ctx: &EbicsCtx<'_>) -> Result<Vec<u8>, EbicsError> { - self.logger.log_request(ctx, &xml)?; - let res = self - .http - .post(self.cfg.base_url.as_str()) - .header(CONTENT_TYPE, HeaderValue::from_static("application/xml")) - .body(xml) - .send() - .await - .ctx(ctx)?; - let status = res.status(); - if status != StatusCode::OK { - self.logger.log_failure(ctx, res).await?; - return Err(EbicsErrKind::HTTP(status).ctx(ctx)); - } - let xml = res.bytes().await.ctx(ctx)?; - self.logger.log_response(ctx, &xml)?; - Ok(xml.into()) - } - - /** POST an EBICS BTS request [xmlReq] using [client] returning a validated and parsed XML response */ - pub async fn post_bts<T>( - &self, - xml: String, - ctx: &EbicsCtx<'_>, - parse: impl FnOnce(&[u8]) -> xml::Result<EbicsResponse<T>>, - ) -> Result<T, EbicsError> { - let xml = self.post_to_bank(xml, ctx).await?; - // TODO verify ebics signature - let res = parse(&xml).ctx(ctx)?; - trace!(target: "ebics", - "{ctx}: {} {} - {}", - res.technical_code, - res.bank_code, - res.technical_text - ); - res.ok_or_fail().ctx(ctx) - } - - pub async fn hev(&self) -> Result<Box<[VersionNumber]>, EbicsError> { - let order = Order::HEV; - info!(target: "ebics", "Doing administrative request {order}"); - let msg = hev_msg(&self.cfg); - let ctx = EbicsCtx::new(&order); - let res = self.post_to_bank(msg, &ctx).await?; - parse_hev(&res).ctx(&ctx)?.ok_or_fail().ctx(&ctx) - } - - pub async fn haa( - &self, - db: &PgPool, - client: &ClientKeys, - bank: &BankKeys, - peek: bool, - ) -> Result<HAA, EbicsError> { - self.download( - db, - client, - bank, - &Order::HAA, - &None, - peek, - async |content| Ok(parse_haa(&content)?), - ) - .await - } - - pub async fn hkd( - &self, - db: &PgPool, - client: &ClientKeys, - bank: &BankKeys, - peek: bool, - ) -> Result<HKD, EbicsError> { - self.download( - db, - client, - bank, - &Order::HKD, - &None, - peek, - async |content| Ok(parse_hkd(&content)?), - ) - .await - } - - /** - * 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. - */ - pub async fn download<T>( - &self, - db: &PgPool, - client: &ClientKeys, - bank: &BankKeys, - order: &Order, - range: &Option<(Timestamp, Timestamp)>, - peek: bool, - processing: impl AsyncFnOnce(Vec<u8>) -> Result<T, EbicsErrKind>, - ) -> Result<T, EbicsError> { - let mut ctx = EbicsCtx::new(order); - debug!(target: "ebics", "Downloading order {order} {}", std::fmt::from_fn(|f| { - if let Some((start, end)) = range { - write!(f, " from {start} to {end}")? - } - Ok(()) - })); - - // Close interrupted - while let Some(tx_id) = ebics_first(db).await.ctx(&ctx)? { - let ctx = EbicsCtx::new(order).interrupt(&tx_id); - let xml = receipt(&self.cfg, client, order, &tx_id, false); - if let Err(e) = self.post_bts(xml, &ctx, parse_d_init).await { - if !matches!( - e.kind, - // Transaction already closed or expired - EBICS protocol error - EbicsErrKind::Code { - technical: EbicsReturnCode::EBICS_TX_UNKNOWN_TXID, - .. - } | - // Transaction already closed or expired - HTTP protocol error for non compliant banks - EbicsErrKind::HTTP(StatusCode::BAD_REQUEST) - ) { - return Err(e); - } else { - debug!(target: "ebics", "{e}") - } - } - ebics_remove(db, &tx_id).await.ctx(&ctx)?; - } - - // Init phase - ctx = ctx.init(); - let xml = d_init(&self.cfg, bank, client, order, range); - let DInit { - tx_id, - nb_segments, - segment, - data_encryption_info, - } = self.post_bts(xml, &ctx, parse_d_init).await?; - ebics_register(db, &tx_id).await.ctx(&ctx)?; - - // Transfer phase - let mut segments = vec![segment]; - for segment_nb in 2..=nb_segments { - ctx = ctx.transfer(&tx_id, segment_nb); - let xml = d_transfer(&self.cfg, client, order, nb_segments, segment_nb, &tx_id); - let DTransfer { segment, .. } = self.post_bts(xml, &ctx, parse_d_transfer).await?; - segments.push(segment); - } - - // Processing phase - ctx = ctx.process(&tx_id); - let payload = decrypt_and_decompress_payload(&client.enc, data_encryption_info, segments); - self.logger.log_payload(&ctx, &payload, order.file_type())?; - let res = processing(payload).await.ctx(&ctx); - - // Receipt phase - ctx = ctx.receipt(&tx_id); - let xml = receipt(&self.cfg, client, order, &tx_id, res.is_ok() && !peek); - if let Err(e) = async { - self.post_bts(xml, &ctx, parse_receipt).await?; - ebics_remove(db, &tx_id).await.ctx(&ctx) - } - .await - { - warn!(target: "ebics", "{e}") - } - - res - } - - /** - * Performs an EBICS upload transaction of [order] using [payload]. - * - * It conducts init -> upload phases. - * - * Returns upload orderID - */ - pub async fn upload( - &self, - client: &ClientKeys, - bank: &BankKeys, - order: &Order, - payload: &str, - ) -> Result<CompactString, EbicsError> { - debug!(target: "ebics", "Uploading order {order}"); - let mut ctx = EbicsCtx::new(order); - - self.logger.log_payload(&ctx, payload.as_bytes(), "xml")?; - let payload = prepare_upload_payload(&self.cfg, client, bank, payload); - - // Init phase - ctx = ctx.init(); - let xml = u_init(&self.cfg, bank, client, order, &payload); - let U { tx_id, order_id } = self.post_bts(xml, &ctx, parse_u_init).await?; - - // Transfer phase - for segment_nb in 1..=payload.nb_segments() { - ctx = ctx.transfer(&tx_id, segment_nb); - let xml = u_transfer(&self.cfg, client, order, &tx_id, &payload, segment_nb); - self.post_bts(xml, &ctx, parse_u_transfer).await?; - } - - Ok(order_id) - } -} - -pub struct PreparedUploadData { - encrypted_key: Vec<u8>, - signature_data: String, - digest: Digest, - payload: String, -} - -impl PreparedUploadData { - const CHUNK_SIZE: usize = 1000000; - - pub fn nb_segments(&self) -> usize { - self.payload.len().div_ceil(Self::CHUNK_SIZE) - } - - pub fn segment(&self, nb: usize) -> &str { - let start = (nb - 1) * Self::CHUNK_SIZE; - let end = (start + Self::CHUNK_SIZE).min(self.payload.len()); - &self.payload[start..end] - } -} - -/** Decrypts and decompresses EBICS BTS payload */ -fn decrypt_and_decompress_payload( - client_encryption_key: &PrivateDecryptingKey, - encryption_info: DataEncryptionInfo, - segments: Vec<Vec<u8>>, -) -> Vec<u8> { - // TODO check bank_pub_digest - let tx_key = decrypt_ebics_e002_key(client_encryption_key.clone(), &encryption_info.tx_key); - let mut decoder = ZlibDecoder::new(Vec::new()); - for segment in segments { - let decrypted = decrypt_ebics_e002(&tx_key, segment); - decoder.write_all(&decrypted).unwrap(); - } - decoder.finish().unwrap() -} - -/** Signs, encrypts and format EBICS BTS payload */ -fn prepare_upload_payload( - cfg: &EbicsHostCfg, - client: &ClientKeys, - bank: &BankKeys, - payload: &str, -) -> PreparedUploadData { - let digest = digest_ebics_order_a006(payload.as_bytes()); - - // Generate ephemeral transaction key - let (tx_key, encrypted_key) = gen_ebics_e002_key(bank.enc.enc.clone()); - - // Compress and encrypt order signature - let signature_data = { - let signed = sign_ebics_a006(digest.as_ref(), &client.sign); - let inner_signed_xml = xml!( - "UserSignatureData" "xmlns"="http://www.ebics.org/S002" { - "OrderSignatureData" { - "SignatureVersion": "A006", - "SignatureValue": b64(&signed), - "PartnerID": cfg.partner_id, - "UserID": cfg.user_id - } - } - ); - let deflated = deflate(inner_signed_xml.as_bytes()); - let encrypted = encrypt_ebics_e002(&tx_key, deflated); - BASE64_STANDARD.encode(encrypted) - }; - - // Compress and encrypt payload - let payload = { - let deflated = deflate(payload.as_bytes()); - let encrypted = encrypt_ebics_e002(&tx_key, deflated); - BASE64_STANDARD.encode(encrypted) - }; - PreparedUploadData { - encrypted_key, - signature_data, - digest, - payload, - } -} - -#[derive(Debug)] -pub struct TxCheckResult { - pub concurrent_fetch_and_fetch: bool, - pub concurrent_fetch_and_submit: bool, - pub concurrent_submit_and_submit: bool, - pub idempotent_close: bool, -} - -/** - * 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 - */ -pub async fn tx_check( - ebics: &EbicsClient, - db: &PgPool, - client: &ClientKeys, - bank: &BankKeys, - fetch: &Order, - submit: &Order, -) -> anyhow::Result<TxCheckResult> { - let mut result = TxCheckResult { - concurrent_fetch_and_fetch: false, - concurrent_fetch_and_submit: false, - concurrent_submit_and_submit: false, - idempotent_close: false, - }; - - let ctx = EbicsCtx::new(fetch).init(); - let DInit { tx_id, .. } = ebics - .post_bts( - d_init(&ebics.cfg, bank, client, fetch, &None), - &ctx, - parse_d_init, - ) - .await?; - ebics_register(db, &tx_id).await?; - { - let ctx = EbicsCtx::new(fetch).init(); - match ebics - .post_bts( - d_init(&ebics.cfg, bank, client, fetch, &None), - &ctx, - parse_d_init, - ) - .await - { - Ok(DInit { tx_id, .. }) => { - ebics_register(db, &tx_id).await?; - result.concurrent_fetch_and_fetch = true; - let ctx = ctx.receipt(&tx_id); - ebics - .post_bts( - receipt(&ebics.cfg, client, fetch, &tx_id, false), - &ctx, - parse_receipt, - ) - .await?; - ebics_remove(db, &tx_id).await?; - } - Err(e) => { - if !matches!(e.kind, EbicsErrKind::Code { .. }) { - return Err(e.into()); - } else { - debug!(target: "testing", "concurrent_fetch_and_fetch {e}") - } - } - } - } - - { - let ctx = EbicsCtx::new(submit).init(); - let random_string: String = rand::rng() - .sample_iter(&Alphanumeric) - .take(2000000) - .map(char::from) - .collect(); - let payload = prepare_upload_payload(&ebics.cfg, client, bank, &random_string); - match ebics - .post_bts( - u_init(&ebics.cfg, bank, client, submit, &payload), - &ctx, - parse_u_init, - ) - .await - { - Ok(U { tx_id, .. }) => { - result.concurrent_fetch_and_submit = true; - let ctx = ctx.transfer(&tx_id, 1); - ebics - .post_bts( - u_transfer(&ebics.cfg, client, fetch, &tx_id, &payload, 1), - &ctx, - parse_u_transfer, - ) - .await?; - let ctx = EbicsCtx::new(submit).init(); - if let Err(e) = ebics - .post_bts( - u_init(&ebics.cfg, bank, client, submit, &payload), - &ctx, - parse_u_init, - ) - .await - { - if !matches!(e.kind, EbicsErrKind::Code { .. }) { - return Err(e.into()); - } else { - debug!(target: "testing", "concurrent_submit_and_submit {e}") - } - } else { - result.concurrent_submit_and_submit = true; - } - } - Err(e) => { - if !matches!(e.kind, EbicsErrKind::Code { .. }) { - return Err(e.into()); - } else { - debug!(target: "testing", "concurrent_fetch_and_submit {e}") - } - } - } - } - - // Close first fetch - let ctx = ctx.receipt(&tx_id); - ebics - .post_bts( - receipt(&ebics.cfg, client, fetch, &tx_id, false), - &ctx, - parse_receipt, - ) - .await?; - - ebics_remove(db, &tx_id).await?; - - // Close first fetch again - let ctx = ctx.interrupt(&tx_id); - if let Err(e) = ebics - .post_bts( - receipt(&ebics.cfg, client, fetch, &tx_id, false), - &ctx, - parse_receipt, - ) - .await - { - debug!(target: "testing", "idempotent_close {e}") - } else { - result.idempotent_close = true - } - - Ok(result) -} - -#[cfg(test)] -pub mod test { - use aws_lc_rs::{ - encoding::AsDer, - rsa::{KeyPair, KeySize, PublicEncryptingKey, PublicKey}, - }; - use compact_str::CompactString; - use jiff::{ - Timestamp, Zoned, - civil::{Date, date}, - tz::TimeZone, - }; - - use crate::{ - crypto::{ebics_pub_key_hash, encrypt_ebics_e002, gen_ebics_e002_key}, - ebics::key_management::{rsa_key_xml, rsa_pub_key}, - rand_ebics_id, - utils::{b64, deflate, inflate}, - xml, - xml::{Xml, XmlAccess}, - xml_sign::sign_ebics, - }; - - pub type Sequence = fn(&mut EbicsState, body: &[u8]) -> EbicsRes; - - pub enum EbicsRes { - Ok(String), - BadRequest, - Failure, - } - pub struct EbicsState { - bank_sign: KeyPair, - bank_enc: KeyPair, - bank_auth: KeyPair, - - client_sign: Option<PublicKey>, - client_enc: Option<PublicKey>, - client_auth: Option<PublicKey>, - - tx_id: Option<CompactString>, - order_id: Option<CompactString>, - } - - impl EbicsState { - pub fn new() -> Self { - Self { - bank_sign: KeyPair::generate(KeySize::Rsa2048).unwrap(), - bank_enc: KeyPair::generate(KeySize::Rsa2048).unwrap(), - bank_auth: KeyPair::generate(KeySize::Rsa2048).unwrap(), - client_sign: None, - client_enc: None, - client_auth: None, - tx_id: None, - order_id: None, - } - } - - fn parse_unsecure_request( - body: &[u8], - order: &str, - root: &str, - parse: impl FnOnce(Xml) -> xml::Result<()>, - ) { - Xml::parse(body, "ebicsUnsecuredRequest", |n| { - let admin_order = n - .one("header") - .one("static") - .one("OrderDetails") - .one("AdminOrderType")? - .text(); - assert_eq!(admin_order, order); - let chunk = n.one("body").one("DataTransfer").one("OrderData").b64()?; - let inflated = inflate(&chunk); - Xml::parse(&inflated, root, parse) - }) - .unwrap() - } - - fn parse_download_init(body: &[u8], order: &str) { - Xml::parse(body, "ebicsRequest", |root| { - let header = root.one("header")?; - let admin_order = header - .one("static") - .one("OrderDetails") - .one("AdminOrderType")? - .text(); - assert_eq!(admin_order, order); - let phase = header.one("mutable").one("TransactionPhase")?.text(); - assert_eq!(phase, "Initialisation"); - Ok(()) - }) - .unwrap(); - } - - fn signed_response(&self, xml: String) -> EbicsRes { - EbicsRes::Ok(sign_ebics(xml, &self.bank_auth)) - } - - fn ebics_response_payload(&mut self, payload: &str, last: bool) -> EbicsRes { - let tx_id = self.tx_id.insert(rand_ebics_id()); - let deflated = deflate(payload.as_bytes()); - let client_enc = PublicEncryptingKey::from_der( - self.client_enc.as_ref().unwrap().as_der().unwrap().as_ref(), - ) - .unwrap(); - let (tx_key, encrypted_key) = gen_ebics_e002_key(client_enc); - let encrypted = encrypt_ebics_e002(&tx_key, deflated); - let xml = xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { - "header" "authenticate"="true" { - "static" { - "TransactionID": tx_id, - "NumSegments": "1" - }, - "mutable" { - "TransactionPhase": "Initialisation", - "SegmentNumber" "lastSegment"=last : 1, - "ReturnCode": "000000", - "ReportText": "[EBICS_OK] OK" - } - }, - "AuthSignature", - "body" { - "DataTransfer" { - "DataEncryptionInfo" "authenticate"="true" { - "EncryptionPubKeyDigest" "Version"="E002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256": b64(ebics_pub_key_hash(&self.client_enc.as_ref().unwrap())), - "TransactionKey": b64(&encrypted_key) - }, - "OrderData": b64(&encrypted) - }, - "ReturnCode" "authenticate"="true": "000000" - } - }); - self.signed_response(xml) - } - - fn ebics_response_no_data(&self) -> EbicsRes { - let xml = xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { - "header" "authenticate"="true" { - "static", - "mutable" { - "TransactionPhase": "Initialisation", - "ReturnCode": "000000", - "ReportText": "[EBICS_OK] OK" - } - }, - "AuthSignature", - "body" { - "ReturnCode" "authenticate"="true": "090005" - } - }); - self.signed_response(xml) - } - - pub fn hev(&mut self, body: &[u8]) -> EbicsRes { - Xml::parse(body, "ebicsHEVRequest", |root| { - root.one("HostID")?; - Ok(()) - }) - .unwrap(); - EbicsRes::Ok( - xml!("ebicsHEVResponse" "xmlns"="http://www.ebics.org/H000" { - "SystemReturnCode" { - "ReturnCode": "000000", - "ReportText": "[EBICS_OK] OK" - }, - "VersionNumber" "ProtocolVersion"="H005" : "03.00" - }), - ) - } - - pub fn ini(&mut self, body: &[u8]) -> EbicsRes { - Self::parse_unsecure_request(body, "INI", "SignaturePubKeyOrderData", |root| { - let n = root.one("SignaturePubKeyInfo")?; - assert_eq!(n.one("SignatureVersion")?.text(), "A006"); - self.client_sign = Some(rsa_pub_key(n)?.key); - Ok(()) - }); - EbicsRes::Ok( - xml!("ebicsKeyManagementResponse" "xmlns"="http://www.ebics.org/H000" { - "header" "authenticate"="true" { - "mutable" { - "ReturnCode": "000000", - "ReportText": "[EBICS_OK] OK" - } - }, - "body" { - "ReturnCode" "authenticate"="true" : "000000" - } - }), - ) - } - - pub fn hia(&mut self, body: &[u8]) -> EbicsRes { - Self::parse_unsecure_request(body, "HIA", "HIARequestOrderData", |root| { - let n = root.one("AuthenticationPubKeyInfo")?; - assert_eq!(n.one("AuthenticationVersion")?.text(), "X002"); - self.client_auth = Some(rsa_pub_key(n)?.key); - - let n = root.one("EncryptionPubKeyInfo")?; - assert_eq!(n.one("EncryptionVersion")?.text(), "E002"); - self.client_enc = Some(rsa_pub_key(n)?.key); - Ok(()) - }); - EbicsRes::Ok( - xml!("ebicsKeyManagementResponse" "xmlns"="http://www.ebics.org/H000" { - "header" "authenticate"="true" { - "mutable" { - "ReturnCode": "000000", - "ReportText": "[EBICS_OK] OK" - } - }, - "body" { - "ReturnCode" "authenticate"="true" : "000000" - } - }), - ) - } - - pub fn hpb(&mut self, body: &[u8]) -> EbicsRes { - // Parse HPB request - Xml::parse(body, "ebicsNoPubKeyDigestsRequest", |root| { - let order = root - .one("header") - .one("static") - .one("OrderDetails") - .one("AdminOrderType")? - .text(); - assert_eq!(order, "HPB"); - Ok(()) - }) - .unwrap(); - - let payload = xml!("HPBResponseOrderData" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { - "AuthenticationPubKeyInfo" { - @ |w| rsa_key_xml(w, &self.bank_auth), - "AuthenticationVersion": "X002" - }, - "EncryptionPubKeyInfo" { - @ |w| rsa_key_xml(w, &self.bank_enc), - "EncryptionVersion": "E002" - } - }); - let deflated = deflate(payload.as_bytes()); - let client_enc = PublicEncryptingKey::from_der( - self.client_enc.as_ref().unwrap().as_der().unwrap().as_ref(), - ) - .unwrap(); - let (tx_key, encrypted_key) = gen_ebics_e002_key(client_enc); - let encrypted = encrypt_ebics_e002(&tx_key, deflated); - EbicsRes::Ok( - xml!("ebicsKeyManagementResponse" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" "xmlns"="http://www.ebics.org/H005" { - "header" "authenticate"="true"{ - "mutable" { - "ReturnCode": "000000", - "ReportText": "[EBICS_OK] OK" - } - }, - "body" { - "DataTransfer" { - "DataEncryptionInfo" "authenticate"="true" { - "EncryptionPubKeyDigest" "Version"="E002" "Algorithm"="http://www.w3.org/2001/04/xmlenc#sha256": b64(ebics_pub_key_hash(&self.client_enc.as_ref().unwrap())), - "TransactionKey": b64(&encrypted_key) - }, - "OrderData": b64(&encrypted) - }, - "ReturnCode" "authenticate"="true": "000000" - } - }), - ) - } - - pub fn hkd(&mut self, body: &[u8]) -> EbicsRes { - Self::parse_download_init(body, "HKD"); - self.ebics_response_payload( - &xml!("HKDResponseOrderData" { - "PartnerInfo" { - "AddressInfo", - "OrderInfo" { - "AdminOrderType": "BTD", - "Service" { - "ServiceName": "STM", - "Scope": "CH", - "Container" "containerType"="ZIP", - "MsgName" "version"="08": "camt.052" - }, - "Description" - }, - "OrderInfo" { - "AdminOrderType": "BTU", - "Service" { - "ServiceName": "SCT", - "MsgName": "pain.001" - }, - "Description": "Direct Debit" - }, - "OrderInfo" { - "AdminOrderType": "BTU", - "Service" { - "ServiceName": "SCI", - "Scope": "DE", - "MsgName": "pain.001" - }, - "Description": "Instant Direct Debit" - } - } - }), - true, - ) - } - - pub fn haa(&mut self, body: &[u8]) -> EbicsRes { - Self::parse_download_init(body, "HAA"); - self.ebics_response_payload( - &xml!("HAAResponseOrderData" { - "Service" { - "ServiceName": "STM", - "Scope": "CH", - "Container" "containerType"="ZIP", - "MsgName" "version"="08": "camt.052" - } - }), - true, - ) - } - - fn receipt(&mut self, body: &[u8], ok: bool) -> EbicsRes { - Xml::parse(body, "ebicsRequest", |root| { - let header = root.one("header")?; - let tx_id = header.one("static").one("TransactionID")?.text(); - assert_eq!(tx_id, self.tx_id.as_deref().unwrap()); - let phase = header.one("mutable").one("TransactionPhase")?.text(); - assert_eq!(phase, "Receipt"); - let code = root - .one("body") - .one("TransferReceipt") - .one("ReceiptCode")? - .text(); - if ok { - assert_eq!(code, "0") - } else { - assert_eq!(code, "1") - } - Ok(()) - }) - .unwrap(); - let tx_id = self.tx_id.take().unwrap(); - self.signed_response(xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { - "header" "authenticate"="true" { - "static" { - "TransactionID": tx_id - }, - "mutable" { - "TransactionPhase": "Receipt", - "ReturnCode": "000000", - "ReportText": "[EBICS_OK] OK", - } - }, - "AuthSignature", - "body" { - "ReturnCode" "authenticate"="true": "000000" - } - })) - } - - pub fn receipt_ok(&mut self, body: &[u8]) -> EbicsRes { - self.receipt(body, true) - } - - pub fn receipt_err(&mut self, body: &[u8]) -> EbicsRes { - self.receipt(body, false) - } - - fn btd_date_check(&self, body: &[u8], pinned: Option<Date>) -> EbicsRes { - Xml::parse(body, "ebicsRequest", |root| { - let header = root.one("header")?; - let details = header.one("static").one("OrderDetails")?; - let admin_order = details.one("AdminOrderType")?.text(); - assert_eq!(admin_order, "BTD"); - let start = details - .one("BTDOrderParams") - .opt("DateRange") - .opt("Start") - .parse()?; - assert_eq!(start, pinned); - let phase = header.one("mutable").one("TransactionPhase")?.text(); - assert_eq!(phase, "Initialisation"); - Ok(()) - }) - .unwrap(); - self.ebics_response_no_data() - } - - pub fn btd_no_data(&mut self, body: &[u8]) -> EbicsRes { - self.btd_date_check(body, None) - } - - pub fn btd_no_data_now(&mut self, body: &[u8]) -> EbicsRes { - self.btd_date_check( - body, - Some(Zoned::new(Timestamp::now(), TimeZone::UTC).date()), - ) - } - - pub fn btd_no_data_pinned(&mut self, body: &[u8]) -> EbicsRes { - self.btd_date_check(body, Some(date(2024, 06, 05))) - } - - pub fn btu_init(&mut self, body: &[u8]) -> EbicsRes { - Self::parse_download_init(body, "BTU"); - let tx_id = self.tx_id.insert(rand_ebics_id()); - let order_id = self.order_id.insert(rand_ebics_id()); - let xml = xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { - "header" "authenticate"="true" { - "static" { - "TransactionID": tx_id - }, - "mutable" { - "TransactionPhase": "Initialisation", - "OrderID": order_id, - "ReturnCode": "000000", - "ReportText": "[EBICS_OK] OK", - } - }, - "AuthSignature", - "body" { - "ReturnCode" "authenticate"="true": "000000" - } - }); - self.signed_response(xml) - } - - pub fn btu_payload(&mut self, body: &[u8]) -> EbicsRes { - let tx_id = self.tx_id.as_ref().unwrap(); - let order_id = self.order_id.as_ref().unwrap(); - let segment_nb: CompactString = Xml::parse(body, "ebicsRequest", |root| { - let header = root.one("header")?; - let txid = header.one("static").one("TransactionID")?.text(); - assert_eq!(txid, tx_id); - let mutable = header.one("mutable")?; - let phase = mutable.one("TransactionPhase")?.text(); - assert_eq!(phase, "Transfer"); - mutable.one("SegmentNumber").parse() - }) - .unwrap(); - self.signed_response(xml!("ebicsResponse" "xmlns"="http://www.ebics.org/H005" "xmlns:ds"="http://www.w3.org/2000/09/xmldsig#" { - "header" "authenticate"="true" { - "static" { - "TransactionID": tx_id - }, - "mutable" { - "TransactionPhase": "Transfer", - "SegmentNumber": segment_nb, - "OrderID": order_id, - "ReturnCode": "000000", - "ReportText": "[EBICS_OK] OK", - } - }, - "AuthSignature", - "body" { - "ReturnCode" "authenticate"="true": "000000" - } - })) - } - - pub fn init_tx(&mut self, _: &[u8]) -> EbicsRes { - self.ebics_response_payload("", false) - } - - pub fn failure(&mut self, _: &[u8]) -> EbicsRes { - EbicsRes::Failure - } - - pub fn bad_request(&mut self, _: &[u8]) -> EbicsRes { - EbicsRes::BadRequest - } - } -} diff --git a/src/ebics/order.rs b/src/ebics/order.rs @@ -1,270 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use compact_str::CompactString; -use taler_enum_meta::EnumMeta; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Direction { - Download, - Upload, -} - -#[derive(Debug, Clone)] -pub struct BTF { - pub service: CompactString, - pub scope: Option<CompactString>, - pub option: Option<CompactString>, - pub container: Option<CompactString>, - pub msg: CompactString, - pub version: Option<CompactString>, -} - -impl PartialEq for BTF { - fn eq(&self, other: &Self) -> bool { - self.service == other.service - && self.scope == other.scope - && self.option == other.option - && self.container == other.container - && self.msg == other.msg - // Ignore msg version - } -} - -impl std::fmt::Display for BTF { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let BTF { - service: name, - scope, - option, - container, - msg, - version, - } = self; - write!(f, "{name}")?; - for part in [scope, container, option].into_iter().flatten() { - write!(f, "-{part}")?; - } - write!(f, "-{msg}")?; - if let Some(version) = version { - write!(f, ".{version}")?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum Order { - /// Download of a file identified by a BTF structure (Mandatory) - BTD(BTF), - /// Upload of a file identified by a BTF structure (Mandatory) - BTU(BTF), - /// Download retrievable order types (Optional) - HAA, - /// Download customer acknowledgment (Mandatory) - HAC, - /// Send amendment of the subscriber key for identification and authentication and encryption (Mandatory) - HCA, - /// Transmission of the subscriber key for ES identification and authentication and encryption (Mandatory) - HCS, - /// Download supported EBICS versions (Mandatory) - HEV, - /// Transmission of the subscriber key for identification and authentication and encryption within the framework of subscriber initialization (Mandatory) - HIA, - /// Download customer’s customer and subscriber data (Optional) - HKD, - /// Transfer the public bank key (Mandatory) - HPB, - /// Download bank parameters (Mandatory) - HPD, - /// Download subscriber’s customer and subscriber data (Mandatory) - HTD, - /// Download subscriber’s customer and subscriber data (Optional) - HVD, - /// Add EDSsignature (Mandatory) - HVE, - /// Cancellation of orders in the EDS (Mandatory) - HVS, - /// Retrieve EDS transaction details (Mandatory) - HVT, - /// Download EDS overview (Mandatory) - HVU, - /// Download EDS overview with additional informations (Mandatory) - HVZ, - /// Transmission of all public keys (subscriber key, key for identification and authentication and key for encryption) for initialisation in case of CA-issued certificates (Optional) - H3K, - /// Send password initialization - INI, - /// Send public key for signature verification - PUB, - /// Suspension of access authorisation - SPR, - /// deprecated - PTK, -} - -impl Order { - pub const WSS_PARAMS: Self = Self::BTD(BTF { - service: CompactString::const_new("OTH"), - scope: Some(CompactString::const_new("DE")), - msg: CompactString::const_new("wssparam"), - version: None, - container: None, - option: None, - }); - - pub fn doc(&self) -> Option<OrderDoc> { - match self { - Self::HAC => Some(OrderDoc::acknowledgement), - Self::BTD(BTF { msg, .. }) => match msg.as_str() { - "pain.002" => Some(OrderDoc::status), - "camt.052" => Some(OrderDoc::report), - "camt.053" => Some(OrderDoc::statement), - "camt.054" => Some(OrderDoc::notification), - _ => None, - }, - _ => None, - } - } - - /** Check if EBICS order is a downloadable one */ - pub fn is_downloadable(&self) -> bool { - matches!( - self.doc(), - Some(OrderDoc::acknowledgement) - | Some(OrderDoc::status) - | Some(OrderDoc::report) - | Some(OrderDoc::statement) - | Some(OrderDoc::notification) - ) - } - - /** Check if EBICS order is an uploadable one */ - pub fn is_upload(&self) -> bool { - matches!(self, Self::BTU { .. }) - } - - pub fn schema(&self) -> &'static str { - "H005" - } - - pub fn file_type(&self) -> &str { - match self { - Order::BTD(BTF { container, .. }) | Order::BTU(BTF { container, .. }) => { - container.as_deref().unwrap_or("xml") - } - _ => "xml", - } - } - - pub fn ty(&self) -> &'static str { - match self { - Order::BTD { .. } => "BTD", - Order::BTU { .. } => "BTU", - Order::HAA => "HAA", - Order::HAC => "HAC", - Order::HCA => "HCA", - Order::HCS => "HCS", - Order::HEV => "HEV", - Order::HIA => "HIA", - Order::HKD => "HKD", - Order::HPB => "HPB", - Order::HPD => "HPD", - Order::HTD => "HTD", - Order::HVD => "HVD", - Order::HVE => "HVE", - Order::HVS => "HVS", - Order::HVT => "HVT", - Order::HVU => "HVU", - Order::HVZ => "HVZ", - Order::H3K => "H3K", - Order::INI => "INI", - Order::PUB => "PUB", - Order::SPR => "SPR", - Order::PTK => "PTK", - } - } - - pub fn from_parts(ty: &str, btf: Option<BTF>) -> Option<Self> { - match (ty, btf) { - ("BTU", Some(btf)) => Some(Self::BTU(btf)), - ("BTD", Some(btf)) => Some(Self::BTD(btf)), - ("HAA", None) => Some(Self::HAA), - ("HAC", None) => Some(Self::HAC), - ("HCA", None) => Some(Self::HCA), - ("HCS", None) => Some(Self::HCS), - ("HEV", None) => Some(Self::HEV), - ("HIA", None) => Some(Self::HIA), - ("HKD", None) => Some(Self::HKD), - ("HPB", None) => Some(Self::HPB), - ("HPD", None) => Some(Self::HPD), - ("HTD", None) => Some(Self::HTD), - ("HVD", None) => Some(Self::HVD), - ("HVE", None) => Some(Self::HVE), - ("HVS", None) => Some(Self::HVS), - ("HVT", None) => Some(Self::HVT), - ("HVU", None) => Some(Self::HVU), - ("HVZ", None) => Some(Self::HVZ), - ("H3K", None) => Some(Self::H3K), - ("INI", None) => Some(Self::INI), - ("PUB", None) => Some(Self::PUB), - ("SPR", None) => Some(Self::SPR), - ("PTK", None) => Some(Self::PTK), - _ => None, - } - } -} - -impl std::fmt::Display for Order { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.ty())?; - match self { - Order::BTD(btf) | Order::BTU(btf) => write!(f, "-{btf}"), - _ => Ok(()), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta, PartialOrd, Ord)] -#[enum_meta(Str, Description)] -#[allow(non_camel_case_types)] -pub enum OrderDoc { - /// EBICS acknowledgement - CustomerAcknowledgement HAC pain.002 - acknowledgement, - /// Payment status - CustomerPaymentStatusReport pain.002 - status, - /// Debit & credit notifications - BankToCustomerDebitCreditNotification camt.054 - notification, - /// Account statements - BankToCustomerStatement camt.053 - statement, - /// Account intraday reports - BankToCustomerAccountReport camt.052 - report, -} - -impl OrderDoc { - pub fn short_description(&self) -> &'static str { - match self { - Self::acknowledgement => "EBICS acknowledgement", - Self::status => "Payment status", - Self::report => "Account intraday reports", - Self::statement => "Account statements", - Self::notification => "Debit & credit notifications", - } - } -} diff --git a/src/iso20022/bank_tx_code.rs b/src/iso20022/bank_tx_code.rs @@ -1,745 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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 - -use taler_enum_meta::EnumMeta; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(Description, Str)] -pub enum BankTxDomainCode { - /// Account Management - ACMT, - /// Cash Management - CAMT, - /// Commodities - CMDT, - /// Derivatives - DERV, - /// Foreign Exchange - FORX, - /// Loans, Deposits & Syndications - LDAS, - /// Precious Metal - PMET, - /// Payments - PMNT, - /// Securities - SECU, - /// Trade Services - TRAD, - /// Extended Domain - XTND, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(Description, Str)] -pub enum BankTxFamilyCode { - /// Account Balancing - ACCB, - /// Additional Miscellaneous Credit Operations - ACOP, - /// Additional Miscellaneous Debit Operations - ADOP, - /// Blocked Transactions - BLOC, - /// Cash Pooling - CAPL, - /// Miscellaneous Securities Operations - CASH, - /// Customer Card Transactions - CCRD, - /// Clean Collection - CLNC, - /// Counter Transactions - CNTR, - /// Custody Collection - COLC, - /// Collateral Management - COLL, - /// Corporate Action - CORP, - /// Consumer Loans - CSLN, - /// Custody - CUST, - /// Documentary Credit - DCCT, - /// Delivery - DLVR, - /// Documentary Collection - DOCC, - /// Drafts - DRFT, - /// Fixed Term Deposits - FTDP, - /// Fixed Term Loans - FTLN, - /// Futures - FTUR, - /// Forwards - FWRD, - /// Guarantees - GUAR, - /// Issued Cash Concentration Transactions - ICCN, - /// Issued Credit Transfers - ICDT, - /// Issued Cheques - ICHQ, - /// Issued Direct Debits - IDDT, - /// Issued Real-Time Credit Transfers - IRCT, - /// Lack - LACK, - /// Lockbox Transactions - LBOX, - /// Listed Derivatives - Futures - LFUT, - /// Stand-By Letter Of Credit - LOCT, - /// Listed Derivatives - Options - LOPT, - /// Miscellaneous Credit Operations - MCOP, - /// Merchant Card Transactions - MCRD, - /// Miscellaneous Debit Operations - MDOP, - /// Mortgage Loans - MGLN, - /// Non Deliverable - NDFX, - /// Non Settled - NSET, - /// Not Available - NTAV, - /// Notice Deposits - NTDP, - /// Notice Loans - NTLN, - /// OTC Derivatives - Bonds - OBND, - /// OTC Derivatives - Credit - OCRD, - /// OTC Derivatives - Equity - OEQT, - /// OTC Derivatives - Interest Rates - OIRT, - /// Opening & Closing - OPCL, - /// Options - OPTN, - /// OTC Derivatives - Structured Exotic Derivatives - OSED, - /// OTC Derivatives – Swaps - OSWP, - /// CSD Blocked transactions - OTHB, - /// Other - OTHR, - /// Received Cash Concentration Transactions - RCCN, - /// Received Credit Transfers - RCDT, - /// Received Cheques - RCHQ, - /// Received Direct Debits - RDDT, - /// Received Real-Time Credit Transfers - RRCT, - /// Trade, Clearing and Settlement - SETT, - /// Spots - SPOT, - /// Swaps - SWAP, - /// Syndications - SYDN, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(Description, Str)] -pub enum BankTxSubFamilyCode { - /// Account Closing - ACCC, - /// Account Opening - ACCO, - /// Account Transfer - ACCT, - /// ACH Credit - ACDT, - /// ACH Concentration - ACON, - /// ACH Corporate Trade - ACOR, - /// ACH Debit - ADBT, - /// Adjustments (Generic) - ADJT, - /// ACH Pre-Authorised - APAC, - /// ACH Return - ARET, - /// ACH Reversal - AREV, - /// ARP Debit - ARPD, - /// ACH Settlement - ASET, - /// ACH Transaction - ATXN, - /// Automatic Transfer - AUTT, - /// Branch Account Transfer - BACT, - /// SEPA B2B Direct Debit - BBDD, - /// Branch Deposit - BCDP, - /// Bank Cheque - BCHQ, - /// Back Value - BCKV, - /// Branch Withdrawl - BCWD, - /// Bond Forward - BFWD, - /// Repurchase offer/Issuer Bid/Reverse Rights - BIDS, - /// Bank Fees - BKFE, - /// Bonus Issue/Capitalisation Issue - BONU, - /// Internal Book Transfer - BOOK, - /// Put Redemption - BPUT, - /// Brokerage Fee - BROK, - /// Sell Buy Back - BSBC, - /// Buy Sell Back - BSBO, - /// Credit Adjustments (Generic) - CAJT, - /// Capital Gains Distribution - CAPG, - /// Cash Letter - CASH, - /// Certified Customer Cheque - CCCH, - /// Cheque - CCHQ, - /// Cross Currency IRS - CCIR, - /// CCP Cleared Initial Margin - CCPC, - /// CCP Cleared Variation Margin - CCPM, - /// CCP Cleared Segregated Initial Margin - CCSM, - /// Controlled Disbursement - CDIS, - /// Cash Deposit - CDPT, - /// Charge/Fees - CHAR, - /// Check Deposit - CHKD, - /// Charges (Generic) - CHRG, - /// Compensation/Claims - CLAI, - /// Circular Cheque - CLCQ, - /// Corporate Mark Broker Owned - CMBO, - /// Corporate Mark Client Owned - CMCO, - /// Corporate Own Account Transfer - COAT, - /// Commission Excluding Taxes (Generic) - COME, - /// Commission Including Taxes (Generic) - COMI, - /// Commission (Generic) - COMM, - /// Non Taxable Commissions (Generic) - COMT, - /// Conversion - CONV, - /// Cover Transaction - COVE, - /// Cash Penalties - CPEN, - /// Corporate Rebate - CPRB, - /// Cheque Reversal - CQRV, - /// Crossed Cheque - CRCQ, - /// Credit DefaultSwap - CRDS, - /// Cross Trade - CROS, - /// Cross Product - CRPR, - /// Credit Support - CRSP, - /// Credit Line - CRTL, - /// Cash Letter Adjustment - CSHA, - /// Cash In Lieu - CSLI, - /// Cash Withdrawal - CWDL, - /// Debit Adjustments (Generic) - DAJT, - /// Discounted Draft - DDFT, - /// Drawdown - DDWN, - /// Decrease in Value - DECR, - /// Draft Maturity Change - DMCG, - /// Domestic Credit Transfer - DMCT, - /// Deposit - DPST, - /// Drawing - DRAW, - /// Dividend Reinvestment - DRIP, - /// Controlled Disbursement - DSBR, - /// Dutch Auction - DTCH, - /// Cash Dividend - DVCA, - /// Dividend Option - DVOP, - /// Nordic Payment Council Credit Transfer - ENCT, - /// Equity Mark Broker Owned - EQBO, - /// Equity Mark Client Owned - EQCO, - /// Equity Option - EQPT, - /// Equity Swap - EQUS, - /// Exchange Rate Adjustment - ERTA, - /// Lending Income - ERWA, - /// Borrowing Fee - ERWI, - /// SEPA Credit Transfer - ESCT, - /// SEPA Core Direct Debit - ESDD, - /// Exchange - EXOF, - /// Exotic Option - EXPT, - /// Call On Intermediate Securities - EXRI, - /// Exchange Traded Derivatives - EXTD, - /// Warrant Exercise/Warrant Conversion - EXWA, - /// Foreign Currencies Deposit - FCDP, - /// Factor Update - FCTA, - /// Foreign Currencies Withdrawal - FCWD, - /// Fees (Generic) - FEES, - /// Financial Institution Credit Transfer - FICT, - /// Financial Institution Direct Debit Payment - FIDD, - /// Financial Institution Own Account Transfer - FIOA, - /// Fixed Income - FIXI, - /// Float Adjustment - FLTA, - /// Freeze Of Funds - FRZF, - /// Futures Commission - FUCO, - /// Future Variation Margin - FUTU, - /// Forwards Broker Owned Collateral - FWBC, - /// Forwards Client Owned Collateral - FWCC, - /// MFA Segregated Broker Cash Collateral - FWSB, - /// MFA Segregated Client Cash Collateral - FWSC, - /// Withdrawal/Distribution - GEN1, - /// Deposit/Contribution - GEN2, - /// Invoice Accepted with Differed Due Date - IADD, - /// Intra Company Transfer - ICCT, - /// Fixed Deposit Interest Amount - INFD, - /// Inspeci/Share Exchange - INSP, - /// Interests (Generic) - INTR, - /// Depositary Receipt Issue - ISSU, - /// Credit Adjustment - LBCA, - /// Debit - LBDB, - /// Deposit - LBDP, - /// Liquidation Dividend / Liquidation Payment - LIQU, - /// Margin Payments - MARG, - /// Mortgage Back Segregated Broker Cash Collateral - MBSB, - /// Mortgage Back Segregated Client Cash Collateral - MBSC, - /// Full Call / Early Redemption - MCAL, - /// Margin Client Owned Cash Collateral - MGCC, - /// Initial Futures Margin Segregated Client Cash Collateral - MGSC, - /// Mixed Deposit - MIXD, - /// Management Fees - MNFE, - /// Merger - MRGR, - /// Miscellaneous Deposit - MSCD, - /// Netting - NETT, - /// Non Presented Circular Cheques - NPCC, - /// Non Syndicated - NSYN, - /// Not Available - NTAV, - /// New issue distribution - NWID, - /// Client owned OCC pledged collateral - OCCC, - /// Overdraft - ODFT, - /// Odd Lot Sale/Purchase - ODLT, - /// One-Off Direct Debit - OODD, - /// Option Broker Owned Collateral - OPBC, - /// Option Client Owned Collateral - OPCC, - /// Open Cheque - OPCQ, - /// OTC Option Segregated Broker Cash Collateral - OPSB, - /// OTC Option Segregated Client Cash Collateral - OPSC, - /// FX Option - OPTN, - /// Order Cheque - ORCQ, - /// OTC CCP - OTCC, - /// OTC Derivatives - OTCD, - /// OTC - OTCG, - /// OTC Non-CCP - OTCN, - /// Other - OTHR, - /// Overdraft Charge - OVCH, - /// External Account Transfer - OWNE, - /// Internal Account Transfer - OWNI, - /// Pre-Authorised Direct Debit - PADD, - /// Pair-Off - PAIR, - /// Partial Redemption with reduction of nominal value - PCAL, - /// Placement - PLAC, - /// Direct Debit - PMDD, - /// Portfolio Move - PORT, - /// Credit Card Payment - POSC, - /// Point-of-Sale (POS) Payment - Debit Card - POSD, - /// Point-of-Sale (POS) Payment - POSP, - /// Principal Payment - PPAY, - /// Priority Credit Transfer - PRCT, - /// Reversal Due To Payment Reversal - PRDD, - /// Partial Redemption Without Reduction of Nominal Value - PRED, - /// Interest Payment with Principles - PRII, - /// Interest Payment with Principles - PRIN, - /// Priority Issue - PRIO, - /// Principal Pay-Down/Pay-Up - PRUD, - /// Posting Error - PSTE, - /// Reversal Due To Payment Cancellation Request - RCDD, - /// Reversal due to a Cover Transaction Return - RCOV, - /// Redemption Asset Allocation - REAA, - /// Redemption - REDM, - /// Repo - REPU, - /// Futures Residual Amount - RESI, - /// Rights Issue/Subscription Rights/Rights Offer - RHTS, - /// Reimbursement (Generic) - RIMB, - /// Renewal - RNEW, - /// Bi-lateral repo broker owned collateral - RPBC, - /// Repo client owned collateral - RPCC, - /// Reversal Due To Payment Cancellation Request - RPCR, - /// Repayment - RPMT, - /// Bi-lateral Repo Segregated Broker Cash Collateral - RPSB, - /// Bi-lateral Repo Segregated Client Cash Collateral - RPSC, - /// Reversal Due To Payment Return - RRTN, - /// Reverse Repo - RVPO, - /// Redemption Withdrawing Plan - RWPL, - /// Settlement Against Bank Guarantee - SABG, - /// Payroll/Salary Payment - SALA, - /// Securities Buy Sell Sell Buy Back - SBSC, - /// Single Currency IRS Exotic - SCIE, - /// Single Currency IRS - SCIR, - /// Securities Cross Products - SCRP, - /// Same Day Value Credit Transfer - SDVA, - /// Securities Borrowing - SECB, - /// Securities Lending - SECL, - /// Broker owned collateral Short Sale - SHBC, - /// Client owned collateral Short Sale - SHCC, - /// Equity Premium Reserve - SHPR, - /// Short Sell - SHSL, - /// Lending Broker Owned Cash Collateral - SLBC, - /// Lending Client Owned Cash Collateral - SLCC, - /// Securities Lending And Borrowing - SLEB, - /// SecuredLoan - SLOA, - /// Smart-Card Payment - SMCD, - /// Smart-Card Payment - SMRT, - /// Settlement Of Sight Export Document - SOSE, - /// Settlement Of Sight Import Document - SOSI, - /// Subscription Savings Plan - SSPL, - /// Settlement After Collection - STAC, - /// Stamp Duty - STAM, - /// Standing Order - STDO, - /// Settlement - STLM, - /// Settlement Under Reserve - STLR, - /// Bill of Exchange Settlement on Demand - STOD, - /// Subscription Asset Allocation - SUAA, - /// Subscription - SUBS, - /// Swap Payment - SWAP, - /// Swap Broker Owned Collateral - SWBC, - /// Swap Client Owned Cash Collateral - SWCC, - /// Sweep - SWEP, - /// Final Payment - SWFP, - /// Switch - SWIC, - /// Partial Payment - SWPP, - /// Swaption - SWPT, - /// Reset Payment - SWRS, - /// ISDA/CSA Segregated Broker Cash Collateral - SWSB, - /// ISDA/CSA Segregated Client Cash Collateral - SWSC, - /// Upfront Payment - SWUF, - /// Syndicated - SYND, - /// Taxes (Generic) - TAXE, - /// TBA Closing - TBAC, - /// To Be Announced - TBAS, - /// TBA Broker owned cash collateral - TBBC, - /// TBA Client owned cash collateral - TBCC, - /// Travellers Cheques Deposit - TCDP, - /// Travellers Cheques Withdrawal - TCWD, - /// Tender - TEND, - /// Topping - TOPG, - /// Transfer Out - TOUT, - /// Trade - TRAD, - /// Treasury Cross Product - TRCP, - /// Tax Reclaim - TREC, - /// Transaction Fees - TRFE, - /// Transfer In - TRIN, - /// Triparty Repo - TRPO, - /// Triparty Reverse Repo - TRVO, - /// Treasury Tax And Loan Service - TTLS, - /// Turnaround - TURN, - /// Dishonoured/Unpaid Draft - UDFT, - /// Underwriting Commission - UNCO, - /// Unpaid Cheque - UPCQ, - /// Unpaid Card Transaction - UPCT, - /// Reversal Due To Return/Unpaid Direct Debit - UPDD, - /// Cheque Under Reserve - URCQ, - /// Direct Debit Under Reserve - URDD, - /// Value Date - VALD, - /// Credit Transfer With Agreed Commercial Information - VCOM, - /// Withholding Tax - WITH, - /// Cross-Border Credit Card Payment - XBCP, - /// Foreign Cheque - XBCQ, - /// Cross-Border Credit Transfer - XBCT, - /// Cross-Border Cash Withdrawal - XBCW, - /// Cross-Border Direct Debit - XBDD, - /// Cross-Border - XBRD, - /// Cross-Border Payroll/Salary Payment - XBSA, - /// Cross-Border Standing Order - XBST, - /// Exchange Traded CCP - XCHC, - /// Exchange Traded - XCHG, - /// Exchange Traded Non-CCP - XCHN, - /// Cross-Border Intra Company Transfer - XICT, - /// Unpaid Foreign Cheque - XPCQ, - /// Foreign Cheque Under Reserve - XRCQ, - /// Cross Border Reversal Due to Payment Return - XRTN, - /// YTD Adjustment - YTDA, - /// Zero Balancing - ZABA, -} diff --git a/src/iso20022/camt.rs b/src/iso20022/camt.rs @@ -1,1248 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{fmt::Write as _, str::FromStr}; - -use compact_str::CompactString; -use jiff::{Timestamp, civil, tz::TimeZone}; -use taler_common::types::{ - amount::{Amount, Currency}, - iban::IBAN, - payto::{BankID, IbanPayto, PaytoImpl, PaytoURI}, -}; -use taler_enum_meta::EnumMeta; -use tracing::{trace, warn}; -use uuid::Uuid; - -use crate::{ - iso20022::{ - ChargeBearer, - bank_tx_code::{BankTxDomainCode, BankTxFamilyCode, BankTxSubFamilyCode}, - status_code::ReturnReason, - }, - model::{BatchId, InId, InTx, OutBatch, OutId, OutReversal, OutTx, Tx}, - xml::{self, Xml, XmlAccess as _}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(Str)] -#[allow(clippy::upper_case_acronyms)] -enum Kind { - CRDT, - DBIT, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AccountId { - Iban(IBAN), - Other(CompactString), -} - -impl std::fmt::Display for AccountId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AccountId::Iban(iban) => iban.fmt(f), - AccountId::Other(id) => id.fmt(f), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AccountTransactions { - pub id: AccountId, - pub currency: Option<Currency>, - pub txs: Vec<Tx>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum OutIds { - Tx(OutId), - Batch(BatchId), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct BankTxCode { - domain: BankTxDomainCode, - family: BankTxFamilyCode, - subfamily: BankTxSubFamilyCode, -} - -impl BankTxCode { - fn is_reversal(&self) -> bool { - matches!( - self.subfamily, - BankTxSubFamilyCode::RPCR | BankTxSubFamilyCode::RRTN | BankTxSubFamilyCode::PSTE - ) - } -} - -impl std::fmt::Display for BankTxCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { - domain, - family, - subfamily, - } = self; - write!( - f, - "{domain} {family} {subfamily} - '{}' '{}' '{}'", - domain.description(), - family.description(), - subfamily.description() - ) - } -} - -/** Parse the instruction execution date */ -fn execution_date(n: Xml) -> xml::Result<Timestamp> { - // Value date if present else booking date - let date = n - .opt("ValDt") - .transpose() - .unwrap_or_else(|| n.one("BookgDt"))?; - let date = if let Some(date) = date.opt("Dt")? { - date.parse::<civil::Date>()?.into() - } else { - date.one("DtTm").parse::<civil::DateTime>()? - }; - Ok(date.to_zoned(TimeZone::UTC).unwrap().timestamp()) -} - -/** Parse a payto */ -fn payto(n: Xml, prefix: &str) -> xml::Result<Option<PaytoURI>> { - let Some(parties) = n.opt("RltdPties")? else { - return Ok(None); - }; - - let Some(iban) = parties - .opt(&format!("{prefix}Acct")) - .one("Id") - .opt("IBAN") - .parse()? - else { - return Ok(None); - }; - // TODO parse BIC - let bank_id = BankID { iban, bic: None }; - Ok(Some(if let Some(p) = parties.opt(prefix)? { - let name = p - .opt("Nm") - .transpose() - .unwrap_or_else(|| p.one("Pty").one("Nm"))? - .text(); - IbanPayto::new(bank_id).as_full_payto(name) - } else { - IbanPayto::new(bank_id).as_payto() - })) -} - -/** Parse batch message ID and transaction end-to-end ID as generated by libeufin-nexus */ -fn outgoing_id(n: Xml, sref: Option<&str>) -> xml::Result<OutIds> { - Ok(if let Some(refs) = n.opt("Refs")? { - let e2e_id: Option<CompactString> = refs.opt("EndToEndId").parse()?; - let msg_id: Option<CompactString> = refs.opt("MsgId").parse()?; - let sref: Option<CompactString> = - sref.filter(|it| *it != "NOTPROVIDED").map(|it| it.into()); - match (e2e_id, msg_id) { - // This is a batch representation - (None, Some(msg_id)) => OutIds::Batch(BatchId { msg_id, sref }), - // If not set use MsgId as end-to-end ID for retrocompatibility - (Some(e2e_id), msg_id) if &e2e_id == "NOTPROVIDED" => OutIds::Tx(OutId { - e2e_id: msg_id.clone(), - msg_id, - sref, - }), - (e2e_id, msg_id) => OutIds::Tx(OutId { - msg_id, - e2e_id, - sref, - }), - } - } else { - OutIds::Tx(OutId { - msg_id: None, - e2e_id: None, - sref: sref.map(|it| it.into()), - }) - }) -} - -/** Parse transaction ids as provided by bank */ -fn incoming_id(n: Xml, sref: Option<&str>) -> xml::Result<InId> { - if let Some(refs) = n.opt("Refs")? { - let uetr: Option<Uuid> = refs.opt("UETR").parse()?; - let tx_id: Option<CompactString> = refs.opt("TxId").parse()?; - Ok(InId { - uetr, - tx_id, - sref: sref.map(|it| it.into()), - }) - } else { - Ok(InId { - uetr: None, - tx_id: None, - sref: sref.map(|it| it.into()), - }) - } -} - -/** Parse transaction wire transfer subject */ -fn wire_transfer_subject(n: Xml) -> xml::Result<Option<String>> { - Ok(n.opt("RmtInf")? - .map(|n| n.many("Ustrd").map(|n| n.text()).collect::<String>())) -} - -/** Parse and format transaction return reasons */ -fn return_reason(n: Xml) -> xml::Result<String> { - let mut buf = String::new(); - if let Some(n) = n.opt("RtrInf")? { - let code: ReturnReason = n.one("Rsn").one("Cd").parse()?; - - write!(&mut buf, "{code} '{}'", code.description()).unwrap(); - let mut infos = n.many("AddtlInf"); - if let Some(first) = infos.next() { - buf.push_str(" - '"); - buf.push_str(first.text()); - for info in infos { - buf.push_str(info.text()); - } - buf.push('\''); - } - } else if let Some(n) = wire_transfer_subject(n)? { - return Ok(n); - } - Ok(buf) -} -/** Parse amount */ -fn amount(n: Xml) -> xml::Result<Amount> { - let amt = n.one("Amt")?; - let currency = amt.attr("Ccy")?; - let amount = amt.text(); - let concat = format!("{currency}:0{amount}"); - Amount::from_str(&concat).map_err(|e| amt.parse_err(e)) -} - -#[derive(Debug, Clone, Copy)] -struct ComplexAmount { - /// Transaction amount - amount: Amount, - /// The applied fee - fee: Amount, -} - -impl ComplexAmount { - /// Check that entry and tx amount are compatible and return the result - fn resolve(&self, tx: &ComplexAmount) -> xml::Result<ComplexAmount> { - // Most time transaction will match - if self.amount == tx.amount && self.fee == tx.fee { - return Ok(*self); - } - - // Or one of the level is missing the fee - if (tx.amount.decimal() > tx.fee.decimal() - && tx.amount.try_sub(&tx.fee).unwrap() == self.amount) - || self.amount.try_sub(&self.fee).unwrap() == tx.amount - { - return if tx.fee.is_zero() { Ok(*self) } else { Ok(*tx) }; - } - - // Or the conversion information are only present at the entry layer - if tx.amount.currency != self.amount.currency { - return Ok(*self); - } - - panic!("Amount mismatch, got {self:?} in the entry and {tx:?} in the tx") - } -} - -struct ChargeRecord { - amount: Amount, - kind: Kind, - included: bool, - bearer: ChargeBearer, -} - -fn charges(n: Xml) -> xml::Result<Vec<ChargeRecord>> { - if let Some(n) = n.opt("Chrgs")? { - n.many("Rcrd") - .map(|n| { - Ok(ChargeRecord { - amount: amount(n)?, - kind: n.opt("CdtDbtInd").parse()?.unwrap_or(Kind::CRDT), - included: n.opt("ChrgInclInd").parse()? == Some(true), // TODO not clear in spec - bearer: n.opt("Br").parse()?.unwrap_or(ChargeBearer::SHAR), - }) - }) - .collect() - } else { - Ok(Vec::new()) - } -} - -fn complex_amount(amt: Xml, charges: &[ChargeRecord]) -> xml::Result<ComplexAmount> { - // Amount before charges - let currency = amt.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 - let amount = amt.text().trim_start_matches('-'); - let concat = format!("{currency}:0{amount}"); - - let mut amount = Amount::from_str(&concat).map_err(|e| amt.parse_err(e))?; - let mut fee = Amount::zero(&amount.currency); - - for chr in charges { - if chr.included && !chr.amount.is_zero() { - fee = fee.try_add(&chr.amount).expect("Should never overflow"); - if chr.kind == Kind::DBIT { - if chr.bearer == ChargeBearer::DEBT { - if chr.amount.decimal() > amount.decimal() { - // This can happen when an incoming transaction fail because of debit fee - amount = chr.amount.try_sub(&amount).expect("Should never overflow"); - } else { - amount = amount.try_sub(&chr.amount).expect("Should never overflow"); - } - } else if chr.bearer == ChargeBearer::CRED { - amount = amount.try_add(&chr.amount).expect("Should never overflow"); - } else { - return Err(amt.parse_err(format_args!( - "Included charge {} with bearer {}", - chr.kind, chr.bearer - ))); - } - } - } - } - - Ok(ComplexAmount { amount, fee }) -} - -/** Parse bank transaction code */ -fn bank_tx_code(n: Xml) -> xml::Result<BankTxCode> { - let domnd = n.one("Domn")?; - let fmly = domnd.one("Fmly")?; - Ok(BankTxCode { - domain: domnd.one("Cd").parse()?, - family: fmly.one("Cd").parse()?, - subfamily: fmly.one("SubFmlyCd").parse()?, - }) -} - -/** Parse camt files */ -pub fn parse_camt(xml: &[u8]) -> xml::Result<Vec<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. - */ - trace!("Parse transactions camt file"); - - fn parse_inner(root: Xml) -> xml::Result<AccountTransactions> { - let (id, currency) = { - let account = root.one("Acct")?; - let id = account.one("Id")?; - let account_id = if let Some(iban) = id.opt("IBAN")? { - AccountId::Iban(iban.parse()?) - } else { - AccountId::Other(id.one("Othr").one("Id").parse()?) - }; - let currency: Option<Currency> = account.opt("Ccy").parse()?; - (account_id, currency) - }; - let txs = root.many("Ntry").try_fold(Vec::new(), |mut txs, entry| { - // Skip if not booked - if !{ - let status = entry.one("Sts")?; - let status = status - .opt("Cd")? - .map(|n| n.text()) - .unwrap_or_else(|| status.text()); - status == "BOOK" - } { - return Ok(txs); - } - - let reversal = entry.opt("RvslInd").parse()? == Some(true); - let entry_code = bank_tx_code(entry.one("BkTxCd")?)?; - let entry_kind = entry.opt("CdtDbtInd").parse::<Kind>()?; - let entry_ref = entry.opt("AcctSvcrRef").parse::<CompactString>()?; - let date = execution_date(entry)?; - let entry_charges = charges(entry)?; - let entry_amount = complex_amount(entry.one("Amt")?, &entry_charges)?; - - let Some(details) = entry.opt("NtryDtls")? else { - return Ok(txs); - }; - // When an entry only contain a single transactions information will sometimes only be stored at the entry level - let unique = details.many("TxDtls").count() == 1; - for tx in details.many("TxDtls") { - // Check information are present and coherent - let kind = tx.opt("CdtDbtInd").parse()?.or(entry_kind).unwrap(); - - // Sometimes the transaction level have a more precise bank transaction code - let code = tx - .opt("BkTxCd")? - .map(bank_tx_code) - .transpose()? - .unwrap_or(entry_code); - - let tx_charges = charges(tx)?; - // Amount - let amount = if unique { - // When unique the charges can be only at the entry level - if let Some(amt) = tx.opt("Amt")? { - let tx_amount = complex_amount( - amt, - if tx_charges.is_empty() { - &entry_charges - } else { - &tx_charges - }, - )?; - // Check coherence - entry_amount.resolve(&tx_amount)? - } else { - entry_amount - } - } else { - // When many inner transaction the entry level is an aggregate of them - // We only use the transaction level information - complex_amount(tx.one("Amt")?, &tx_charges)? - }; - - // We can only use the entry ref as the transaction ref if there is a single transaction in the batch - let sref: Option<CompactString> = tx - .opt("Refs") - .opt("AcctSvcrRef") - .parse::<CompactString>()? - .or_else(|| unique.then(|| entry_ref.clone()).flatten()); - - match (kind, code.is_reversal() || reversal) { - (Kind::CRDT, true) => { - let out_id = outgoing_id(tx, sref.as_deref())?; - if let OutIds::Tx(OutId { - msg_id, - e2e_id: Some(e2e_id), - .. - }) = out_id - { - txs.push(Tx::Reversal(OutReversal { - e2e_id, - msg_id, - reason: return_reason(tx)?, - execution_time: date, - })) - } else { - warn!("missing unique ID for Credit reversal {out_id:?}"); - } - } - (Kind::DBIT, true) | (Kind::CRDT, false) => { - let id = incoming_id(tx, sref.as_deref())?; - if id.uetr.is_none() && id.tx_id.is_none() && id.sref.is_none() { - warn!("missing unique ID for Credit") - } else { - txs.push(Tx::In(InTx { - id, - amount: amount.amount, - credit_fee: amount.fee, - subject: wire_transfer_subject(tx)?, - execution_time: date, - debtor: payto(tx, "Dbtr")?, - })); - } - } - (Kind::DBIT, false) => { - let id = outgoing_id(tx, sref.as_deref())?; - match id { - OutIds::Tx(id) => { - if id.e2e_id.is_none() && id.msg_id.is_none() && id.sref.is_none() { - warn!("missing unique ID for Debit") - } else { - txs.push(Tx::Out(OutTx { - id, - amount: amount.amount, - debit_fee: amount.fee, - subject: wire_transfer_subject(tx)?, - execution_time: date, - creditor: payto(tx, "Cdtr")?, - })); - } - } - OutIds::Batch(BatchId { msg_id, .. }) => { - txs.push(Tx::Batch(OutBatch { - msg_id, - execution_time: date, - })); - } - } - } - } - } - Ok(txs) - })?; - Ok(AccountTransactions { id, currency, txs }) - } - - Xml::parse(xml, "Document", |root| { - if let Some(camt053) = root.opt("BkToCstmrStmt")? { - camt053.many("Stmt").map(parse_inner).collect() - } else if let Some(camt052) = root.opt("BkToCstmrAcctRpt")? { - camt052.many("Rpt").map(parse_inner).collect() - } else if let Some(camt054) = root.opt("BkToCstmrDbtCdtNtfctn")? { - camt054.many("Ntfctn").map(parse_inner).collect() - } else { - Err(root.parse_err("Malformed camt file")) - } - }) -} - -#[cfg(test)] -pub mod test { - use std::str::FromStr; - - use jiff::{Timestamp, civil::Date}; - use taler_common::types::{ - amount::{Amount, Currency}, - iban::IBAN, - payto::{BankID, IbanPayto, PaytoImpl as _, PaytoURI}, - utils::date_to_utc_ts, - }; - - use crate::{ - iso20022::camt::{AccountId, parse_camt}, - model::{InId, InTx, OutBatch, OutId, OutReversal, OutTx, Tx}, - }; - - pub fn date_to_timestamp(date: &str) -> Timestamp { - date_to_utc_ts(&Date::from_str(date).unwrap()) - } - - fn iban_payto(iban: impl AsRef<str>, name: impl AsRef<str>) -> PaytoURI { - IbanPayto::new(BankID { - iban: iban.as_ref().parse().expect("invalid IBAN"), - bic: None, - }) - .as_full_payto(name.as_ref()) - } - - pub fn check_tx(path: &str, iban: &str, currency: Option<&str>, txs: &[Tx]) { - let content = std::fs::read(path).unwrap(); - let res = parse_camt(&content).unwrap(); - assert_eq!(res.len(), 1); - - let first = &res[0]; - assert_eq!(first.id, AccountId::Iban(IBAN::from_str(iban).unwrap())); - assert_eq!( - first.currency, - currency.map(|it| Currency::from_str(it).unwrap()) - ); - pretty_assertions::assert_eq!(first.txs, txs); - } - - pub fn tx_out( - id: (Option<&str>, Option<&str>, Option<&str>), - amount: &str, - debit_fee: &str, - subject: Option<&str>, - execution_time: &str, - creditor: Option<(&str, &str)>, - ) -> Tx { - Tx::Out(OutTx { - id: OutId::new( - id.0.map(Into::into), - id.1.map(Into::into), - id.2.map(Into::into), - ), - amount: Amount::from_str(amount).unwrap(), - debit_fee: Amount::from_str(debit_fee).unwrap(), - subject: subject.map(Into::into), - execution_time: date_to_timestamp(execution_time), - creditor: creditor.map(|(iban, name)| iban_payto(iban, name)), - }) - } - - pub fn tx_in( - id: (Option<&str>, Option<&str>, Option<&str>), - amount: &str, - credit_fee: &str, - subject: Option<&str>, - execution_time: &str, - debtor: Option<(&str, &str)>, - ) -> Tx { - Tx::In(InTx { - id: InId::new( - id.0.map(|it| it.parse().unwrap()), - id.1.map(Into::into), - id.2.map(Into::into), - ), - amount: Amount::from_str(amount).unwrap(), - credit_fee: Amount::from_str(credit_fee).unwrap(), - subject: subject.map(Into::into), - execution_time: date_to_timestamp(execution_time), - debtor: debtor.map(|(iban, name)| iban_payto(iban, name)), - }) - } - - pub fn tx_reversal( - e2e_id: &str, - msg_id: Option<&str>, - reason: &str, - execution_time: &str, - ) -> Tx { - Tx::Reversal(OutReversal { - e2e_id: e2e_id.parse().unwrap(), - msg_id: msg_id.map(Into::into), - reason: reason.into(), - execution_time: date_to_timestamp(execution_time), - }) - } - - pub fn tx_batch(msg_id: &str, execution_time: &str) -> Tx { - Tx::Batch(OutBatch { - msg_id: msg_id.into(), - execution_time: date_to_timestamp(execution_time), - }) - } - - #[test] - fn postfinance_camt054() { - check_tx( - "libeufin-nexus/sample/platform/postfinance_camt054.xml", - "CH9289144596463965762", - Some("CHF"), - &[ - tx_out( - ( - Some("ZS1PGNTSV0ZNDFAJBBWWB8015G"), - Some("ZS1PGNTSV0ZNDFAJBBWWB8015G"), - None, - ), - "CHF:3.00", - "CHF:0", - None, - "2024-01-15", - None, - ), - tx_in( - ( - Some("62e2b511-7313-4ccd-8d40-c9d8e612cd71"), - None, - Some("231121CH0AZWCR9T"), - ), - "CHF:10", - "CHF:0", - Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YBG"), - "2023-12-19", - Some(("CH7389144832588726658", "Mr Test")), - ), - tx_in( - ( - Some("62e2b511-7313-4ccd-8d40-c9d8e612cd71"), - None, - Some("231121CH0AZWCVR1"), - ), - "CHF:2.53", - "CHF:0", - Some("G1XTY6HGWGMVRM7E6XQ4JHJK561ETFDFTJZ7JVGV543XZCB27YB"), - "2023-12-19", - Some(("CH7389144832588726658", "Mr Test")), - ), - tx_reversal( - "50820f78-9024-44ff-978d-63a18c", - Some("50820f78-9024-44ff-978d-63a18c"), - "", - "2024-01-15", - ), - tx_batch("ZS1PGNTSV0ZNDFAJBBWWB8015G", "2024-01-15"), - ], - ); - } - - #[test] - fn postfinance_camt053() { - check_tx( - "libeufin-nexus/sample/platform/postfinance_camt053.xml", - "CH9289144596463965762", - Some("CHF"), - &[ - tx_reversal( - "889d1a80-1267-49bd-8fcc-85701a", - Some("889d1a80-1267-49bd-8fcc-85701a"), - "InconsistenWithEndCustomer 'Identification of end customer is not consistent with associated account number, organisation ID or private ID' - 'more info here ...'", - "2023-11-22", - ), - tx_reversal( - "4cc61cc7-6230-49c2-b5e2-b40bbb", - Some("4cc61cc7-6230-49c2-b5e2-b40bbb"), - "MissingCreditorNameOrAddress 'Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing' - 'more info here ...'", - "2023-11-22", - ), - tx_batch("EB4D22D428214261B2B3012D2A8CEC36", "2024-08-26"), - ], - ); - } - - #[test] - fn raiffeisen_camt053() { - check_tx( - "libeufin-nexus/sample/platform/raiffeisen_camt053.xml", - "CH7389144832588726658", - None, - &[ - tx_in( - (None, None, Some("A200020494367552")), - "CHF:20000", - "CHF:0", - Some("1. TZ 2025"), - "2025-12-23", - Some(("CH7389144832588726658", "KANTON BERN")), - ), - tx_out( - (None, None, Some("19868398389")), - "CHF:15", - "CHF:0", - None, - "2025-12-31", - None, - ), - tx_out( - (None, None, Some("19890406743")), - "CHF:2", - "CHF:0", - None, - "2025-12-31", - None, - ), - tx_out( - (None, None, Some("19885172770")), - "CHF:3", - "CHF:0", - None, - "2025-12-31", - None, - ), - ], - ); - } - - #[test] - fn valiant_camt052() { - check_tx( - "libeufin-nexus/sample/platform/valiant_camt052.xml", - "CH7389144832588726658", - Some("CHF"), - &[ - tx_out( - ( - Some("MJDJO2BDDBL7YSL2P96SXHG3TQZEZQD26L"), - Some("4UWWIDGTEIGDU6Z721QE95PYJSIEA48PYE"), - Some("ZV20251030/511372/1"), - ), - "CHF:0.1", - "CHF:0", - Some("single 2025-10-30T09:46:04.55293090 9Z"), - "2025-10-30", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), - Some("SKMU2891PAAYBDW22DBWX2W7KTFZ1CDFO8"), - Some("ZV20251030/511373/1"), - ), - "CHF:0.1", - "CHF:0", - Some("multi 0 2025-10-30T09:46:10.3877961 30Z"), - "2025-10-30", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), - Some("RC9YD301NZ17YKD6WDWLNOROFHIIN29VJN"), - Some("ZV20251030/511373/2"), - ), - "CHF:0.11", - "CHF:0", - Some("multi 1 2025-10-30T09:46:10.3877961 30Z"), - "2025-10-30", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), - Some("GKDGTHLB82X6XVHBJIJ1CK8MEGU9XJ2EL7"), - Some("ZV20251030/511373/3"), - ), - "CHF:0.12", - "CHF:0", - Some("multi 2 2025-10-30T09:46:10.3877961 30Z"), - "2025-10-30", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U"), - Some("PXCH2VVVTXEXBVDWICP23HZ4NV0H2CWW28"), - Some("ZV20251030/511373/4"), - ), - "CHF:0.13", - "CHF:0", - Some("multi 3 2025-10-30T09:46:10.3877961 30Z"), - "2025-10-30", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_in( - (None, Some("51030655601.0001"), Some("ZV20251030/514778/1")), - "CHF:0.85", - "CHF:0", - Some("fun stuff"), - "2025-10-30", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_in( - (None, Some("51030655601.0002"), Some("ZV20251030/514779/1")), - "CHF:0.95", - "CHF:0", - Some("Taler PC2MKG0B7CK32K1T7DP08P6E1B7FHB6HY6R Q0PT3VTPBPRPYM1B0"), - "2025-10-30", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("X166701F6RV59LP71RVWVIW9SV2AFZYLG4"), - Some("R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX"), - Some("ZV20251030/524078/1"), - ), - "CHF:0.21", - "CHF:0", - Some("bad name 2025-10-30T12:03:24.997478 811Z"), - "2025-10-30", - Some(("CH6208704048981247126", "John Smith")), - ), - tx_out( - ( - Some("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X"), - Some("02WDIX4J90Z1M1WNFHLNSXY59SHXQTQCMQ"), - Some("ZV20251030/524079/1"), - ), - "CHF:0.1", - "CHF:0", - Some("single 2025-10-30T12:04:00.37042083 6Z"), - "2025-10-30", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X"), - Some("XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y"), - Some("ZV20251030/524079/2"), - ), - "CHF:0.21", - "CHF:0", - Some("bad name 2025-10-30T12:03:53.042190 686Z"), - "2025-10-30", - Some(("CH6208704048981247126", "John Smith")), - ), - tx_reversal( - "XAP5L7HVWPLCEMECU4GZK6GKUPBL0TD13Y", - None, - "Error msg in german", - "2025-10-30", - ), - tx_reversal( - "R48UBIIB7B4LX0DMVOSI0ZTJWMMG8FMNKX", - None, - "Error msg in german", - "2025-10-30", - ), - tx_out( - ( - Some("OLAMDPI6YPMNRZHQ5PQ6JCVUQV2AN5NW6P"), - Some("TU2WJ54DR9Z6HT5VE494BNH4EXUSM0DRF7"), - Some("ZV20251030/524077/1"), - ), - "CHF:0.23", - "CHF:5", - Some("foreign iban 2025-10-30T12:03:44.0972 63765Z"), - "2025-10-30", - Some(("DE48330605920000686018", "Christian Grothoff")), - ), - tx_out( - ( - Some("6OZN5T9W7MK6BIZYE01E62NHGP5JLMUD4X"), - Some("GM8I8GIETR72LP6CFBGRBUDKNO2CEQBGOE"), - Some("ZV20251030/524080/1"), - ), - "CHF:0.23", - "CHF:5", - Some("foreign iban 2025-10-30T12:03:58.0046 73747Z"), - "2025-10-30", - Some(("DE48330605920000686018", "Christian Grothoff")), - ), - tx_in( - ( - Some("7b76d488-05d5-44ab-9d77-31d4165ec158"), - Some("00204EQY370"), - Some("ZV20251118/685062/1"), - ), - "CHF:4.55", - "CHF:0", - Some("TEST"), - "2025-11-18", - None, - ), - ], - ) - } - - #[test] - fn gls_camt052() { - check_tx( - "libeufin-nexus/sample/platform/gls_camt052.xml", - "DE84500105177118117964", - Some("EUR"), - &[ - tx_out( - ( - Some("COMPAT_SUCCESS"), - Some("COMPAT_SUCCESS"), - Some("2024041801514102000"), - ), - "EUR:2", - "EUR:0", - Some("TestABC123"), - "2024-04-18", - Some(("DE20500105172419259181", "John Smith")), - ), - tx_reversal( - "8XK8Z7RAX224FGWK832FD40GYC", - None, - "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN fehlerhaft und ungültig'", - "2024-09-05", - ), - tx_in( - ( - None, - Some("BYLADEM1WOR-G2910276709458A2"), - Some("2024041210041357000"), - ), - "EUR:3", - "EUR:0", - Some("Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-"), - "2024-04-12", - Some(("DE84500105177118117964", "John Smith")), - ), - tx_reversal( - "COMPAT_FAILURE", - None, - "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'", - "2024-04-12", - ), - tx_out( - ( - Some("BATCH_SINGLE_SUCCESS"), - Some("FD622SMXKT5QWSAHDY0H8NYG3G"), - Some("2024090216552232000"), - ), - "EUR:1.1", - "EUR:0", - Some("single 2024-09-02T14:29:52.875253314Z"), - "2024-09-02", - Some(("DE89500105173198527518", "Grothoff Hans")), - ), - tx_out( - ( - Some("YF5QBARGQ0MNY0VK59S477VDG4"), - Some("YF5QBARGQ0MNY0VK59S477VDG4"), - Some("2024041810552821000"), - ), - "EUR:1.1", - "EUR:0", - Some("Simple tx"), - "2024-04-18", - Some(("DE20500105172419259181", "John Smith")), - ), - tx_batch("BATCH_MANY_SUCCESS", "2024-09-20"), - tx_out( - ( - Some("BATCH_SINGLE_RETURN"), - Some("KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5"), - Some("2024092100252498000"), - ), - "EUR:0.42", - "EUR:0", - Some("This should fail because bad iban"), - "2024-09-23", - Some(("DE18500105173385245163", "John Smith")), - ), - tx_reversal( - "KLJJ28S1LVNDK1R2HCHLN884M7EKM5XGM5", - None, - "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN fehlerhaft und ungültig'", - "2024-09-24", - ), - ], - ) - } - - #[test] - fn gls_camt053() { - check_tx( - "libeufin-nexus/sample/platform/gls_camt053.xml", - "DE84500105177118117964", - Some("EUR"), - &[ - tx_out( - ( - Some("COMPAT_SUCCESS"), - Some("COMPAT_SUCCESS"), - Some("2024041801514102000"), - ), - "EUR:2", - "EUR:0", - Some("TestABC123"), - "2024-04-18", - Some(("DE20500105172419259181", "John Smith")), - ), - tx_reversal( - "KGTDBASWTJ6JM89WXD3Q5KFQC4", - None, - "Retoure aus SEPA Überweisung multi line", - "2024-09-04", - ), - tx_batch("BATCH_MANY_PART", "2024-09-04"), - tx_in( - ( - None, - Some("BYLADEM1WOR-G2910276709458A2"), - Some("2024041210041357000"), - ), - "EUR:3", - "EUR:0", - Some("Taler FJDQ7W6G7NWX4H9M1MKA12090FRC9K7DA6N0FANDZZFXTR6QHX5G Test.,-"), - "2024-04-12", - Some(("DE84500105177118117964", "John Smith")), - ), - tx_reversal( - "COMPAT_FAILURE", - None, - "IncorrectAccountNumber 'Format of the account number specified is not correct' - 'IBAN ...'", - "2024-04-12", - ), - tx_out( - ( - Some("BATCH_SINGLE_SUCCESS"), - Some("FD622SMXKT5QWSAHDY0H8NYG3G"), - Some("2024090216552232000"), - ), - "EUR:1.1", - "EUR:0", - Some("single 2024-09-02T14:29:52.875253314Z"), - "2024-09-02", - Some(("DE89500105173198527518", "Grothoff Hans")), - ), - tx_out( - ( - Some("YF5QBARGQ0MNY0VK59S477VDG4"), - Some("YF5QBARGQ0MNY0VK59S477VDG4"), - Some("2024041810552821000"), - ), - "EUR:1.1", - "EUR:0", - Some("Simple tx"), - "2024-04-18", - Some(("DE20500105172419259181", "John Smith")), - ), - ], - ) - } - - #[test] - fn gls_camt054() { - check_tx( - "libeufin-nexus/sample/platform/gls_camt054.xml", - "DE84500105177118117964", - Some("EUR"), - &[tx_in( - (None, Some("IS11PGENODEFF2DA8899900378806"), None), - "EUR:2.5", - "EUR:0", - Some("Test ICT"), - "2024-05-05", - Some(("DE84500105177118117964", "Mr Test")), - )], - ); - } - - #[test] - fn maerki_baumann_camt053() { - check_tx( - "libeufin-nexus/sample/platform/maerki_baumann_camt053.xml", - "CH7389144832588726658", - Some("CHF"), - &[ - tx_in( - ( - Some("adbe4a5a-6cea-4263-b259-8ab964561a32"), - Some("41103099704.0002"), - Some("ZV20241104/765446/1"), - ), - "CHF:1", - "CHF:0.2", - Some("SFHP6H24C16A5J05Q3FJW2XN1PB3EK70ZPY 5SJ30ADGY68FWN68G"), - "2024-11-04", - Some(("CH7389144832588726658", "Mr Test")), - ), - tx_in( - ( - Some("7371795e-62fa-42dd-93b7-da89cc120faa"), - Some("41103099704.0003"), - Some("ZV20241104/765447/1"), - ), - "CHF:1", - "CHF:0.2", - Some("Random subject"), - "2024-11-04", - Some(("CH7389144832588726658", "Mr Test")), - ), - tx_in( - (None, Some("50523424675.0001"), Some("ZV20250523/851716/1")), - "CHF:0.5", - "CHF:0.2", - None, - "2025-05-23", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("BATCH_SINGLE_REPORTING"), - Some("5IBJZOWESQGPCSOXSNNBBY49ZURI5W7Q4H"), - Some("ZV20241121/773541/1"), - ), - "CHF:0.1", - "CHF:0", - Some("multi 0 2024-11-21T15:21:59.8859234 63Z"), - "2024-11-27", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("BATCH_SINGLE_REPORTING"), - Some("XZ15UR0XU52QWI7Q4XB88EDS44PLH7DYXH"), - Some("ZV20241121/773541/4"), - ), - "CHF:0.13", - "CHF:0", - Some("multi 3 2024-11-21T15:21:59.8859234 63Z"), - "2024-11-27", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("BATCH_SINGLE_REPORTING"), - Some("A09R35EW0359SZ51464E7TC37A0P2CBK04"), - Some("ZV20241121/773541/3"), - ), - "CHF:0.12", - "CHF:0", - Some("multi 2 2024-11-21T15:21:59.8859234 63Z"), - "2024-11-27", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - ( - Some("BATCH_SINGLE_REPORTING"), - Some("UYXZ78LE9KAIMBY6UNXFYT1K8KNY8VLZLT"), - Some("ZV20241121/773541/2"), - ), - "CHF:0.11", - "CHF:0", - Some("multi 1 2024-11-21T15:21:59.8859234 63Z"), - "2024-11-27", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_in( - ( - Some("f203fbb4-6e13-4c78-9b2a-d852fea6374a"), - Some("41202060702.0001"), - Some("ZV20241202/778108/1"), - ), - "CHF:0.05", - "CHF:0.2", - Some("mini"), - "2024-12-02", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_in( - ( - Some("81b0d8c6-a677-4577-b75e-a639dcc03681"), - Some("41120636093.0001"), - Some("ZV20241121/773118/1"), - ), - "CHF:0.1", - "CHF:0.2", - Some("small transfer test"), - "2024-11-21", - Some(("CH7389144832588726658", "Grothoff Hans")), - ), - tx_out( - (None, None, Some("GB20241220/205792/1")), - "CHF:3000", - "CHF:0", - None, - "2024-12-20", - None, - ), - tx_in( - (None, None, Some("ZV20250114/796191/1")), - "CHF:3003", - "CHF:0", - Some("Fix bad payment by MB."), - "2025-01-27", - None, - ), - tx_in( - (None, Some("F000787951230001"), Some("ZV20250526/852733/1")), - "CHF:1.38", - "CHF:0.2", - Some("Taler XT3D9MADR4V85JBWX47SMJFDQD2FDZDHHPH8R25YDG1KNVTSEH6G"), - "2025-05-26", - Some(("DE20500105172419259181", "Mr German")), - ), - ], - ) - } -} diff --git a/src/iso20022/hac.rs b/src/iso20022/hac.rs @@ -1,197 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::fmt::Display; - -use compact_str::CompactString; -use jiff::Timestamp; -use taler_common::types::utils::date_time_to_utc_ts; - -use crate::{ - iso20022::{HacAction, status_code::StatusReason}, - xml::{self, Xml, XmlAccess}, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CustomerAck { - pub action: HacAction, - pub order_id: Option<CompactString>, - pub code: Option<StatusReason>, - pub info: Box<str>, - pub timestamp: Timestamp, -} - -impl CustomerAck { - fn msg_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { - action, code, info, .. - } = self; - write!(f, "{action}")?; - if let Some(code) = code { - write!(f, "{}", code.code())?; - } - write!(f, " - '{}'", action.description())?; - if let Some(code) = code { - write!(f, " '{}'", code.description())?; - } - if !info.is_empty() { - write!(f, " - '{info}'")?; - } - Ok(()) - } -} - -impl Display for CustomerAck { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { - order_id, - timestamp, - .. - } = self; - write!(f, "{timestamp}")?; - if let Some(id) = order_id { - write!(f, "{id}")?; - } - write!(f, " {}", std::fmt::from_fn(|f| self.msg_fmt(f))) - } -} -/** Parse HAC pain.002 XML file */ -pub fn parse_hac(xml: &[u8]) -> xml::Result<Vec<CustomerAck>> { - Xml::parse(xml, "Document", |root| { - root.one("CstmrPmtStsRpt")? - .many("OrgnlPmtInfAndSts") - .map(|n| { - let mut timestamp = None; - let mut order_id = None; - let info = n.one("StsRsnInf")?; - for entry in info.one("Orgtr").one("Id").one("OrgId")?.many("Othr") { - let value = entry.one("Id"); - let key = entry.one("SchmeNm").one("Prtry")?.text(); - match key { - "TimeStamp" => { - timestamp = Some(date_time_to_utc_ts( - &value.decode(|dt| dt.trim_end_matches('Z').parse())?, - )) - } - "OrderID" => order_id = Some(value.parse()?), - _ => {} - } - } - Ok(CustomerAck { - action: n.one("OrgnlPmtInfId").parse()?, - order_id, - code: info.opt("Rsn").one("Cd").parse()?, - info: info.many("AddtlInf").map(|n| n.text()).collect(), - timestamp: timestamp.unwrap(), - }) - }) - .collect() - }) -} - -#[cfg(test)] -mod test { - use taler_common::types::utils::date_time_to_utc_ts; - - use crate::iso20022::{ - HacAction, - hac::{CustomerAck, parse_hac}, - status_code::StatusReason, - }; - - #[test] - fn hac() { - pub fn ack( - action: HacAction, - order_id: Option<&str>, - code: Option<StatusReason>, - info: &str, - timestamp: &str, - ) -> CustomerAck { - CustomerAck { - action, - order_id: order_id.map(Into::into), - code, - info: info.into(), - timestamp: date_time_to_utc_ts(&timestamp.trim_end_matches('Z').parse().unwrap()), - } - } - pretty_assertions::assert_eq!( - parse_hac(&std::fs::read("libeufin-nexus/sample/platform/hac.xml").unwrap()).unwrap(), - [ - ack( - HacAction::FILE_DOWNLOAD, - None, - Some(StatusReason::TransmissionSuccessful), - "", - "2024-09-02T15:47:30.350Z" - ), - ack( - HacAction::FILE_UPLOAD, - Some("ORDER_SUCCESS"), - Some(StatusReason::TransmissionSuccessful), - "", - "2024-09-02T20:48:43.153Z" - ), - ack( - HacAction::ES_VERIFICATION, - Some("ORDER_SUCCESS"), - Some(StatusReason::ElectronicSignaturesCorrect), - "", - "2024-09-02T20:48:43.153Z" - ), - ack( - HacAction::ORDER_HAC_FINAL_POS, - Some("ORDER_SUCCESS"), - None, - "Some multiline info", - "2024-09-02T20:48:43.153Z" - ), - ack( - HacAction::FILE_DOWNLOAD, - None, - Some(StatusReason::NoDataAvailable), - "", - "2024-09-02T15:47:31.754Z" - ), - ack( - HacAction::FILE_UPLOAD, - Some("ORDER_FAILURE"), - Some(StatusReason::TransmissionSuccessful), - "", - "2024-08-23T15:34:11.987Z" - ), - ack( - HacAction::ES_VERIFICATION, - Some("ORDER_FAILURE"), - Some(StatusReason::IncorrectFileStructure), - "", - "2024-08-23T15:34:13.307Z" - ), - ack( - HacAction::ORDER_HAC_FINAL_NEG, - Some("ORDER_FAILURE"), - None, - "", - "2024-08-23T15:34:13.307Z" - ), - ] - ) - } -} diff --git a/src/iso20022/mod.rs b/src/iso20022/mod.rs @@ -1,182 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use taler_enum_meta::EnumMeta; - -pub mod bank_tx_code; -pub mod camt; -pub mod hac; -pub mod pain001; -pub mod pain002; -pub mod status_code; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(Description, Str)] -#[allow(non_camel_case_types)] -pub enum HacAction { - /// File submitted to the bank - FILE_UPLOAD, - /// File downloaded from the bank - FILE_DOWNLOAD, - /// Electronic signature submitted to the bank - ES_UPLOAD, - /// Electronic signature downloaded from the bank - ES_DOWNLOAD, - /// Signature verification - ES_VERIFICATION, - /// Forwarding to EDS - VEU_FORWARDING, - /// EDS signature verification - VEU_VERIFICATION, - /// Forwarded for postprocessing - VEU_VERIFICATION_END, - /// Cancellation of EDS order - VEU_CANCEL_ORDER, - /// Additional information - ADDITIONAL, - /// HAC end of order (positive) - ORDER_HAC_FINAL_POS, - /// HAC end of order (negative) - ORDER_HAC_FINAL_NEG, - // Not in the spec but Credit Suisse test suite use it - /// HAC end of order - ORDER_HAC_FINAL, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(Description, Str)] -pub enum ChargeBearer { - /// BorneByDebtor - DEBT, - /// BorneByCreditor - CRED, - /// Shared - SHAR, - /// SLEV - SLEV, -} - -#[cfg(test)] -pub mod test { - use tracing::info; - - use crate::{ - ebics::administrative::{parse_haa, parse_hkd}, - iso20022::{camt::parse_camt, hac::parse_hac, pain002::parse_pain002}, - }; - - #[test] - pub fn sample() { - taler_test_utils::setup_tracing(); - let mut samples = Vec::new(); - for entry in std::fs::read_dir("testbench/sample").unwrap() { - let entry = entry.unwrap(); - let path = entry.path(); - if path.is_dir() { - for entry in std::fs::read_dir(path).unwrap() { - let entry = entry.unwrap(); - samples.push((entry.path(), entry.file_name())); - } - } else { - samples.push((path, entry.file_name())); - } - } - for (path, name) in samples { - let xml = std::fs::read(&path).unwrap(); - let name = name.to_string_lossy(); - - info!("Parse sample {path:?}"); - - if name.contains("hac") { - parse_hac(&xml).unwrap(); - } else if name.contains("camt") { - parse_camt(&xml).unwrap(); - } else if name.contains("pain002") { - parse_pain002(&xml).unwrap(); - } else if name.contains("pain001") { - // Ignore - } else { - panic!("Unsupported file type {name}") - } - } - } - - #[test] - pub fn logs() { - taler_test_utils::setup_tracing(); - - if !std::fs::exists("testbench/test").unwrap() { - return; - } - for platform in std::fs::read_dir("testbench/test") - .unwrap() - .map(Result::unwrap) - { - let path = platform.path(); - if !path.is_dir() || platform.file_name() == "platform" { - continue; - } - - // List logs - let mut logs = Vec::new(); - for date in std::fs::read_dir(path).unwrap().map(Result::unwrap) { - let path = date.path(); - if !path.is_dir() { - continue; - } - for tx in std::fs::read_dir(path).unwrap().map(Result::unwrap) { - let payload = tx.path().join("payload"); - if payload.exists() { - logs.extend( - std::fs::read_dir(payload) - .unwrap() - .map(|it| it.unwrap().path()), - ); - } - let payload = tx.path().join("payload.xml"); - if payload.exists() { - logs.push(payload); - } - } - } - for path in logs { - let xml = std::fs::read(&path).unwrap(); - let path = path.to_string_lossy(); - - info!("Parse sample {path:?}"); - - if path.contains("HAC") { - parse_hac(&xml).unwrap(); - } else if path.contains("HKD") { - parse_hkd(&xml).unwrap(); - } else if path.contains("HAA") { - parse_haa(&xml).unwrap(); - } else if path.contains("camt") { - parse_camt(&xml).unwrap(); - } else if path.contains("pain.002") { - parse_pain002(&xml).unwrap(); - } else if path.contains("pain.001") { - // Ignore - } else { - panic!("Unsupported file type {path}") - } - } - } - } -} diff --git a/src/iso20022/pain001.rs b/src/iso20022/pain001.rs @@ -1,246 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use jiff::{Timestamp, Zoned, tz::TimeZone}; -use taler_common::types::{ - amount::{Amount, Decimal}, - payto::FullIbanPayto, -}; - -use crate::{ - dialect::{Dialect, Standard}, - ebics::EbicsErrKind, - xml, - xml::XmlWriter, -}; - -/** pain.001 transaction metadata */ -pub struct Pain001Tx<'a> { - pub creditor: FullIbanPayto, - pub amount: Amount, - pub subject: &'a str, - pub e2e_id: &'a str, -} - -/** pain.001 message metadata */ -pub struct Pain001Msg<'a> { - pub msg_id: &'a str, - pub timestamp: &'a Timestamp, - pub debtor: &'a FullIbanPayto, - pub sum: Amount, - pub txs: Vec<Pain001Tx<'a>>, -} - -/** Check EBICS compability of an amount */ -fn ebics_amount(amount: &Amount) -> Result<Decimal, EbicsErrKind> { - if amount.is_sub_cent() { - return Err(EbicsErrKind::Custom( - "Sub-cent amounts not supported".into(), - )); - } - Ok(amount.decimal()) -} - -/** Create a pain.001 XML document [msg] valid for [dialect] */ -pub fn create_pain001( - msg: &Pain001Msg, - dialect: &Dialect, - instant: bool, -) -> Result<String, EbicsErrKind> { - let version = "09"; - let suffix = match dialect.standard() { - Standard::SIX => ".ch.03", - Standard::GBIC => "", - }; - let total = ebics_amount(&msg.sum)?; - Ok(xml!( - "Document" - "xmlns"=(format_args!("urn:iso:std:iso:20022:tech:xsd:pain.001.001.{version}")) - "xmlns:xsi"=(format_args!("http://www.w3.org/2001/XMLSchema-instance")) - "xsi:schemaLocation"=(format_args!("urn:iso:std:iso:20022:tech:xsd:pain.001.001.{version} pain.001.001.{version}{suffix}.xsd")) - { - "CstmrCdtTrfInitn" { - "GrpHdr" { - // Used for idempotency as banks will refuse to process EBICS request with the same MsgId for a pre-agreed period - // Used to uniquely identify batches of transactions in other files - "MsgId": msg.msg_id, - "CreDtTm": msg.timestamp, - "NbOfTxs": msg.txs.len(), - "CtrlSum": total, - "InitgPty" { - "Nm": msg.debtor.name - }/* - // TODO fail with GLS: ES_VERIFICATION IncorrectFileStructure - 'Signature verification' 'The file format is incomplete or invalid' - "InitnSrc" { - "Nm": "LibEuFin", - "Prvdr": "Taler Systems SA", - "Vrsn": taler_build::long_version() - }*/ - }, - "PmtInf" { - "PmtInfId": "NOTPROVIDED", - "PmtMtd": "TRF", - "BtchBookg": "false", - "NbOfTxs": msg.txs.len(), - "CtrlSum": total, - @ |w: &mut XmlWriter| if dialect.standard() == Standard::GBIC { - xml!(w => "PmtTpInf" { - "SvcLvl" { - "Cd": "SEPA" - }, - @ |w: &mut XmlWriter| if instant { - xml!(w => "LclInstrm" { - "Cd": "INST" - }) - } - }) - }, - "ReqdExctnDt" { - "Dt": Zoned::new(*msg.timestamp, TimeZone::UTC).date().to_string() + "Z" - }, - "Dbtr" { - "Nm": msg.debtor.name - }, - "DbtrAcct" { - "Id" { - "IBAN": msg.debtor.iban - } - }, - "DbtrAgt" { - "FinInstnId" { - @ |w: &mut XmlWriter| if let Some(bic) = &msg.debtor.bic { - xml!(w => "BICFI": bic) - } else { - xml!(w => "Othr" { - "Id": "NOTPROVIDED" - }) - } - } - - }, - "ChrgBr": "SLEV", - @ |w: &mut XmlWriter| for tx in &msg.txs { - xml!(w => "CdtTrfTxInf" { - "PmtId" { - "InstrId": tx.e2e_id, - // Used to uniquely identify transactions in other files - "EndToEndId": tx.e2e_id - }, - "Amt" { - "InstdAmt" "Ccy"=(tx.amount.currency) : ebics_amount(&tx.amount).unwrap() - }, - @ |w: &mut XmlWriter| if let Some(bic) = &tx.creditor.bic { - xml!(w => "CdtrAgt" { - "FinInstnId" { - "BICFI": bic - } - }) - }, - "Cdtr" { - "Nm": tx.creditor.name - // Addr might become a requirement in the future - /*"PstlAdr" { - "TwnNm": "Bochum", - "Ctry": "DE" - }*/ - }, - "CdtrAcct" { - "Id" { - "IBAN": tx.creditor.iban - } - }, - "RmtInf" { - "Ustrd": tx.subject - } - }) - } - } - } - } - )) -} - -#[cfg(test)] -mod test { - use taler_common::types::{ - amount::amount, - payto::{BankID, FullIbanPayto}, - }; - - use crate::{ - dialect::Dialect, - iso20022::{ - camt::test::date_to_timestamp, - pain001::{Pain001Msg, Pain001Tx, create_pain001}, - }, - }; - - #[test] - fn pain001() { - let creditor = FullIbanPayto::new( - BankID { - iban: "CH4189144589712575493".parse().expect("invalid IBAN"), - bic: None, - }, - "Test", - ); - - let msg = Pain001Msg { - msg_id: "MESSAGE_ID".into(), - timestamp: &date_to_timestamp("2024-09-09"), - debtor: &FullIbanPayto::new( - BankID { - iban: "CH7789144474425692816".parse().expect("invalid IBAN"), - bic: Some("AAAABBCC123".parse().expect("invalid BIC")), - }, - "myname", - ), - sum: amount("CHF:47.32"), - txs: vec![ - Pain001Tx { - creditor: creditor.clone(), - amount: amount("CHF:42"), - subject: "Test 42", - e2e_id: "TX_FIRST", - }, - Pain001Tx { - creditor: creditor.clone(), - amount: amount("CHF:5.11"), - subject: "Test 5.11".into(), - e2e_id: "TX_SECOND", - }, - Pain001Tx { - creditor: creditor, - amount: amount("CHF:0.21"), - subject: "Test 0.21", - e2e_id: "TX_THIRD", - }, - ], - }; - for dialect in Dialect::entries { - pretty_assertions::assert_eq!( - std::fs::read_to_string(format!( - "libeufin-nexus/sample/platform/{dialect}_pain001.xml" - )) - .unwrap(), - create_pain001(&msg, dialect, false).unwrap() - ); - } - } -} diff --git a/src/iso20022/pain002.rs b/src/iso20022/pain002.rs @@ -1,270 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::fmt::{Display, Formatter, Write, from_fn}; - -use compact_str::CompactString; - -use crate::{ - iso20022::status_code::{PaymentGroupStatus, PaymentTransactionStatus, StatusReason}, - xml::{self, Xml, XmlAccess}, -}; - -fn fmt_msg( - f: &mut Formatter<'_>, - code: Option<&str>, - description: Option<&str>, - reasons: &[Reason], -) -> std::fmt::Result { - if let Some(code) = code { - write!(f, "{code}")?; - if let Some(description) = description { - write!(f, " '{description}'")?; - } - if !reasons.is_empty() { - f.write_char(':')?; - } - } - for Reason { - code, - info: information, - } in reasons - { - if let Some(code) = code { - write!(f, " {} '{}'", code.code(), code.description())?; - } - if !information.is_empty() { - write!(f, " '{information}'")?; - } - } - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Reason { - pub code: Option<StatusReason>, - pub info: Box<str>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TxStatus { - pub id: CompactString, - pub e2e_id: CompactString, - pub status: PaymentTransactionStatus, - pub reasons: Box<[Reason]>, -} - -impl TxStatus { - fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - fmt_msg( - f, - Some(self.status.code()), - Some(self.status.description()), - &self.reasons, - ) - } - - pub fn msg(&self) -> String { - format!("{}", from_fn(|f| self.fmt_msg(f))) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PmtStatus { - pub id: CompactString, - pub status: Option<PaymentGroupStatus>, - pub reasons: Box<[Reason]>, - pub txs: Box<[TxStatus]>, -} - -impl PmtStatus { - fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - fmt_msg( - f, - self.status.map(|it| it.code()), - self.status.map(|it| it.description()), - &self.reasons, - ) - } - pub fn msg(&self) -> String { - format!("{}", from_fn(|f| self.fmt_msg(f))) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MsgStatus { - pub id: CompactString, - pub status: Option<PaymentGroupStatus>, - pub reasons: Box<[Reason]>, - pub payments: Box<[PmtStatus]>, -} - -impl MsgStatus { - fn fmt_msg(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - fmt_msg( - f, - self.status.map(|it| it.code()), - self.status.map(|it| it.description()), - &self.reasons, - ) - } - pub fn msg(&self) -> String { - format!("{}", from_fn(|f| self.fmt_msg(f))) - } -} - -impl Display for MsgStatus { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{} {}", self.id, from_fn(|f| self.fmt_msg(f)))?; - for p in &self.payments { - write!(f, "\n>{} {}", p.id, from_fn(|f| p.fmt_msg(f)))?; - for tx in &p.txs { - if tx.id != tx.e2e_id { - write!(f, "{} ", tx.id)?; - } - write!(f, "\n>>{} {}", tx.e2e_id, from_fn(|f| tx.fmt_msg(f)))?; - } - } - Ok(()) - } -} - -/** Parse pain.002 XML file */ -pub fn parse_pain002(xml: &[u8]) -> xml::Result<MsgStatus> { - fn reasons(x: Xml) -> xml::Result<Box<[Reason]>> { - x.many("StsRsnInf") - .map(|n| { - let code = n.opt("Rsn").one("Cd").parse()?; - let info = n.many("AddtlInf").map(Xml::text).collect(); - Ok(Reason { code, info }) - }) - .collect() - } - - Xml::parse(xml, "Document", |root| { - let n = root.one("CstmrPmtStsRpt")?; - let status = n.one("OrgnlGrpInfAndSts")?; - Ok(MsgStatus { - id: status.one("OrgnlMsgId").parse()?, - status: status.opt("GrpSts").parse()?, - reasons: reasons(status)?, - payments: n - .many("OrgnlPmtInfAndSts") - .map(|n| { - Ok(PmtStatus { - id: n.one("OrgnlPmtInfId").parse()?, - status: n.opt("PmtInfSts").parse()?, - reasons: reasons(n)?, - txs: n - .many("TxInfAndSts") - .map(|n| { - Ok(TxStatus { - id: n.one("OrgnlInstrId").parse()?, - e2e_id: n.one("OrgnlEndToEndId").parse()?, - status: n.one("TxSts").parse()?, - reasons: reasons(n)?, - }) - }) - .collect::<xml::Result<_>>()?, - }) - }) - .collect::<xml::Result<_>>()?, - }) - }) -} - -#[cfg(test)] -mod test { - use crate::iso20022::{ - pain002::{MsgStatus, PmtStatus, Reason, TxStatus, parse_pain002}, - status_code::{PaymentGroupStatus, PaymentTransactionStatus, StatusReason}, - }; - - #[test] - fn pain002() { - pretty_assertions::assert_eq!( - parse_pain002(&std::fs::read("libeufin-nexus/sample/platform/pain002_part.xml").unwrap()).unwrap(), - MsgStatus { - id: "05BD4C5B4A2649B5B08F6EF6A31F197A".into(), - status: Some(PaymentGroupStatus::PartiallyAccepted), - reasons: Box::default(), - payments: Box::new([PmtStatus { - id: "NOTPROVIDED".into(), - status: Some(PaymentGroupStatus::PartiallyAccepted), - reasons: Box::new([ - Reason { - code: Some(StatusReason::ExecutionDateChanged), - info: "Due date is not a working day. Order will be executed on the next working day".into() - } - ]), - txs: Box::new([ - TxStatus { - id: "AQCXNCPWD8PHW5JTN65Y5XTF7R".into(), - e2e_id: "AQCXNCPWD8PHW5JTN65Y5XTF7R".into(), - status: PaymentTransactionStatus::Rejected, - reasons: Box::new([ - Reason { - code: Some(StatusReason::ClosedAccountNumber), - info: "Error message".into() - } - ]) - }, - TxStatus { - id: "EE9SX76FC5YSC657EK3GMVZ9TC".into(), - e2e_id: "EE9SX76FC5YSC657EK3GMVZ9TC".into(), - status: PaymentTransactionStatus::Rejected, - reasons: Box::new([ - Reason { - code: Some(StatusReason::NotSpecifiedReasonAgentGenerated), - info: "Error message".into() - } - ]) - }, - TxStatus { - id: "V5B3MXPEWES9VQW1JDRD6VAET4".into(), - e2e_id: "V5B3MXPEWES9VQW1JDRD6VAET4".into(), - status: PaymentTransactionStatus::Rejected, - reasons: Box::new([ - Reason { - code: Some(StatusReason::MissingDebtorNameOrAddress), - info: "Error message".into() - } - ]) - } - ]) - }]) - } - ); - pretty_assertions::assert_eq!( - parse_pain002( - &std::fs::read("libeufin-nexus/sample/platform/pain002_accp.xml").unwrap() - ) - .unwrap(), - MsgStatus { - id: "5HIS3433VVIBAANHW3GX9DR1AXRS43KZ4U".into(), - status: Some(PaymentGroupStatus::AcceptedCustomerProfile), - reasons: Box::new([Reason { - code: None, - info: "PN10630020F0297329.20251030104613.EBTUAAAC.PN1.0002372".into() - }]), - payments: Box::default() - } - ); - } -} diff --git a/src/iso20022/status_code.rs b/src/iso20022/status_code.rs @@ -1,1374 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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 - -use taler_enum_meta::EnumMeta; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(DomainCode, Description, Str)] -pub enum StatusReason { - /// Clearing process aborted due to timeout - #[code = "AB01"] - AbortedClearingTimeout, - /// Clearing process aborted due to a fatal error - #[code = "AB02"] - AbortedClearingFatalError, - /// Settlement aborted due to timeout - #[code = "AB03"] - AbortedSettlementTimeout, - /// Settlement process aborted due to a fatal error - #[code = "AB04"] - AbortedSettlementFatalError, - /// Transaction stopped due to timeout at the Creditor Agent - #[code = "AB05"] - TimeoutCreditorAgent, - /// Transaction stopped due to timeout at the Instructed Agent - #[code = "AB06"] - TimeoutInstructedAgent, - /// Agent of message is not online - #[code = "AB07"] - OfflineAgent, - /// Creditor Agent is not online - #[code = "AB08"] - OfflineCreditorAgent, - /// Transaction stopped due to error at the Creditor Agent - #[code = "AB09"] - ErrorCreditorAgent, - /// Transaction stopped due to error at the Instructed Agent - #[code = "AB10"] - ErrorInstructedAgent, - /// Transaction stopped due to timeout at the Debtor Agent - #[code = "AB11"] - TimeoutDebtorAgent, - /// Duplicate Concurrent Batch Sequence number– for Settlement Instructions - #[code = "AB12"] - InvalidConcurrentBatch, - /// Wrong Message Routing Type for Return-of-Funds - #[code = "AB13"] - InvalidRoutingCodeUtilised, - /// Instruction may not be placed on the Continuous Processing Line settlement processor - #[code = "AB15"] - InvalidAccountNumberForSettlementType, - /// Agreement number not valid (beneficiary) - #[code = "AB21"] - InvalidSettlementAgreementNumberSpecified, - /// Settlement Instruction does not exist - #[code = "AB26"] - InvalidBatchSettlementInstruction, - /// Account number is invalid or missing - #[code = "AC01"] - IncorrectAccountNumber, - /// Debtor account number invalid or missing - #[code = "AC02"] - InvalidDebtorAccountNumber, - /// Creditor account number invalid or missing - #[code = "AC03"] - InvalidCreditorAccountNumber, - /// Account number specified has been closed on the bank of account's books - #[code = "AC04"] - ClosedAccountNumber, - /// Debtor account number closed - #[code = "AC05"] - ClosedDebtorAccountNumber, - /// Account specified is blocked, prohibiting posting of transactions against it - #[code = "AC06"] - BlockedAccount, - /// Creditor account number closed - #[code = "AC07"] - ClosedCreditorAccountNumber, - /// Branch code is invalid or missing - #[code = "AC08"] - InvalidBranchCode, - /// Account currency is invalid or missing - #[code = "AC09"] - InvalidAccountCurrency, - /// Debtor account currency is invalid or missing - #[code = "AC10"] - InvalidDebtorAccountCurrency, - /// Creditor account currency is invalid or missing - #[code = "AC11"] - InvalidCreditorAccountCurrency, - /// Account type missing or invalid - #[code = "AC12"] - InvalidAccountType, - /// Debtor account type missing or invalid - #[code = "AC13"] - InvalidDebtorAccountType, - /// Creditor account type missing or invalid - #[code = "AC14"] - InvalidCreditorAccountType, - /// The account details for the counterparty have changed - #[code = "AC15"] - AccountDetailsChanged, - /// Credit or debit card number is invalid - #[code = "AC16"] - CardNumberInvalid, - /// Request-to-pay Expiry Date and Time has already passed - #[code = "AEXR"] - AlreadyExpiredRTP, - /// Transaction forbidden on this type of account (formerly NoAgreement) - #[code = "AG01"] - TransactionForbidden, - /// Bank Operation code specified in the message is not valid for receiver - #[code = "AG02"] - InvalidBankOperationCode, - /// Transaction type not supported/authorized on this account - #[code = "AG03"] - TransactionNotSupported, - /// Agent country code is missing or invalid - #[code = "AG04"] - InvalidAgentCountry, - /// Debtor agent country code is missing or invalid - #[code = "AG05"] - InvalidDebtorAgentCountry, - /// Creditor agent country code is missing or invalid - #[code = "AG06"] - InvalidCreditorAgentCountry, - /// Debtor account cannot be debited for a generic reason - #[code = "AG07"] - UnsuccesfulDirectDebit, - /// Transaction failed due to invalid or missing user or access right - #[code = "AG08"] - InvalidAccessRights, - /// Original payment never received - #[code = "AG09"] - PaymentNotReceived, - /// Agent of message is suspended from the Real Time Payment system - #[code = "AG10"] - AgentSuspended, - /// Creditor Agent of message is suspended from the Real Time Payment system - #[code = "AG11"] - CreditorAgentSuspended, - /// Payment orders made by transferring funds from one account to another at the same financial institution (bank or payment institution) are not allowed - #[code = "AG12"] - NotAllowedBookTransfer, - /// Returned payments derived from previously returned transactions are not allowed - #[code = "AG13"] - ForbiddenReturnPayment, - /// Agent in the payment workflow is incorrect - #[code = "AGNT"] - IncorrectAgent, - /// Request-to-pay has already been accepted by the Debtor - #[code = "ALAC"] - AlreadyAcceptedRTP, - /// Specified message amount is equal to zero - #[code = "AM01"] - ZeroAmount, - /// Specific transaction/message amount is greater than allowed maximum - #[code = "AM02"] - NotAllowedAmount, - /// Specified message amount is an non processable currency outside of existing agreement - #[code = "AM03"] - NotAllowedCurrency, - /// Amount of funds available to cover specified message amount is insufficient - #[code = "AM04"] - InsufficientFunds, - /// Duplication - #[code = "AM05"] - Duplication, - /// Specified transaction amount is less than agreed minimum - #[code = "AM06"] - TooLowAmount, - /// Amount specified in message has been blocked by regulatory authorities - #[code = "AM07"] - BlockedAmount, - /// Amount received is not the amount agreed or expected - #[code = "AM09"] - WrongAmount, - /// Sum of instructed amounts does not equal the control sum - #[code = "AM10"] - InvalidControlSum, - /// Transaction currency is invalid or missing - #[code = "AM11"] - InvalidTransactionCurrency, - /// Amount is invalid or missing - #[code = "AM12"] - InvalidAmount, - /// Transaction amount exceeds limits set by clearing system - #[code = "AM13"] - AmountExceedsClearingSystemLimit, - /// Transaction amount exceeds limits agreed between bank and client - #[code = "AM14"] - AmountExceedsAgreedLimit, - /// Transaction amount below minimum set by clearing system - #[code = "AM15"] - AmountBelowClearingSystemMinimum, - /// Control Sum at the Group level is invalid - #[code = "AM16"] - InvalidGroupControlSum, - /// Control Sum at the Payment Information level is invalid - #[code = "AM17"] - InvalidPaymentInfoControlSum, - /// Number of transactions is invalid or missing - #[code = "AM18"] - InvalidNumberOfTransactions, - /// Number of transactions at the Group level is invalid or missing - #[code = "AM19"] - InvalidGroupNumberOfTransactions, - /// Number of transactions at the Payment Information level is invalid - #[code = "AM20"] - InvalidPaymentInfoNumberOfTransactions, - /// Transaction amount exceeds limits agreed between bank and client - #[code = "AM21"] - LimitExceeded, - /// Unable to apply zero amount to designated account - #[code = "AM22"] - ZeroAmountNotApplied, - /// Transaction amount exceeds settlement limit - #[code = "AM23"] - AmountExceedsSettlementLimit, - /// Size of the attachment exceeds the allowed maximum - #[code = "AMSE"] - AttachmentMaximumSize, - /// Request To Pay has already been paid by the Debtor - #[code = "APAR"] - AlreadyPaidRTP, - /// Request-to-pay has already been refused by the Debtor - #[code = "ARFR"] - AlreadyRefusedRTP, - /// Request-to-pay has already been rejected - #[code = "ARJR"] - AlreadyRejectedRTP, - /// Attachments to the request-to-pay are not supported - #[code = "ATNS"] - AttachementsNotSupported, - /// Settlement Cycle Day and Calendar day should be the same - #[code = "BDAY"] - NotBusinessDay, - /// Identification of end customer is not consistent with associated account number - #[code = "BE01"] - InconsistenWithEndCustomer, - /// Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress) - #[code = "BE04"] - MissingCreditorAddress, - /// Party who initiated the message is not recognised by the end customer - #[code = "BE05"] - UnrecognisedInitiatingParty, - /// End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books - #[code = "BE06"] - UnknownEndCustomer, - /// Specification of debtor's address, which is required for payment, is missing/not correct - #[code = "BE07"] - MissingDebtorAddress, - /// Debtor name is missing - #[code = "BE08"] - MissingDebtorName, - /// Country code is missing or Invalid - #[code = "BE09"] - InvalidCountry, - /// Debtor country code is missing or invalid - #[code = "BE10"] - InvalidDebtorCountry, - /// Creditor country code is missing or invalid - #[code = "BE11"] - InvalidCreditorCountry, - /// Country code of residence is missing or Invalid - #[code = "BE12"] - InvalidCountryOfResidence, - /// Country code of debtor's residence is missing or Invalid - #[code = "BE13"] - InvalidDebtorCountryOfResidence, - /// Country code of creditor's residence is missing or Invalid - #[code = "BE14"] - InvalidCreditorCountryOfResidence, - /// Identification code missing or invalid - #[code = "BE15"] - InvalidIdentificationCode, - /// Debtor or Ultimate Debtor identification code missing or invalid - #[code = "BE16"] - InvalidDebtorIdentificationCode, - /// Creditor or Ultimate Creditor identification code missing or invalid - #[code = "BE17"] - InvalidCreditorIdentificationCode, - /// Contact details missing or invalid - #[code = "BE18"] - InvalidContactDetails, - /// Charge bearer code for transaction type is invalid - #[code = "BE19"] - InvalidChargeBearerCode, - /// Name length exceeds local rules for payment type - #[code = "BE20"] - InvalidNameLength, - /// Name missing or invalid - #[code = "BE21"] - MissingName, - /// Creditor name is missing - #[code = "BE22"] - MissingCreditorName, - /// Phone number or email address, or any other proxy, used as the account proxy is unknown or invalid - #[code = "BE23"] - AccountProxyInvalid, - /// Credit transfer is not tagged as an Extended Remittance Information (ERI) transaction but contains ERI - #[code = "CERI"] - CheckERI, - /// Value in Requested Execution Date or Requested Collection Date is too far in the future - #[code = "CH03"] - RequestedExecutionDateOrRequestedCollectionDateTooFarInFuture, - /// Value in Requested Execution Date or Requested Collection Date is too far in the past - #[code = "CH04"] - RequestedExecutionDateOrRequestedCollectionDateTooFarInPast, - /// Element is not to be used at B- and C-Level - #[code = "CH07"] - ElementIsNotToBeUsedAtBandCLevel, - /// Mandate changes are not allowed - #[code = "CH09"] - MandateChangesNotAllowed, - /// Information on mandate changes are missing - #[code = "CH10"] - InformationOnMandateChangesMissing, - /// Value in Creditor Identifier is incorrect - #[code = "CH11"] - CreditorIdentifierIncorrect, - /// Creditor Identifier is ambiguous at Transaction Level - #[code = "CH12"] - CreditorIdentifierNotUnambiguouslyAtTransactionLevel, - /// Original Debtor Account is not to be used - #[code = "CH13"] - OriginalDebtorAccountIsNotToBeUsed, - /// Original Debtor Agent is not to be used - #[code = "CH14"] - OriginalDebtorAgentIsNotToBeUsed, - /// Content Remittance Information/Structured includes more than 140 characters - #[code = "CH15"] - ElementContentIncludesMoreThan140Characters, - /// Content is incorrect - #[code = "CH16"] - ElementContentFormallyIncorrect, - /// Element is not allowed - #[code = "CH17"] - ElementNotAdmitted, - /// Values in Interbank Settlement Date or Requested Collection Date will be set to the next TARGET day - #[code = "CH19"] - ValuesWillBeSetToNextTARGETday, - /// Number of decimal points not compatible with the currency - #[code = "CH20"] - DecimalPointsNotCompatibleWithCurrency, - /// Mandatory element is missing - #[code = "CH21"] - RequiredCompulsoryElementMissing, - /// SDD CORE and B2B not permitted within one message - #[code = "CH22"] - COREandB2BwithinOnemessage, - /// Related to a Charge message to convey that the code in Charge Breakdown / Type / Code is not accepted by the receiving party - #[code = "CHCO"] - UnacceptedChargeCodeType, - /// Cheque has been presented in cheque clearing and settled on the creditor’s account - #[code = "CHQC"] - ChequeSettledOnCreditorAccount, - /// Related to a Charge message to convey that the charge bearer code used in the corresponding Payment message was not debt - #[code = "CHRG"] - UnderlyingChargeBearerWasNotDebt, - /// Authorisation is cancelled - #[code = "CN01"] - AuthorisationCancelled, - /// Credit notes are not supported - #[code = "CNNS"] - CreditNotesNotSupported, - /// Creditor bank is not registered under this BIC in the CSM - #[code = "CNOR"] - CreditorBankIsNotRegistered, - /// Currency of the payment is incorrect - #[code = "CURR"] - IncorrectCurrency, - /// Cancellation requested by the Debtor - #[code = "CUST"] - RequestedByCustomer, - /// Rejection of a payment due to covering FI settlement not being received - #[code = "DC02"] - SettlementNotReceived, - /// Debtor bank is not registered under this BIC in the CSM - #[code = "DNOR"] - DebtorBankIsNotRegistered, - /// The electronic signature(s) is/are correct - #[code = "DS01"] - ElectronicSignaturesCorrect, - /// An authorized user has cancelled the order - #[code = "DS02"] - OrderCancelled, - /// The user’s attempt to cancel the order was not successful - #[code = "DS03"] - OrderNotCancelled, - /// The order was rejected by the bank side (for reasons concerning content) - #[code = "DS04"] - OrderRejected, - /// The order was correct and could be forwarded for postprocessing - #[code = "DS05"] - OrderForwardedForPostprocessing, - /// The order was transferred to VEU - #[code = "DS06"] - TransferOrder, - /// All actions concerning the order could be done by the EBICS bank server - #[code = "DS07"] - ProcessingOK, - /// The decompression of the file was not successful - #[code = "DS08"] - DecompressionError, - /// The decryption of the file was not successful - #[code = "DS09"] - DecryptionError, - /// Data signature is required - #[code = "DS0A"] - DataSignRequested, - /// Data signature for the format is not available or invalid - #[code = "DS0B"] - UnknownDataSignFormat, - /// The signer certificate is revoked - #[code = "DS0C"] - SignerCertificateRevoked, - /// The signer certificate is not valid (revoked or not active) - #[code = "DS0D"] - SignerCertificateNotValid, - /// The signer certificate is not present - #[code = "DS0E"] - IncorrectSignerCertificate, - /// The authority of the signer certification sending the certificate is unknown - #[code = "DS0F"] - SignerCertificationAuthoritySignerNotValid, - /// Signer is not allowed to sign this operation type - #[code = "DS0G"] - NotAllowedPayment, - /// Signer is not allowed to sign for this account - #[code = "DS0H"] - NotAllowedAccount, - /// The number of transaction is over the number allowed for this signer - #[code = "DS0K"] - NotAllowedNumberOfTransaction, - /// The certificate is revoked for the first signer - #[code = "DS10"] - Signer1CertificateRevoked, - /// The certificate is not valid (revoked or not active) for the first signer - #[code = "DS11"] - Signer1CertificateNotValid, - /// The certificate is not present for the first signer - #[code = "DS12"] - IncorrectSigner1Certificate, - /// The authority of signer certification sending the certificate is unknown for the first signer - #[code = "DS13"] - SignerCertificationAuthoritySigner1NotValid, - /// The user is unknown on the server - #[code = "DS14"] - UserDoesNotExist, - /// The same signature has already been sent to the bank - #[code = "DS15"] - IdenticalSignatureFound, - /// The public key version is not correct - #[code = "DS16"] - PublicKeyVersionIncorrect, - /// Order data and signatures don’t match - #[code = "DS17"] - DifferentOrderDataInSignatures, - /// File cannot be tested, the complete order has to be repeated - #[code = "DS18"] - RepeatOrder, - /// The user’s rights (concerning his signature) are insufficient to execute the order - #[code = "DS19"] - ElectronicSignatureRightsInsufficient, - /// The certificate is revoked for the second signer - #[code = "DS20"] - Signer2CertificateRevoked, - /// The certificate is not valid (revoked or not active) for the second signer - #[code = "DS21"] - Signer2CertificateNotValid, - /// The certificate is not present for the second signer - #[code = "DS22"] - IncorrectSigner2Certificate, - /// The authority of signer certification sending the certificate is unknown for the second signer - #[code = "DS23"] - SignerCertificationAuthoritySigner2NotValid, - /// Waiting time expired due to incomplete order - #[code = "DS24"] - WaitingTimeExpired, - /// The order file was deleted by the bank server - #[code = "DS25"] - OrderFileDeleted, - /// The same user has signed multiple times - #[code = "DS26"] - UserSignedMultipleTimes, - /// The user is not yet activated (technically) - #[code = "DS27"] - UserNotYetActivated, - /// Message routed to the wrong environment - #[code = "DS28"] - ReturnForTechnicalReason, - /// Invalid date (eg, wrong or missing settlement date) - #[code = "DT01"] - InvalidDate, - /// Invalid creation date and time in Group Header (eg, historic date) - #[code = "DT02"] - InvalidCreationDate, - /// Invalid non bank processing date (eg, weekend or local public holiday) - #[code = "DT03"] - InvalidNonProcessingDate, - /// Future date not supported - #[code = "DT04"] - FutureDateNotSupported, - /// Associated message, payment information block or transaction was received after agreed processing cut-off date, i - #[code = "DT05"] - InvalidCutOffDate, - /// Execution Date has been modified in order for transaction to be processed - #[code = "DT06"] - ExecutionDateChanged, - /// Message Identification is not unique - #[code = "DU01"] - DuplicateMessageID, - /// Payment Information Block is not unique - #[code = "DU02"] - DuplicatePaymentInformationID, - /// Transaction is not unique - #[code = "DU03"] - DuplicateTransaction, - /// End To End ID is not unique - #[code = "DU04"] - DuplicateEndToEndID, - /// Instruction ID is not unique - #[code = "DU05"] - DuplicateInstructionID, - /// Payment or charge is a duplicate of another payment or charge - #[code = "DUPL"] - DuplicatePaymentOrCharge, - /// Correspondent bank not possible - #[code = "ED01"] - CorrespondentBankNotPossible, - /// Balance of payments complementary info is requested - #[code = "ED03"] - BalanceInfoRequest, - /// Settlement of the transaction has failed - #[code = "ED05"] - SettlementFailed, - /// Interbank settlement system not available - #[code = "ED06"] - SettlementSystemNotAvailable, - /// Requested execution date of the payment is not accepted - #[code = "EDNA"] - ExecutionDateNotAccepted, - /// Expiry date time of the request-to-pay is too far in the future - #[code = "EDTL"] - ExpiryDateTooLong, - /// Expiry date time of the request-to-pay is already reached - #[code = "EDTR"] - ExpiryDateTimeReached, - /// Expiration of the payment authorisation due to no use for too long - #[code = "EOL1"] - EndOfLife, - /// Extended Remittance Information (ERI) option is not supported - #[code = "ERIN"] - ERIOptionNotSupported, - /// File Format incomplete or invalid - #[code = "FF01"] - InvalidFileFormat, - /// Syntax error reason is provided as narrative information in the additional reason information - #[code = "FF02"] - SyntaxError, - /// Payment Type Information is missing or invalid - #[code = "FF03"] - InvalidPaymentTypeInformation, - /// Service Level code is missing or invalid - #[code = "FF04"] - InvalidServiceLevelCode, - /// Local Instrument code is missing or invalid - #[code = "FF05"] - InvalidLocalInstrumentCode, - /// Category Purpose code is missing or invalid - #[code = "FF06"] - InvalidCategoryPurposeCode, - /// Purpose is missing or invalid - #[code = "FF07"] - InvalidPurpose, - /// End to End Id missing or invalid - #[code = "FF08"] - InvalidEndToEndId, - /// Cheque number missing or invalid - #[code = "FF09"] - InvalidChequeNumber, - /// File or transaction cannot be processed due to technical issues at the bank side - #[code = "FF10"] - BankSystemProcessingError, - /// Clearing request rejected due it being subject to an abort operation - #[code = "FF11"] - ClearingRequestAborted, - /// Original payment is not eligible to be returned given its current status - #[code = "FF12"] - OriginalTransactionNotEligibleForRequestedReturn, - /// No record of request for cancellation found - #[code = "FF13"] - RequestForCancellationNotFound, - /// Return following a cancellation request - #[code = "FOCR"] - FollowingCancellationRequest, - /// Returned as a result of fraud - #[code = "FR01"] - Fraud, - /// Cancellation requested following a transaction that was originated fraudulently - #[code = "FRAD"] - FraudulentOrigin, - /// In an FI To FI Customer Credit Transfer: The Status Originator transferred the payment to the next Agent or to a Market Infrastructure - #[code = "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 - #[code = "G001"] - PaymentTransferredAndNotTracked, - /// In a FIToFI Customer Credit Transfer: Credit to the creditor’s account may not be confirmed same day - #[code = "G002"] - CreditDebitNotConfirmed, - /// In a FIToFI Customer Credit Transfer: Credit to creditor’s account is pending receipt of required documents - #[code = "G003"] - CreditPendingDocuments, - /// In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover - #[code = "G004"] - CreditPendingFunds, - /// Payment has been delivered to creditor agent with service level - #[code = "G005"] - DeliveredWithServiceLevel, - /// Payment has been delivered to creditor agent without service level - #[code = "G006"] - DeliveredWIthoutServiceLevel, - /// Signature file was sent to the bank but the corresponding original file has not been sent yet - #[code = "ID01"] - CorrespondingOriginalFileStillNotSent, - /// Expiry date time of the request-to-pay is incorrect - #[code = "IEDT"] - IncorrectExpiryDateTime, - /// Payer’s activation reference is invalid - #[code = "INAR"] - InvalidActivationReference, - /// Details not valid for this field - #[code = "INDT"] - InvalidDetails, - /// Payments in instalments are not supported - #[code = "IPNS"] - InstalmentPaymentsNotSupported, - /// No initial request-to-pay has been received - #[code = "IRNR"] - InitialRTPNeverReceived, - /// Cannot schedule instruction for Night Window - #[code = "ISWS"] - InvalidSettlementWindow, - /// No Mandate - #[code = "MD01"] - NoMandate, - /// Mandate related information data required by the scheme is missing - #[code = "MD02"] - MissingMandatoryInformationInMandate, - /// Creditor or creditor's agent should not have collected the direct debit - #[code = "MD05"] - CollectionNotDue, - /// Return of funds requested by end customer - #[code = "MD06"] - RefundRequestByEndCustomer, - /// End customer is deceased - #[code = "MD07"] - EndCustomerDeceased, - /// Information missing for the field or cannot be empty - #[code = "MINF"] - MissingInformation, - /// Reason has not been specified by end customer - #[code = "MS02"] - NotSpecifiedReasonCustomerGenerated, - /// Reason has not been specified by agent - #[code = "MS03"] - NotSpecifiedReasonAgentGenerated, - /// Reason is provided as narrative information in the additional reason information - #[code = "NARR"] - Narrative, - /// Credit transfer is tagged as an Extended Remittance Information (ERI) transaction but does not contain ERI - #[code = "NERI"] - NoERI, - /// No existing agreement for receiving request-to-pay messages - #[code = "NOAR"] - NonAgreedRTP, - /// No response from Beneficiary - #[code = "NOAS"] - NoAnswerFromCustomer, - /// 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 - #[code = "NOCM"] - NotCompliantGeneric, - /// Continuous Processing Line on Hold Instruction - #[code = "NOFR"] - OutstandingFundingForSettlement, - /// Requested payment guarantee (by Creditor) related to a request-to-pay cannot be provided - #[code = "NOPG"] - NoPaymentGuarantee, - /// Recipient side of the request-to-pay (payer or its request-to-pay service provider) is not reachable - #[code = "NRCH"] - PayerOrPayerRTPSPNotReachable, - /// Requested optional service (for example instalment payments) is not supported - #[code = "OSNS"] - OptionalServiceNotSupported, - /// Type of payment requested in the request-to-pay is not supported by the payer - #[code = "PINS"] - TypeOfPaymentInstrumentNotSupported, - /// Error code used for RTP-initiated CTR when the pacs - #[code = "PNRT"] - PaymentNotAlignedWithRTPRequest, - /// Bank identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode) - #[code = "RC01"] - BankIdentifierIncorrect, - /// Bank identifier is invalid or missing - #[code = "RC02"] - InvalidBankIdentifier, - /// Debtor bank identifier is invalid or missing - #[code = "RC03"] - InvalidDebtorBankIdentifier, - /// Creditor bank identifier is invalid or missing - #[code = "RC04"] - InvalidCreditorBankIdentifier, - /// BIC identifier is invalid or missing - #[code = "RC05"] - InvalidBICIdentifier, - /// Debtor BIC identifier is invalid or missing - #[code = "RC06"] - InvalidDebtorBICIdentifier, - /// Creditor BIC identifier is invalid or missing - #[code = "RC07"] - InvalidCreditorBICIdentifier, - /// ClearingSystemMemberidentifier is invalid or missing - #[code = "RC08"] - InvalidClearingSystemMemberIdentifier, - /// Debtor ClearingSystemMember identifier is invalid or missing - #[code = "RC09"] - InvalidDebtorClearingSystemMemberIdentifier, - /// Creditor ClearingSystemMember identifier is invalid or missing - #[code = "RC10"] - InvalidCreditorClearingSystemMemberIdentifier, - /// Intermediary Agent is invalid or missing - #[code = "RC11"] - InvalidIntermediaryAgent, - /// Creditor Scheme Id is invalid or missing - #[code = "RC12"] - MissingCreditorSchemeId, - /// Originator not active any more - #[code = "RC13"] - ParticipantNotAnActiveMemberofRTGS, - /// Settlement agreement required - #[code = "RC15"] - ParticipantNotActiveMemberSettlementType, - /// Participant blocked from SADC-RTGS - #[code = "RC16"] - ParticipantNotActiveMemberofSADCRTGS, - /// Conflict with R-Message - #[code = "RCON"] - RMessageConflict, - /// Further information regarding the intended recipient - #[code = "RECI"] - ReceiverCustomerInformation, - /// Request-to-pay has been received and can be processed further - #[code = "REPR"] - RTPReceivedCanBeProcessed, - /// Transaction reference is not unique within the message - #[code = "RF01"] - NotUniqueTransactionReference, - /// Payer did not recognize the request from Payee Participant, - #[code = "RQNR"] - RequestNotRecognized, - /// Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing - #[code = "RR01"] - MissingDebtorAccountOrIdentification, - /// Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing - #[code = "RR02"] - MissingDebtorNameOrAddress, - /// Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing - #[code = "RR03"] - MissingCreditorNameOrAddress, - /// Regulatory Reason - #[code = "RR04"] - RegulatoryReason, - /// Regulatory or Central Bank Reporting information missing, incomplete or invalid - #[code = "RR05"] - RegulatoryInformationInvalid, - /// Tax information missing, incomplete or invalid - #[code = "RR06"] - TaxInformationInvalid, - /// Remittance information structure does not comply with rules for payment type - #[code = "RR07"] - RemittanceInformationInvalid, - /// Remittance information truncated to comply with rules for payment type - #[code = "RR08"] - RemittanceInformationTruncated, - /// Structured creditor reference invalid or missing - #[code = "RR09"] - InvalidStructuredCreditorReference, - /// Character set supplied not valid for the country and payment type - #[code = "RR10"] - InvalidCharacterSet, - /// Invalid or missing identification of a bank proprietary service - #[code = "RR11"] - InvalidDebtorAgentServiceID, - /// Invalid or missing identification required within a particular country or payment type - #[code = "RR12"] - InvalidPartyID, - /// Debtor does not support request-to-pay transactions - #[code = "RTNS"] - RTPNotSupportedForDebtor, - /// Return following investigation request and no remediation possible - #[code = "RUTA"] - ReturnUponUnableToApply, - /// Request for Cancellation is acknowledged following validation - #[code = "S000"] - ValidRequestForCancellationAcknowledged, - /// Unique End-to-end Transaction Reference (UETR) relating to a payment has been identified as being associated with a Request for Cancellation - #[code = "S001"] - UETRFlaggedForCancellation, - /// Unique End-to-end Transaction Reference (UETR) relating to a payment has been prevent from traveling across a messaging network - #[code = "S002"] - NetworkStopOfUETR, - /// Request for Cancellation has been forwarded to the payment processing/last payment processing agent - #[code = "S003"] - RequestForCancellationForwarded, - /// Request for Cancellation has been acknowledged as delivered to payment processing/last payment processing agent - #[code = "S004"] - RequestForCancellationDeliveryAcknowledgement, - /// Remove Concurrent Batch Processing Line on hold instruction - #[code = "SBRN"] - SettlementBatchRemovalNotification, - /// Due to specific service offered by the Debtor Agent - #[code = "SL01"] - SpecificServiceOfferedByDebtorAgent, - /// Due to specific service offered by the Creditor Agent - #[code = "SL02"] - SpecificServiceOfferedByCreditorAgent, - /// Due to a specific service offered by the clearing system - #[code = "SL03"] - ServiceofClearingSystem, - /// Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet) - #[code = "SL11"] - CreditorNotOnWhitelistOfDebtor, - /// Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist” - #[code = "SL12"] - CreditorOnBlacklistOfDebtor, - /// Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent - #[code = "SL13"] - MaximumNumberOfDirectDebitTransactionsExceeded, - /// Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent - #[code = "SL14"] - MaximumDirectDebitTransactionAmountExceeded, - /// Maximum number of credit transactions allowed by the account servicer per service period exceeded - #[code = "SL15"] - MaximumNumberOfCreditTransactionsExceeded, - /// Maximum total credit amount allowed by the account servicer per service period exceeded - #[code = "SL16"] - MaximumCreditTransactionsAmountExceeded, - /// Whitelisting service offered by payment system operator or financial institution - #[code = "SL17"] - DebtorNotOnWhitelistOfCreditorSide, - /// Blacklisting service offered by payment system operator or financial institution - #[code = "SL18"] - DebtorOnBlacklistOfCreditorSide, - /// Services are not yet rendered by the Payee Participant (Creditor) - #[code = "SNRD"] - ServiceNotRendered, - /// Identifier of the request-to-pay service provider is incorrect - #[code = "SPII"] - RTPServiceProviderIdentifierIncorrect, - /// The transmission of the file was not successful – it had to be aborted (for technical reasons) - #[code = "TA01"] - TransmissonAborted, - /// There is no data available (for download) - #[code = "TD01"] - NoDataAvailable, - /// The file cannot be read (e - #[code = "TD02"] - FileNonReadable, - /// The file format is incomplete or invalid - #[code = "TD03"] - IncorrectFileStructure, - /// Token is invalid - #[code = "TK01"] - TokenInvalid, - /// Token used for the sender does not exist - #[code = "TK02"] - SenderTokenNotFound, - /// Token used for the receiver does not exist - #[code = "TK03"] - ReceiverTokenNotFound, - /// Token required for request is missing - #[code = "TK09"] - TokenMissing, - /// Token found with counterparty mismatch - #[code = "TKCM"] - TokenCounterpartyMismatch, - /// Single Use Token already used - #[code = "TKSG"] - TokenSingleUse, - /// Token found with suspended status - #[code = "TKSP"] - TokenSuspended, - /// Token found with value limit rule violation - #[code = "TKVE"] - TokenValueLimitExceeded, - /// Token expired - #[code = "TKXP"] - TokenExpired, - /// Associated message, payment information block, or transaction was received after agreed processing cut-off time - #[code = "TM01"] - InvalidCutOffTime, - /// The (technical) transmission of the file was successful - #[code = "TS01"] - TransmissionSuccessful, - /// The order was transferred to pass by accompanying note signed by hand - #[code = "TS04"] - TransferToSignByHand, - /// Unknown Creditor - #[code = "UCRD"] - UnknownCreditor, - /// Payment is not justified - #[code = "UPAY"] - UnduePayment, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(DomainCode, Description, Str)] -pub enum PaymentGroupStatus { - /// Settlement on the creditor's account has been completed - #[code = "ACCC"] - AcceptedSettlementCompletedCreditorAccount, - /// Preceding check of technical validation was successful - #[code = "ACCP"] - AcceptedCustomerProfile, - /// Settlement on the debtor's account has been completed - #[code = "ACSC"] - AcceptedSettlementCompletedDebitorAccount, - /// All preceding checks such as technical validation and customer profile were successful and therefore the payment initiation has been accepted for execution - #[code = "ACSP"] - AcceptedSettlementInProcess, - /// Authentication and syntactical and semantical validation are successful - #[code = "ACTC"] - AcceptedTechnicalValidation, - /// Instruction is accepted but a change will be made, such as date or remittance not sent - #[code = "ACWC"] - AcceptedWithChange, - /// A number of transactions have been accepted, whereas another number of transactions have not yet achieved - #[code = "PART"] - PartiallyAccepted, - /// Payment initiation or individual transaction included in the payment initiation is pending - #[code = "PDNG"] - Pending, - /// Verification of Payee check have been applied to received transactions stating to be complete without mismatching data - #[code = "RCVC"] - ReceivedVerificationCompleted, - /// Payment initiation has been received by the receiving agent - #[code = "RCVD"] - Received, - /// Payment initiation or individual transaction included in the payment initiation has been rejected - #[code = "RJCT"] - Rejected, - /// Verification of Payee checks have been applied to received transactions stating to be complete containing mismatching data - #[code = "RVCM"] - ReceivedVerificationCompletedWithMismatches, - /// Verification of party check on transactions received is not yet completed - #[code = "RVNC"] - ReceivedVerificationNotCompleted, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(DomainCode, Description, Str)] -pub enum PaymentTransactionStatus { - /// Settlement on the creditor's account has been completed - #[code = "ACCC"] - AcceptedSettlementCompletedCreditorAccount, - /// Preceding check of technical validation was successful - #[code = "ACCP"] - AcceptedCustomerProfile, - /// Preceding check of technical validation and customer profile was successful and an automatic funds check was positive - #[code = "ACFC"] - AcceptedFundsChecked, - /// 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 - #[code = "ACFW"] - AcceptedFundsCheckedWaitingConfirmation, - /// Payment instruction to issue a cheque has been accepted, and the cheque has been issued but not yet been deposited or cleared - #[code = "ACIS"] - AcceptedandChequeIssued, - /// Status of transaction released from the Debtor Agent and accepted by the clearing - #[code = "ACPD"] - AcceptedClearingProcessed, - /// Settlement completed - #[code = "ACSC"] - AcceptedSettlementCompletedDebitorAccount, - /// All preceding checks such as technical validation and customer profile were successful and therefore the payment instruction has been accepted for execution - #[code = "ACSP"] - AcceptedSettlementInProcess, - /// Authentication and syntactical and semantical validation are successful - #[code = "ACTC"] - AcceptedTechnicalValidation, - /// Instruction is accepted but a change will be made, such as date or remittance not sent - #[code = "ACWC"] - AcceptedWithChange, - /// Payment instruction included in the credit transfer is accepted without being posted to the creditor customer’s account - #[code = "ACWP"] - AcceptedWithoutPosting, - /// 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 - #[code = "BLCK"] - Blocked, - /// Payment initiation has been successfully cancelled after having received a request for cancellation - #[code = "CANC"] - Cancelled, - /// Cash has been picked up by the Creditor - #[code = "CPUC"] - CashPickedUpByCreditor, - /// Payment initiation needs multiple authentications, where some but not yet all have been performed - #[code = "PATC"] - PartiallyAcceptedTechnicalCorrect, - /// Payment instruction is pending - #[code = "PDNG"] - Pending, - /// Request for Payment has been presented to the Debtor - #[code = "PRES"] - Presented, - /// Verification of Payee check has been applied to received transaction stating to be complete without mismatching data - #[code = "RCVC"] - ReceivedVerificationCompleted, - /// Payment instruction has been received - #[code = "RCVD"] - Received, - /// Payment instruction has been rejected - #[code = "RJCT"] - Rejected, - /// Verification of Payee checks have been applied to received transaction stating to be completed containing mismatching data - #[code = "RVCM"] - ReceivedVerificationCompletedWithMismatches, - /// Verification of Payee check has been applied to received transaction stating to be complete with data matching closely - #[code = "RVMC"] - ReceivedVerificationCompletedMatchClosely, - /// Verification of Payee check has been applied to received transaction stating to be complete with not applicable data - #[code = "RVNA"] - ReceivedVerificationCompletedNotApplicable, - /// Verification of party check on the transaction is not yet completed - #[code = "RVNC"] - ReceivedVerificationNotCompleted, - /// Verification of Payee check has been applied to received transaction stating to be complete with mismatching data - #[code = "RVNM"] - ReceivedVerificationCompletedNoMatch, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumMeta)] -#[enum_meta(DomainCode, Description, Str)] -pub enum ReturnReason { - /// Format of the account number specified is not correct - #[code = "AC01"] - IncorrectAccountNumber, - /// Debtor account number invalid or missing - #[code = "AC02"] - InvalidDebtorAccountNumber, - /// Wrong IBAN in SCT - #[code = "AC03"] - InvalidCreditorAccountNumber, - /// Account number specified has been closed on the bank of account's books - #[code = "AC04"] - ClosedAccountNumber, - /// Account specified is blocked, prohibiting posting of transactions against it - #[code = "AC06"] - BlockedAccount, - /// Creditor account number closed - #[code = "AC07"] - ClosedCreditorAccountNumber, - /// Debtor account type is missing or invalid - #[code = "AC13"] - InvalidDebtorAccountType, - /// An agent in the payment chain is invalid - #[code = "AC14"] - InvalidAgent, - /// Account details have changed - #[code = "AC15"] - AccountDetailsChanged, - /// Account is in sequestration - #[code = "AC16"] - AccountInSequestration, - /// Account is in liquidation - #[code = "AC17"] - AccountInLiquidation, - /// Transaction forbidden on this type of account (formerly NoAgreement) - #[code = "AG01"] - TransactionForbidden, - /// Bank Operation code specified in the message is not valid for receiver - #[code = "AG02"] - InvalidBankOperationCode, - /// Debtor account cannot be debited for a generic reason - #[code = "AG07"] - UnsuccesfulDirectDebit, - /// Agent in the payment workflow is incorrect - #[code = "AGNT"] - IncorrectAgent, - /// Specified message amount is equal to zero - #[code = "AM01"] - ZeroAmount, - /// Specific transaction/message amount is greater than allowed maximum - #[code = "AM02"] - NotAllowedAmount, - /// Specified message amount is an non processable currency outside of existing agreement - #[code = "AM03"] - NotAllowedCurrency, - /// Amount of funds available to cover specified message amount is insufficient - #[code = "AM04"] - InsufficientFunds, - /// Duplication - #[code = "AM05"] - Duplication, - /// Specified transaction amount is less than agreed minimum - #[code = "AM06"] - TooLowAmount, - /// Amount specified in message has been blocked by regulatory authorities - #[code = "AM07"] - BlockedAmount, - /// Amount received is not the amount agreed or expected - #[code = "AM09"] - WrongAmount, - /// Sum of instructed amounts does not equal the control sum - #[code = "AM10"] - InvalidControlSum, - /// Already returned original SCT - #[code = "ARDT"] - AlreadyReturnedTransaction, - /// Identification of end customer is not consistent with associated account number, organisation ID or private ID - #[code = "BE01"] - InconsistenWithEndCustomer, - /// Specification of creditor's address, which is required for payment, is missing/not correct (formerly IncorrectCreditorAddress) - #[code = "BE04"] - MissingCreditorAddress, - /// Party who initiated the message is not recognised by the end customer - #[code = "BE05"] - UnrecognisedInitiatingParty, - /// End customer specified is not known at associated Sort/National Bank Code or does no longer exist in the books - #[code = "BE06"] - UnknownEndCustomer, - /// Specification of debtor's address, which is required for payment, is missing/not correct - #[code = "BE07"] - MissingDebtorAddress, - /// Returned as a result of a bank error - #[code = "BE08"] - BankError, - /// Debtor country code is missing or invalid - #[code = "BE10"] - InvalidDebtorCountry, - /// Creditor country code is missing or invalid - #[code = "BE11"] - InvalidCreditorCountry, - /// Debtor or Ultimate Debtor identification code missing or invalid - #[code = "BE16"] - InvalidDebtorIdentificationCode, - /// Creditor or Ultimate Creditor identification code missing or invalid - #[code = "BE17"] - InvalidCreditorIdentificationCode, - /// Authorisation is cancelled - #[code = "CN01"] - AuthorisationCancelled, - /// Creditor bank is not registered under this BIC in the CSM - #[code = "CNOR"] - CreditorBankIsNotRegistered, - /// Cash not picked up by Creditor or cash could not be delivered to Creditor - #[code = "CNPC"] - CashNotPickedUp, - /// Currency of the payment is incorrect - #[code = "CURR"] - IncorrectCurrency, - /// Cancellation requested by the Debtor - #[code = "CUST"] - RequestedByCustomer, - /// Return of Covering Settlement due to the underlying Credit Transfer details not being received - #[code = "DC04"] - NoCustomerCreditTransferReceived, - /// Debtor bank is not registered under this BIC in the CSM - #[code = "DNOR"] - DebtorBankIsNotRegistered, - /// Return following technical problems resulting in erroneous transaction - #[code = "DS28"] - ReturnForTechnicalReason, - /// Invalid date (eg, wrong settlement date) - #[code = "DT01"] - InvalidDate, - /// Cheque has been issued but not deposited and is considered expired - #[code = "DT02"] - ChequeExpired, - /// Future date not supported - #[code = "DT04"] - FutureDateNotSupported, - /// Payment is a duplicate of another payment - #[code = "DUPL"] - DuplicatePayment, - /// Correspondent bank not possible - #[code = "ED01"] - CorrespondentBankNotPossible, - /// Balance of payments complementary info is requested - #[code = "ED03"] - BalanceInfoRequest, - /// Settlement of the transaction has failed - #[code = "ED05"] - SettlementFailed, - /// The card payment is fraudulent and was not processed with EMV technology for an EMV card - #[code = "EMVL"] - EMVLiabilityShift, - /// The Extended Remittance Information (ERI) option is not supported - #[code = "ERIN"] - ERIOptionNotSupported, - /// Payment Type Information is missing or invalid - #[code = "FF03"] - InvalidPaymentTypeInformation, - /// Service Level code is missing or invalid - #[code = "FF04"] - InvalidServiceLevelCode, - /// Local Instrument code is missing or invalid - #[code = "FF05"] - InvalidLocalInstrumentCode, - /// Category Purpose code is missing or invalid - #[code = "FF06"] - InvalidCategoryPurposeCode, - /// Purpose is missing or invalid - #[code = "FF07"] - InvalidPurpose, - /// Return following a cancellation request - #[code = "FOCR"] - FollowingCancellationRequest, - /// Returned as a result of fraud - #[code = "FR01"] - Fraud, - /// Final response/tracking is recalled as mandate is cancelled - #[code = "FRTR"] - FinalResponseMandateCancelled, - /// In a FIToFI Customer Credit Transfer: Credit to the creditor’s account is pending, status Originator is waiting for funds provided via a cover - #[code = "G004"] - CreditPendingFunds, - /// No Mandate - #[code = "MD01"] - NoMandate, - /// Mandate related information data required by the scheme is missing - #[code = "MD02"] - MissingMandatoryInformationInMandate, - /// Creditor or creditor's agent should not have collected the direct debit - #[code = "MD05"] - CollectionNotDue, - /// Return of funds requested by end customer - #[code = "MD06"] - RefundRequestByEndCustomer, - /// End customer is deceased - #[code = "MD07"] - EndCustomerDeceased, - /// Reason has not been specified by end customer - #[code = "MS02"] - NotSpecifiedReasonCustomerGenerated, - /// Reason has not been specified by agent - #[code = "MS03"] - NotSpecifiedReasonAgentGenerated, - /// Reason is provided as narrative information in the additional reason information - #[code = "NARR"] - Narrative, - /// No response from Beneficiary - #[code = "NOAS"] - NoAnswerFromCustomer, - /// 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 - #[code = "NOCM"] - NotCompliant, - /// Original SCT never received - #[code = "NOOR"] - NoOriginalTransactionReceived, - /// The card payment is fraudulent (lost and stolen fraud) and was processed as EMV transaction without PIN verification - #[code = "PINL"] - PINLiabilityShift, - /// Bank Identifier code specified in the message has an incorrect format (formerly IncorrectFormatForRoutingCode) - #[code = "RC01"] - BankIdentifierIncorrect, - /// Debtor bank identifier is invalid or missing - #[code = "RC03"] - InvalidDebtorBankIdentifier, - /// Creditor bank identifier is invalid or missing - #[code = "RC04"] - InvalidCreditorBankIdentifier, - /// Incorrrect BIC of the beneficiary Bank in the SCTR - #[code = "RC07"] - InvalidCreditorBICIdentifier, - /// ClearingSystemMemberidentifier is invalid or missing - #[code = "RC08"] - InvalidClearingSystemMemberIdentifier, - /// Intermediary Agent is invalid or missing - #[code = "RC11"] - InvalidIntermediaryAgent, - /// Transaction reference is not unique within the message - #[code = "RF01"] - NotUniqueTransactionReference, - /// Specification of the debtor’s account or unique identification needed for reasons of regulatory requirements is insufficient or missing - #[code = "RR01"] - MissingDebtorAccountOrIdentification, - /// Specification of the debtor’s name and/or address needed for regulatory requirements is insufficient or missing - #[code = "RR02"] - MissingDebtorNameOrAddress, - /// Specification of the creditor’s name and/or address needed for regulatory requirements is insufficient or missing - #[code = "RR03"] - MissingCreditorNameOrAddress, - /// Regulatory Reason - #[code = "RR04"] - RegulatoryReason, - /// Regulatory or Central Bank Reporting information missing, incomplete or invalid - #[code = "RR05"] - RegulatoryInformationInvalid, - /// Tax information missing, incomplete or invalid - #[code = "RR06"] - TaxInformationInvalid, - /// Remittance information structure does not comply with rules for payment type - #[code = "RR07"] - RemittanceInformationInvalid, - /// Remittance information truncated to comply with rules for payment type - #[code = "RR08"] - RemittanceInformationTruncated, - /// Structured creditor reference invalid or missing - #[code = "RR09"] - InvalidStructuredCreditorReference, - /// Invalid or missing identification of a bank proprietary service - #[code = "RR11"] - InvalidDebtorAgentServiceIdentification, - /// Invalid or missing identification required within a particular country or payment type - #[code = "RR12"] - InvalidPartyIdentification, - /// Return following investigation request and no remediation possible - #[code = "RUTA"] - ReturnUponUnableToApply, - /// Due to specific service offered by the Debtor Agent - #[code = "SL01"] - SpecificServiceOfferedByDebtorAgent, - /// Due to specific service offered by the Creditor Agent - #[code = "SL02"] - SpecificServiceOfferedByCreditorAgent, - /// Whitelisting service offered by the Debtor Agent; Debtor has not included the Creditor on its “Whitelist” (yet) - #[code = "SL11"] - CreditorNotOnWhitelistOfDebtor, - /// Blacklisting service offered by the Debtor Agent; Debtor included the Creditor on his “Blacklist” - #[code = "SL12"] - CreditorOnBlacklistOfDebtor, - /// Due to Maximum allowed Direct Debit Transactions per period service offered by the Debtor Agent - #[code = "SL13"] - MaximumNumberOfDirectDebitTransactionsExceeded, - /// Due to Maximum allowed Direct Debit Transaction amount service offered by the Debtor Agent - #[code = "SL14"] - MaximumDirectDebitTransactionAmountExceeded, - /// Payment is stopped by account holder - #[code = "SP01"] - PaymentStopped, - /// Previously stopped by means of a stop payment advise - #[code = "SP02"] - PreviouslyStopped, - /// 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 - #[code = "SVNR"] - ServiceNotRendered, - /// Associated message was received after agreed processing cut-off time - #[code = "TM01"] - CutOffTime, - /// Return following direct debit being removed from tracking process - #[code = "TRAC"] - RemovedFromTracking, - /// Payment is not justified - #[code = "UPAY"] - UnduePayment, -} diff --git a/src/keys.rs b/src/keys.rs @@ -1,235 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{borrow::Cow, io::ErrorKind, path::Path}; - -use anyhow::bail; -use aws_lc_rs::{ - encoding::{AsDer, Pkcs8V1Der}, - error::KeyRejected, - rsa::{KeySize, PrivateDecryptingKey, PublicEncryptingKey, PublicKey, PublicKeyComponents}, - signature::RsaKeyPair, -}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use taler_common::{ - json_file, - types::base32::{self}, -}; - -use crate::config::EbicsKeysCfg; - -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct ClientKeys { - #[serde( - rename = "signature_private_key", - serialize_with = "ser_pkcs8", - deserialize_with = "de_ras_sign_base32" - )] - pub sign: RsaKeyPair, - #[serde( - rename = "encryption_private_key", - serialize_with = "ser_pkcs8", - deserialize_with = "de_ras_priv_base32" - )] - pub enc: PrivateDecryptingKey, - #[serde( - rename = "authentication_private_key", - serialize_with = "ser_pkcs8", - deserialize_with = "de_ras_sign_base32" - )] - pub auth: RsaKeyPair, - pub submitted_ini: bool, - pub submitted_hia: bool, -} - -impl ClientKeys { - pub fn generate() -> anyhow::Result<Self> { - Ok(Self { - sign: RsaKeyPair::generate(KeySize::Rsa2048)?, - enc: PrivateDecryptingKey::generate(KeySize::Rsa2048)?, - auth: RsaKeyPair::generate(KeySize::Rsa2048)?, - submitted_ini: false, - submitted_hia: false, - }) - } -} - -#[derive(Debug)] -pub struct RsaPub { - pub enc: PublicEncryptingKey, - pub key: PublicKey, -} - -impl RsaPub { - pub fn from_der(der: &[u8]) -> Result<Self, KeyRejected> { - let key = PublicKey::from_der(der)?; - let component = PublicKeyComponents { - n: key.modulus().big_endian_without_leading_zero(), - e: key.exponent().big_endian_without_leading_zero(), - }; - let enc = component.try_into().map_err(|_| KeyRejected::from(()))?; - Ok(Self { enc, key }) - } -} - -impl serde::Serialize for RsaPub { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - let der = self - .key - .as_der() - .map_err(|e| serde::ser::Error::custom(e.to_string()))?; - let base32 = base32::encode(der.as_ref()); - base32.serialize(serializer) - } -} - -impl<'de> serde::Deserialize<'de> for RsaPub { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: Deserializer<'de>, - { - let base32 = Cow::<str>::deserialize(deserializer)?; - let der = base32::decode(base32.as_bytes()) - .map_err(|e| serde::de::Error::custom(e.to_string()))?; - Self::from_der(&der).map_err(|e| serde::de::Error::custom(e.to_string())) - } -} - -impl PartialEq for RsaPub { - fn eq(&self, other: &Self) -> bool { - self.key.exponent().big_endian_without_leading_zero() - == other.key.exponent().big_endian_without_leading_zero() - && self.key.modulus().big_endian_without_leading_zero() - == other.key.modulus().big_endian_without_leading_zero() - } -} - -impl Eq for RsaPub {} - -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct BankKeys { - #[serde(rename = "bank_encryption_public_key")] - pub enc: RsaPub, - #[serde(rename = "bank_authentication_public_key")] - pub auth: RsaPub, - pub accepted: bool, -} - -fn ser_pkcs8<S, K>(key: &K, serializer: S) -> Result<S::Ok, S::Error> -where - K: AsDer<Pkcs8V1Der<'static>>, - S: Serializer, -{ - let der = key - .as_der() - .map_err(|e| serde::ser::Error::custom(e.to_string()))?; - let base32 = base32::encode(der.as_ref()); - base32.serialize(serializer) -} - -fn de_ras_priv_base32<'de, D>(deserializer: D) -> Result<PrivateDecryptingKey, D::Error> -where - D: Deserializer<'de>, -{ - let base32 = Cow::<str>::deserialize(deserializer)?; - let der = - base32::decode(base32.as_bytes()).map_err(|e| serde::de::Error::custom(e.to_string()))?; - let key = PrivateDecryptingKey::from_pkcs8(&der) - .map_err(|e| serde::de::Error::custom(e.to_string()))?; - Ok(key) -} - -fn de_ras_sign_base32<'de, D>(deserializer: D) -> Result<RsaKeyPair, D::Error> -where - D: Deserializer<'de>, -{ - let base32 = Cow::<str>::deserialize(deserializer)?; - let der = - base32::decode(base32.as_bytes()).map_err(|e| serde::de::Error::custom(e.to_string()))?; - let key = RsaKeyPair::from_pkcs8(&der).map_err(|e| serde::de::Error::custom(e.to_string()))?; - Ok(key) -} - -/// Persist the bank keys file to disk -pub fn persist_bank_keys(keys: &BankKeys, location: &Path) -> std::io::Result<()> { - json_file::persist(location, keys)?; - // TODO better error message "bank public keys" - Ok(()) -} - -pub fn persist_client_keys(keys: &ClientKeys, location: &Path) -> std::io::Result<()> { - json_file::persist(location, keys)?; - // TODO better error message "client private keys" - Ok(()) -} - -/// Load the bank keys file from disk -pub fn load_bank_keys(path: &Path) -> anyhow::Result<Option<BankKeys>> { - match json_file::load(path) { - Ok(existing) => Ok(Some(existing)), - Err(e) if e.kind() == ErrorKind::NotFound => Ok(None), - Err(e) => anyhow::bail!( - "Could not read bank public keys at '{}': {}", - path.to_string_lossy(), - e.kind() - ), - } -} - -/// Load the client keys file from disk -pub fn load_client_keys(path: &Path) -> anyhow::Result<Option<ClientKeys>> { - match json_file::load(path) { - Ok(existing) => Ok(Some(existing)), - Err(e) if e.kind() == ErrorKind::NotFound => Ok(None), - Err(e) => anyhow::bail!( - "Could not read client private keys at '{}': {}", - path.to_string_lossy(), - e.kind() - ), - } -} - -/// Load client and bank keys from disk and checks that the keying process has been fully completed -pub fn expect_full_keys(cfg: &EbicsKeysCfg) -> anyhow::Result<(ClientKeys, BankKeys)> { - let setup_cmd = "TODO"; - let client_keys = load_client_keys(cfg.client_priv_keys_path.as_ref())?; - let Some(client_keys) = client_keys else { - bail!( - "Missing client private keys file at '{}', run '{setup_cmd}' first", - cfg.client_priv_keys_path - ) - }; - if !client_keys.submitted_ini || !client_keys.submitted_hia { - bail!("Unsubmitted client private keys, run '{setup_cmd}' first") - } - let bank_keys = load_bank_keys(cfg.bank_pub_keys_path.as_ref())?; - let Some(bank_keys) = bank_keys else { - bail!( - "Missing bank public keys file at '{}', run '{setup_cmd}' first", - cfg.bank_pub_keys_path - ) - }; - if !bank_keys.accepted { - bail!("Unaccepted bank public keys, run '{setup_cmd}' until accepting the bank keys") - } - Ok((client_keys, bank_keys)) -} diff --git a/src/lib.rs b/src/lib.rs @@ -1,965 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{ - collections::BTreeMap, - io::{Cursor, Read}, - path::{Path, PathBuf}, - str::FromStr, - time::Duration, -}; - -use anyhow::{anyhow, bail}; -use compact_str::{CompactString, CompactStringExt}; -use jiff::{Timestamp, Zoned, civil::Date, tz::TimeZone}; -use rand::prelude::IndexedRandom; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use sqlx::PgPool; -use taler_build::long_version; -use taler_common::{ - CommonArgs, - cli::ConfigCmd, - config::{Config, parser::ConfigSource}, - types::{ - amount::Amount, - payto::{FullIbanPayto, TransferIbanPayto}, - utils::date_to_utc_ts, - }, -}; -use tokio::{time::timeout, try_join}; -use tracing::{debug, error, info, trace, warn}; - -use crate::{ - config::{EbicsKeysCfg, NexusCfg}, - crypto::ebics_pub_key_hash, - db::{ - dbinit, get_task_status, - initiated::{ - batch_initiated, batch_status_update, batch_sub_failure, batch_sub_success, initiate, - initiated_submittable, order_failure, order_step, order_success, tx_status_update, - }, - pool, update_task_status, - }, - ebics::{ - EbicsClient, EbicsCtx, EbicsErrKind, EbicsError, EbicsErrorHelper, - administrative::VersionNumber, - ebics_code::EbicsReturnCode, - order::{Order, OrderDoc}, - }, - iso20022::{ - HacAction, - camt::{AccountId, parse_camt}, - hac::parse_hac, - pain001::{Pain001Msg, Pain001Tx, create_pain001}, - pain002::parse_pain002, - status_code::{PaymentGroupStatus, PaymentTransactionStatus}, - }, - keys::{ - BankKeys, ClientKeys, expect_full_keys, load_bank_keys, load_client_keys, - persist_bank_keys, persist_client_keys, - }, - list::ListCmd, - model::{InTx, OutTx, PaymentBatch, SubmissionState, Tx}, - testing::TestingCmd, - utils::hex_chunk_by_two, - worker::register_tx, - ws::listen_for_notification, -}; - -pub mod api; -pub mod bench; -pub mod config; -pub mod crypto; -pub mod db; -pub mod dialect; -pub mod ebics; -pub mod iso20022; -pub mod keys; -pub mod list; -pub mod model; -#[cfg(test)] -pub mod test; -pub mod testing; -pub mod utils; -pub mod worker; -pub mod ws; -pub mod xml; -pub mod xml_sign; - -// KV -const CHECKPOINT_KEY: &str = "checkpoint"; -const SUBMIT_TASK_KEY: &str = "submit_task"; -const FETCH_TASK_KEY: &str = "fetch_task"; - -pub const CONFIG_SOURCE: ConfigSource = - ConfigSource::new("libeufin", "libeufin-nexus", "libeufin-nexus"); - -const EBICS_ID_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - -pub fn rand_ebics_id() -> CompactString { - let mut rng = rand::rng(); - (0..34) - .map(|_| *EBICS_ID_ALPHABET.choose(&mut rng).unwrap() as char) - .collect() -} - -#[derive(clap::Parser, Debug, Clone)] -pub struct EbicsArgs { - #[command(flatten)] - logs: EbicsLogs, - - /// Execute once and return, ignoring the 'FREQUENCY' configuration value - #[clap(long)] - transient: bool, -} - -#[derive(clap::Parser, Debug, Clone)] -pub struct EbicsLogs { - /// Log EBICS transactions steps and payload at log_dir - #[clap(long = "debug-ebics", value_name = "log_dir")] - #[arg(global = true)] - dir: Option<PathBuf>, -} - -#[derive(clap::Subcommand, Debug)] -pub enum Cmd { - /// Initialize libeufin-nexus database - Dbinit { - /// Reset database (DANGEROUS: All existing data is lost) - #[clap(long, short)] - reset: bool, - }, - /// Set up the EBICS subscriber - EbicsSetup { - #[command(flatten)] - ebics_logs: EbicsLogs, - - /// Resubmits all the keys to the bank - #[clap(long)] - force_keys_resubmission: bool, - - /// Accepts the bank keys without interactively asking the user - #[clap(long)] - auto_accept_keys: bool, - - /// Generates the PDF with the client public keys to send to the bank - #[clap(long)] - generate_registration_pdf: bool, - }, - /// Submits pending initiated payments found in the database - EbicsSubmit { - #[clap(flatten)] - ebics: EbicsArgs, - }, - /// Downloads and parse EBICS files from the bank and register them into the database - EbicsFetch { - #[clap(flatten)] - ebics: EbicsArgs, - - /// Only supported in --transient mode, this option lets specify the earliest timestamp of the downloaded documents - #[clap(long, value_name = "YYYY-MM-DD")] - pinned_start: Option<Date>, - - /// Only supported in --transient mode, do not consume fetched documents - #[clap(long, requires = "transient")] - peek: bool, - - /// Only supported in --transient mode, run a checkpoint - #[clap(long, requires = "transient")] - checkpoint: bool, - }, - Serve {}, - /// Initiate an outgoing payment - InitiatePayment { - /// The amount to transfer, payto 'amount' parameter takes the precedence - #[clap(long)] - amount: Option<Amount>, - - /// The payment subject, payto 'message' parameter takes the precedence - #[clap(long)] - subject: Option<CompactString>, - - /// The payment end-to-end UID - #[clap(long, alias = "request-uid")] - end_to_end_id: Option<CompactString>, - - /// The credited account IBAN payto UR - payto: TransferIbanPayto, - }, - Manual {}, - #[command(subcommand)] - List(ListCmd), - #[command(subcommand)] - Config(ConfigCmd), - #[command(subcommand)] - Testing(TestingCmd), -} - -#[derive(clap::Parser, Debug)] -#[command(long_version = long_version(), about, long_about = None)] -pub struct Args { - #[clap(flatten)] - pub common: CommonArgs, - - #[command(subcommand)] - pub cmd: Cmd, -} - -/** Load client private keys at or create new ones if missing */ -pub fn load_or_generate_client_keys(path: &Path) -> anyhow::Result<ClientKeys> { - // If exists load from disk - let current = load_client_keys(path)?; - if let Some(current) = current { - return Ok(current); - } - // Else create new keys - let new = ClientKeys::generate()?; - persist_client_keys(&new, path)?; - info!( - "New client private keys created at '{}'", - path.to_string_lossy() - ); - Ok(new) -} - -pub async fn ebics_setup( - ebics: &EbicsClient, - cfg: &EbicsKeysCfg, - force_keys_submission: bool, - auto_accept_keys: bool, - generate_registration_pdf: bool, -) -> anyhow::Result<()> { - let mut client = load_or_generate_client_keys(cfg.client_priv_keys_path.as_ref())?; - let bank = load_bank_keys(cfg.bank_pub_keys_path.as_ref())?; - - // Check EBICS 3 support - let versions = ebics.hev().await?; - debug!(target: "setup", - "HEV: {}", - versions - .iter() - .map(|v| v.to_string()) - .collect::<Vec<_>>() - .join(", ") - ); - if !versions.contains(&VersionNumber { - number: "03.00".into(), - schema: "H005".into(), - }) && versions.contains(&VersionNumber { - number: "03.02".into(), - schema: "H005".into(), - }) { - bail!("EBICS 3 is not supported by your bank"); - } - - // Privs exist. Upload their pubs - let keys_not_sub = !client.submitted_ini; - if !client.submitted_ini || force_keys_submission { - ebics - .submit_client_keys(cfg, &mut client, Order::INI) - .await?; - } - // Eject PDF if the keys were submitted for the first time, or the user asked. - // TODO if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, hostCfg) - if !client.submitted_hia || force_keys_submission { - ebics - .submit_client_keys(cfg, &mut client, Order::HIA) - .await?; - } - - let new = ebics.hpb(&client).await?; - if let Some(current) = bank { - // Check current bank keys - if current.enc != new.enc { - bail!( - "On disk bank encryption key stored at {} doesn't match server key\nDisk: {}\nServer: {}", - cfg.bank_pub_keys_path, - hex_chunk_by_two(ebics_pub_key_hash(&current.enc.key)), - hex_chunk_by_two(ebics_pub_key_hash(&new.enc.key)) - ) - } else if current.auth != new.auth { - bail!( - "On disk bank authentication key stored at {} doesn't match server key\nDisk: {}\nServer: {}", - cfg.bank_pub_keys_path, - hex_chunk_by_two(ebics_pub_key_hash(&current.auth.key)), - hex_chunk_by_two(ebics_pub_key_hash(&new.auth.key)) - ) - } - } else { - // Accept bank keys - info!("Bank keys stored at {}", cfg.bank_pub_keys_path); - persist_bank_keys(&new, cfg.bank_pub_keys_path.as_ref())?; - }; - let mut bank = new; - if !bank.accepted { - // Finishing the setup by accepting the bank keys. - if !auto_accept_keys { - panic!("Cannot successfully finish the setup without accepting the bank keys"); - } - bank.accepted = true; - persist_bank_keys(&bank, cfg.bank_pub_keys_path.as_ref())?; - } - - // Check account information - info!("Doing administrative request HKD"); - // TODO HKD - - eprintln!("setup ready"); - Ok(()) -} - -pub async fn ebics_submit( - ebics: &EbicsClient, - cfg: &NexusCfg, - client: &ClientKeys, - bank: &BankKeys, - db: &PgPool, - transient: bool, -) -> anyhow::Result<()> { - let ebics_cfg = cfg.ebics()?; - let submit_cfg = cfg.submit()?; - - let submit_batch = async |order: &Order, - batch: &PaymentBatch, - instant: bool| - -> Result<CompactString, EbicsError> { - let ctx = EbicsCtx::new(order); - let msg = Pain001Msg { - msg_id: &batch.msg_id, - timestamp: &Timestamp::now(), - debtor: &ebics_cfg.account, - sum: batch.sum, - txs: batch - .payments - .iter() - .map(|tx| { - let creditor = FullIbanPayto::from_str(tx.creditor.as_ref().as_str()).unwrap(); - // TODO handle missing name ? - Pain001Tx { - creditor, - amount: tx.amount, - subject: &tx.subject, - e2e_id: &tx.e2e_id, - } - }) - .collect(), - }; - let xml = create_pain001(&msg, &ebics_cfg.dialect, instant).ctx(&ctx)?; - ebics.upload(client, bank, order, &xml).await - }; - - let submit_all = async || -> anyhow::Result<()> { - let standard = cfg.ebics()?.dialect.standard(); - - // Find a supported debit order - let mut instant_order = standard.instant_direct_debit(); - let debit_order = standard.direct_debit(); - - // Create batch if necessary - batch_initiated( - db, - &Timestamp::now(), - &rand_ebics_id(), - submit_cfg.require_ack, - ) - .await?; - - // Send submittable batches - for batch in initiated_submittable(db, &cfg.currency).await? { - debug!(target: "ebics-submit", "Submitting batch {}", batch.msg_id); - let res = async { - if let Some(instant) = standard.instant_direct_debit() { - match submit_batch(&instant, &batch, true).await { - Ok(id) => return Ok(id), - Err(e) => if let EbicsErrKind::Code { .. } = e.kind { - // No longer try to submit using the instant method for now - debug!(target: "ebics-submit", "Failed to submit using instant credit order {e}"); - instant_order = None; - } else { - return Err(e) - }, - } - } - submit_batch(&debit_order, &batch, false).await - }.await; - match res { - Ok(order_id) => { - batch_sub_success(db, batch.id, &Timestamp::now(), &order_id).await?; - let txs = batch - .payments - .iter() - .map(|it| &it.e2e_id) - .collect::<Vec<_>>() - .join_compact(","); - if instant_order.is_some() { - info!(target: "ebics-submit", "Instant batch {} submitted as order {order_id}: {txs}", batch.msg_id); - } else { - info!(target: "ebics-submit", "Batch {} submitted as order {order_id}: {txs}", batch.msg_id); - } - } - Err(e) => { - batch_sub_failure(db, batch.id, &Timestamp::now(), &e.to_string()).await?; - error!(target: "ebics-submit", "Batch {} submission failure: {e}", batch.msg_id); - return Err(e.into()); - } - } - } - - Ok(()) - }; - if transient { - debug!(target: "ebics-submit", "Transient mode: submitting what found and returning"); - submit_all().await - } else { - debug!(target: "ebics-submit", "Running with a frequency of {}", submit_cfg.frequency_raw); - loop { - let now = Timestamp::now(); - let success = match submit_all().await { - Ok(_) => true, - Err(e) => { - error!(target: "ebics-submit", "{e}"); - false - } - }; - if let Err(e) = update_task_status(db, SUBMIT_TASK_KEY, &now, success).await { - warn!(target: "ebics-submit", "{e}"); - } - tokio::time::sleep(Duration::from_millis( - Timestamp::now() - .duration_until(now + submit_cfg.frequency) - .abs() - .as_millis() as u64, - )) - .await; - } - } -} - -async fn register_camt(db: &PgPool, cfg: &NexusCfg, xml: &[u8]) -> anyhow::Result<usize> { - let account = &cfg.ebics()?.account; - let ingest_cfg = cfg.ingest()?; - let mut nb_tx = 0; - for actx in parse_camt(xml)? { - if let AccountId::Iban(iban) = &actx.id - && iban == &account.iban - { - if let Some(currency) = actx.currency - && currency != cfg.currency - { - bail!( - "Expected transactions of currency {} got {currency}", - cfg.currency - ) - } - for tx in actx.txs { - match tx { - Tx::In(InTx { amount, .. }) | Tx::Out(OutTx { amount, .. }) => { - if amount.currency != cfg.currency { - bail!( - "Expected transactions of currency {} got {}", - cfg.currency, - amount.currency - ) - } - } - Tx::Batch(_) | Tx::Reversal(_) => {} - } - register_tx(db, &ingest_cfg, &tx).await?; - nb_tx += 1; - } - } else { - warn!(target: "ebics-fetch", "Skip transaction for unknown account {}", actx.id); - } - } - Ok(nb_tx) -} - -pub async fn ebics_fetch( - ebics: &EbicsClient, - cfg: &NexusCfg, - client: &ClientKeys, - bank: &BankKeys, - db: &PgPool, - documents: Option<&[OrderDoc]>, - pinned_start: &Option<Timestamp>, - peek: bool, - transient: bool, - transient_checkpoint: bool, -) -> anyhow::Result<()> { - let ebics_cfg = cfg.ebics()?; - - let register_file = async |doc: &OrderDoc, xml: Vec<u8>| -> anyhow::Result<()> { - match doc { - OrderDoc::acknowledgement => { - for ack in parse_hac(&xml)? { - debug!(target: "ebics-fetch", "{ack}"); - if let Some(order_id) = &ack.order_id { - match ack.action { - HacAction::ORDER_HAC_FINAL_POS => { - if let Some(msg_id) = order_success(db, order_id).await? { - info!(target: "ebics-fetch", "Batch {msg_id} order {order_id} accepted at {}", ack.timestamp); - } - } - HacAction::ORDER_HAC_FINAL_NEG => { - if let Some((msg_id, msg)) = order_failure(db, order_id).await? { - info!(target: "ebics-fetch", "Batch {msg_id} order {order_id} refused at {}{}", ack.timestamp, std::fmt::from_fn( |f| if let Some(msg) = &msg { - write!(f, ": {msg}") - } else { - Ok(()) - })); - } - } - _ => { - order_step(db, order_id, &ack.to_string()).await?; - } - } - } - } - } - OrderDoc::status => { - let msg_status = parse_pain002(&xml)?; - debug!(target: "ebics-fetch", "{msg_status}"); - if let Some(code) = msg_status.status { - let msg = msg_status.msg(); - batch_status_update( - db, - &msg_status.id, - match code { - PaymentGroupStatus::AcceptedSettlementCompletedDebitorAccount => { - SubmissionState::success - } - PaymentGroupStatus::Rejected => { - error!(target: "ebics-fetch", "Batch {} failed: {msg}", msg_status.id); - SubmissionState::success - } - _ => SubmissionState::pending - }, - &msg, - ) - .await?; - } - for p_status in msg_status.payments { - if p_status.id != "NOTPROVIDED" { - warn!(target: "ebics-fetch", "Unexpected payment status for {}.{}", msg_status.id, p_status.id); - } else if let Some(code) = p_status.status { - let msg = p_status.msg(); - batch_status_update( - db, - &msg_status.id, - match code { - PaymentGroupStatus::AcceptedSettlementCompletedDebitorAccount => { - SubmissionState::success - } - PaymentGroupStatus::Rejected => { - error!(target: "ebics-fetch", "Batch {} failed: {msg}", msg_status.id); - SubmissionState::success - } - _ => SubmissionState::pending - }, - &msg, - ) - .await?; - } - for tx_status in p_status.txs { - let msg = tx_status.msg(); - tx_status_update( - db, - &tx_status.e2e_id, - &msg_status.id, - match tx_status.status { - PaymentTransactionStatus::Rejected | PaymentTransactionStatus::Blocked => { - error!(target: "ebics-fetch", "Transaction {} failed: {msg}", tx_status.e2e_id); - SubmissionState::permanent_failure - } - _ => SubmissionState::pending - }, - &msg, - ) - .await?; - } - } - } - OrderDoc::report | OrderDoc::statement | OrderDoc::notification => { - register_camt(db, cfg, &xml).await?; - } - } - Ok(()) - }; - let register_payload = async |doc: &OrderDoc, content: Vec<u8>| -> anyhow::Result<()> { - // Unzip payload if necessary - match doc { - OrderDoc::acknowledgement => register_file(doc, content).await, - OrderDoc::status | OrderDoc::report | OrderDoc::statement | OrderDoc::notification => { - let mut z = zip::ZipArchive::new(Cursor::new(content))?; - for i in 0..z.len() { - let mut file = z.by_index(i)?; - trace!(target: "ebics-fetch", "parse {}", file.name()); - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - register_file(doc, buf).await?; - } - Ok(()) - } - } - }; - let fetch = async |orders: &[Order], since: Option<Timestamp>| -> anyhow::Result<bool> { - let mut grouped_orders = BTreeMap::new(); - - for order in orders { - grouped_orders - .entry(order.doc()) - .or_insert_with(Vec::new) - .push(order); - } - - let mut success = true; - for (doc, orders) in grouped_orders { - if let Some(doc) = doc { - for order in orders { - if let Err(e) = ebics - .download( - db, - client, - bank, - order, - &since.map(|it| (it, Timestamp::now())), - transient && peek, - async |content| { - register_payload(&doc, content) - .await - .map_err(|e| EbicsErrKind::Custom(e.to_string().into())) - }, - ) - .await - { - if let EbicsErrKind::Code { bank, .. } = e.kind { - match bank { - EbicsReturnCode::EBICS_NO_DOWNLOAD_DATA_AVAILABLE => continue, - EbicsReturnCode::EBICS_AUTHORISATION_ORDER_IDENTIFIER_FAILED => { - error!(target: "ebics-fetch", "{e}"); - success = false; - continue; - } - _ => {} - } - } - return Err(e.into()); - } - } - } else { - debug!(target: "ebics-fetch", "Skip unsupported orders {orders:?}") - } - } - Ok(success) - }; - - // EBICS order than should be fetched - let orders: Vec<_> = documents - .unwrap_or(OrderDoc::entries) - .iter() - .flat_map(|it| ebics_cfg.dialect.standard().downloads(it)) - .collect(); - - let fetch_cfg = cfg.fetch()?; - - let (sender, mut receiver) = tokio::sync::mpsc::channel::<Vec<Order>>(10); - - let fetch = async { - if transient { - info!(target: "ebics-fetch", "Transient mode: fetching once and returning"); - } else { - info!(target: "ebics-fetch", "Running with a frequency of {}", fetch_cfg.frequency_raw); - } - - // TODO loop - - let mut last_fetch = Timestamp::UNIX_EPOCH; - loop { - let now = Timestamp::now(); - let checkpoint = get_task_status(db, CHECKPOINT_KEY) - .await? - .unwrap_or_default(); - let next_fetch = last_fetch + fetch_cfg.frequency; - let next_checkpoint = { - if let Some(last_trial) = checkpoint.last_trial { - // We run today at checkpointTime - let checkpoint_date = Zoned::new(now, TimeZone::UTC) - .with() - .time(fetch_cfg.checkpoint_time) - .build() - .unwrap(); - // If we already ran today we ran tomorrow - if last_trial > checkpoint_date.timestamp() { - checkpoint_date.tomorrow().unwrap().timestamp() - } else { - checkpoint_date.timestamp() - } - } else { - // We never ran, we must checkpoint now - now - } - }; - - let mut success = true; - if - // Run transient checkpoint at request - (transient && transient_checkpoint) - // Or run recurrent checkpoint - || (!transient && now > next_checkpoint) - { - info!(target: "ebics-fetch", "Running checkpoint"); - - let since = if let Some(pinned_start) = pinned_start - && transient - && checkpoint - .last_successfull - .map(|it| *pinned_start <= it) - .unwrap_or(true) - { - Some(*pinned_start) - } else { - checkpoint.last_successfull - }; - let res = async { - // We fetch HKD to only fetch supported EBICS orders and get the document versions - let hkd = ebics.hkd(db, client, bank, false).await?; - let mut supported_orders = hkd - .partner - .orders - .into_iter() - .map(|it| it.order) - .collect::<Vec<_>>(); - debug!( - "HKD: {}", - std::fmt::from_fn(|f| f.write_str( - &supported_orders - .iter() - .map(|it| it.to_string()) - .collect::<Vec<_>>() - .join(",") - )) - ); - supported_orders - .retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); - fetch(&supported_orders, since).await - } - .await; - if let Err(e) = res { - success = false; - error!(target: "ebics-fetch", "{e}"); - } - try_join!( - update_task_status(db, CHECKPOINT_KEY, &now, success), - update_task_status(db, FETCH_TASK_KEY, &now, success) - )?; - last_fetch = now; - } else if transient || now > next_fetch { - if !transient { - info!(target: "ebics-fetch", "Running at frequency"); - } - let res = async { - // We fetch HAA to only fetch pending & supported EBICS orders and get the document versions - let mut haa = ebics.haa(db, client, bank, false).await?; - debug!( - "HAA: {}", - std::fmt::from_fn(|f| f.write_str( - &haa.orders - .iter() - .map(|it| it.to_string()) - .collect::<Vec<_>>() - .join(",") - )) - ); - haa.orders - .retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); - fetch(&haa.orders, *pinned_start).await - } - .await; - if let Err(e) = res { - success = false; - error!(target: "ebics-fetch", "{e}"); - } - update_task_status(db, FETCH_TASK_KEY, &now, success).await?; - last_fetch = now; - } - - if transient { - if success { - return anyhow::Ok(()); - } else { - return Err(anyhow!("ebics-fetch failed")); - } - } - - let delay = now.duration_until(next_fetch.min(next_checkpoint)); - let tx = timeout( - Duration::from_millis(delay.abs().as_millis() as u64), - receiver.recv(), - ) - .await; - if let Ok(Some(mut notification)) = tx { - notification.retain(|order| orders.iter().find(|it| order.eq(it)).is_some()); - if !notification.is_empty() { - info!(target: "ebics-fetch", "Running at real-time notifications reception"); - fetch(&notification, None).await?; - } - } - } - }; - - if transient { - fetch.await?; - } else { - tokio::try_join!(fetch, async { - listen_for_notification(ebics, db, client, bank, sender).await; - Ok(()) - })?; - } - - Ok(()) -} - -pub async fn run(cfg: Config, cmd: Cmd) -> anyhow::Result<()> { - match cmd { - Cmd::Dbinit { reset } => { - dbinit(&cfg, reset).await?; - } - Cmd::EbicsSetup { - ebics_logs, - force_keys_resubmission, - auto_accept_keys, - generate_registration_pdf, - } => { - let cfg = NexusCfg::parse(cfg)?; - let ebics = EbicsClient::new(&cfg, ebics_logs)?; - ebics_setup( - &ebics, - cfg.keys()?, - force_keys_resubmission, - auto_accept_keys, - generate_registration_pdf, - ) - .await?; - } - Cmd::EbicsFetch { - pinned_start, - peek, - checkpoint, - ebics: EbicsArgs { logs, transient }, - } => { - let pool = pool(&cfg).await?; - let cfg = NexusCfg::parse(cfg)?; - let key_cfg = cfg.keys()?; - let ebics = EbicsClient::new(&cfg, logs)?; - let (client, bank) = expect_full_keys(key_cfg)?; - ebics_fetch( - &ebics, - &cfg, - &client, - &bank, - &pool, - None, - &pinned_start.map(|it| date_to_utc_ts(&it)), - peek, - transient, - transient && checkpoint, - ) - .await? - } - Cmd::EbicsSubmit { - ebics: EbicsArgs { logs, transient }, - } => { - let pool = pool(&cfg).await?; - let cfg = NexusCfg::parse(cfg)?; - let ebics = EbicsClient::new(&cfg, logs)?; - let key_cfg = cfg.keys()?; - let (client, bank) = expect_full_keys(key_cfg)?; - ebics_submit(&ebics, &cfg, &client, &bank, &pool, transient).await? - } - Cmd::InitiatePayment { - amount, - subject, - end_to_end_id, - payto, - } => { - let pool = pool(&cfg).await?; - let cfg = NexusCfg::parse(cfg)?; - - let subject = payto - .subject - .as_ref() - .or(subject.as_ref()) - .ok_or(anyhow!("Mising subject"))?; - let amount = payto - .amount - .as_ref() - .or(amount.as_ref()) - .ok_or(anyhow!("Mising amount"))?; - - if cfg.currency != amount.currency { - bail!( - "Wrong currency: expected {} got {}", - cfg.currency, - amount.currency - ); - } - initiate( - &pool, - amount, - subject, - &payto.as_payto(), - &Timestamp::now(), - &end_to_end_id - .as_ref() - .cloned() - .unwrap_or_else(rand_ebics_id), - ) - .await?; - } - Cmd::Serve {} => todo!(), - Cmd::Manual {} => todo!(), - Cmd::List(cmd) => { - let pool = pool(&cfg).await?; - let cfg = NexusCfg::parse(cfg)?; - cmd.run(&pool, &cfg.currency).await?; - } - Cmd::Config(cmd) => cmd.run(&cfg)?, - Cmd::Testing(cmd) => cmd.run(cfg).await?, - } - Ok(()) -} - -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -pub struct TaskStatus { - #[serde(serialize_with = "ser_micros", deserialize_with = "de_micros", default)] - pub last_successfull: Option<Timestamp>, - #[serde(serialize_with = "ser_micros", deserialize_with = "de_micros", default)] - pub last_trial: Option<Timestamp>, -} - -fn ser_micros<S: Serializer>(key: &Option<Timestamp>, serializer: S) -> Result<S::Ok, S::Error> { - key.map(|it| it.as_microsecond()).serialize(serializer) -} - -fn de_micros<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Timestamp>, D::Error> { - Option::<i64>::deserialize(deserializer)? - .map(Timestamp::from_microsecond) - .transpose() - .map_err(|e| serde::de::Error::custom(e.to_string())) -} diff --git a/src/main.rs b/src/main.rs @@ -1,27 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use clap::Parser as _; -use libeufin::{Args, CONFIG_SOURCE, run}; -use taler_common::taler_main; - -fn main() { - let args = Args::parse(); - taler_main(CONFIG_SOURCE, args.common, |cfg| run(cfg, args.cmd)) -} diff --git a/src/model.rs b/src/model.rs @@ -1,485 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::fmt::{Display, Write as _}; - -use compact_str::{CompactString, ToCompactString as _}; -use jiff::Timestamp; -use taler_common::{ - api_wire::TransferState, - types::{amount::Amount, payto::PaytoURI}, -}; -use uuid::Uuid; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] -#[allow(non_camel_case_types)] -#[sqlx(type_name = "submission_state")] -/** Outgoing transactions and batches submission status */ -pub enum SubmissionState { - // Initiated but not yet submitted - unsubmitted, - // Submission failed, retry possible - transient_failure, - // Submission succeed, pending settltment - pending, - // Definitive failure, will never succeed - permanent_failure, - // Definitive success, booked and settled - success, - // Late failure after a success, happens when a payment is returned - late_failure, -} - -impl SubmissionState { - pub fn to_transfer_status(self) -> TransferState { - match self { - SubmissionState::unsubmitted | SubmissionState::pending => TransferState::pending, - SubmissionState::transient_failure => TransferState::transient_failure, - SubmissionState::permanent_failure => TransferState::permanent_failure, - SubmissionState::success | SubmissionState::late_failure => TransferState::success, - } - } -} - -impl From<TransferState> for SubmissionState { - fn from(value: TransferState) -> Self { - match value { - TransferState::pending => SubmissionState::pending, - TransferState::transient_failure => SubmissionState::transient_failure, - TransferState::permanent_failure => SubmissionState::permanent_failure, - TransferState::late_failure => SubmissionState::late_failure, - TransferState::success => SubmissionState::success, - } - } -} - -/// ID for incoming transactions -#[derive(Clone, PartialEq, Eq)] -pub struct InId { - /** ISO20022 UETR */ - pub uetr: Option<Uuid>, - /// ISO20022 TxID - pub tx_id: Option<CompactString>, - /// ISO20022 AcctSvcrRef - pub sref: Option<CompactString>, -} - -impl InId { - pub fn new( - uetr: Option<Uuid>, - tx_id: Option<CompactString>, - acct_svcr_ref: Option<CompactString>, - ) -> Self { - assert!(uetr.is_some() || tx_id.is_some() || acct_svcr_ref.is_some()); - Self { - uetr, - tx_id, - sref: acct_svcr_ref, - } - } - - pub fn r#ref(&self) -> CompactString { - self.uetr - .map(|e| e.to_compact_string()) - .or(self.tx_id.clone()) - .or(self.sref.clone()) - .expect("must be at least one ref") - } -} - -impl std::fmt::Display for InId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_char('(')?; - let mut prepend = false; - if let Some(uetr) = &self.uetr { - write!(f, "uetr={uetr}")?; - prepend = true; - } - if let Some(tx_id) = &self.tx_id { - if prepend { - f.write_char(' ')?; - } - f.write_str("tx=")?; - f.write_str(tx_id)?; - prepend = true; - } - if let Some(acct_svcr_ref) = &self.sref { - if prepend { - f.write_char(' ')?; - } - f.write_str("ref=")?; - f.write_str(acct_svcr_ref)?; - } - f.write_char(')')?; - Ok(()) - } -} - -impl std::fmt::Debug for InId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self, f) - } -} - -/// ID for outgoing transactions -#[derive(Clone, PartialEq, Eq)] -pub struct OutId { - /// Unique msg ID generated by libeufin-nexus - /// ISO20022 MessageId - pub msg_id: Option<CompactString>, - /// Unique end-to-end ID generated by libeufin-nexus - /// ISO20022 EndToEndId or MessageId (retrocompatibility) - pub e2e_id: Option<CompactString>, - /// Unique end-to-end ID generated by the bank - /// ISO20022 AcctSvcrRef - pub sref: Option<CompactString>, -} - -impl OutId { - pub fn new( - msg_id: Option<CompactString>, - e2e_id: Option<CompactString>, - acct_svcr_ref: Option<CompactString>, - ) -> Self { - assert!(msg_id.is_some() || e2e_id.is_some() || acct_svcr_ref.is_some()); - Self { - msg_id, - e2e_id, - sref: acct_svcr_ref, - } - } - - pub fn r#ref(&self) -> CompactString { - self.e2e_id - .clone() - .or(self.sref.clone()) - .or(self.sref.clone()) - .expect("must be at least one ref") - } -} - -impl std::fmt::Display for OutId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_char('(')?; - let mut prepend = false; - if let Some(msg_id) = &self.msg_id - && self.msg_id != self.e2e_id - { - f.write_str("msg=")?; - f.write_str(msg_id)?; - prepend = true; - } - if let Some(end_to_end_id) = &self.e2e_id { - if prepend { - f.write_char(' ')?; - } - f.write_str("e2e=")?; - f.write_str(end_to_end_id)?; - prepend = true; - } - if let Some(acct_svcr_ref) = &self.sref { - if prepend { - f.write_char(' ')?; - } - f.write_str("ref=")?; - f.write_str(acct_svcr_ref)?; - } - f.write_char(')')?; - Ok(()) - } -} - -impl std::fmt::Debug for OutId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self, f) - } -} - -/// ID for outgoing batches -#[derive(Clone, PartialEq, Eq)] -pub struct BatchId { - /// Unique msg ID generated by libeufin-nexus - /// ISO20022 MessageId - pub msg_id: CompactString, - /// Unique end-to-end ID generated by the bank - /// ISO20022 AcctSvcrRef - pub sref: Option<CompactString>, -} - -impl BatchId { - pub fn r#ref(&self) -> CompactString { - self.msg_id.clone() - } -} - -impl std::fmt::Display for BatchId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("(msg=")?; - f.write_str(&self.msg_id)?; - if let Some(acct_svcr_ref) = &self.sref { - f.write_str("ref=")?; - f.write_str(acct_svcr_ref)?; - } - f.write_char(')')?; - Ok(()) - } -} - -impl std::fmt::Debug for BatchId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self, f) - } -} - -/// ISO20022 incoming payment -#[derive(Clone, PartialEq, Eq)] -pub struct InTx { - pub id: InId, - pub amount: Amount, - pub credit_fee: Amount, - pub subject: Option<String>, - pub execution_time: Timestamp, - pub debtor: Option<PaytoURI>, -} - -impl InTx { - pub fn with_execution_time(self, execution_time: Timestamp) -> Self { - Self { - execution_time, - ..self - } - } -} - -impl Display for InTx { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { - id, - amount, - credit_fee, - subject, - execution_time, - debtor, - } = self; - write!(f, "IN {execution_time} {amount}")?; - if !credit_fee.is_zero() { - write!(f, "-{credit_fee}")?; - } - write!(f, " {id}")?; - if let Some(creditor) = debtor { - write!(f, " creditor={creditor}")?; - } - if let Some(subject) = subject { - write!(f, " subject='{subject}'")?; - } - Ok(()) - } -} - -impl std::fmt::Debug for InTx { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self, f) - } -} - -/// ISO20022 outgoing payment -#[derive(Clone, PartialEq, Eq)] -pub struct OutTx { - pub id: OutId, - pub amount: Amount, - pub debit_fee: Amount, - pub subject: Option<String>, - pub execution_time: Timestamp, - pub creditor: Option<PaytoURI>, -} - -impl OutTx { - pub fn with_execution_time(self, execution_time: Timestamp) -> Self { - Self { - execution_time, - ..self - } - } - - pub fn with_e2e_id(self, end_to_end_id: impl Into<CompactString>) -> Self { - Self { - id: OutId { - e2e_id: Some(end_to_end_id.into()), - ..self.id - }, - ..self - } - } - - pub fn with_msg_id(self, msg_id: impl Into<CompactString>) -> Self { - Self { - id: OutId { - msg_id: Some(msg_id.into()), - ..self.id - }, - ..self - } - } -} - -impl Display for OutTx { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { - id, - amount, - debit_fee, - subject, - execution_time, - creditor, - } = self; - write!(f, "OUT {execution_time} {amount}")?; - if !debit_fee.is_zero() { - write!(f, "-{debit_fee}")?; - } - write!(f, " {id}")?; - if let Some(creditor) = creditor { - write!(f, " creditor={creditor}")?; - } - if let Some(subject) = subject { - write!(f, " subject='{subject}'")?; - } - Ok(()) - } -} - -impl std::fmt::Debug for OutTx { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self, f) - } -} - -/** ISO20022 outgoing batch */ -#[derive(Clone, PartialEq, Eq)] -pub struct OutBatch { - /** ISO20022 MessageId */ - pub msg_id: CompactString, - pub execution_time: Timestamp, -} - -impl Display for OutBatch { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { - msg_id, - execution_time, - } = self; - // TODO fmt date - write!(f, "BATCH {execution_time} {msg_id}") - } -} - -impl std::fmt::Debug for OutBatch { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self, f) - } -} - -/** ISO20022 outgoing reversal */ -#[derive(Clone, PartialEq, Eq)] -pub struct OutReversal { - /** ISO20022 EndToEndId */ - pub e2e_id: CompactString, - /** ISO20022 MessageId */ - pub msg_id: Option<CompactString>, - pub reason: String, - pub execution_time: Timestamp, -} - -impl Display for OutReversal { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Self { - e2e_id, - msg_id, - reason, - execution_time, - } = self; - // TODO fmt date - match msg_id { - Some(msg_id) => write!(f, "BATCH {execution_time} {msg_id}.{e2e_id}: {reason}"), - None => write!(f, "BATCH {execution_time} {e2e_id}: {reason}"), - } - } -} - -impl std::fmt::Debug for OutReversal { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self, f) - } -} - -/** Batch of initiated outgoing payment to sent together */ -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PaymentBatch { - pub id: u64, - pub msg_id: CompactString, - pub creation_date: Timestamp, - pub sum: Amount, - pub payments: Vec<Initiated>, -} - -/** Initiated outgoing transaction */ -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Initiated { - pub id: u64, - pub amount: Amount, - pub subject: String, - pub creditor: PaytoURI, - pub initiation_time: Timestamp, - pub e2e_id: CompactString, -} - -#[derive(Clone, PartialEq, Eq)] -pub enum Tx { - In(InTx), - Out(OutTx), - Batch(OutBatch), - Reversal(OutReversal), -} - -impl Tx { - pub fn execution_time(&self) -> &Timestamp { - match self { - Tx::In(InTx { execution_time, .. }) - | Tx::Out(OutTx { execution_time, .. }) - | Tx::Batch(OutBatch { execution_time, .. }) - | Tx::Reversal(OutReversal { execution_time, .. }) => execution_time, - } - } -} - -impl Display for Tx { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Tx::In(incoming_payment) => incoming_payment.fmt(f), - Tx::Out(outgoing_payment) => outgoing_payment.fmt(f), - Tx::Batch(outgoing_batch) => outgoing_batch.fmt(f), - Tx::Reversal(outgoing_reversal) => outgoing_reversal.fmt(f), - } - } -} - -impl std::fmt::Debug for Tx { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self, f) - } -} diff --git a/src/test.rs b/src/test.rs @@ -1,568 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{str::FromStr as _, sync::LazyLock}; - -use compact_str::CompactString; -use jiff::Timestamp; -use sqlx::PgPool; -use taler_api::subject::{fmt_in_subject, fmt_out_subject, subject_fmt_qr_bill}; -use taler_common::{ - api_common::{EddsaPublicKey, EddsaSignature}, - db::IncomingType, - types::{ - amount::{Amount, Currency}, - base32::Base32, - payto::{IbanPayto, PaytoURI, payto}, - }, -}; -use url::Url; - -use crate::{ - config::{AccountType, NexusIngestCfg}, - db::{ - initiated::{PaymentInitiationResult, initiate}, - transfer::{RegistrationResult, transfer_register}, - }, - model::{InId, InTx, Initiated, OutId, OutTx}, - rand_ebics_id, - worker::{register_incoming, register_outgoing}, -}; - -pub const CURR: Currency = Currency::KUDOS; -pub static ACCOUNT: LazyLock<PaytoURI> = - LazyLock::new(|| payto("payto://iban/CH4189144589712575493?receiver-name=Test")); - -/** Generates an outgoing payment, given its subject */ -pub fn gen_out_pay(subject: impl Into<String>) -> OutTx { - OutTx { - id: OutId { - msg_id: None, - e2e_id: Some(rand_ebics_id()), - sref: None, - }, - amount: Amount::new(&CURR, 44, 0), - debit_fee: Amount::zero(&CURR), - creditor: Some( - IbanPayto::from_str("payto://iban/CH4189144589712575493?receiver-name=Test") - .unwrap() - .as_payto(), - ), - subject: Some(subject.into()), - execution_time: Timestamp::now(), - } -} - -/** Generates a payment initiation, given its subject and end-to-end ID */ -pub fn gen_init_pay( - end_to_end_id: impl Into<CompactString>, - subject: impl Into<String>, -) -> Initiated { - Initiated { - id: 0, - amount: Amount::new(&CURR, 44, 0), - creditor: IbanPayto::from_str("payto://iban/CH4189144589712575493?receiver-name=Test") - .unwrap() - .as_payto(), - subject: subject.into(), - initiation_time: Timestamp::now(), - e2e_id: end_to_end_id.into(), - } -} - -/** Generates an incoming payment, given its subject */ -pub fn gen_in_pay(subject: impl Into<String>) -> InTx { - InTx { - id: InId::new(None, Some(rand_ebics_id()), None), - amount: Amount::new(&CURR, 44, 0), - credit_fee: Amount::zero(&CURR), - debtor: Some( - IbanPayto::from_str("payto://iban/DE84500105177118117964?receiver-name=John+Smith") - .unwrap() - .as_payto(), - ), - subject: Some(subject.into()), - execution_time: Timestamp::now(), - } -} - -pub async fn gen_initiate( - db: &PgPool, - end_to_end_id: impl Into<CompactString>, - subject: impl Into<String>, -) -> PaymentInitiationResult { - let init = gen_init_pay(end_to_end_id, subject); - initiate( - &db, - &init.amount, - &init.subject, - &init.creditor, - &init.initiation_time, - &init.e2e_id, - ) - .await - .unwrap() -} - -const CFG: NexusIngestCfg = NexusIngestCfg::simple(AccountType::Exchange, &CURR); - -async fn prepare(db: &PgPool) -> String { - let key = EddsaPublicKey::rand(); - let sig = EddsaSignature::rand(); - let reference_number = subject_fmt_qr_bill(key.as_ref()); - assert_eq!( - RegistrationResult::Success, - transfer_register( - db, - IncomingType::reserve, - &key, - &key, - &sig, - false, - &reference_number, - &Timestamp::now() - ) - .await - .unwrap() - ); - return reference_number; -} - -/// Register a talerable reserve prepared incoming transaction -pub async fn prepared_in(db: &PgPool) { - let ref_nb = prepare(db).await; - register_incoming(db, &CFG, &gen_in_pay(ref_nb)) - .await - .unwrap(); -} - -/// Register an incomplete talerable reserve prepared incoming transaction -pub async fn prepared_incomplete_in(db: &PgPool) { - let ref_nb = prepare(db).await; - let incomplete = InTx { - subject: None, - debtor: None, - ..gen_in_pay(ref_nb) - }; - register_incoming(db, &CFG, &incomplete).await.unwrap(); -} - -/// Register a completed talerable reserve prepared incoming transaction -pub async fn prepared_completeted_in(db: &PgPool) { - let ref_nb = prepare(db).await; - let original = gen_in_pay(ref_nb); - let incomplete = InTx { - subject: None, - debtor: None, - ..original.clone() - }; - register_incoming(db, &CFG, &incomplete).await.unwrap(); - register_incoming(db, &CFG, &original).await.unwrap(); -} - -/// Register a talerable reserve incoming transaction -pub async fn talerable_in(db: &PgPool) { - register_incoming( - db, - &CFG, - &gen_in_pay(fmt_in_subject( - IncomingType::reserve, - &EddsaPublicKey::rand(), - )), - ) - .await - .unwrap(); -} - -/// Register a talerable kyc incoming transaction -pub async fn talerable_kyc_in(db: &PgPool) { - register_incoming( - db, - &CFG, - &gen_in_pay(fmt_in_subject(IncomingType::kyc, &EddsaPublicKey::rand())), - ) - .await - .unwrap(); -} - -/// Register an incomplete talerable reserve incoming transaction -pub async fn talerable_incomplete_in(db: &PgPool) { - let incomplete = InTx { - subject: None, - debtor: None, - ..gen_in_pay(fmt_in_subject( - IncomingType::reserve, - &EddsaPublicKey::rand(), - )) - }; - register_incoming(db, &CFG, &incomplete).await.unwrap(); -} - -/// Register a completed talerable reserve incoming transaction -pub async fn talerable_completeted_in(db: &PgPool) { - let original = gen_in_pay(fmt_in_subject( - IncomingType::reserve, - &EddsaPublicKey::rand(), - )); - let incomplete = InTx { - subject: None, - debtor: None, - ..original.clone() - }; - register_incoming(db, &CFG, &incomplete).await.unwrap(); - register_incoming(db, &CFG, &original).await.unwrap(); -} - -/// Register incoming malformed transaction -pub async fn malformed_in(db: &PgPool) { - register_incoming(db, &CFG, &gen_in_pay("ignored")) - .await - .unwrap(); -} - -/// Register incoming incomplete malformed incoming transaction -pub async fn malformed_incomplete_in(db: &PgPool) { - let incomplete = InTx { - subject: None, - debtor: None, - ..gen_in_pay("ignored") - }; - register_incoming(db, &CFG, &incomplete).await.unwrap(); -} - -/// Register incoming completed malformed transaction -pub async fn malformed_completeted_in(db: &PgPool) { - let original = gen_in_pay("ignored"); - let incomplete = InTx { - subject: None, - debtor: None, - ..original.clone() - }; - register_incoming(db, &CFG, &incomplete).await.unwrap(); - register_incoming(db, &CFG, &original).await.unwrap(); -} - -/** Register an outgoing transaction */ -pub async fn malformed_out(db: &PgPool) { - register_outgoing(db, &gen_out_pay("ignored")) - .await - .unwrap(); -} - -/** Register an incomplete outgoing transaction */ -pub async fn incomplete_out(db: &PgPool) { - let incomplete = OutTx { - subject: None, - creditor: None, - ..gen_out_pay("ignored") - }; - register_outgoing(db, &incomplete).await.unwrap(); -} - -/// Register outgoing talerable transaction -pub async fn talerable_out(db: &PgPool) { - register_outgoing( - db, - &gen_out_pay(fmt_out_subject( - &Base32::rand(), - &Url::from_str("https://exchange.test.com").unwrap(), - None, - )), - ) - .await - .unwrap(); -} - -mod ebics { - use std::{ - fs::Permissions, - os::unix::fs::PermissionsExt as _, - sync::{Arc, Mutex}, - time::Duration, - }; - - use axum::{body::Bytes, response::IntoResponse, routing::post}; - use clap::Parser as _; - use reqwest::StatusCode; - use sqlx::{ConnectOptions, PgPool}; - use taler_api::{Serve, api::TalerRouter as _}; - use taler_common::config::Config; - use taler_test_utils::setup_tracing; - use tempfile::{TempDir, tempdir}; - use tokio::net::UnixStream; - - use crate::{ - Args, CHECKPOINT_KEY, CONFIG_SOURCE, - db::test::db_setup, - ebics::test::{EbicsRes, EbicsState, Sequence}, - run, - }; - - pub async fn nexus_cmd(cfg: &Config, cmd: &str) -> anyhow::Result<()> { - let parts = shlex::split(cmd).unwrap(); - let args = std::iter::once("libeufin_nexus").chain(parts.iter().map(|it| it.as_str())); - - let cmd = Args::try_parse_from(args).unwrap(); - run(cfg.clone(), cmd.cmd).await - } - - struct EbicsTestBank { - pub dir: TempDir, - pub sock_path: String, - pub sequence: Arc<Mutex<Vec<Sequence>>>, - } - - impl EbicsTestBank { - pub async fn new() -> Self { - setup_tracing(); - let dir = tempdir().unwrap(); - let sock_path = dir.path().join("bank.sock").to_str().unwrap().to_string(); - let sequence = Arc::new(Mutex::new(Vec::new())); - let server_sequence = sequence.clone(); - let bank = Arc::new(Mutex::new(EbicsState::new())); - let server = axum::Router::new() - .route( - "/", - post(async move |body: Bytes| { - let sequence: Sequence = server_sequence.lock().unwrap().pop().unwrap(); - let mut bank = bank.lock().unwrap(); - let res = sequence(&mut *bank, &body); - match res { - EbicsRes::Ok(xml) => xml.into_response(), - EbicsRes::BadRequest => StatusCode::BAD_REQUEST.into_response(), - EbicsRes::Failure => StatusCode::SERVICE_UNAVAILABLE.into_response(), - } - }), - ) - .serve( - Serve::Unix { - path: sock_path.clone(), - permission: Permissions::from_mode(660), - }, - None, - ); - tokio::spawn(server); - // Wait for server to start - for _ in 0..100 { - if UnixStream::connect(&sock_path).await.is_ok() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - Self { - dir, - sock_path, - sequence, - } - } - - pub fn sequences(&self, sequences: &[Sequence]) { - let mut state = self.sequence.lock().unwrap(); - assert_eq!(state.len(), 0); - state.extend(sequences.into_iter().rev()); - } - } - - impl Drop for EbicsTestBank { - fn drop(&mut self) { - assert_eq!(self.sequence.lock().unwrap().len(), 0); - } - } - - async fn test_setup() -> (EbicsTestBank, Config, PgPool) { - let (_, db) = db_setup().await; - let test = EbicsTestBank::new().await; - let cfg = Config::from_mem_with_env( - CONFIG_SOURCE, - &format!( - " - [paths] - LIBEUFIN_NEXUS_HOME = {:?} - - {} - - [nexus-ebics] - UNIXPATH = {} - - [libeufin-nexusdb-postgres] - CONFIG = postgresql:///{} - ", - test.dir.path(), - include_str!("../testbench/conf/mini.conf"), - test.sock_path, - db.connect_options().get_database().unwrap() - ), - ) - .unwrap(); - test.sequences(&[ - EbicsState::hev, - EbicsState::ini, - EbicsState::hia, - EbicsState::hpb, - ]); - nexus_cmd(&cfg, "ebics-setup --auto-accept-keys") - .await - .unwrap(); - - (test, cfg, db) - } - - #[tokio::test] - async fn setup() { - test_setup().await; - } - - #[tokio::test] - async fn fetch_pinned_date() { - let (test, cfg, db) = test_setup().await; - - let reset_checkpoint = async || { - let res = sqlx::query("DELETE FROM kv WHERE key=$1") - .bind(CHECKPOINT_KEY) - .execute(&db) - .await - .unwrap(); - assert_eq!(res.rows_affected(), 1); - }; - - // Default transient - test.sequences(&[ - EbicsState::haa, - EbicsState::receipt_ok, - EbicsState::btd_no_data, - ]); - nexus_cmd(&cfg, "ebics-fetch --transient").await.unwrap(); - - // Pinned transient - test.sequences(&[ - EbicsState::haa, - EbicsState::receipt_ok, - EbicsState::btd_no_data_pinned, - ]); - nexus_cmd(&cfg, "ebics-fetch --transient --pinned-start 2024-06-05") - .await - .unwrap(); - - // Init checkpoint - test.sequences(&[ - EbicsState::hkd, - EbicsState::receipt_ok, - EbicsState::btd_no_data, - ]); - nexus_cmd(&cfg, "ebics-fetch --transient --checkpoint") - .await - .unwrap(); - - // Default checkpoint - test.sequences(&[ - EbicsState::hkd, - EbicsState::receipt_ok, - EbicsState::btd_no_data_now, - ]); - nexus_cmd(&cfg, "ebics-fetch --transient --checkpoint") - .await - .unwrap(); - - // Pinned checkpoint - test.sequences(&[ - EbicsState::hkd, - EbicsState::receipt_ok, - EbicsState::btd_no_data_pinned, - ]); - nexus_cmd( - &cfg, - "ebics-fetch --transient --checkpoint --pinned-start 2024-06-05", - ) - .await - .unwrap(); - - // Reset checkpoint - reset_checkpoint().await; - test.sequences(&[ - EbicsState::hkd, - EbicsState::receipt_ok, - EbicsState::btd_no_data, - ]); - nexus_cmd(&cfg, "ebics-fetch --transient --checkpoint") - .await - .unwrap(); - - // Reset pinned checkpoint - reset_checkpoint().await; - test.sequences(&[ - EbicsState::hkd, - EbicsState::receipt_ok, - EbicsState::btd_no_data_pinned, - ]); - nexus_cmd( - &cfg, - "ebics-fetch --transient --checkpoint --pinned-start 2024-06-05", - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn close_pending_transaction() { - let (test, cfg, _) = test_setup().await; - - // Failure before first segment - test.sequences(&[ - // Failure to perform download - EbicsState::failure, - // Then continue - EbicsState::haa, - EbicsState::receipt_ok, - EbicsState::btd_no_data, - ]); - nexus_cmd(&cfg, "ebics-fetch --transient") - .await - .unwrap_err(); - nexus_cmd(&cfg, "ebics-fetch --transient").await.unwrap(); - - // Compliant server - test.sequences(&[ - EbicsState::haa, - EbicsState::receipt_ok, - // Failure to perform download - EbicsState::init_tx, - EbicsState::failure, - // Retry fail once - EbicsState::failure, - // Retry fail twice - EbicsState::failure, - // Retry succeed - EbicsState::bad_request, - // Then continue - EbicsState::haa, - EbicsState::receipt_ok, - EbicsState::btd_no_data, - ]); - nexus_cmd(&cfg, "ebics-fetch --transient") - .await - .unwrap_err(); - nexus_cmd(&cfg, "ebics-fetch --transient") - .await - .unwrap_err(); - nexus_cmd(&cfg, "ebics-fetch --transient") - .await - .unwrap_err(); - nexus_cmd(&cfg, "ebics-fetch --transient").await.unwrap(); - } -} diff --git a/src/testing.rs b/src/testing.rs @@ -1,260 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use anyhow::{anyhow, bail}; -use compact_str::CompactString; -use jiff::{Timestamp, civil::Date}; -use taler_common::{ - config::Config, - types::{ - amount::Amount, - iban::{Country, IBAN}, - payto::TransferIbanPayto, - }, -}; -use tracing::debug; - -use crate::{ - EbicsClient, EbicsLogs, InTx, - config::NexusCfg, - db::pool, - ebics::{ - EbicsErrKind, - order::{BTF, Order, OrderDoc}, - tx_check, - }, - keys::expect_full_keys, - list::ListCmd, - model::InId, - rand_ebics_id, - worker::register_incoming, - ws::listen_for_notification, -}; - -#[derive(clap::Subcommand, Debug)] -pub enum IbanCmd { - /// Generate fake IBANs for testing - Gen { country: Country }, -} - -impl IbanCmd { - pub fn run(self) -> anyhow::Result<()> { - match self { - IbanCmd::Gen { country } => { - println!("{}", IBAN::random(country)) - } - } - Ok(()) - } -} - -/// Testing helper commands -#[derive(clap::Subcommand, Debug)] -pub enum TestingCmd { - /// List incoming transactions - #[clap(subcommand)] - Iban(IbanCmd), - /// Genere a fake incoming payment - FakeIncoming { - /// The amount to transfer, payto 'amount' parameter takes the precedence - #[clap(long)] - amount: Option<Amount>, - - /// The payment credit fee - #[clap(long)] - credit_fee: Option<Amount>, - - /// The payment subject, payto 'message' parameter takes the precedence - #[clap(long)] - subject: Option<CompactString>, - - /// The debited account IBAN payto URI - payto: TransferIbanPayto, - }, - #[clap(subcommand)] - List(ListCmd), - /// Perform EBICS requests - EbicsBtd { - #[clap(long = "type", default_value_t = CompactString::const_new("BTD"))] - ty: CompactString, - #[clap(long)] - name: CompactString, - #[clap(long)] - scope: Option<CompactString>, - #[clap(long)] - message_name: CompactString, - #[clap(long)] - message_version: Option<CompactString>, - #[clap(long)] - container: Option<CompactString>, - #[clap(long)] - option: Option<CompactString>, - #[clap(flatten)] - logs: EbicsLogs, - /// Erliest timestamp of the downloaded documents - #[clap(long, value_name = "YYYY-MM-DD")] - pinned_start: Option<Date>, - /// Do not consume fetched documents - #[clap(long)] - peek: bool, - #[clap(long)] - dry_run: bool, - }, - /// Check transaction semantic - TxCheck { - #[clap(flatten)] - logs: EbicsLogs, - }, - /// Listen to EBICS instant notification over websocket - Wss { - #[clap(flatten)] - logs: EbicsLogs, - }, -} - -impl TestingCmd { - pub async fn run(self, cfg: Config) -> anyhow::Result<()> { - match self { - TestingCmd::Iban(cmd) => cmd.run()?, - TestingCmd::FakeIncoming { - amount, - credit_fee, - subject, - payto, - } => { - let db = pool(&cfg).await?; - let cfg = NexusCfg::parse(cfg)?; - let subject = payto - .subject - .as_ref() - .or(subject.as_ref()) - .ok_or(anyhow!("Mising subject"))?; - let amount = payto - .amount - .as_ref() - .or(amount.as_ref()) - .ok_or(anyhow!("Mising amount"))?; - - if cfg.currency != amount.currency { - bail!( - "Wrong currency: expected {} got {}", - cfg.currency, - amount.currency - ); - } - register_incoming( - &db, - &cfg.ingest()?, - &InTx { - id: InId::new(None, Some(rand_ebics_id()), None), - amount: *amount, - credit_fee: credit_fee.unwrap_or(Amount::zero(&cfg.currency)), - subject: Some(subject.clone().into_string()), - execution_time: Timestamp::now(), - debtor: Some(payto.as_payto()), - }, - ) - .await?; - } - TestingCmd::List(list_cmd) => { - let db = pool(&cfg).await?; - let cfg = NexusCfg::parse(cfg)?; - list_cmd.run(&db, &cfg.currency).await?; - } - TestingCmd::EbicsBtd { - ty, - name, - scope, - message_name, - message_version, - container, - option, - logs, - pinned_start, - peek, - dry_run, - } => { - let db = pool(&cfg).await?; - let cfg = NexusCfg::parse(cfg)?; - let order = Order::from_parts( - &ty, - Some(BTF { - service: name, - scope, - option, - container, - msg: message_name, - version: message_version, - }), - ) - .ok_or(anyhow!("Unknown ebics order"))?; - let (client, bank) = expect_full_keys(cfg.keys()?)?; - let ebics = EbicsClient::new(&cfg, logs)?; - ebics - .download( - &db, - &client, - &bank, - &order, - &None, // TODO - peek, - async |_| { - if dry_run { - Err(EbicsErrKind::Custom("dry run".into())) - } else { - Ok(()) - } - }, - ) - .await?; - } - TestingCmd::TxCheck { logs } => { - let db = pool(&cfg).await?; - let cfg = NexusCfg::parse(cfg)?; - let ebics = EbicsClient::new(&cfg, logs)?; - let (client, bank) = expect_full_keys(cfg.keys()?)?; - let dialect = cfg.ebics()?.dialect.standard(); - let res = tx_check( - &ebics, - &db, - &client, - &bank, - &dialect.downloads(&OrderDoc::acknowledgement)[0], - &dialect.direct_debit(), - ) - .await?; - println!("{res:?}") - } - TestingCmd::Wss { logs } => { - let db = pool(&cfg).await?; - let cfg = NexusCfg::parse(cfg)?; - let ebics = EbicsClient::new(&cfg, logs)?; - let (client, bank) = expect_full_keys(cfg.keys()?)?; - let (sender, mut receiver) = tokio::sync::mpsc::channel(10); - tokio::spawn(async move { - while let Some(orders) = receiver.recv().await { - debug!(target: "testing", "{orders:?}") - } - }); - listen_for_notification(&ebics, &db, &client, &bank, sender).await - } - } - Ok(()) - } -} diff --git a/src/utils.rs b/src/utils.rs @@ -1,51 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{fmt::Display, io::Write as _}; - -use base64::{display::Base64Display, prelude::BASE64_STANDARD}; -use flate2::{ - Compression, - write::{ZlibDecoder, ZlibEncoder}, -}; - -pub fn deflate(bytes: &[u8]) -> Vec<u8> { - let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(bytes).unwrap(); - encoder.finish().unwrap() -} - -pub fn inflate(bytes: &[u8]) -> Vec<u8> { - let mut encoder = ZlibDecoder::new(Vec::new()); - encoder.write_all(bytes).unwrap(); - encoder.finish().unwrap() -} - -pub fn hex_chunk_by_two<'a>(bytes: impl AsRef<[u8]> + 'a) -> impl Display + 'a { - std::fmt::from_fn(move |f| { - for b in bytes.as_ref() { - write!(f, "{b:X} ")?; - } - Ok(()) - }) -} - -pub fn b64<'a>(bytes: impl AsRef<[u8]> + 'a) -> impl Display + 'a { - std::fmt::from_fn(move |f| Base64Display::new(bytes.as_ref(), &BASE64_STANDARD).fmt(f)) -} diff --git a/src/worker.rs b/src/worker.rs @@ -1,243 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use jiff::Timestamp; -use sqlx::PgPool; -use taler_api::subject::{ - IncomingSubject, parse_incoming_unstructured, parse_outgoing, subject_is_qr_bill, -}; -use taler_common::types::amount::Currency; -use tracing::{debug, info, warn}; - -use crate::{ - config::{AccountType, NexusIngestCfg}, - db::{ - initiated::unsettled_tx_in_batch, - payment::{ - InResult, IncomingBounceRegistrationResult, IncomingRegistrationResult, - OutgoingRegistrationResult, register_in, register_in_malformed, register_in_qr_bill, - register_in_talerable, register_out_tx, - }, - }, - model::{InTx, OutBatch, OutTx, Tx}, - rand_ebics_id, -}; - -pub async fn register_incoming( - db: &PgPool, - cfg: &NexusIngestCfg, - payment: &InTx, -) -> sqlx::Result<()> { - let log_res = |res: InResult, kind: &str, suffix: &str| { - let fmt = std::fmt::from_fn(|f| { - write!(f, "{payment}")?; - if kind.is_empty() { - write!(f, " {kind}")?; - } - if res.new { - if let Some(id) = &res.bounce_id { - write!(f, " bounced in {id}")?; - } - } else { - if res.completed { - f.write_str(" completed")?; - if let Some(id) = &res.bounce_id { - write!(f, " bounced in {id}")?; - } - } else { - if let Some(id) = &res.bounce_id { - write!(f, " already bounced in {id}")?; - } - } - } - if suffix.is_empty() { - write!(f, " {suffix}")?; - } - Ok(()) - }); - - if res.completed || res.new { - info!(target: "worker", "{fmt}") - } else { - debug!(target: "worker", "{fmt}") - } - }; - let bounce = async |cause: &str| { - match cfg.account_type { - AccountType::Exchange => { - if payment.execution_time < cfg.ignore_bounces_before { - let res = register_in(db, payment).await?; - log_res(res, "", &format!("ignored bounce: {cause}")); - } else { - let mut bounce_amount = payment.amount; - if !payment.credit_fee.is_zero() && cfg.bounce_deduce_fee { - if let Some(res) = bounce_amount.try_sub(&payment.credit_fee) { - bounce_amount = res - } else { - let res = register_in(db, payment).await?; - log_res( - res, - "", - &format!("skip bounce (transfer fee higher than amount): {cause}"), - ); - return Ok(()); - } - } - if let Some(res) = bounce_amount.try_sub(&cfg.bounce_fee) { - bounce_amount = res - } else { - let res = register_in(db, payment).await?; - log_res( - res, - "", - &format!("skip bounce (bounce fee higher than amount): {cause}"), - ); - return Ok(()); - } - let res = register_in_malformed( - db, - payment, - &bounce_amount, - &rand_ebics_id(), - &Timestamp::now(), - cause, - ) - .await?; - match res { - IncomingBounceRegistrationResult::Talerable => { - warn!(target: "worker", "{payment} tried to bounce a talerable transaction"); - } - IncomingBounceRegistrationResult::Success(res) => { - log_res(res, "", &format!(": {cause}")); - } - } - } - } - AccountType::Normal => { - let res = register_in(db, payment).await?; - log_res(res, "", ""); - } - } - sqlx::Result::<_, sqlx::Error>::Ok(()) - }; - - // Check we have enough info to handle this transaction - if payment.debtor.is_none() { - // TODO payment.debtor.receiverName == null - let res = register_in(db, payment).await?; - log_res(res, "incomplete", ""); - return Ok(()); - } - // TODO if payment.debtor.is_none() && payment.debtor.map(|it| it.rec) - if let Some(regex) = &cfg.restriction_payto_regex - && let Some(debtor) = &payment.debtor - && !regex.is_match(debtor.as_ref().as_str()) - { - bounce("restricted account").await?; - return Ok(()); - } - - if let Some(subject) = &payment.subject - && subject_is_qr_bill(subject) - { - match register_in_qr_bill(db, payment, subject).await? { - IncomingRegistrationResult::ReservePubReuse => bounce("reverse pub reuse").await?, - IncomingRegistrationResult::MappingReuse => bounce("mapping reuse").await?, - IncomingRegistrationResult::UnknownMapping => bounce("unknown mapping").await?, - IncomingRegistrationResult::Success(res) => { - log_res(res, "", ""); - } - } - } else { - match parse_incoming_unstructured(payment.subject.as_deref().unwrap_or_default()) { - Ok(None) => bounce("missing public key").await?, - Ok(Some(IncomingSubject::AdminBalanceAdjust)) => { - let res = register_in(db, payment).await?; - log_res(res, "admin balance adjust", ""); - } - Ok(Some(subject)) => match register_in_talerable(db, payment, &subject).await? { - IncomingRegistrationResult::ReservePubReuse => bounce("reverse pub reuse").await?, - IncomingRegistrationResult::MappingReuse => bounce("mapping reuse").await?, - IncomingRegistrationResult::UnknownMapping => bounce("unknown mapping").await?, - IncomingRegistrationResult::Success(res) => { - log_res(res, "", ""); - } - }, - Err(e) => { - bounce(&e.to_string()).await?; - } - } - } - - Ok(()) -} - -pub async fn register_outgoing( - db: &PgPool, - payment: &OutTx, -) -> sqlx::Result<OutgoingRegistrationResult> { - let metadata = payment - .subject - .as_ref() - .and_then(|s| parse_outgoing(s).ok()); - let res = register_out_tx(db, payment, metadata.as_ref()).await?; - if res.new { - if res.initiated { - info!(target: "worker", "{payment}"); - } else { - warn!(target: "worker", "{payment} recovered"); - } - } else { - debug!(target: "worker", "{payment} already seen"); - } - Ok(res) -} - -pub async fn register_outgoing_batch( - db: &PgPool, - currency: &Currency, - batch: &OutBatch, -) -> sqlx::Result<()> { - info!(target: "worker", "{batch}"); - let txs = unsettled_tx_in_batch(db, currency, &batch.msg_id, &batch.execution_time).await?; - for tx in txs { - register_outgoing(db, &tx).await?; - } - Ok(()) -} - -pub async fn register_tx(db: &PgPool, cfg: &NexusIngestCfg, tx: &Tx) -> sqlx::Result<()> { - if tx.execution_time() < &cfg.ignore_txs_before { - debug!(target: "worker", "IGNORE {tx}"); - } else { - match tx { - Tx::In(payment) => { - register_incoming(db, cfg, payment).await?; - } - Tx::Out(payment) => { - register_outgoing(db, payment).await?; - } - Tx::Batch(batch) => { - register_outgoing_batch(db, &cfg.currency, batch).await?; - } - Tx::Reversal(_) => todo!(), - } - } - Ok(()) -} diff --git a/src/ws.rs b/src/ws.rs @@ -1,439 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::time::Duration; - -use compact_str::CompactString; -use futures_util::TryStreamExt as _; -use reqwest::{Client, StatusCode}; -use reqwest_websocket::{Message, Upgrade}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; -use taler_common::ExpoBackoffDecorr; -use thiserror::Error; -use tracing::{debug, error, info, trace}; - -use crate::{ - ebics::{ - EbicsClient, EbicsErrKind, - ebics_code::EbicsReturnCode, - order::{BTF, Order}, - }, - keys::{BankKeys, ClientKeys}, -}; - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "UPPERCASE")] -pub struct WssParams { - pub url: String, - pub token: String, - pub ott: String, - pub validity: String, - pub partnerid: String, - pub userid: Option<String>, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "UPPERCASE")] -pub struct WssNotificationClass { - pub name: String, - pub vers: String, - pub timestamp: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "UPPERCASE")] -pub struct WssNotificationBTF { - pub service: CompactString, - pub scope: Option<CompactString>, - pub option: Option<CompactString>, - pub conttype: Option<CompactString>, - pub msgname: CompactString, - pub variant: Option<CompactString>, - pub version: Option<CompactString>, - pub format: Option<CompactString>, -} -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "UPPERCASE")] -pub struct WssInfo { - pub lang: String, - pub free: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(untagged)] -pub enum WssNotification { - // INFO - #[serde(rename_all = "UPPERCASE")] - GeneralInfo { - mclass: Vec<WssNotificationClass>, - info: Vec<WssInfo>, - }, - #[serde(rename_all = "UPPERCASE")] - NewData { - mclass: Vec<WssNotificationClass>, - partnerid: String, - userid: Option<String>, - btf: Vec<WssNotificationBTF>, - ordertype: Vec<String>, - }, -} - -impl WssParams { - async fn connect( - &self, - client: &Client, - mut lambda: impl AsyncFnMut(WssNotification), - ) -> Result<(), WssError> { - let Self { - url, - token, - partnerid, - userid, - .. - } = self; - let username = format!( - "{partnerid}{}", - std::fmt::from_fn(|f| if let Some(userid) = userid { - write!(f, "_{userid}") - } else { - Ok(()) - }) - ); - - let mut ws = client - .get( - url.replace("https://", "wss://") - .replace("http://", "ws://"), - ) - .basic_auth(username, Some(&token)) - .upgrade() - .send() - .await? - .into_websocket() - .await?; - trace!(target: "wss", "wait for ws msg"); - while let Some(msg) = ws.try_next().await? { - match msg { - Message::Text(str) => { - // TODO handle error - let msg: WssNotification = serde_json::from_str(&str)?; - trace!(target: "wss", "received: {msg:?}"); - lambda(msg).await; - } - Message::Binary(bytes) => { - // TODO what should we do ? - } - Message::Ping(_) | Message::Pong(_) => { - // Handled by tungstenite - } - Message::Close { code, reason } => { - debug!(target: "wss", "closed {code} {reason}"); - break; - } - } - trace!(target: "wss", "wait for ws msg"); - } - Ok(()) - } -} - -#[derive(Error, Debug)] -pub enum WssError { - #[error("ws: {0}")] - Ws(#[from] reqwest_websocket::Error), - #[error("ws JSON msg: {0}")] - ReqJson(#[from] serde_json::Error), -} - -pub async fn listen_for_notification( - ebics: &EbicsClient, - db: &PgPool, - client: &ClientKeys, - bank: &BankKeys, - sender: tokio::sync::mpsc::Sender<Vec<Order>>, -) { - let mut backoff = ExpoBackoffDecorr::new(Duration::from_secs(30), Duration::from_mins(30), 2.5); - loop { - let res: Result<(), anyhow::Error> = async { - let res = ebics - .download( - db, - client, - bank, - &Order::WSS_PARAMS, - &None, - false, - async |content| { - serde_json::from_slice::<WssParams>(&content) - .map_err(|e| EbicsErrKind::Custom(e.to_string().into())) - }, - ) - .await; - let params = match res { - Ok(params) => params, - Err(e) => { - if matches!( - e.kind, - // Expected EBICS error - EbicsErrKind::Code { - technical: EbicsReturnCode::EBICS_INVALID_ORDER_TYPE, - .. - } | - // Netzbon HTTP error - EbicsErrKind::HTTP(StatusCode::BAD_REQUEST) - ) { - // Failure is expected if this wss is not supported - info!(target: "ws", "Real-time EBICS notifications is not supported"); - return Ok(()); - } else { - return Err(e.into()); - } - } - }; - info!(target: "ws", "Listening to real-time EBICS notifications"); - trace!(target: "ws", "{params:?}"); - - params - .connect(&ebics.http, async |msg| { - backoff.reset(); - match msg { - WssNotification::GeneralInfo { info, .. } => { - for info in info { - info!(target: "ws", "info: {}", info.free); - } - } - WssNotification::NewData { btf, .. } => { - let orders = btf - .into_iter() - .map(|it| { - Order::BTD(BTF { - service: it.service, - scope: it.scope, - option: it.option, - container: it.conttype, - msg: it.msgname, - version: it.version, - }) - }) - .collect(); - sender.send(orders).await.ok(); - } - } - }) - .await?; - Ok(()) - } - .await; - if let Err(e) = res { - error!(target: "ws", "{e}"); - tokio::time::sleep(backoff.backoff()).await; - } else { - return; - } - } -} - -#[cfg(test)] -mod test { - use std::{fmt::Debug, fs::Permissions, os::unix::fs::PermissionsExt as _, time::Duration}; - - use axum::{ - extract::{ - WebSocketUpgrade, - ws::{CloseFrame, Message, Utf8Bytes}, - }, - http::HeaderMap, - routing::get, - }; - use reqwest::header::AUTHORIZATION; - use serde::{Serialize, de::DeserializeOwned}; - use taler_api::api::TalerRouter as _; - - use crate::ws::{WssNotification, WssParams}; - - // WSS params example from the spec - const PARAMS_EXAMPLE: &str = r#" - { - "URL": "http://bankmitwebsocket.de", - "TOKEN": "550e8400-e29b-11d4-a716-446655440000", - "OTT": "N", - "VALIDITY": "2019-03-21T10:35:22Z", - "PARTNERID": "K1234567", - "USERID": "USER4711" - } - "#; - // Authorization header example from the spec - const AUTH_EXAMPLE: &str = - "Basic SzEyMzQ1NjdfVVNFUjQ3MTE6NTUwZTg0MDAtZTI5Yi0xMWQ0LWE3MTYtNDQ2NjU1NDQwMDAw"; - // Notifications examples from the spec - const NOTIFICATION_EXAMPLES: [&str; 3] = [ - r#" - { - "MCLASS": [ - { - "NAME": "EBICS-HAA", - "VERS": "1.0", - "TIMESTAMP": "2019-05-13T12:21:50Z" - } - ], - "PARTNERID": "K1234567", - "USERID": "USER471", - "BTF": [ - { - "SERVICE": "REP", - "SCOPE": "DE", - "CONTTYPE": "ZIP", - "MSGNAME": "camt.054" - } - ], - "ORDERTYPE": [ - "C5N" - ] - } - "#, - r#" - { - "MCLASS": [ - { - "NAME": "EBICS-HAA", - "VERS": "1.0", - "TIMESTAMP": "2019-05-13T12:21:53Z" - } - ], - "PARTNERID": "K1234567", - "USERID": "USER471", - "BTF": [ - { - "SERVICE": "REP", - "SCOPE": "DE", - "CONTTYPE": "ZIP", - "MSGNAME": "camt.052" - }, - { - "SERVICE": "REP", - "SCOPE": "DE", - "OPTION": "SCI", - "CONTTYPE": "ZIP", - "MSGNAME": "pain.002" - } - ], - "ORDERTYPE": [ - "C52", - "CIZ" - ] - } - "#, - r#" - { - "MCLASS": [ - { - "NAME": "INFO", - "VERS": "1.0", - "TIMESTAMP": "2019-03-25T12:25:34Z" - } - ], - "INFO": [ - { - "LANG": "EN", - "FREE": " The EBICS-Service is limited on 30.03.2019 from 10:00 a.m. - 11:00a.m. due to maintenance work " - } - ] - } - "#, - ]; - - #[test] - pub fn serialization() { - fn roundrip<T: Serialize + DeserializeOwned + Eq + Debug>(src: &str) { - let it: T = serde_json::from_str(src).unwrap(); - let roundrip: T = serde_json::from_str(&serde_json::to_string(&it).unwrap()).unwrap(); - assert_eq!(it, roundrip); - } - roundrip::<WssParams>(PARAMS_EXAMPLE); - for ex in NOTIFICATION_EXAMPLES { - roundrip::<WssNotification>(ex); - } - } - - #[tokio::test] - pub async fn params() { - let path = "/tmp/libeufin_nexus_wss_test.sock"; - std::fs::remove_file(&path).ok(); - let server = axum::Router::new() - .route( - "/", - get(async |headers: HeaderMap, ws: WebSocketUpgrade| { - assert_eq!( - headers.get(AUTHORIZATION).map(|it| it.as_bytes()), - Some(AUTH_EXAMPLE.as_bytes()) - ); - ws.on_upgrade(async |mut it| { - for ex in NOTIFICATION_EXAMPLES { - it.send(Message::Text(Utf8Bytes::from_static(ex))) - .await - .unwrap(); - } - it.send(Message::Close(Some(CloseFrame { - code: 1000, - reason: Utf8Bytes::from_static("Test done"), - }))) - .await - .unwrap(); - }) - }), - ) - .serve( - taler_api::Serve::Unix { - path: path.into(), - permission: Permissions::from_mode(660), - }, - None, - ); - tokio::spawn(server); - for _ in 0..100 { - if std::fs::exists(path).unwrap() { - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - - let client = reqwest::ClientBuilder::new() - .unix_socket(path) - .build() - .unwrap(); - let params: WssParams = serde_json::from_str(PARAMS_EXAMPLE).unwrap(); - let mut count = 0; - params - .connect(&client, async |msg| { - count += 1; - // Check message number and type - assert!(count <= 3); - if count == 3 { - assert!(matches!(msg, WssNotification::GeneralInfo { .. })) - } else { - assert!(matches!(msg, WssNotification::NewData { .. })) - } - }) - .await - .unwrap(); - // Check receive all messages - assert_eq!(3, count); - } -} diff --git a/src/xml.rs b/src/xml.rs @@ -1,471 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{ - fmt::{Display, Write}, - str::{FromStr, Utf8Error}, -}; - -use base64::{Engine, prelude::BASE64_STANDARD}; -use roxmltree::{Document, Node}; - -#[macro_export] -macro_rules! xml { - // Trailing comma - ($w:ident => $(,)?) => {{}}; - // Logic escape - ($w:ident => @ $logic:expr$(, $($rest:tt)*)?) => {{ - ($logic)($w); - $($crate::xml!($w => $($rest)*);)* - }}; - // Text element - ($w:ident => $name:tt $($k:literal=$v:tt)* : $content:expr $(, $($rest:tt)*)?) => {{ - $w.text(&$name, &[$((&$k, &$v)),*], &$content); - $($crate::xml!($w => $($rest)*);)* - }}; - // Nested block - ($w:ident => $name:tt $($k:literal=$v:tt)* { $($body:tt)* }$(, $($rest:tt)*)?) => {{ - let name = &$name; - $w.open(&name, &[$((&$k, &$v)),*]); - $crate::xml!($w => $($body)*); - $w.close(&name); - $($crate::xml!($w => $($rest)*);)* - }}; - // Empty element - ($w:ident => $name:tt $($k:literal=$v:tt)* $(, $($rest:tt)*)?) => {{ - $w.empty(&$name, &[$((&$k, &$v)),*]); - $($crate::xml!($w => $($rest)*);)* - }}; - // Root builder - ($name:tt $($k:literal=$v:tt)* { $($body:tt)* }) => {{ - let mut writer = $crate::xml::XmlWriter::init(); - let w = &mut writer; - let name = &$name; - w.open(&name, &[$((&$k, &$v)),*]); - $crate::xml!(w => $($body)*); - w.close(&name); - writer.finish() - }}; -} - -pub struct XmlWriter { - xml: String, -} - -impl XmlWriter { - pub fn init() -> Self { - let mut xml = String::with_capacity(1024); - xml.push_str(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#); - Self { xml } - } - - pub fn open<N: Display>(&mut self, name: N, attrs: &[(&dyn Display, &dyn Display)]) { - self.xml.push('<'); - self.write_tag_attrs(name, attrs); - self.xml.push('>'); - } - - pub fn close<N: Display>(&mut self, name: N) { - self.xml.push_str("</"); - self.xml.write_fmt(format_args!("{name}")).unwrap(); - self.xml.push('>'); - } - - pub fn empty<N: Display>(&mut self, name: N, attrs: &[(&dyn Display, &dyn Display)]) { - self.xml.push('<'); - self.write_tag_attrs(name, attrs); - self.xml.push_str("/>"); - } - - pub fn text<N: Display, C: Display>( - &mut self, - name: N, - attrs: &[(&dyn Display, &dyn Display)], - content: C, - ) { - self.open(&name, attrs); - self.write_escaped(content); - self.close(&name); - } - - fn write_tag_attrs<N: Display>(&mut self, name: N, attrs: &[(&dyn Display, &dyn Display)]) { - self.xml.write_fmt(format_args!("{name}")).unwrap(); - - for (key, value) in attrs { - self.xml.push(' '); - self.xml.write_fmt(format_args!("{}", *key)).unwrap(); - self.xml.push_str("=\""); - self.write_escaped(*value); - self.xml.push('"'); - } - } - - fn write_escaped<D: Display>(&mut self, content: D) { - std::fmt::write(self, format_args!("{content}")).unwrap(); - } - - pub fn finish(self) -> String { - self.xml - } -} - -/// Write XML text content following XML escape rules -impl std::fmt::Write for XmlWriter { - fn write_str(&mut self, s: &str) -> std::fmt::Result { - // Single pass over bytes. For each special character, bulk-copy - // everything before it, then push the entity. No double-scan, - // no char-at-a-time pushing for clean runs. - let mut start = 0; - for (i, &b) in s.as_bytes().iter().enumerate() { - let entity = match b { - b'<' => "&lt;", - b'>' => "&gt;", - b'&' => "&amp;", - b'\'' => "&apos;", - b'"' => "&quot;", - _ => continue, - }; - self.xml.push_str(&s[start..i]); // bulk copy of clean prefix - self.xml.push_str(entity); - start = i + 1; - } - self.xml.push_str(&s[start..]); // bulk copy of clean suffix - Ok(()) - } -} - -#[derive(Debug)] -pub enum Error { - Str(Utf8Error), - Xml(roxmltree::Error), - Root(Box<str>, Box<str>), - Parent(Box<str>), - MissingEl(Box<str>), - MissingAttr(Box<str>, Box<str>), - Duplicate(Box<str>, usize), - Parse(Box<str>, Box<str>), -} - -impl Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Str(e) => e.fmt(f), - Self::Xml(e) => e.fmt(f), - Self::Root(expected, got) => write!(f, "expected root '{expected}' got '{got}'"), - Self::Parent(path) => write!(f, "not parent for element '{path}'"), - Self::MissingEl(path) => write!(f, "missing element '{path}'"), - Self::MissingAttr(path, name) => write!(f, "missing attribute '{name}' on <{path}>"), - Self::Duplicate(path, nb) => write!(f, "expected one '{path}', got {nb}"), - Self::Parse(path, err) => write!(f, "malformed '{path}': {err}"), - } - } -} - -impl std::error::Error for Error {} - -pub type Result<T> = std::result::Result<T, Error>; - -#[derive(Debug, Clone, Copy)] -pub struct Xml<'xml> { - pub node: Node<'xml, 'xml>, -} - -impl<'xml> Xml<'xml> { - pub fn parse<F, R>(raw: &[u8], tag: &str, f: F) -> Result<R> - where - R: 'static, - F: for<'local> FnOnce(Xml<'local>) -> Result<R>, - { - let str = std::str::from_utf8(raw).map_err(Error::Str)?; - let xml = Document::parse(str).map_err(Error::Xml)?; - Self::doc(xml, tag, f) - } - - pub fn doc<F, R>(xml: Document, tag: &str, f: F) -> Result<R> - where - R: 'static, - F: for<'local> FnOnce(Xml<'local>) -> Result<R>, - { - let root = xml.root_element(); - if !root.has_tag_name(tag) { - return Err(Error::Root(tag.into(), root.tag_name().name().into())); - } - let node = Xml { node: root }; - let res = f(node); - drop(xml); - res - } - - fn path(self, tag: Option<&str>) -> Box<str> { - let mut ancestors = Vec::new(); - let mut cur = Some(self.node); - while let Some(n) = cur { - if n.is_element() { - ancestors.push(n); - } - cur = n.parent(); - } - let mut buf = String::new(); - for n in ancestors.into_iter().rev() { - // Add prefix if it exists - if let Some(prefix) = n.tag_name().namespace().and_then(|ns| n.lookup_prefix(ns)) { - buf.push_str(prefix); - buf.push(':'); - } - - buf.push_str(n.tag_name().name()); - buf.push('.'); - } - match tag { - Some(t) => buf.push_str(t), - None => { - buf.pop(); - } - } - buf.into() - } - - pub fn parse_err(self, err: impl Display) -> Error { - Error::Parse(self.path(None), err.to_string().into_boxed_str()) - } - - pub fn parent(self) -> Result<Xml<'xml>> { - Ok(Self { - node: self - .node - .parent() - .ok_or_else(|| Error::Parent(self.path(None)))?, - }) - } - - fn children(self, tag: &str, signed: bool) -> impl Iterator<Item = Node<'xml, 'xml>> { - self.node.children().filter(move |n| { - n.has_tag_name(tag) && (!signed || n.attribute("authenticate") == Some("true")) - }) - } - - fn opt_inner(self, tag: &str, signed: bool) -> Result<Option<Xml<'xml>>> { - let mut iter = self.children(tag, signed); - match (iter.next(), iter.next()) { - (None, _) => Ok(None), - (Some(_), Some(_)) => Err(Error::Duplicate(self.path(Some(tag)), iter.count() + 2)), - (Some(node), None) => Ok(Some(Xml { node })), - } - } - - fn one_inner(self, tag: &str, signed: bool) -> Result<Xml<'xml>> { - self.opt_inner(tag, signed) - .transpose() - .unwrap_or_else(|| Err(Error::MissingEl(self.path(Some(tag))))) - } - - pub fn many(self, tag: &str) -> impl Iterator<Item = Xml<'xml>> { - self.children(tag, false).map(|node| Xml { node }) - } - - pub fn text(self) -> &'xml str { - self.node.text().unwrap_or_default() - } - - pub fn attr(self, name: &str) -> Result<&'xml str> { - self.node - .attribute(name) - .ok_or_else(|| Error::MissingAttr(self.path(None), name.into())) - } - - pub fn opt_attr(self, name: &str) -> Option<&'xml str> { - self.node.attribute(name) - } -} - -pub trait XmlAccess<'xml>: Sized { - type Out<T>; - type Opt<T>; - - fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>>; - fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>>; - - fn one(self, tag: &'xml str) -> Result<Self::Out<Xml<'xml>>> { - self.lift(|n| n.one_inner(tag, false)) - } - - fn one_signed(self, tag: &'xml str) -> Result<Self::Out<Xml<'xml>>> { - self.lift(|n| n.one_inner(tag, true)) - } - - fn opt(self, tag: &'xml str) -> Result<Self::Opt<Xml<'xml>>> { - self.opt_lift(|n| n.opt_inner(tag, false)) - } - - fn opt_signed(self, tag: &'xml str) -> Result<Self::Opt<Xml<'xml>>> { - self.opt_lift(|n| n.opt_inner(tag, true)) - } - - fn parse_attr<T: FromStr>(self, name: &str) -> Result<Self::Out<T>> - where - T::Err: Display, - { - self.lift(|n| n.attr(name)?.parse().map_err(|e| n.parse_err(e))) - } - - fn parse_opt_attr<T: FromStr>(self, name: &str) -> Result<Self::Opt<T>> - where - T::Err: Display, - { - self.opt_lift(|n| { - n.opt_attr(name) - .map(|it| it.parse().map_err(|e| n.parse_err(e))) - .transpose() - }) - } - - fn decode<T, E: Display>( - self, - lambda: impl FnOnce(&str) -> std::result::Result<T, E>, - ) -> Result<Self::Out<T>> { - // TODO error not a node text ? - self.lift(|n| lambda(n.text()).map_err(|e| n.parse_err(e))) - } - - fn parse<T: FromStr>(self) -> Result<Self::Out<T>> - where - T::Err: Display, - { - self.decode(T::from_str) - } - - fn b64(self) -> Result<Self::Out<Vec<u8>>> { - self.decode(|it| BASE64_STANDARD.decode(it)) - } -} - -impl<'xml> XmlAccess<'xml> for Xml<'xml> { - type Out<T> = T; - type Opt<T> = Option<T>; - - fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { - f(self) - } - - fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { - self.lift(f) - } -} - -impl<'xml> XmlAccess<'xml> for Option<Xml<'xml>> { - type Out<T> = Option<T>; - type Opt<T> = Option<T>; - - fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { - self.map(|it| it.lift(f)).transpose() - } - - fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { - match self { - Some(xml) => xml.opt_lift(f), - None => Ok(None), - } - } -} - -impl<'xml> XmlAccess<'xml> for Result<Xml<'xml>> { - type Out<T> = T; - type Opt<T> = Option<T>; - - fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { - self?.lift(f) - } - - fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { - self.lift(f) - } -} - -impl<'xml> XmlAccess<'xml> for Result<Option<Xml<'xml>>> { - type Out<T> = Option<T>; - type Opt<T> = Option<T>; - - fn lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<T>) -> Result<Self::Out<T>> { - self?.map(|it| it.lift(f)).transpose() - } - - fn opt_lift<T>(self, f: impl FnOnce(Xml<'xml>) -> Result<Option<T>>) -> Result<Self::Opt<T>> { - match self? { - Some(xml) => xml.opt_lift(f), - None => Ok(None), - } - } -} - -#[cfg(test)] -mod test { - use crate::xml::XmlWriter; - - #[test] - pub fn basic() { - assert_eq!( - xml!("ebicsRequest" "version"="H004" { - "a" { - "b" { - "c" "attribute-of"="c" { - "d" { - "e" { - "f" "nested"="true" { - "g" { - "h" - } - } - } - } - } - } - }, - "one_more" - }), - r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsRequest version="H004"><a><b><c attribute-of="c"><d><e><f nested="true"><g><h/></g></f></e></d></c></b></a><one_more/></ebicsRequest>"# - ) - } - - #[test] - pub fn modularity() { - fn module(w: &mut XmlWriter) { - xml!(w => "module"); - } - assert_eq!( - xml!("root" { @ module }), - r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><root><module/></root>"# - ) - } - - #[test] - pub fn iterable() { - assert_eq!( - xml!("iterable" { - "endOfDocument" { - @ |w: &mut XmlWriter| for i in 1..=10 { - xml!(w => (format_args!("e{i}")) { - (format_args!("e{i}{i}")): (format_args!("{i}{i}{i}")) - }) - } - } - }), - r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><iterable><endOfDocument><e1><e11>111</e11></e1><e2><e22>222</e22></e2><e3><e33>333</e33></e3><e4><e44>444</e44></e4><e5><e55>555</e55></e5><e6><e66>666</e66></e6><e7><e77>777</e77></e7><e8><e88>888</e88></e8><e9><e99>999</e99></e9><e10><e1010>101010</e1010></e10></endOfDocument></iterable>"# - ) - } -} diff --git a/src/xml_sign.rs b/src/xml_sign.rs @@ -1,292 +0,0 @@ -/* -* This file is part of LibEuFin. -* Copyright (C) 2026 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/> -*/ - -use std::{ - borrow::Cow, - collections::{BTreeMap, HashSet}, -}; - -use aws_lc_rs::{digest::Digest, signature::RSA_PKCS1_SHA256}; -use aws_lc_rs::{rand::SystemRandom, signature::RsaKeyPair}; -use base64::{Engine as _, prelude::BASE64_STANDARD}; -use roxmltree::{Document, Node}; - -fn escape<'a>(text: &'a str, replacements: &[(char, &str)]) -> Cow<'a, str> { - // Find the first character that needs escaping - let Some(first_pos) = text.find(|c| replacements.iter().any(|(r, _)| *r == c)) else { - return Cow::Borrowed(text); // No escaping needed — zero allocations - }; - - // Pre-allocate with a reasonable estimate - let mut output = String::with_capacity(text.len() + 16); - output.push_str(&text[..first_pos]); - - for ch in text[first_pos..].chars() { - match replacements.iter().find(|(r, _)| *r == ch) { - Some((_, escaped)) => output.push_str(escaped), - None => output.push(ch), - } - } - - Cow::Owned(output) -} - -// C14N requires specific escaping for Text nodes -fn escape_text(text: &str) -> Cow<'_, str> { - escape( - text, - &[ - ('&', "&amp;"), - ('<', "&lt;"), - ('>', "&gt;"), - ('\r', "&#xD;"), - ], - ) -} - -// C14N requires specific escaping for Attributes -fn escape_attr(text: &str) -> Cow<'_, str> { - escape( - text, - &[ - ('&', "&amp;"), - ('<', "&lt;"), - ('"', "&quot;"), - ('\t', "&#x9;"), - ('\n', "&#xA;"), - ('\r', "&#xD;"), - ], - ) -} - -/// Updated C14N logic to prevent redundant namespace declarations -fn c14n_inclusive<'a>( - node: Node<'a, 'a>, - mut active_namespaces: HashSet<(&'a str, &'a str)>, - out: &mut String, -) { - if node.is_text() { - out.push_str(&escape_text(node.text().unwrap_or(""))); - } else if node.is_element() { - let prefix = node - .tag_name() - .namespace() - .and_then(|uri| node.lookup_prefix(uri)); - let push_tag_name = |out: &mut String| { - if let Some(ns) = prefix { - out.push_str(ns); - out.push(':'); - }; - out.push_str(node.tag_name().name()); - }; - - // Open element - out.push('<'); - push_tag_name(out); - - // Write sorted missing namespaces - let missing: BTreeMap<&str, &str> = node - .namespaces() - .filter_map(|ns| { - let value = (ns.name().unwrap_or_default(), ns.uri()); - active_namespaces.insert(value).then_some(value) - }) - .collect(); - for (prefix, uri) in missing { - out.push(' '); - out.push_str("xmlns"); - if !prefix.is_empty() { - out.push(':'); - out.push_str(prefix); - } - out.push_str("=\""); - out.push_str(uri); - out.push('"'); - } - - // Write sorted attributes - let attributes: BTreeMap<&str, &str> = node - .attributes() - .map(|it| (it.name(), it.value())) - .collect(); - for (k, v) in attributes { - out.push(' '); - out.push_str(k); - out.push_str("=\""); - out.push_str(&escape_attr(v)); - out.push('"'); - } - - out.push('>'); - - for child in node.children() { - // Pass the cloned active_namespaces down to children - c14n_inclusive(child, active_namespaces.clone(), out); - } - - out.push_str("</"); - push_tag_name(out); - out.push('>'); - } -} - -fn digest_authenticated(doc: &Document) -> Digest { - fn find_top_level_authenticators<'a>(node: Node<'a, 'a>, results: &mut Vec<Node<'a, 'a>>) { - if node.attribute("authenticate") == Some("true") { - results.push(node); - } else { - for child in node.children().filter(|n| n.is_element()) { - find_top_level_authenticators(child, results); - } - } - } - let mut nodes = Vec::new(); - - find_top_level_authenticators(doc.root(), &mut nodes); - - let mut out = String::new(); - for node in nodes { - c14n_inclusive(node, HashSet::new(), &mut out); - } - aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, out.as_bytes()) -} - -const C14N_ALG: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"; -const SIG_ALG: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; -const DIGEST_ALG: &str = "http://www.w3.org/2001/04/xmlenc#sha256"; -const DSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; - -pub fn sign_ebics(mut xml: String, key: &RsaKeyPair) -> String { - let doc = Document::parse(&xml).unwrap(); - - let digest = digest_authenticated(&doc); - let digest = BASE64_STANDARD.encode(digest.as_ref()); - - // Wrap signed info for signature in a canonical form - let default_namespace = doc - .root_element() - .default_namespace() - .expect("must be a root EBICS schema namespace"); - let signed_info = format!( - r##"<ds:SignedInfo xmlns="{default_namespace}" xmlns:ds="{DSIG_NS}"><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"></ds:CanonicalizationMethod><ds:SignatureMethod Algorithm="{SIG_ALG}"></ds:SignatureMethod><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"></ds:DigestMethod><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo>"## - ); - let mut sig = vec![0u8; key.public_modulus_len()]; - key.sign( - &RSA_PKCS1_SHA256, - &SystemRandom::new(), - signed_info.as_bytes(), - &mut sig, - ) - .unwrap(); - let sig = BASE64_STANDARD.encode(sig); - let signature = format!( - r##"<AuthSignature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="{C14N_ALG}"/><ds:SignatureMethod Algorithm="{SIG_ALG}"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="{C14N_ALG}"/></ds:Transforms><ds:DigestMethod Algorithm="{DIGEST_ALG}"/><ds:DigestValue>{digest}</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>{sig}</ds:SignatureValue></AuthSignature>"## - ); - let pattern = "<AuthSignature/>"; - let start = xml.find(pattern).unwrap(); - xml.replace_range(start..start + pattern.len(), &signature); - xml -} - -#[cfg(test)] -mod test { - use aws_lc_rs::signature::RsaKeyPair; - use base64::{Engine as _, prelude::BASE64_STANDARD}; - use roxmltree::Document; - use taler_common::types::base32; - - use crate::xml_sign::{digest_authenticated, sign_ebics}; - - #[test] - fn canonicalize() { - let xml = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsNoPubKeyDigestsRequest xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Revision="1" Version="H005"><header authenticate="true"><static><HostID>PFEBICS</HostID><Nonce>BC750C641453F93EBF236A9B25F6B70A</Nonce><Timestamp>2026-02-14T18:10:31.125926573Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><mutable/></header><AuthSignature><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#xpointer(//*[@authenticate='true'])"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>RvVxaDRsgtyZITf3C/UfmWGLERFRWZFxbwb5yhoJBOu5f6KsythhNvF28gznE1VN7E+5oP+nRkba&#13; -hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA&#13; -S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i&#13; -a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC&#13; -kJMyqsK/0dKW+4n3JgWuVGK8YdoUuvmYooqgFw==</ds:SignatureValue></AuthSignature><body/></ebicsNoPubKeyDigestsRequest> -"##; - - let doc = Document::parse(xml).unwrap(); - let res = digest_authenticated(&doc); - let hex = BASE64_STANDARD.encode(res); - assert_eq!(hex, "ws6QyiLpZVu+CbpqlhQ11PGwCdHSgmtmL7FvwrqZqmU="); - - let xml = r##"<?xml version="1.0" encoding="UTF-8"?> -<ebicsResponse xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" Version="H005" Revision="1" xsi:schemaLocation="urn:org:ebics:H005 ebics_response_H005.xsd"> - <header authenticate="true"> - <static> - <TransactionID>7FD993238073A6ADAE3B0E5C2A8010E6</TransactionID> - </static> - <mutable> - <TransactionPhase>Initialisation</TransactionPhase> - <OrderID>N0NU</OrderID> - <ReturnCode>000000</ReturnCode> - <ReportText>[EBICS_OK] OK</ReportText> - </mutable> - </header> - <AuthSignature> - <ds:SignedInfo> - <ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> - <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/> - <ds:Reference URI="#xpointer(//*[@authenticate='true'])"> - <ds:Transforms> - <ds:Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/> - </ds:Transforms> - <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/> - <ds:DigestValue>WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI=</ds:DigestValue> - </ds:Reference> - </ds:SignedInfo> - <ds:SignatureValue>Ug6LWlR5FCrOjKjqa37Y6D/vYdYxDp3FcLnj/SEJU5kCGpqd+MrEJDg0/q726ozlxkw50hEbK+Kh+MDxRPTztxOdc78V9PuAK9mzo41+G6cv26SKZqX3wtCIrcaFhsEfzIqe9m8NwlnQ3aATMxEevjVPLE+TzSd+Tb6vFybt3a6Qi3iHmjTTeNVPTcAte91A2wqI/k+aPbg2ndRio/stGjuvVYDXNy9YuXvg8XEtgkbDtkx90O5shexaUMI/W5YqY49kd7aY4gSY6jf1/rfkWHU556mtPjuYBLg0TL9nOQWIrzw3eIWpVB0xoPvdPfzRtYvT7KEuk5LtSwEfHiLqgw==</ds:SignatureValue> - </AuthSignature> - <body> - <ReturnCode authenticate="true">000000</ReturnCode> - <TimestampBankParameter authenticate="true">2020-11-25T19:03:45.693Z</TimestampBankParameter> - </body> -</ebicsResponse> -"##; - let doc = Document::parse(xml).unwrap(); - let res = digest_authenticated(&doc); - assert_eq!( - BASE64_STANDARD.encode(res), - "WJz3HUYjV3HMK0Cy+69XCnAcmiD21mJ5BRiQPwsi1VI=" - ); - } - - #[test] - fn sign() { - let key = "62109F820403038614N8CJ46YW6G20810M0090G4MWR84153080G00M2040G18GPPFA8VSJD6G0ENC3SH2ET37BXQ1RTRAX162GWVTW33BX14FBAC0N7DFJBKKNS0EJ4DY07TABNKDHGX0EX7XWV47P456NXX8DP33MVZ010X2F248GDXQK3WWYZKAYMA61KYNTJ4QD1BAZWES5GBA8F9WD9EM9WN9ZYQHFGNWVDQ2BEE54CQAGF82AFR0NJEJWFT4QKNVR8QB79VESG623W5NZPFQM1ZSJ20ZDYCJC7KJ1Q23TDJA0C1SY3KWRM97R60BZXKQ9Q9FM1AFQ8CAYDG0FJSQQM0D5W8QQENK79W8VZ0605A1FKYZYBFQX34FBFJKTCSDEZWSYDQM4HD9JF8F86HXW3F2GE9A7H7JHZG7441MJ91H24ND5M8YK4VXYAB7RX8JAXSS8K33BQXF5QBRR20C0G0082G80G0079GETRHX75MR929BDEYWFKTXE82F0QV71AHY3MKKQXNK545W4XSGHGZARZTH5TGABDTW2SJHKXQ787X2CQFY8ZDDHN5WEMXT5VMBZK5B3TJW5XM98GRREWWPA8AJPA8QZ30Q9RYX49228NHSAM8F2DEH40KAXMFT8HB1PDVCVQRQV5HKYBD1Z6Z1ZZZXXJ114X0E4X6R3FMVWQGANAKYRT2S75FKCG00C96EBM1B8RBZPBQZ7FVA9ZB8F64PYG4KRFHT4CYQZ5D6HP1K42PKYB7TA45SZKRDXE3PYTZE6XASJSP9H1EH1JM0ZPMC1Y2FBWPQ8SR2233J55HX6PXWSJ2ZJYTC8V9FM9F442SQM5EGNYK59RCSEGWMZ0WSF98WX4QVDX4CVN3E1GQPNH9K8ERG82G60G1M763Z4MZSJG1MJZQV32HYNMBEV26J8JKC6E53GWKY101RCB1JXN08H8P64P8QTDV9WRS3EQV30557E7QJAYG1K5A4D76DYTNE0GBC7KE5FD6S8ADSJV9XWVVQHVV1BRQVZ4ZWWSK5M9VEVZNRXXBJVZPKR26XEBT6ANVEBTSQ538K1BZQGBQTKWVMDFMG91A4ATXQM2B5J1MC183080RTHA7FVF7SN90B4XQ87GST3Z50H6T6CRQGR0PDDV51HGH1JE76AAWP1VCEWGASWZ2C9KVB8Z5GH71SCW0MF89A02NFNGVQ9D3QV3PMZQSGM6RC35DDBD33J8N5G2V2SN5MCNB3RA7SMJVVG5DG7QHCJ83QX11G5P8KHQ8MFEV69HGXSS7DTHBV71EWP78PPEMSXP7C7ASYZC20M1G02CCQEFM8PYF0M5DPZBEYG56RRD8JYK9PDADYXM7P1SGSZT2J07705TZNKF0Y34W9T28FZ340PRAA5HSDX8SP3EFSFMNV8RC109HKRW0ZP4WRE1E8QJVGS19CZVPAKMRQA17VF9H4HK3FVXYCA2B3FAZTMRVABA9D03P01BYNERVDEJMQ2173562N24FEBYB18EYF94WD3GVX82G60G15KVSJV527Q6723V93NANH5CYPN6H8JE8CTXXA030S1EEBEDT4KYEDZE1BVJ2A42GPCS60BA448VKT83A793QEW5A7EDKFCKWFQYPSZTSCBWRS4ZQTYG00Z5T2G4JMY6PWPS92D6YNJCYK0EGNNFF7QGDXXYWN33MM0296SQ1MK0R0F45EXAQT5S2Q39SXAA8KHR32A8BC0HG418300KMYBR5ZKNTX7SANSJB5SSYGP9RZVHN23RC1J29QRH5XFSMABMDA582GJH5JAE0D31AH5PTAHP04Y61KNCSKNC748N1PRN503VZY7EA1C4G75C0F13K04TE8RVP63K0TQ693E2XB23WSHYERKSZ6AKCTR3E15N1AF70HEKK13E4QCCY2JN896YEWWT9B8CVW50D8C87S75EK3G"; - - let key: RsaKeyPair = - RsaKeyPair::from_pkcs8(&base32::decode(key.as_bytes()).unwrap()).unwrap(); - let tmp = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><ebicsNoPubKeyDigestsRequest xmlns="urn:org:ebics:H005" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Revision="1" Version="H005"><header authenticate="true"><static><HostID>PFEBICS</HostID><Nonce>6BC48C9C2576ABD00295788E56DFCD0A</Nonce><Timestamp>2026-02-21T17:01:53.186561035Z</Timestamp><PartnerID>PFC00563</PartnerID><UserID>PFC00563</UserID><OrderDetails><AdminOrderType>HPB</AdminOrderType></OrderDetails><SecurityMedium>0000</SecurityMedium></static><mutable/></header><AuthSignature/><body/></ebicsNoPubKeyDigestsRequest>"##; - let xml = tmp.to_owned(); - let signed = sign_ebics(xml, &key); - let doc = Document::parse(&signed).unwrap(); - let signature = doc - .descendants() - .find(|it| it.has_tag_name("SignatureValue")) - .unwrap() - .text() - .unwrap(); - assert_eq!( - signature, - "eYyb1v/dGVOPndpMhXZlVQM2q9H9BJP77nYOWaa7jjoeLef7/8HjKIv8oq6Kaf6Z9mAfh/Pcip3a75gkdKpz7ocl1YdsaD+CcQkO1J/n4NwY821ccSh0Ahm2PBE168hyEMzPJrDeDtJrYqs+J/+nC8ek0hbo4/WPsH4UoxVu+ANsHR+BnQFQW3k9BFv+XKZbrBltIY62SN73tYwU8QzRtINJLzjhNB3T6S101n4CYwycXpL5b/oXXOUxxfDnn9EmIFt4DIgjxxqDYdQEBytULLORdkIdf563aw2wDaN12OQV2TB9gAs4Uu203FkUbmIagarMhbKKlqa1NkOteZ13Xw==" - ); - } -}