commit e5ba71f533773395048627e4f45c9c46dfff4407
parent ee6824621640a553408d4cc97c8549b3a788305f
Author: Antoine A <>
Date: Fri, 24 Apr 2026 14:05:13 +0200
common: split libeufin-nexus and liibeufin-ebics
Diffstat:
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(×tamp.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(¤t.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(¤t.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'<' => "<",
+ b'>' => ">",
+ b'&' => "&",
+ b'\'' => "'",
+ b'"' => """,
+ _ => 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,
+ &[
+ ('&', "&"),
+ ('<', "<"),
+ ('>', ">"),
+ ('\r', "
"),
+ ],
+ )
+}
+
+// C14N requires specific escaping for Attributes
+fn escape_attr(text: &str) -> Cow<'_, str> {
+ escape(
+ text,
+ &[
+ ('&', "&"),
+ ('<', "<"),
+ ('"', """),
+ ('\t', "	"),
+ ('\n', "
"),
+ ('\r', "
"),
+ ],
+ )
+}
+
+/// 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
+hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA
+S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i
+a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC
+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, ¶ms, || {
+ 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, ¶ms, || {
+ 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, ¶ms, || {
+ 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(¬ification, 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, ¶ms, || {
- 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, ¶ms, || {
- 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, ¶ms, || {
- 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(×tamp.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(¤t.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(¤t.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(¬ification, 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'<' => "<",
- b'>' => ">",
- b'&' => "&",
- b'\'' => "'",
- b'"' => """,
- _ => 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,
- &[
- ('&', "&"),
- ('<', "<"),
- ('>', ">"),
- ('\r', "
"),
- ],
- )
-}
-
-// C14N requires specific escaping for Attributes
-fn escape_attr(text: &str) -> Cow<'_, str> {
- escape(
- text,
- &[
- ('&', "&"),
- ('<', "<"),
- ('"', """),
- ('\t', "	"),
- ('\n', "
"),
- ('\r', "
"),
- ],
- )
-}
-
-/// 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
-hUBX3Y+0PahH+XeOnPGuUYdiOy0/FydtG2E1oQELNRojWhxxJMKPpN6jO9Y3j8QmS31oAWUiLjgA
-S//AU924Wh0rIwA8L3riSzGZDAgf6c0Wg+loPk581AD9QtzMiDi6onLVQvlKYtlVJNheTIreG54i
-a6vPTIqlMWB5iA5ZqoE6zO+VWr4sxTPswlHD29dDar7B4YJ1vYLLTzFHc0yJaDjWaURQNr0mDqUC
-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=="
- );
- }
-}