sepa 0.7.0

SEPA payment utilities: IBAN validation (ISO 13616 + 89-country registry), BIC validation (ISO 9362:2022), 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.007 SDD reversal XML builder (pain.007.001.09), pain.002 status report parser incl. Verification of Payee, camt.055 payment cancellation (recall) builder + camt.029 resolution parser, camt.052/053/054 parsers, structured and hybrid postal addresses for the Nov 2026 EPC cut-over, ISO 11649 RF creditor references, EPC field validation and SEPA charset transliteration. Output is XSD-validated in CI against the ISO schemas and the German GBIC 5 subsets. Typed ISO 8601 dates, never defaulted from a clock. No 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
# 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.**
> ⚡ No I/O, no async, one clock read. 🔢 No `f64` in monetary arithmetic.
> 📅 No hand-formatted dates — and no defaulted ones.
> ✅ 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 **and generation** — ISO 13616 mod-97 + national BBAN structure + 89-country registry + SEPA membership |
| `bic` | BIC/SWIFT validation — ISO 9362:2022, real country codes, per-schema pattern gate |
| `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**, reasons at all three levels |
| `camt055` | 📤 Payment Cancellation Request — **recall** a file, a group or single transactions |
| `camt029` | 📥 Resolution of Investigation — the bank's answer to a recall |
| `camt052` / `camt053` / `camt054` | 📥 Intraday report, end-of-day statement, notifications & returns |
| `camt` | Shared entry model — batch bookings, `Btch/PmtInfId` back-reference, return fees, 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 →](https://hupe1980.github.io/sepa/)** ·
[API reference on docs.rs](https://docs.rs/sepa)

---

## 🚀 Quick start

```sh
cargo add sepa
```

Optional features: `serde`, `time`, `chrono` — see [Dependencies](#-dependencies).

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

let creditor = validate_iban("DE89 3704 0044 0532 0130 00")?;
let debtor   = validate_iban("NL91ABNA0417164300")?;
let ci       = validate_creditor_id("DE98ZZZ09999999999")?;

// pain.001 — Überweisung
let ct = Pain001Builder::new("Acme GmbH", "CT-2026-07-001")
    .add_group(
        CreditTransferGroup::new("Acme GmbH", &creditor, IsoDate::new(2026, 7, 20)?)   // typed, never a string
            .add_entry(
                CreditTransferEntry::new("Supplier AG", debtor.clone(), 12_000, "INV-001")
                    .with_description("Rechnung 2026-07"), // 12_000 ct, never f64
            ),
    )
    .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 = Pain008Builder::new("Stadtwerke GmbH", "DD-2026-07-001")
    .add_group(
        DirectDebitGroup::new("Stadtwerke GmbH", &creditor, &ci, IsoDate::new(2026, 7, 20)?)
            .sequence_type(SequenceType::Frst)
            .add_entry(DirectDebitEntry::new(
                "MND-1", "2026-06-01".parse()?, "Neu Kunde", debtor.clone(), 5_000, "E2E-1",
            )),
    )
    .add_group(
        DirectDebitGroup::new("Stadtwerke GmbH", &creditor, &ci, IsoDate::new(2026, 7, 18)?)
            .sequence_type(SequenceType::Rcur)
            .add_entry(DirectDebitEntry::new(
                "MND-2", "2024-06-01".parse()?, "Alt Kunde", debtor, 7_500, "E2E-2",
            )),
    )
    .build()?;

assert!(dd.contains("<SeqTp>FRST</SeqTp>") && dd.contains("<SeqTp>RCUR</SeqTp>"));
# let _ = ct;
# Ok::<(), Box<dyn std::error::Error>>(())
```

`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.

Note what the constructors take: **`MsgId` and the payment date are arguments,
not defaults** — see [Design](#-design) for why neither has a safe one.

---

## 💡 Design

**Nothing that matters is defaulted from a clock.** A generated `MsgId` looks
like an identifier and is not one — it does not survive a restart, which is the
only property a bank's duplicate detection needs. A collection date of
"today + 5" is a banking-calendar answer that depends on the scheme, the
sequence type, TARGET2 and your bank's cut-off, none of which this crate knows.
Both are now required arguments. The single remaining clock read is
`GrpHdr/CreDtTm`, and `.created_at(…)` removes it — so a submitted file can be
regenerated byte-for-byte for an audit.

**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.

```rust
use sepa::{IsoDate, PostalAddress, validate_iban};
use sepa::iban::{BbanCharClass, IbanError};

// A capital O typed for a zero — mod-97 lets this through 96 times in 97.
assert!(matches!(
    validate_iban("DE8937O400440532013000"),
    Err(IbanError::InvalidBbanFormat { position: 7, expected: BbanCharClass::Digit, .. }),
));

assert!("2026-02-30".parse::<IsoDate>().is_err());      // February never has 30 days
assert!(PostalAddress::new("Atlantis", "ZZ").is_err()); // ZZ is not a country
```

The same structure works in reverse. All three of SEPA's check-digit schemes
can *generate*, not only verify — and building an IBAN from a national bank code
still goes through the registry, so a malformed one fails here rather than at
the bank:

```rust
use sepa::{Iban, RfReference, creditor_id_check_digits, iban_check_digits};

assert_eq!(Iban::from_bban("DE", "3704 0044 0532 0130 00")?.as_str(), "DE89370400440532013000");
assert_eq!(iban_check_digits("NL", "ABNA0417164300"), "91");
assert_eq!(creditor_id_check_digits("09999999999", "DE"), "98");
assert_eq!(RfReference::generate("2348231")?.as_str(), "RF712348231");
# Ok::<(), Box<dyn std::error::Error>>(())
```

**A stricter validator is not a safer one.** ISO 9362:2022 widened the BIC's
business party prefix to four *alphanumerics*, and `E097AEXX` is a real BIC that
every `[A-Z]{6}` check refuses. `validate_bic` follows the current standard — and
because the pre-2019 schemas cannot hold such a BIC, `build()` refuses it *there*
by name rather than emitting a document the XSD rejects:

```rust
use sepa::{BicPattern, validate_bic};

let modern = validate_bic("E097AEXX")?;             // valid since ISO 9362:2022
assert!(modern.fits(BicPattern::Alphanumeric));     // pain.008.001.08 takes it
assert!(!modern.fits(BicPattern::LettersOnly));     // pain.008.001.02 cannot
# Ok::<(), Box<dyn std::error::Error>>(())
```

**Failures name the row.** In a collection run of ten thousand,
"`InstdAmt` is out of range" is only actionable once it says which one:

```rust
# use sepa::{DirectDebitEntry, DirectDebitGroup, IsoDate, Pain008Builder,
#            ValidationError, validate_creditor_id, validate_iban};
# let iban = validate_iban("DE89370400440532013000")?;
# let ci = validate_creditor_id("DE98ZZZ09999999999")?;
# let date = IsoDate::new(2026, 7, 20)?;
let err = Pain008Builder::new("Stadtwerke GmbH", "DD-1")
    .add_group(
        DirectDebitGroup::new("Stadtwerke GmbH", &iban, &ci, date)
            .add_entry(DirectDebitEntry::new("MND-1", date, "Erste", iban.clone(), 100, "E2E-1"))
            .add_entry(DirectDebitEntry::new("MND-2", date, "Zweiter", 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>>(())
```

**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`:

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

assert_eq!(transliterate("Müller & Söhne", Transliteration::German), "Mueller + Soehne");
assert_eq!(transliterate("Ψυχή", Transliteration::Epc), "PSychi");
```

Use `.charset(CharsetPolicy::Strict)` to reject instead of rewrite.

---

## 🔄 The whole direct debit lifecycle

| Event | Message | Module |
|---|---|---|
| You collect | pain.008 | `pain008` |
| **You change your mind, before settlement** | camt.055 → camt.029 | `camt055`, `camt029` |
| 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, after settlement | pain.007 | `pain007` |

Three of those undo a payment and they are not interchangeable. **camt.055 is a
request** — the bank may refuse it, and nothing is cancelled until the camt.029
answer says so. **pain.007 is an instruction**, and only applies once a
collection has actually been taken. Ask for the wrong one and the window in
which anything could still be done is gone.

A reversal has to restate the collection it undoes. Rather than retyping a dozen
fields, hand it the objects you already sent:

```rust
# use sepa::{DirectDebitEntry, DirectDebitGroup, IsoDate, Pain007Builder,
#            ReversalEntry, ReversalGroup, ReversalReason,
#            validate_creditor_id, validate_iban};
# let creditor = validate_iban("DE89370400440532013000")?;
# let debtor = validate_iban("NL91ABNA0417164300")?;
# let ci = validate_creditor_id("DE98ZZZ09999999999")?;
let group = DirectDebitGroup::new("Stadtwerke GmbH", &creditor, &ci, IsoDate::new(2026, 7, 20)?);
let entry = DirectDebitEntry::new(
    "MND-42", "2024-06-01".parse()?, "Max Mustermann", debtor, 7_500, "E2E-1",
);

let xml = Pain007Builder::new("Stadtwerke GmbH", "DD-2026-07-001", "RVSL-001")
    .add_group(
        ReversalGroup::new("DD-2026-07-001")
            .add_entry(ReversalEntry::reverse(&group, &entry, ReversalReason::Ms02)),
    )
    .build()?;

assert!(xml.contains("<RvsdInstdAmt Ccy=\"EUR\">75.00</RvsdInstdAmt>"));
# Ok::<(), Box<dyn std::error::Error>>(())
```

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.

### Recalling before it settles

`camt.055` has three scopes — the whole file, a whole `PmtInf`, or named
transactions — and the schema cheerfully admits combinations of them that mean
nothing. Here they are alternatives, and mixing them is a build error rather
than a file the bank cannot action:

```rust
use sepa::{Camt055Builder, CancellationEntry, CancellationGroup, CancellationReason,
           OriginalMessage, parse_camt029, validate_bic};
# use sepa::{DirectDebitEntry, DirectDebitGroup, IsoDate, Pain008Builder,
#            validate_creditor_id, validate_iban};
# let iban = validate_iban("DE89370400440532013000")?;
# let ci = validate_creditor_id("DE98ZZZ09999999999")?;
# let submitted = Pain008Builder::new("Stadtwerke GmbH", "DD-2026-07-001")
#     .created_at("2026-07-15T09:00:00".parse()?)
#     .add_group(DirectDebitGroup::new("Stadtwerke GmbH", &iban, &ci, IsoDate::new(2026,7,20)?)
#         .payment_info_id("PMT-2026-07-A")
#         .add_entry(DirectDebitEntry::new("MND-1","2024-06-01".parse()?,"Max",iban.clone(),7_500,"E2E-1")));
let xml = Camt055Builder::new(
    "CXL-2026-07-001",
    "Stadtwerke GmbH",                    // Assgnr — you
    validate_bic("COBADEFFXXX")?,         // Assgne — your bank
    OriginalMessage::from_direct_debit(&submitted),  // copies MsgId, CreDtTm, totals
)
.add_group(
    CancellationGroup::new("PMT-2026-07-A")
        .add_entry(CancellationEntry::new("E2E-1", CancellationReason::Dupl)),
)
.build()?;

assert!(xml.contains("<Cd>DUPL</Cd>"));
# Ok::<(), Box<dyn std::error::Error>>(())
```

A reason is a constructor argument, not an option: ISO types `CxlRsnInf` as
optional and no bank can act on a reasonless recall.

Then read the answer — and note that `PDCR` is **neither** outcome:

```rust,no_run
use sepa::{parse_camt029, RejectionReason};

# let xml = "";
let answer = parse_camt029(xml)?;
if !answer.is_final() {
    println!("still pending — do not post it either way");
} else if answer.is_accepted() {
    println!("stopped");
} else if answer.rejection_reasons().iter().any(|r| r.is_too_late()) {
    println!("already settled — a pain.007 reversal is the remaining route");
}
# Ok::<(), Box<dyn std::error::Error>>(())
```

---

## ✅ 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:

```rust,no_run
use sepa::{VerificationOutcome, parse_pain002};

# let xml = "";
for block in &parse_pain002(xml)?.payment_info_statuses {
    for tx in &block.transactions {
        match tx.status.as_ref().and_then(|s| s.verification()) {
            // The payee's *actual* name comes back for the payer to confirm.
            Some(VerificationOutcome::CloseMatch) => println!("{:?}", tx.additional_info),
            Some(VerificationOutcome::NoMatch) => println!("proceeding shifts liability to you"),
            Some(VerificationOutcome::NotApplicable) => println!("no answer from payee's PSP"),
            Some(VerificationOutcome::Match) | None => {}
        }
    }
}
# Ok::<(), Box<dyn std::error::Error>>(())
```

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`](https://crates.io/crates/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.

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

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

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

    if entry.batch_booked {
        // `Btch` names the PmtInfId of the group you sent — that is how a
        // booking is matched to your own file without guessing from amounts.
        if let Some(b) = &entry.batch {
            println!("  batch from {:?}", b.payment_info_id);
        }
        // Do the parts account for the whole? If not, escalate rather than post.
        assert!(entry.details_reconcile());
    }
}
# Ok::<(), Box<dyn std::error::Error>>(())
```

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.055 / camt.029 | `.001.05` / `.001.06` — the pair the DFÜ-Abkommen names; no version choice |
| 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.

<details>
<summary><b>Why not ISO's newest versions?</b></summary>

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](https://www.iso20022.org/catalogue-messages/iso-20022-messages-archive),
not the current-version catalogue.
</details>

| 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:2022 · 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 |
| ISO 20022 camt.055.001.05 / camt.029.001.06 | Customer recall and its resolution |
| 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. |
| Extended Remittance Information (EPC092-19) | The ERI option raises `Strd` from one 140-character block to 999 × 280, but binds only PSPs that adhered to it separately — sending it to one that did not is a rejection. The base scheme is what every SEPA PSP accepts. |
| 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 | 370 unit + 36 integration tests, plus 134 documentation tests |
| **XSD validation** | every generated document, every schema version — *and* the read-path fixtures, so a hand-built camt or pain.002 example is a real document rather than one the parser happens to agree with |
| **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, in CI on every push — a builder that *accepts* a batch must emit a document whose every text node is SEPA-legal |
| **Vendored data** | all fifteen pinned XSDs re-hashed against `tests/xsd/README.md` in CI; a schema with no recorded digest fails the build |
| Exhaustive sweep | all 1,112,064 Unicode scalar values × both transliteration styles, asserting SEPA-legal output |
| Registry conformance | the 78 real IBAN examples SWIFT publishes |

Schema validation is necessary but not sufficient. `<InstdAmt>0</InstdAmt>`, a
140-character `<Nm>`, `<BICFI>NOTPROVIDED</BICFI>`, a `ZZ` Creditor Identifier,
two payment groups sharing a `PmtInfId`, a `ReqdExctnDt/DtTm` on a non-instant
transfer and a `Strd` block over the EPC's 140-character cap 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 | Error derives |
| [`quick-xml`]https://crates.io/crates/quick-xml | Correct XML parsing |
| [`serde`]https://crates.io/crates/serde | `serde` feature — `Serialize` / `Deserialize` on every public type |
| [`time`]https://crates.io/crates/time · [`chrono`]https://crates.io/crates/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](CHANGELOG.md), which always calls out changes to
**emitted output** and to **public API** separately. While `0.x`, a minor bump
(`0.6 → 0.7`) may break both; a patch release changes neither. Pin
`sepa = "0.7"` and read the changelog before moving on.

## 📄 License

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