sepa
๐ฆ SEPA payment utilities for Rust. โก Zero I/O. No async. ๐ข No
f64in monetary arithmetic. ๐ No hand-formatted dates. โ Every generated file is validated in CI against the real ISO 20022 XSDs โ and against the stricter German GBIC 5 validation subsets.
โจ What's in it
| Module | What it provides |
|---|---|
iban |
IBAN validation โ ISO 13616 mod-97 + national BBAN structure + 89-country registry + SEPA membership |
bic |
BIC/SWIFT validation โ ISO 9362, location-code rules, real country codes |
creditor_id |
SEPA Creditor Identifier โ EPC AT-02 |
pain001 |
๐ค SEPA Credit Transfer โ SCT, SCT Instant, scheduled instant |
pain008 |
๐ค SEPA Direct Debit โ CORE + B2B |
pain007 |
๐ค SEPA Direct Debit reversal |
pain002 |
๐ฅ Payment Status Report + Verification of Payee |
camt052 / camt053 / camt054 |
๐ฅ Intraday report, end-of-day statement, notifications & returns |
camt |
Shared entry model โ batch bookings, Btch/PmtInfId back-reference, reconciliation guard |
address |
PstlAdr โ structured & hybrid, ready for the 15 Nov 2026 cut-over |
reference |
ISO 11649 RF Creditor Reference โ validate and generate |
party ยท purpose ยท country |
Ultimate parties, the two distinct purpose code sets, ISO 3166 |
date |
IsoDate / IsoDateTime โ validated at construction, time + chrono interop |
validate ยท charset |
EPC rules the XSD does not enforce; the real EPC217-08 conversion table |
Common types are re-exported at the crate root. Guides and documentation โ ยท API reference on docs.rs
๐ Quick start
Optional features: serde, time, chrono โ see Dependencies.
use ;
let creditor = validate_iban?;
let debtor = validate_iban?;
let ci = validate_creditor_id?;
// pain.001 โ รberweisung
let ct = new
.msg_id
.add_group
.build?;
// pain.008 โ a collection run with FRST and RCUR in ONE file.
// SeqTp lives on the group, so each sequence type needs its own.
let dd = new
.msg_id
.add_group
.add_group
.build?;
assert!;
# let _ = ct;
# Ok::
build() returns a Result โ the batch is checked against the EPC rules
before any XML exists. Each add_group becomes one PmtInf; sequence type,
execution date, debtor account and batch booking all live at that level.
๐ก Design
Invalid states are unconstructible, not caught late. An Iban has passed
mod-97 and the registered national BBAN structure. An IsoDate is a real
calendar day. A PostalAddress carries the town and country the EPC requires
from 15 November 2026 โ so the address format that is about to be rejected
cannot be built at all.
use ;
use ;
// A capital O typed for a zero โ mod-97 lets this through 96 times in 97.
assert!;
assert!; // February never has 30 days
assert!; // ZZ is not a country
Failures name the row. In a collection run of ten thousand,
"InstdAmt is out of range" is only actionable once it says which one:
# use ;
# let iban = validate_iban?;
# let ci = validate_creditor_id?;
# let date = new?;
let err = new
.msg_id
.add_group
.build
.unwrap_err;
assert_eq!; // the second collection
assert!;
assert!;
# Ok::
Text is transliterated, not mangled. The EPC217-08 table is transcribed from
the published spreadsheet โ 1010 mappings, not an approximation โ so รโA (not
AE), ะฉโSHT, โฌโE:
use ;
assert_eq!;
assert_eq!;
Use .charset(CharsetPolicy::Strict) to reject instead of rewrite.
๐ The whole direct debit lifecycle
| Event | Message | Module |
|---|---|---|
| You collect | pain.008 | pain008 |
| The bank accepts or rejects | pain.002 | pain002 |
| The money arrives, batch-booked | camt.053 / camt.054 | camt053, camt054 |
| The debtor claims it back | camt.054 return | camt054 |
| You send it back | pain.007 | pain007 |
A reversal has to restate the collection it undoes. Rather than retyping a dozen fields, hand it the objects you already sent:
# use ;
# let creditor = validate_iban?;
# let debtor = validate_iban?;
# let ci = validate_creditor_id?;
let group = new
.collection_date;
let entry = new;
let xml = new
.msg_id
.add_group
.build?;
assert!;
# Ok::
The mandate, creditor identifier, scheme, sequence type, collection date and both parties are copied across, so the reversal cannot disagree with the collection it reverses. Reversing more than was collected is rejected.
โ Verification of Payee
Mandatory for euro credit transfers since 9 October 2025. The payer's PSP checks the payee name against the account before execution and reports the outcome inside the pain.002 โ so a status report is no longer only about acceptance and rejection:
use ;
# let xml = "";
for block in &parse_pain002?.payment_info_statuses
# Ok::
A verification status is deliberately not an acceptance: RCVC says a name
matched, which is a different question from whether the payment was taken โ so
is_accepted() stays false and verification() answers the other question.
๐ฅ Reading bank files
Parsing is built on quick-xml, so
comments, entities, CDATA and self-closing tags are handled correctly, and
<ns2:Document> parses identically to <Document xmlns=โฆ>. <!DOCTYPE> is
rejected outright as defence in depth against entity expansion.
use parse_camt053;
# let xml = "";
let stmt = &parse_camt053?.statements;
for entry in &stmt.entries
# Ok::
Bank input is kept verbatim and typed alongside: booking_date() returns an
IsoDate and booking_date_raw the text that arrived, so a non-conforming file
is readable rather than rejected. reference() joins every RmtInf/Ustrd
occurrence โ German banks split a long Verwendungszweck into 35-character
chunks, and reading only the first cuts it where the invoice number sits.
๐ Standards & schema versions
| Message | Versions (default first) |
|---|---|
| pain.001 | .001.09 ยท .001.03 ยท .003.03 (DK, EOL) |
| pain.008 | .001.08 ยท .001.02 ยท .003.02 (DK, EOL) |
| pain.007 | .001.09 โ the only version SEPA defines |
| pain.002 | parses .001.10, .001.03, DK variants |
| camt.052/053/054 | parses .001.02 โ .001.13 |
Select with .schema(โฆ); the enums implement FromStr over both the message
identifier and the namespace URN, so the target version can come from config.
ISO advises using the most recent message definition, and has published
pain.001.001.13, pain.008.001.12 and pain.002.001.15. For SEPA that
advice is wrong โ the version is fixed by the scheme rulebook, and sending
pain.001.001.13 to a SEPA bank gets it rejected. .001.09 / .001.08 have
been the mandated versions since 19 November 2023, and nothing on the EPC's
roadmap moves SEPA past them. (15 November 2026 is a different rule: structured
addresses, not a newer message.) Those older versions live in the ISO 20022
Message Archive,
not the current-version catalogue.
| Standard | Used for |
|---|---|
| ISO 13616-1 + SWIFT IBAN Registry r102 | 89-country BBAN structure + length table |
| EPC409-09 v8.0 | SEPA scheme country list (42 codes) |
| ISO 9362 ยท ISO 3166-1 alpha-2 | BIC format; 249 country codes + XK |
| EPC262-08 ยท ISO 11649 | Creditor Identifier and RF reference check digits |
| EPC153-22 v2.1 | Structured addresses, 15 Nov 2026 cut-over |
| EPC103-24 | Verification of Payee outcomes |
| EPC217-08 | SEPA character set + conversion table |
| DK Anlage 3 GBIC 5 (TVS) | Stricter German validation subsets, applied in CI |
| EPC SEPA Rulebooks 2023/2025 | SCT, SCT Inst, SDD Core, SDD B2B |
Scope โ deliberately not included
| Not included | Why |
|---|---|
| Verification of Payee requests | The VoP scheme is a REST API with live PSP endpoints; this crate does no I/O. The VoP results in pain.002 are parsed. |
camt.055 recall / camt.029 resolution |
Recalling a credit transfer after settlement. A genuine candidate, simply not built yet. |
| IBAN โ BIC derivation | Needs a licensed, constantly-changing directory. A stale table silently misroutes payments. |
| Banking-day calendars | IsoDate::plus_days is calendar arithmetic and says so. TARGET2 holidays are a policy input. |
| Currencies other than EUR | The SEPA schemes are EUR-only. camt parsing does report the entry currency, because statements are not. |
๐ฌ Testing
| Layer | What it checks |
|---|---|
| Unit + integration | 357 tests, plus 99 documentation tests |
| XSD validation | every generated document, every schema version, via xmllint in CI |
| DK subset validation | the default SCT/SDD output also against GBIC 5 โ stricter than ISO, and what German banks apply |
| Fuzzing | three cargo-fuzz targets over parsers, identifiers, addresses and builders |
| Exhaustive sweep | all 1,114,112 Unicode code points ร both transliteration styles |
| Registry conformance | the 78 real IBAN examples SWIFT publishes |
Schema validation is necessary but not sufficient โ <InstdAmt>0</InstdAmt>, a
140-character <Nm> and <BICFI>NOTPROVIDED</BICFI> all pass the XSD and are
rejected by banks. So do a ZZ Creditor Identifier, two payment groups sharing
a PmtInfId, and a ReqdExctnDt/DtTm on a non-instant transfer. Those rules
live in validate and are enforced by build().
๐ฆ Dependencies
| Crate | Role |
|---|---|
thiserror |
Error derives |
quick-xml |
Correct XML parsing |
serde |
serde feature โ Serialize / Deserialize on every public type |
time ยท chrono |
Date interop (time / chrono features) |
time and chrono are conversion-only: the crate's own calendar arithmetic is
dependency-free and behaves identically whichever features are enabled.
๐ Versioning
This crate tracks a moving regulatory target, so releases are documented
per-version in CHANGELOG.md, which always calls out changes to
emitted output and to public API separately. While 0.x, a minor bump
(0.5 โ 0.6) may break both; a patch release changes neither. Pin
sepa = "0.6" and read the changelog before moving on.
๐ License
MIT or Apache-2.0, at your option.