sepa 0.8.0

SEPA payment utilities for all five EPC payment schemes: SCT, SCT Inst, SDD Core, SDD B2B and One-Leg Out Instant (OCT Inst). Builds pain.001, pain.008, pain.007 and camt.055; parses pain.002 (incl. Verification of Payee), camt.029 and camt.052/053/054. IBAN, BIC, Creditor Identifier and ISO 11649 RF reference validation, structured addresses, EPC field rules and charset transliteration. Output is XSD-validated in CI against the ISO schemas and the German GBIC 5 subsets. 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
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
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
//! SEPA Basic Latin character set — validation and transliteration.
//!
//! SEPA payment messages may only carry a 73-character subset of Latin text.
//! Anything outside it — German umlauts, French accents, the euro sign — must be
//! converted before the message is sent, or the bank rejects the file.
//!
//! ## The permitted set (EPC132-08 §1.4)
//!
//! ```text
//! a b c d e f g h i j k l m n o p q r s t u v w x y z
//! A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
//! 0 1 2 3 4 5 6 7 8 9
//! / - ? : ( ) . , ' +
//! (space)
//! ```
//!
//! ## Transliteration
//!
//! [`transliterate`] converts unsupported characters into that set using the
//! **published EPC217-08 conversion table**, transcribed verbatim from the
//! spreadsheet the EPC distributes alongside the guidance document — 1010
//! mappings covering Latin, Latin Extended, Greek, Cyrillic and symbols.
//!
//! This matters because a generic accent-folding transliterator agrees with the
//! EPC only for plain Latin accents and diverges elsewhere:
//!
//! | Input | EPC217-08 | Typical generic folding |
//! |---|---|---|
//! | `Æ` | `A` | `AE` |
//! | `Œ` | `O` | `OE` |
//! | `Θ` | `TH` | `TH` or dropped |
//! | `Щ` | `SHT` | `SHCH` |
//! | `Я` | `YA` | `YA` or dropped |
//! | `€` | `E` | dropped |
//!
//! Twenty Greek and Cyrillic letters have a genuine multi-character
//! romanisation (ISO 843 / ISO 9) — six Greek (`Θ Χ Ψ θ χ ψ`) and fourteen
//! Cyrillic (`Ж Ц Ч Ш Щ Ю Я` and their lower-case forms). The rest of the
//! table is strictly one character to one character, and characters absent
//! from it become `.`.
//!
//! ### The two styles
//!
//! | Input | [`Transliteration::German`] (default) | [`Transliteration::Epc`] |
//! |---|---|---|
//! | `ä ö ü` | `ae oe ue` | `a o u` |
//! | `Ä Ö Ü` | `Ae Oe Ue` | `A O U` |
//! | `ß` | `ss` | `s` |
//! | everything else | identical — the published table | identical |
//!
//! [`Transliteration::Epc`] is the table exactly as published.
//! [`Transliteration::German`] overrides precisely seven characters, following
//! the *"Alternativ auch zulässig"* column of DFÜ-Abkommen Anlage 3, which the
//! Deutsche Kreditwirtschaft sanctions. It is the default because losing the
//! umlaut changes how a name reads — and on a bank statement, who it appears to
//! be. `Müller` becomes `Mueller` rather than `Muller`.
//!
//! ### Where the spreadsheet and the prose disagree
//!
//! EPC217-08 §6.2 names some replacements in prose that the spreadsheet does
//! not carry, and contradicts it in one case. The precedence applied here is:
//! **the spreadsheet wins where it has a value**, and the prose fills the gaps.
//!
//! | Character | Spreadsheet | §6.2 prose | Emitted |
//! |---|---|---|---|
//! | `@` | `.` | `(at)` | `.` — the spreadsheet is the machine-readable source |
//! | `&` | *N/A* | `+` | `+` |
//! | `_` `~` | *absent* | `-` | `-` |
//! | `€` | `E` | `E` | `E` |
//! | `ẞ` | *absent* | — | `S` (`SS` in the German style) |
//!
//! `ẞ` — U+1E9E, the capital sharp S — was encoded in Unicode 5.1, after the
//! table was drawn up, so the spreadsheet carries only the lower-case `ß`.
//! Falling through to the `.` rule would drop a letter out of a name, so it
//! takes the upper case of what its lower-case counterpart maps to.
//!
//! `"`, `<` and `>` are marked *N/A* with no prose rule, since XML escaping
//! handles them at the syntax level. They are still not SEPA-legal, so they map
//! to the nearest legal punctuation (`'`, `(`, `)`).
//!
//! ## Examples
//!
//! ```
//! use sepa::charset::{is_sepa_char, transliterate, Transliteration};
//!
//! assert!(is_sepa_char('A'));
//! assert!(is_sepa_char('+'));
//! assert!(!is_sepa_char('ü'));
//!
//! assert_eq!(transliterate("Müller & Söhne GmbH", Transliteration::German),
//!            "Mueller + Soehne GmbH");
//! assert_eq!(transliterate("Müller & Söhne GmbH", Transliteration::Epc),
//!            "Muller + Sohne GmbH");
//! ```

use crate::charset_table::EPC_CONVERSION_TABLE;

/// Which transliteration table [`transliterate`] applies.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Transliteration {
    /// German banking convention: `ä→ae`, `ö→oe`, `ü→ue`, `ß→ss` (default).
    ///
    /// Sanctioned by DFÜ-Abkommen Anlage 3 as an accepted alternative to the
    /// EPC table. Preserves the German reading of names.
    #[default]
    German,
    /// Strict EPC217-08 one-to-one mapping: `ä→a`, `ö→o`, `ü→u`, `ß→s`.
    ///
    /// This is the table as published: strictly one character to one character
    /// for Latin, including the ligatures (`Æ→A`, not `AE`). The only entries
    /// that lengthen are the 20 Greek and Cyrillic letters with a genuine
    /// multi-character romanisation (`Θ→TH`, `Щ→SHT`, `Я→YA`).
    Epc,
}

/// Returns `true` if `c` is in the SEPA Basic Latin character set.
///
/// # Examples
///
/// ```
/// use sepa::charset::is_sepa_char;
///
/// assert!(is_sepa_char('Z'));
/// assert!(is_sepa_char('7'));
/// assert!(is_sepa_char(' '));
/// assert!(is_sepa_char('\''));
/// assert!(!is_sepa_char('ß'));
/// assert!(!is_sepa_char('&'));
/// ```
#[inline]
#[must_use]
pub const fn is_sepa_char(c: char) -> bool {
    matches!(c,
        'a'..='z' | 'A'..='Z' | '0'..='9'
        | '/' | '-' | '?' | ':' | '(' | ')' | '.' | ',' | '\'' | '+' | ' '
    )
}

/// Returns `true` if every character of `s` is in the SEPA character set.
///
/// # Examples
///
/// ```
/// use sepa::charset::is_sepa_text;
///
/// assert!(is_sepa_text("Rechnung 2026-07 (Teil 1)"));
/// assert!(!is_sepa_text("Zahlung für Müller"));
/// ```
#[inline]
#[must_use]
pub fn is_sepa_text(s: &str) -> bool {
    s.chars().all(is_sepa_char)
}

/// Returns the first character of `s` that is not in the SEPA character set.
///
/// # Examples
///
/// ```
/// use sepa::charset::first_invalid_char;
///
/// assert_eq!(first_invalid_char("Müller"), Some('ü'));
/// assert_eq!(first_invalid_char("Mueller"), None);
/// ```
#[inline]
#[must_use]
pub fn first_invalid_char(s: &str) -> Option<char> {
    s.chars().find(|c| !is_sepa_char(*c))
}

/// Convert `s` into the SEPA Basic Latin character set.
///
/// Characters already in the set pass through untouched. Everything else is
/// mapped per `style` (see the [module docs](self)); anything unmapped becomes
/// `.`.
///
/// Returns `Cow::Borrowed` when no conversion was needed, so the common
/// all-ASCII case costs nothing.
///
/// # Examples
///
/// ```
/// use sepa::charset::{transliterate, Transliteration};
///
/// // Untouched — already valid
/// assert_eq!(transliterate("Rechnung 12/2026", Transliteration::German),
///            "Rechnung 12/2026");
///
/// // German style keeps the pronunciation
/// assert_eq!(transliterate("Größe", Transliteration::German), "Groesse");
/// // EPC style is strictly one-to-one, so ß becomes a single 's'
/// assert_eq!(transliterate("Größe", Transliteration::Epc), "Grose");
///
/// // Accents fold to their base letter in both styles
/// assert_eq!(transliterate("Café Ångström", Transliteration::Epc), "Cafe Angstrom");
///
/// // Symbols map to SEPA-legal equivalents
/// assert_eq!(transliterate("A & B @ 100€", Transliteration::Epc), "A + B . 100E");
/// ```
#[must_use]
pub fn transliterate(s: &str, style: Transliteration) -> std::borrow::Cow<'_, str> {
    if is_sepa_text(s) {
        return std::borrow::Cow::Borrowed(s);
    }
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if is_sepa_char(c) {
            out.push(c);
        } else {
            out.push_str(map_char(c, style));
        }
    }
    std::borrow::Cow::Owned(out)
}

/// Map a single out-of-set character to its SEPA replacement.
///
/// Looks the character up in the published [EPC217-08 conversion
/// table](crate::charset). Characters absent from the table have no defined
/// mapping and become `.`, per EPC217-08 §5.
fn map_char(c: char, style: Transliteration) -> &'static str {
    // The German style overrides exactly seven characters. EPC217-08 maps these
    // one-to-one (ä→a, ß→s); the DFÜ-Abkommen lists the digraph forms in its
    // "Alternativ auch zulässig" column, and they are what German banks and
    // their customers expect to see on a statement.
    if style == Transliteration::German {
        match c {
            'ä' => return "ae",
            'ö' => return "oe",
            'ü' => return "ue",
            'Ä' => return "Ae",
            'Ö' => return "Oe",
            'Ü' => return "Ue",
            'ß' => return "ss",
            // U+1E9E LATIN CAPITAL LETTER SHARP S. Its case-folded expansion is
            // "SS", not "ss" — a name written GROẞE must not come out GROssE.
            'ẞ' => return "SS",
            _ => {}
        }
    }

    // EPC217-08 §6.2 states these in prose. `&` is marked "N/A" in the
    // spreadsheet (XML escaping handles it at the syntax level) and `_`/`~` are
    // absent from it entirely, so the prose rule is the only guidance. Where the
    // spreadsheet *does* carry a value it wins — see the module docs on the `@`
    // discrepancy.
    match c {
        '&' => return "+",
        '_' | '~' => return "-",
        // U+1E9E LATIN CAPITAL LETTER SHARP S is absent from the table: it was
        // encoded in Unicode 5.1 (2008), after EPC217-08 was drawn up, so the
        // spreadsheet carries only the lower-case U+00DF. Mapping it to the
        // upper-case of that row's target is the reading the table implies;
        // the alternative is the '.' fallback, which loses a letter of a name.
        'ẞ' => return "S",
        // No EPC rule: these are not SEPA-legal, and mapping them to the
        // nearest legal punctuation preserves more than a bare '.' would.
        '"' => return "'",
        '<' => return "(",
        '>' => return ")",
        _ => {}
    }

    if let Ok(i) = EPC_CONVERSION_TABLE.binary_search_by_key(&(c as u32), |(cp, _)| *cp) {
        // `binary_search_by_key` returned an in-range index.
        if let Some((_, replacement)) = EPC_CONVERSION_TABLE.get(i) {
            return replacement;
        }
    }

    // Not in the table: collapse whitespace to a space, everything else to '.'.
    if c.is_whitespace() { " " } else { "." }
}

// ── tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn permitted_set_is_exactly_73_characters() {
        let count = (0u32..=0x10_FFFF)
            .filter_map(char::from_u32)
            .filter(|c| is_sepa_char(*c))
            .count();
        assert_eq!(count, 73, "SEPA Basic Latin set must hold exactly 73 chars");
    }

    #[test]
    fn punctuation_membership() {
        for c in "/-?:().,'+ ".chars() {
            assert!(is_sepa_char(c), "{c:?} must be permitted");
        }
        for c in "&@#$%*!\"<>[]{}=;_~\\|".chars() {
            assert!(!is_sepa_char(c), "{c:?} must be rejected");
        }
    }

    #[test]
    fn valid_text_is_borrowed_not_copied() {
        let s = "Rechnung 2026-07 (Teil 1)";
        assert!(is_sepa_text(s));
        assert!(matches!(
            transliterate(s, Transliteration::German),
            std::borrow::Cow::Borrowed(_)
        ));
    }

    #[test]
    fn german_style_preserves_pronunciation() {
        assert_eq!(
            transliterate("Müller & Söhne GmbH", Transliteration::German),
            "Mueller + Soehne GmbH"
        );
        assert_eq!(
            transliterate("Ärzte Öl Übung Straße", Transliteration::German),
            "Aerzte Oel Uebung Strasse"
        );
    }

    #[test]
    fn the_capital_sharp_s_expands_in_upper_case() {
        // Regression: U+1E9E shared an arm with 'ß' and produced "ss", so an
        // upper-cased name came back mixed-case — "STRAssE" on the statement.
        assert_eq!(transliterate("STRAẞE", Transliteration::German), "STRASSE");
        assert_eq!(transliterate("straße", Transliteration::German), "strasse");
        // The EPC table is one-to-one for both cases and keeps them apart too.
        assert_eq!(transliterate("STRAẞE", Transliteration::Epc), "STRASE");
        assert_eq!(transliterate("straße", Transliteration::Epc), "strase");
    }

    #[test]
    fn epc_maps_ligatures_one_to_one() {
        // EPC217-08 is strictly one character to one character for Latin — the
        // ligatures do NOT expand. A generic transliterator produces "AE"/"OE",
        // which is a defensible reading but is not what the EPC table says.
        for (input, expected) in [
            ('Æ', "A"),
            ('æ', "a"),
            ('Œ', "O"),
            ('œ', "o"),
            ('IJ', "I"),
            ('ij', "i"),
        ] {
            assert_eq!(
                transliterate(&input.to_string(), Transliteration::Epc),
                expected,
                "EPC217-08 maps {input:?} one-to-one"
            );
        }
    }

    #[test]
    fn greek_and_cyrillic_use_the_published_romanisation() {
        // Twenty letters have a real multi-character romanisation in EPC217-08
        // (ISO 843 / ISO 9). Collapsing them to '.' loses the name entirely,
        // and the count is pinned by `exactly_twenty_entries_lengthen_the_text`.
        for (input, expected) in [
            ('Θ', "TH"),
            ('Χ', "CH"),
            ('Ψ', "PS"),
            ('θ', "th"),
            ('Ж', "ZH"),
            ('Ц', "TS"),
            ('Щ', "SHT"),
            ('Я', "YA"),
            ('ж', "zh"),
            ('я', "ya"),
        ] {
            for style in [Transliteration::German, Transliteration::Epc] {
                assert_eq!(
                    transliterate(&input.to_string(), style),
                    expected,
                    "{input:?} must romanise per EPC217-08"
                );
            }
        }
        // A whole word, to show it stays readable rather than becoming dots.
        // Ψ→PS, υ→y, χ→ch, ή→i per the published table.
        assert_eq!(transliterate("Ψυχή", Transliteration::Epc), "PSychi");
    }

    #[test]
    fn table_is_sorted_for_binary_search() {
        let table = crate::charset_table::EPC_CONVERSION_TABLE;
        assert!(
            table.windows(2).all(|w| w[0].0 < w[1].0),
            "EPC table must be strictly sorted by code point"
        );
        assert_eq!(
            table.len(),
            1010,
            "the transcribed EPC table changed size; update the module docs too"
        );
    }

    #[test]
    fn no_table_entry_maps_a_character_that_is_already_legal() {
        // The table is generated with "entries whose target equals the source
        // and is already SEPA-legal are omitted". A row for a legal character
        // means the transcription slipped — and it is invisible in normal use,
        // because `transliterate` short-circuits on `is_sepa_char` before ever
        // consulting the table. Regression: U+0020 SPACE was mapped to '.'.
        for (cp, replacement) in crate::charset_table::EPC_CONVERSION_TABLE {
            if let Some(ch) = char::from_u32(*cp) {
                assert!(
                    !is_sepa_char(ch),
                    "{ch:?} (U+{cp:04X}) is already SEPA-legal but the table \
                     rewrites it to {replacement:?}"
                );
            }
        }
    }

    #[test]
    fn every_replacement_is_itself_legal_and_non_empty() {
        // The table is the last step before text reaches the wire, so a single
        // illegal replacement would leak straight into a payment field.
        for (cp, replacement) in crate::charset_table::EPC_CONVERSION_TABLE {
            assert!(
                !replacement.is_empty(),
                "U+{cp:04X} maps to nothing; characters with no mapping are \
                 meant to be absent from the table and become '.'"
            );
            assert!(
                is_sepa_text(replacement),
                "U+{cp:04X} maps to {replacement:?}, which is not SEPA-legal"
            );
        }
    }

    #[test]
    fn exactly_twenty_entries_lengthen_the_text() {
        // The documented claim, pinned. Everything else in the published
        // table is one character to one character, which is what makes the
        // EPC style length-preserving. A count stated in prose and nowhere
        // else drifts.
        let multi: Vec<_> = crate::charset_table::EPC_CONVERSION_TABLE
            .iter()
            .filter(|(_, r)| r.chars().count() > 1)
            .collect();
        assert_eq!(multi.len(), 20, "multi-character romanisations: {multi:?}");
        // All of them are Greek (U+0370–U+03FF) or Cyrillic (U+0400–U+04FF).
        assert!(
            multi.iter().all(|(cp, _)| (0x0370..=0x04FF).contains(cp)),
            "only Greek and Cyrillic have a multi-character romanisation"
        );
    }

    #[test]
    fn epc_style_is_one_to_one() {
        assert_eq!(
            transliterate("Müller & Söhne GmbH", Transliteration::Epc),
            "Muller + Sohne GmbH"
        );
        // Length is preserved for everything but the ligatures above.
        let input = "Ärzte Öl Übung Straße";
        let out = transliterate(input, Transliteration::Epc);
        assert_eq!(out, "Arzte Ol Ubung Strase");
        assert_eq!(out.chars().count(), input.chars().count());
    }

    #[test]
    fn non_umlaut_accents_fold_identically_in_both_styles() {
        // The two styles differ only on ä/ö/ü/ß; every other accent folds the same.
        for style in [Transliteration::German, Transliteration::Epc] {
            assert_eq!(transliterate("Café", style), "Cafe");
            assert_eq!(transliterate("Señor Niño", style), "Senor Nino");
            assert_eq!(transliterate("Łódź", style), "Lodz");
            assert_eq!(transliterate("Français", style), "Francais");
        }
    }

    #[test]
    fn styles_differ_only_on_umlauts_and_eszett() {
        // "Ångström" contains 'ö', so the styles legitimately diverge here.
        assert_eq!(
            transliterate("Ångström", Transliteration::German),
            "Angstroem"
        );
        assert_eq!(transliterate("Ångström", Transliteration::Epc), "Angstrom");
    }

    #[test]
    fn symbols_map_to_legal_equivalents() {
        assert_eq!(
            transliterate("A & B @ 100€", Transliteration::Epc),
            "A + B . 100E"
        );
        assert_eq!(transliterate("a_b~c", Transliteration::Epc), "a-b-c");
        assert_eq!(
            transliterate("\"quoted\"", Transliteration::Epc),
            "'quoted'"
        );
        assert_eq!(transliterate("x[1]{2}", Transliteration::Epc), "x(1)(2)");
    }

    #[test]
    fn unmapped_characters_become_a_dot() {
        assert_eq!(transliterate("日本語", Transliteration::Epc), "...");
        assert_eq!(transliterate("emoji 🎉", Transliteration::Epc), "emoji .");
    }

    #[test]
    fn control_and_exotic_whitespace_becomes_a_space() {
        assert_eq!(transliterate("a\tb\nc", Transliteration::Epc), "a b c");
        assert_eq!(transliterate("a\u{00A0}b", Transliteration::Epc), "a b");
    }

    #[test]
    fn every_unicode_code_point_transliterates_into_the_sepa_set() {
        // The claim the documentation makes, as the test it names: all
        // 1_114_112 code points, through both styles, asserting the output is
        // SEPA-legal. A fixed hostile string exercises the table; only the
        // sweep covers the fallback path for everything absent from it.
        let mut checked = 0u32;
        for cp in 0u32..=0x10_FFFF {
            let Some(ch) = char::from_u32(cp) else {
                continue; // surrogate range: not a `char`
            };
            checked += 1;
            let mut buf = [0u8; 4];
            let input = ch.encode_utf8(&mut buf);
            for style in [Transliteration::German, Transliteration::Epc] {
                let out = transliterate(input, style);
                assert!(
                    is_sepa_text(&out),
                    "U+{cp:04X} under {style:?} produced {out:?}, which is not SEPA-legal"
                );
                assert!(
                    !out.is_empty(),
                    "U+{cp:04X} under {style:?} produced nothing; unmapped characters become '.'"
                );
            }
        }
        // 0x110000 code points minus the 2048 surrogates.
        assert_eq!(checked, 1_112_064);
    }

    #[test]
    fn transliteration_output_is_always_valid() {
        // The whole point: whatever goes in, what comes out is SEPA-legal.
        let nasty = "Müller\tÖl 🎉 €100 «señor» 日本 \\|<>{}[]!@#$%^&*_~;=`";
        for style in [Transliteration::German, Transliteration::Epc] {
            let out = transliterate(nasty, style);
            assert!(
                is_sepa_text(&out),
                "{style:?} produced non-SEPA output: {out:?}"
            );
        }
    }

    #[test]
    fn first_invalid_char_reports_the_offender() {
        assert_eq!(first_invalid_char("Mueller"), None);
        assert_eq!(first_invalid_char("Müller"), Some('ü'));
        assert_eq!(first_invalid_char("A&B"), Some('&'));
    }

    #[test]
    fn empty_input() {
        assert!(is_sepa_text(""));
        assert_eq!(transliterate("", Transliteration::German), "");
    }
}