sepa 0.5.0

SEPA payment utilities: IBAN validation (ISO 13616 + 89-country registry), BIC validation (ISO 9362), SEPA Creditor Identifier (EPC AT-02), pain.001 SCT + SCT Inst XML builder (pain.001.001.09 / .001.03 / .003.03), pain.008 SDD CORE + B2B XML builder (pain.008.001.08 / .001.02 / .003.02), pain.002 status report parser, camt.052/053/054 parsers, ISO 11649 RF creditor references, EPC field validation and SEPA charset transliteration. Output is XSD-validated in CI. Typed ISO 8601 dates. Zero I/O. No async. No f64 in monetary arithmetic.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
# sepa

[![Crates.io](https://img.shields.io/crates/v/sepa.svg)](https://crates.io/crates/sepa)
[![Docs.rs](https://img.shields.io/docsrs/sepa)](https://docs.rs/sepa)
[![CI](https://github.com/hupe1980/sepa/actions/workflows/ci.yml/badge.svg)](https://github.com/hupe1980/sepa/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)
[![MSRV](https://img.shields.io/badge/rustc-1.88+-orange.svg)](https://blog.rust-lang.org/2025/06/26/Rust-1.88.0/)

> ๐Ÿฆ **SEPA payment utilities for Rust.**
> โšก Zero I/O. No async. ๐Ÿ”ข No `f64` in monetary arithmetic. ๐Ÿ“… No hand-formatted dates.
> โœ… Generated files are validated against the real ISO 20022 XSDs in CI โ€” every schema version.

---

## โœจ Features

| 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, SEPA pattern, real country codes |
| `creditor_id` | ๐Ÿ” SEPA Creditor Identifier โ€” EPC AT-02 |
| `pain001` | ๐Ÿ“ค pain.001 XML builder โ€” SCT + SCT Instant |
| `pain008` | ๐Ÿ“ค pain.008 XML builder โ€” SDD CORE + B2B |
| `pain002` | ๐Ÿ“ฅ pain.002 XML parser โ€” Payment Status Report (bank โ†’ customer) |
| `camt052` | ๐Ÿ“ฅ camt.052 XML parser โ€” intraday account report |
| `camt053` | ๐Ÿ“ฅ camt.053 XML parser โ€” Bank-to-Customer Statement (end-of-day) |
| `camt054` | ๐Ÿ“ฅ camt.054 XML parser โ€” debit/credit notification, incl. returns |
| `reference` | ๐Ÿ”— ISO 11649 RF Creditor Reference โ€” validate **and generate** |
| `party` | ๐Ÿ‘ฅ Ultimate debtor/creditor (`UltmtDbtr` / `UltmtCdtr`) |
| `purpose` | ๐Ÿท๏ธ `Purp` and `CtgyPurp` codes, as the two distinct code sets |
| `date` | ๐Ÿ“… `IsoDate` / `IsoDateTime` โ€” validated at construction, `time` + `chrono` interop |
| `validate` | โœ… EPC field rules the XSD does not enforce, with the failing group and transaction |
| `charset` | ๐Ÿ”ค SEPA Basic Latin set + the real **EPC217-08** conversion table |
| `ct_to_eur_str` / `ct_from_eur_str` | ๐Ÿ’ถ Integer-safe `i64 ct โ†” "1234.56"` round-trip |

All commonly used types are re-exported at the crate root (`sepa::SequenceType`, `sepa::DirectDebitScheme`, โ€ฆ).

---

## ๐Ÿš€ Quick start

```toml
[dependencies]
sepa = "0.5"
```

Optional features: `serde`, `json`, `time`, `chrono` โ€” see
[Dependencies](#-dependencies).

### ๐Ÿ“ค Building payment files

`build()` returns a `Result`: the batch is checked against the EPC rules before
any XML is produced.

```rust
use sepa::{
    validate_iban, validate_creditor_id, IsoDate,
    Pain008Builder, DirectDebitGroup, DirectDebitEntry, SequenceType,
    Pain001Builder, CreditTransferGroup, CreditTransferEntry, LocalInstrument,
};

let creditor = validate_iban("DE89 3704 0044 0532 0130 00")?;
let debtor   = validate_iban("NL91ABNA0417164300")?;
assert_eq!(creditor.to_string(), "DE89 3704 0044 0532 0130 00"); // grouped Display
assert!(creditor.is_sepa());                                     // SEPA scheme area

// pain.001 โ€” SEPA Credit Transfer (รœberweisung), pain.001.001.09
let ct_xml = Pain001Builder::new("Debtor GmbH")
    .msg_id("CT-2026-07-001")
    .add_group(
        CreditTransferGroup::new("Debtor GmbH", &creditor)
            .execution_date(IsoDate::new(2026, 7, 20)?)  // typed, never a string
            .add_entry(
                CreditTransferEntry::new(
                    "Supplier AG",
                    debtor.clone(),
                    12_000,         // 120.00 EUR โ€” integer cents, never f64
                    "REFUND-001",
                ).with_description("Erstattung 2025"),
            ),
    )
    .build()?;

// pain.008 โ€” a direct debit run with FRST and RCUR in ONE file.
// Sequence type lives on the group, so each needs its own.
let ci = validate_creditor_id("DE98ZZZ09999999999")?;
let dd_xml = Pain008Builder::new("Stadtwerke GmbH")
    .msg_id("DD-2026-07-001")
    .add_group(
        DirectDebitGroup::new("Stadtwerke GmbH", &creditor, &ci)
            .sequence_type(SequenceType::Frst)
            .collection_date(IsoDate::new(2026, 7, 20)?)
            .add_entry(DirectDebitEntry::new(
                "MND-001", "2026-06-01".parse()?, "Neu Kunde", debtor.clone(), 5_000, "E2E-001",
            )),
    )
    .add_group(
        DirectDebitGroup::new("Stadtwerke GmbH", &creditor, &ci)
            .sequence_type(SequenceType::Rcur)
            .collection_date(IsoDate::new(2026, 7, 18)?)
            .add_entry(DirectDebitEntry::new(
                "MND-002", "2024-06-01".parse()?, "Alt Kunde", debtor, 7_500, "E2E-002",
            )),
    )
    .build()?;
assert!(dd_xml.contains("<SeqTp>FRST</SeqTp>"));
assert!(dd_xml.contains("<SeqTp>RCUR</SeqTp>"));
# Ok::<(), Box<dyn std::error::Error>>(())
```

Each `add_group` becomes one `PmtInf` block. Sequence type, execution date,
debtor account, batch booking and category purpose all sit at that level โ€” so
groups are what let one file mix `FRST` with `RCUR`, or carry two execution
dates, instead of forcing a separate submission per combination.

### ๐Ÿ“… Dates are values, not strings

`ReqdColltnDt`, `ReqdExctnDt`, `DtOfSgntr` and `CreDtTm` take an `IsoDate` /
`IsoDateTime`, exactly as the IBAN fields take a validated `Iban`. An impossible
date is rejected where it is written, so it can never reach a submitted file:

```rust
use sepa::IsoDate;

let d = IsoDate::new(2026, 7, 20)?;               // from components
let e: IsoDate = "2026-07-20".parse()?;           // from stored text
assert_eq!(d, e);
assert_eq!(d.plus_days(5)?.to_string(), "2026-07-25");   // collection offsets

assert!("2026-02-30".parse::<IsoDate>().is_err()); // February never has 30 days
assert!("20.07.2026".parse::<IsoDate>().is_err()); // nor is this ISO 8601
# Ok::<(), sepa::DateError>(())
```

With the `time` or `chrono` feature the corresponding types convert both ways,
so an application that already has a typed date never formats one:

```toml
sepa = { version = "0.5", features = ["time"] }
```

```rust,ignore
let collection: IsoDate = my_record.collection_date.try_into()?; // time::Date
```

### ๐ŸŽฏ Targeting a bank's schema version

The required version varies by bank and by regulatory cutover, so it is a
per-message choice โ€” and parseable, for reading out of configuration:

```rust
use sepa::{Pain008Builder, pain008::DirectDebitSchema};

// The 2023 rulebook default โ€ฆ
assert_eq!(DirectDebitSchema::default().message_id(), "pain.008.001.08");

// โ€ฆ or whatever this particular bank still mandates.
let schema: DirectDebitSchema = "pain.008.001.02".parse()?;
let builder = Pain008Builder::new("Stadtwerke GmbH").schema(schema);
# let _ = builder;
# Ok::<(), sepa::UnknownSchema>(())
```

| Message | Available versions |
|---|---|
| pain.001 | `pain.001.001.09` (default) ยท `pain.001.001.03` ยท `pain.001.003.03` |
| pain.008 | `pain.008.001.08` (default) ยท `pain.008.001.02` ยท `pain.008.003.02` |

Each is validated against its own real XSD in CI, and the differences between
them are handled rather than papered over โ€” the `BIC` โ†’ `BICFI` rename, the
`ReqdExctnDt` date/time choice, and the `SMNDA` marker's pre-2016 position in
the DK schema.

### ๐Ÿ” Identifier validation goes past the checksum

Mod-97 is a checksum, not a format check: it detects an altered character about
96 times in 97, and never says which one. The SWIFT registry publishes each
country's BBAN structure, so the crate checks that too โ€” and names the position:

```rust
use sepa::{validate_iban, validate_bic};
use sepa::iban::{BbanCharClass, IbanError};
use sepa::bic::BicError;

// A capital O typed for a zero. The German BBAN is 18 digits.
let err = validate_iban("DE8937O400440532013000").unwrap_err();
assert!(matches!(
    err,
    IbanError::InvalidBbanFormat { position: 7, expected: BbanCharClass::Digit, .. },
));
assert!(err.to_string().contains("position 7 must be a digit"));

// The IBAN header is structural too โ€” mod-97 would happily expand a digit here.
assert!(matches!(
    validate_iban("1289370400440532013000"),
    Err(IbanError::InvalidCountryCode { .. }),
));

// And a BIC country code has to be a country, not merely two letters.
assert!(matches!(
    validate_bic("COBAZZFF"),
    Err(BicError::UnknownCountryCode { .. }),
));
```

The registry table is the crate's single source of truth โ€” `iban_country_length`
is derived from it, so length and structure cannot disagree โ€” and it is checked
in CI against the 78 real IBAN examples the registry publishes.

### โœ… Validation and character handling

The published ISO schemas are far more permissive than the banks. A file can
validate cleanly against the XSD and still be rejected โ€” a zero amount, a
141-character name, `<BICFI>NOTPROVIDED</BICFI>`. This crate enforces the EPC
rules that actually decide acceptance:

A failure comes back as a `BuildError`: the typed `ValidationError` naming the
ISO 20022 element, **plus the group and transaction it came from** โ€” because in
a collection run of ten thousand rows, "`InstdAmt` is out of range" is only
actionable once it says which row.

```rust
use sepa::{
    DirectDebitEntry, DirectDebitGroup, IsoDate, Pain001Builder, Pain008Builder,
    ValidationError, validate_creditor_id, validate_iban,
};

// An empty message is schema-invalid (PmtInf and CdtTrfTxInf are both 1..n).
assert!(matches!(
    Pain001Builder::new("Acme GmbH").build().unwrap_err().kind,
    ValidationError::EmptyBatch,
));

let iban = validate_iban("DE89370400440532013000")?;
let ci = validate_creditor_id("DE98ZZZ09999999999")?;
let date = IsoDate::new(2026, 7, 20)?;

let err = Pain008Builder::new("Stadtwerke GmbH")
    .msg_id("DD-1")
    .add_group(
        DirectDebitGroup::new("Stadtwerke GmbH", &iban, &ci)
            .collection_date(date)
            .add_entry(DirectDebitEntry::new("MND-1", date, "Erste Kundin", iban.clone(), 100, "E2E-1"))
            .add_entry(DirectDebitEntry::new("MND-2", date, "Zweiter Kunde", iban.clone(), 0, "E2E-2")),
    )
    .build()
    .unwrap_err();

assert_eq!(err.location.transaction, Some(1));   // the second collection
assert!(matches!(err.kind, ValidationError::AmountOutOfRange { .. }));
assert!(err.to_string().starts_with("PmtInf[0]/Tx[1]: "));
# Ok::<(), Box<dyn std::error::Error>>(())
```

| Rule | Enforced |
|---|---|
| `MsgId`, `EndToEndId`, `MndtId` | 1โ€“35 chars, no leading/trailing `/`, no `//` |
| Party name `Nm` | 1โ€“70 chars (XSD permits 140) |
| Remittance `Ustrd` | 1โ€“140 chars |
| `InstdAmt` | 0.01 โ€“ 999,999,999.99 EUR |
| Batch | at least one transaction |
| Direct debit | Creditor Identifier required |
| Ultimate party `Nm` | 1โ€“70 chars; only `Nm` and `Id` permitted |
| Purpose codes | 1โ€“4 alphanumerics, in the correct code set |
| Schema features | e.g. SCT Inst rejected on a schema with no `LclInstrm` |

Dates are absent from that table on purpose โ€” an `IsoDate` is validated where it
is constructed, so `build()` never has to catch one.

Text outside the SEPA Basic Latin set is transliterated by default, since banks
reject it outright:

```rust
use sepa::{transliterate, Transliteration};

// German style (default) โ€” preserves the reading of the name
assert_eq!(transliterate("Mรผller & Sรถhne GmbH", Transliteration::German),
           "Mueller + Soehne GmbH");

// The EPC217-08 table exactly as published โ€” strictly one-to-one for Latin
assert_eq!(transliterate("Mรผller & Sรถhne GmbH", Transliteration::Epc),
           "Muller + Sohne GmbH");
```

The mapping is transcribed from the spreadsheet the EPC publishes, not
approximated โ€” 1011 entries. That matters beyond Latin accents, where generic
transliterators diverge from the standard:

```rust
# use sepa::{transliterate, Transliteration};
// 26 Greek and Cyrillic letters have a published romanisation (ISO 843 / ISO 9)
assert_eq!(transliterate("ฮจฯ…ฯ‡ฮฎ", Transliteration::Epc), "PSychi");
assert_eq!(transliterate("ะฉัƒะบะฐ", Transliteration::Epc), "SHTuka");

// โ€ฆand the ligatures do NOT expand, whatever a generic folder would do
assert_eq!(transliterate("ร†on", Transliteration::Epc), "Aon");
```

Use `.charset(CharsetPolicy::Strict)` to reject non-SEPA characters instead of
rewriting them.

### ๐Ÿงพ Corporate extras

Ultimate parties, purpose codes, structured references and mandate amendments โ€”
all XSD-validated in the right element positions:

```rust
use sepa::{
    validate_iban, validate_creditor_id, DirectDebitEntry, MandateAmendment,
    Pain008Builder, Party, Purpose, RfReference,
};

let iban = validate_iban("DE89370400440532013000")?;
let debtor = validate_iban("NL91ABNA0417164300")?;

let xml = Pain008Builder::new("Stadtwerke GmbH")
    .msg_id("DD-2026-07")
    .add_group(
        sepa::DirectDebitGroup::new(
            "Stadtwerke GmbH", &iban, &validate_creditor_id("DE98ZZZ09999999999")?,
        )
        .collection_date(sepa::IsoDate::new(2026, 7, 20)?)
        .add_entry(
            DirectDebitEntry::new("MND-042", "2024-06-01".parse()?, "Max Mustermann", debtor, 7_500, "E2E-1")
                // Collecting on behalf of the network operatorโ€ฆ
                .with_ultimate_creditor(Party::new("Netzbetreiber AG"))
                // โ€ฆfrom someone other than the account holder.
                .with_ultimate_debtor(Party::new("Erika Mustermann"))
                .with_purpose(Purpose::Elec)
                // A self-checking invoice reference that round-trips to camt.
                .with_reference(RfReference::generate("INV-2026-0042")?)
                // The debtor switched account since the last collection.
                .with_amendment(MandateAmendment::debtor_account_changed()),
        ),
    )
    .build()?;

assert!(xml.contains("<AmdmntInd>true</AmdmntInd>"));
assert!(xml.contains("<Othr><Id>SMNDA</Id></Othr>"));
assert!(xml.contains("<Purp><Cd>ELEC</Cd></Purp>"));
# Ok::<(), Box<dyn std::error::Error>>(())
```

### ๐Ÿ’พ Streaming large batches (no intermediate `String`)

```rust,no_run
use sepa::{Pain008Builder, validate_iban, validate_creditor_id};
use std::io::BufWriter;
use std::fs::File;

let iban = validate_iban("DE89370400440532013000")?;
let builder = Pain008Builder::new("Payroll GmbH")
    .msg_id("PAYROLL-2026-07")
    .add_group(
        sepa::DirectDebitGroup::new("Payroll GmbH", &iban, &validate_creditor_id("DE98ZZZ09999999999")?)
            .collection_date(sepa::IsoDate::new(2026, 7, 20)?),
        // โ€ฆ .add_entry(โ€ฆ) ร— 10 000 โ€ฆ
    );

// `write_to` validates first, so a rejected batch leaves the file untouched
// rather than writing a truncated document.
let file = File::create("payroll.xml")?;
builder.write_to(&mut BufWriter::new(file))?;
# Ok::<(), Box<dyn std::error::Error>>(())
```

### ๐Ÿ“ฅ Parsing bank responses

Parsing is built on [`quick-xml`](https://crates.io/crates/quick-xml), so
comments, entity references, CDATA and self-closing tags are all handled
correctly, and `<ns2:Document>` and `<Document xmlns=โ€ฆ>` parse identically.
`<!DOCTYPE>` is rejected outright as defence in depth against entity-expansion
attacks.

**pain.002 โ€” Payment Status Report**

```rust,no_run
use sepa::parse_pain002;

# let xml = "";
let doc = parse_pain002(xml)?;

if doc.is_fully_accepted() {
    println!("โœ… Batch accepted");
} else {
    for tx in doc.rejected_transactions() {
        println!("โŒ {} โ€” {:?}", tx.original_end_to_end_id, tx.reason_codes);
    }
}
# Ok::<(), Box<dyn std::error::Error>>(())
```

**camt.053 โ€” End-of-Day Statement**

Handles every generation from `camt.053.001.02` to `.13`, including the v07
changes (`<Sts><Cd>BOOK</Cd></Sts>`, `RltdPties/Dbtr/Pty/Nm`).

```rust,no_run
use sepa::parse_camt053;

# let xml = "";
let doc = parse_camt053(xml)?;
let stmt = &doc.statements[0];

println!("Account: {}", stmt.account_iban);
println!("Closing: {} ct", stmt.closing_balance().unwrap().signed_ct());

for entry in &stmt.entries {
    println!("{:+} ct  {}", entry.signed_ct(), entry.reference().unwrap_or(""));

    // Dates arrive as `ISODate` from one bank and `ISODateTime` from the next;
    // `booking_date()` is typed either way, `booking_date_raw` keeps the text.
    println!("  booked {:?}", entry.booking_date());

    // A batch-booked entry carries one detail per original transaction, and
    // `signed_ct()` resolves each one's amount and direction โ€” from the detail,
    // from `AmtDtls/TxAmt`, or from the entry when there is only one detail.
    // `None` means the statement does not determine it, which is exactly when
    // the tempting fallback (reuse the entry total) would multiply the batch.
    if entry.batch_booked {
        for d in &entry.details {
            println!("  โ†ณ {:?} {:?} ct", d.end_to_end_id, d.signed_ct());
        }
        // Do the parts account for the whole? If not, escalate rather than post.
        assert!(entry.details_reconcile());
    }
}
# Ok::<(), Box<dyn std::error::Error>>(())
```

**camt.054 โ€” Intraday Notifications** (`json` feature)

```toml
sepa = { version = "0.5", features = ["json"] }
```

Every parser returns a `Result` with a typed error, so a bank row that gets
skipped on import carries the reason it was skipped:

```rust
# #[cfg(feature = "json")]
# fn demo() -> Result<(), Box<dyn std::error::Error>> {
use sepa::camt054::{SimpleJsonError, parse_simple_json};

let entry = parse_simple_json(&serde_json::json!({
    "iban":       "DE89370400440532013000",
    "amount_eur": "155.42",
    "reference":  "Invoice-001",
    "date":       "2026-07-10"
}))?;
assert_eq!(entry.amount_ct, 15_542);
assert_eq!(entry.to_ledger_ct(), -15_542); // credit โ†’ negative in open-items

// A German-formatted amount names the field and the reason, rather than
// vanishing into a silent skip.
let err = parse_simple_json(&serde_json::json!({
    "iban": "DE89370400440532013000", "amount_eur": "155,42", "date": "2026-07-10",
})).unwrap_err();
assert!(matches!(err, SimpleJsonError::InvalidAmount { field: "amount_eur", .. }));
assert_eq!(err.to_string(), r#"field "amount_eur": "155,42" is not a decimal amount"#);
# Ok(())
# }
# #[cfg(feature = "json")]
# demo().unwrap();
```

---

## ๐Ÿ“‹ Standards

| Standard | Module | Notes |
|---|---|---|
| ISO 13616-1 + SWIFT IBAN Registry r101 | `iban` | 89-country BBAN structure + length table |
| EPC409-09 v8.0 | `iban` | SEPA scheme country list (42 codes) |
| ISO 9362 | `bic` | 8- and 11-char BIC, SEPA pattern, location-code rules |
| ISO 3166-1 alpha-2 | `bic` | BIC country codes (249 + `XK`) |
| EPC262-08 | `creditor_id` | Creditor Identifier check digits |
| ISO 20022 pain.001.001.09 | `pain001` | SCT + SCT Instant โ€” **default** |
| ISO 20022 pain.001.001.03 | `pain001` | EPC version until Nov 2023 |
| ISO 20022 pain.001.003.03 | `pain001` | Legacy DK V2.7 |
| ISO 20022 pain.008.001.08 | `pain008` | SDD CORE + B2B โ€” **default** |
| ISO 20022 pain.008.001.02 | `pain008` | EPC version until Nov 2023 |
| ISO 20022 pain.008.003.02 | `pain008` | Legacy DK V2.7 |
| ISO 20022 pain.002 (all variants) | `pain002` | namespace-agnostic parser |
| ISO 20022 camt.053 (v02โ€“v13) | `camt053` | namespace-agnostic parser |
| EPC217-08 | `charset` | SEPA character set + conversion table |
| EPC SEPA Rulebooks 2023/2025 | all | SCT, SDD Core, SDD B2B, SCT Inst |

### Schema versions

`pain.001.001.09` and `pain.008.001.08` are the versions the EPC 2023 rulebooks
mandated from 19 November 2023, and are the defaults here. `pain.001.001.03` and
`pain.008.001.02` are the EPC versions they replaced, still accepted โ€” and in
places still required โ€” by banks and corporate channels. The German DK variants
`pain.001.003.03` / `pain.008.003.02` reached end-of-life in November 2022.
All six are selectable via `.schema(โ€ฆ)` and validated against their own XSD in CI.

### Versioning policy

This crate tracks a moving regulatory target, so releases are documented
per-version in [CHANGELOG.md](CHANGELOG.md), which always calls out:

- any change to the **schema version or shape** of emitted XML, and
- any **API signature** change.

While `0.x`, a bump of the **minor** version (`0.4 โ†’ 0.5`) may contain breaking
changes; the patch version is reserved for fixes that do not change emitted
output or public signatures. Pin `sepa = "0.5"` and read the changelog before
moving to `0.6`. A change to the *default* schema version will always be a minor
bump with a migration note.

---

## ๐Ÿ”ฌ Testing

| Layer | What it checks |
|---|---|
| Unit + integration | 310 unit + integration tests, plus 67 documentation tests |
| **XSD validation** | generated documents validated against the real ISO 20022 schemas with `xmllint`, **every schema version**, in CI |
| **Fuzzing** | `cargo-fuzz` targets over parsers, validators and builders (`fuzz/`) |
| Exhaustive sweep | all 1,114,112 Unicode code points ร— both transliteration styles |
| **Registry conformance** | the 78 real IBAN examples SWIFT publishes, structure and checksum |

Schema validation is necessary but not sufficient โ€” `<InstdAmt>0</InstdAmt>`,
a 140-character name and `<BICFI>NOTPROVIDED</BICFI>` all pass the XSD and are
rejected by banks. Those rules live in `validate` and are enforced by `build()`.

---

## ๐Ÿ“ฆ Dependencies

| Crate | Role |
|---|---|
| [`thiserror`]https://crates.io/crates/thiserror | Ergonomic error derives |
| [`quick-xml`]https://crates.io/crates/quick-xml | Correct, streaming XML parsing |
| [`serde`]https://crates.io/crates/serde | Serialize/Deserialize on all types (`serde` feature) |
| [`serde_json`]https://crates.io/crates/serde_json | `camt054::parse_simple_json` (`json` feature) |
| [`time`]https://crates.io/crates/time | `IsoDate` โ†” `time::Date` conversion (`time` feature) |
| [`chrono`]https://crates.io/crates/chrono | `IsoDate` โ†” `chrono::NaiveDate` conversion (`chrono` feature) |

`time` and `chrono` are conversion-only: the crate's own calendar arithmetic is
dependency-free, so it behaves identically whichever features are enabled.

---

## ๐Ÿ“„ License

Licensed under either of [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.