Skip to main content

fints/
types.rs

1//! FinTS data types and Data Element Group definitions.
2//!
3//! Provides typed representations of common FinTS structures
4//! that appear across multiple segments.
5
6use chrono::NaiveDate;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::str::FromStr;
11
12use crate::parser::{DataElement, RawSegment, DEG};
13
14// ═══════════════════════════════════════════════════════════════════════════════
15// Newtypes — prevent parameter swaps at compile time
16// ═══════════════════════════════════════════════════════════════════════════════
17
18macro_rules! newtype_string {
19    ($(#[$meta:meta])* $name:ident) => {
20        $(#[$meta])*
21        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22        pub struct $name(String);
23
24        impl $name {
25            pub fn new(s: impl Into<String>) -> Self { Self(s.into()) }
26            pub fn as_str(&self) -> &str { &self.0 }
27        }
28
29        impl fmt::Display for $name {
30            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) }
31        }
32
33        impl AsRef<str> for $name {
34            fn as_ref(&self) -> &str { &self.0 }
35        }
36    };
37}
38
39newtype_string!(/// Bank code (Bankleitzahl), e.g. "12030000".
40    Blz);
41newtype_string!(/// FinTS user ID / login name.
42    UserId);
43newtype_string!(/// System ID assigned by the bank via HKSYN.
44    SystemId);
45newtype_string!(/// FinTS product registration ID.
46    ProductId);
47newtype_string!(/// Dialog ID assigned by the bank in HNHBK.
48    DialogId);
49newtype_string!(/// Security function code, e.g. "940" for pushTAN, "999" for PIN-only.
50    SecurityFunction);
51newtype_string!(/// Task reference from HITAN, used in HKTAN process 2/S.
52    TaskReference);
53newtype_string!(/// Segment type identifier, e.g. "HKSAL", "HKKAZ".
54    SegmentType);
55newtype_string!(/// TAN medium name (e.g. device name for pushTAN).
56    TanMediumName);
57newtype_string!(/// Touchdown/pagination point returned by the bank (code 3040).
58    TouchdownPoint);
59newtype_string!(/// Segment type reference for HKTAN process 4 (e.g. "HKIDN", "HKSAL").
60    SegmentRef);
61newtype_string!(/// Currency code (ISO 4217), e.g. "EUR".
62    Currency);
63newtype_string!(/// IBAN (International Bank Account Number).
64    Iban);
65newtype_string!(/// BIC (Bank Identifier Code).
66    Bic);
67newtype_string!(/// Human-readable bank name.
68    BankName);
69newtype_string!(/// FinTS server URL.
70    FinTSUrl);
71newtype_string!(/// TAN challenge text to display to the user.
72    ChallengeText);
73
74/// HHD-UC binary data for optical/QR TAN methods.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct HhdUcData(pub Vec<u8>);
77
78/// MT940/SWIFT binary data (WINDOWS-1252 encoded).
79#[derive(Debug, Clone)]
80pub struct Mt940Data(pub Vec<u8>);
81
82impl Mt940Data {
83    pub fn new() -> Self {
84        Self(Vec::new())
85    }
86    pub fn is_empty(&self) -> bool {
87        self.0.is_empty()
88    }
89    pub fn as_bytes(&self) -> &[u8] {
90        &self.0
91    }
92    pub fn extend(&mut self, data: Vec<u8>) {
93        if !self.0.is_empty() && !self.0.ends_with(b"\r\n") {
94            self.0.extend_from_slice(b"\r\n");
95        }
96        self.0.extend(data);
97    }
98}
99
100/// PIN — redacts on Display to prevent logging.
101#[derive(Clone)]
102pub struct Pin(String);
103
104impl Pin {
105    pub fn new(s: impl Into<String>) -> Self {
106        Self(s.into())
107    }
108    pub fn as_str(&self) -> &str {
109        &self.0
110    }
111}
112
113impl fmt::Debug for Pin {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.write_str("Pin(****)")
116    }
117}
118
119impl fmt::Display for Pin {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str("****")
122    }
123}
124
125// ── Constants ───────────────────────────────────────────────────────────────
126
127impl SystemId {
128    /// The uninitialized system ID — means "not yet assigned by bank".
129    pub fn unassigned() -> Self {
130        Self("0".into())
131    }
132    pub fn is_assigned(&self) -> bool {
133        !self.0.is_empty() && self.0 != "0"
134    }
135}
136
137impl DialogId {
138    pub fn unassigned() -> Self {
139        Self("0".into())
140    }
141    pub fn is_assigned(&self) -> bool {
142        !self.0.is_empty() && self.0 != "0"
143    }
144}
145
146impl SecurityFunction {
147    /// PIN-only (no two-step TAN).
148    pub fn pin_only() -> Self {
149        Self("999".into())
150    }
151}
152
153/// TAN process as defined by FinTS spec.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub enum TanProcess {
156    /// One-step: TAN entered with the business message.
157    OneStep,
158    /// Two-step: business message first, then TAN in a second message.
159    TwoStep,
160}
161
162impl TanProcess {
163    pub fn from_str_val(s: &str) -> Self {
164        match s {
165            "1" => TanProcess::OneStep,
166            _ => TanProcess::TwoStep,
167        }
168    }
169}
170
171/// Classified response code with typed parameters.
172/// The parameters are embedded in the variant — no raw `Vec<String>`.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum ResponseCodeKind {
175    /// 0010 — Message received.
176    MessageReceived,
177    /// 0020 — Order executed.
178    OrderExecuted,
179    /// 0030 — Order received, TAN required.
180    TanRequired,
181    /// 0100 — Dialog ended.
182    DialogEnded,
183    /// 0900 — TAN valid.
184    TanValid,
185    /// 3040 — More data available. Contains the touchdown point.
186    Touchdown(TouchdownPoint),
187    /// 3060 — Partial warnings.
188    PartialWarnings,
189    /// 3076 — No strong authentication required (SCA exemption).
190    ScaExemption,
191    /// 3920 — Allowed security functions. Contains the list.
192    AllowedSecurityFunctions(Vec<SecurityFunction>),
193    /// 3955 — Decoupled TAN initiated (confirm in app).
194    DecoupledInitiated,
195    /// 3956 — Decoupled TAN not yet confirmed.
196    DecoupledPending,
197    /// 9010 — General error.
198    GeneralError,
199    /// 9040 — Authentication missing.
200    AuthenticationMissing,
201    /// 9050 — Partial errors.
202    PartialErrors,
203    /// 9110 — Unexpected order in sync dialog.
204    UnexpectedInSync,
205    /// 9160 — Data element missing.
206    DataElementMissing,
207    /// 9340 — PIN wrong.
208    PinWrong,
209    /// 9800 — Dialog aborted.
210    DialogAborted,
211    /// 9942 — Account/user locked.
212    AccountLocked,
213    /// Other success code (0xxx).
214    OtherSuccess(String),
215    /// Other warning code (3xxx).
216    OtherWarning(String),
217    /// Other error code (9xxx).
218    OtherError(String),
219    /// Unrecognized code.
220    Unknown(String),
221}
222
223impl ResponseCodeKind {
224    fn default_unknown() -> Self {
225        Self::Unknown(String::new())
226    }
227
228    pub fn from_code(code: &str, parameters: &[String]) -> Self {
229        match code {
230            "0010" => Self::MessageReceived,
231            "0020" => Self::OrderExecuted,
232            "0030" => Self::TanRequired,
233            "0100" => Self::DialogEnded,
234            "0900" => Self::TanValid,
235            "3040" => Self::Touchdown(TouchdownPoint::new(
236                parameters.first().map(|s| s.as_str()).unwrap_or(""),
237            )),
238            "3060" => Self::PartialWarnings,
239            "3076" => Self::ScaExemption,
240            "3920" => Self::AllowedSecurityFunctions(
241                parameters
242                    .iter()
243                    .map(|s| SecurityFunction::new(s))
244                    .collect(),
245            ),
246            "3955" => Self::DecoupledInitiated,
247            "3956" => Self::DecoupledPending,
248            "9010" => Self::GeneralError,
249            "9040" => Self::AuthenticationMissing,
250            "9050" => Self::PartialErrors,
251            "9110" => Self::UnexpectedInSync,
252            "9160" => Self::DataElementMissing,
253            "9340" => Self::PinWrong,
254            "9800" => Self::DialogAborted,
255            "9942" => Self::AccountLocked,
256            _ if code.starts_with('0') => Self::OtherSuccess(code.to_string()),
257            _ if code.starts_with('3') => Self::OtherWarning(code.to_string()),
258            _ if code.starts_with('9') => Self::OtherError(code.to_string()),
259            _ => Self::Unknown(code.to_string()),
260        }
261    }
262
263    pub fn is_success(&self) -> bool {
264        matches!(
265            self,
266            Self::MessageReceived
267                | Self::OrderExecuted
268                | Self::TanRequired
269                | Self::DialogEnded
270                | Self::TanValid
271                | Self::OtherSuccess(_)
272        )
273    }
274
275    pub fn is_warning(&self) -> bool {
276        matches!(
277            self,
278            Self::Touchdown(_)
279                | Self::PartialWarnings
280                | Self::ScaExemption
281                | Self::AllowedSecurityFunctions(_)
282                | Self::DecoupledInitiated
283                | Self::DecoupledPending
284                | Self::OtherWarning(_)
285        )
286    }
287
288    pub fn is_error(&self) -> bool {
289        matches!(
290            self,
291            Self::GeneralError
292                | Self::AuthenticationMissing
293                | Self::PartialErrors
294                | Self::UnexpectedInSync
295                | Self::DataElementMissing
296                | Self::PinWrong
297                | Self::DialogAborted
298                | Self::AccountLocked
299                | Self::OtherError(_)
300        )
301    }
302}
303
304// ---- Helper functions for reading DEs from segments ----
305
306/// Read a string DE from a segment at (deg_idx, de_idx).
307pub(crate) fn read_str(seg: &RawSegment, deg: usize, de: usize) -> String {
308    seg.deg(deg).get(de).as_text()
309}
310
311/// Read an optional string — returns None if empty.
312pub(crate) fn read_opt_str(seg: &RawSegment, deg: usize, de: usize) -> Option<String> {
313    let s = seg.deg(deg).get(de).as_text();
314    if s.is_empty() {
315        None
316    } else {
317        Some(s)
318    }
319}
320
321/// Read an integer from a DE.
322pub(crate) fn read_int(seg: &RawSegment, deg: usize, de: usize) -> i64 {
323    seg.deg(deg)
324        .get(de)
325        .as_str()
326        .and_then(|s| s.parse().ok())
327        .unwrap_or(0)
328}
329
330/// Read a u16 from a DE.
331pub(crate) fn read_u16(seg: &RawSegment, deg: usize, de: usize) -> u16 {
332    seg.deg(deg)
333        .get(de)
334        .as_str()
335        .and_then(|s| s.parse().ok())
336        .unwrap_or(0)
337}
338
339/// Read a FinTS date (YYYYMMDD format) from a DE.
340pub(crate) fn read_date(seg: &RawSegment, deg: usize, de: usize) -> Option<NaiveDate> {
341    let s = seg.deg(deg).get(de).as_text();
342    if s.len() == 8 {
343        NaiveDate::parse_from_str(&s, "%Y%m%d").ok()
344    } else {
345        None
346    }
347}
348
349/// Read a FinTS amount (comma as decimal separator) from a DE.
350pub(crate) fn read_amount(seg: &RawSegment, deg: usize, de: usize) -> Option<Decimal> {
351    let s = seg.deg(deg).get(de).as_text();
352    if s.is_empty() {
353        return None;
354    }
355    let normalized = s.replace(',', ".");
356    Decimal::from_str(&normalized).ok()
357}
358
359/// Read binary data from a DE.
360pub(crate) fn read_binary(seg: &RawSegment, deg: usize, de: usize) -> Option<Vec<u8>> {
361    seg.deg(deg).get(de).as_bytes().map(|b| b.to_vec())
362}
363
364/// Read a boolean (J/N) from a DE.
365pub(crate) fn read_bool(seg: &RawSegment, deg: usize, de: usize) -> bool {
366    seg.deg(deg).get(de).as_text() == "J"
367}
368
369// ---- DE construction helpers ----
370
371/// Create a text DE.
372pub fn de_text(s: &str) -> DataElement {
373    if s.is_empty() {
374        DataElement::Empty
375    } else {
376        DataElement::Text(s.to_string())
377    }
378}
379
380/// Create a numeric DE from a number.
381pub fn de_num<T: ToString>(n: T) -> DataElement {
382    DataElement::Text(n.to_string())
383}
384
385/// Create a FinTS date DE (YYYYMMDD format).
386pub fn de_date(date: NaiveDate) -> DataElement {
387    DataElement::Text(date.format("%Y%m%d").to_string())
388}
389
390/// Create an empty DE.
391pub fn de_empty() -> DataElement {
392    DataElement::Empty
393}
394
395/// Create a binary DE.
396pub fn de_binary(data: Vec<u8>) -> DataElement {
397    DataElement::Binary(data)
398}
399
400/// Create a boolean DE (J/N).
401pub fn de_bool(val: bool) -> DataElement {
402    DataElement::Text(if val { "J" } else { "N" }.to_string())
403}
404
405/// Create a DEG from data elements.
406pub fn deg(elements: Vec<DataElement>) -> DEG {
407    DEG(elements)
408}
409
410/// Create a single-element DEG.
411pub fn deg1(de: DataElement) -> DEG {
412    DEG(vec![de])
413}
414
415// ---- High-level response types ----
416
417/// A single response code from HIRMG/HIRMS.
418/// All data is typed — the `kind` field carries any parameters.
419#[derive(Debug, Clone)]
420pub struct ResponseCode {
421    /// Classified response code with typed parameters.
422    pub kind: ResponseCodeKind,
423    /// Human-readable description from the bank.
424    pub text: String,
425}
426
427impl ResponseCode {
428    /// Create a ResponseCode with auto-classified kind.
429    pub fn new(code: &str, text: &str) -> Self {
430        Self {
431            kind: ResponseCodeKind::from_code(code, &[]),
432            text: text.to_string(),
433        }
434    }
435
436    /// Create a ResponseCode with parameters.
437    pub fn with_params(code: &str, text: &str, params: Vec<String>) -> Self {
438        Self {
439            kind: ResponseCodeKind::from_code(code, &params),
440            text: text.to_string(),
441        }
442    }
443
444    /// Parse response codes from a segment's DEGs (skip header at index 0).
445    pub fn parse_from_segment(seg: &RawSegment) -> Vec<ResponseCode> {
446        let mut codes = Vec::new();
447        for i in 1..seg.deg_count() {
448            let d = seg.deg(i);
449            if d.len() >= 3 {
450                let code = d.get_str(0);
451                if code.is_empty() {
452                    continue;
453                }
454                let text = d.get_str(2);
455                let mut params = Vec::new();
456                for j in 3..d.len() {
457                    let p = d.get(j).as_text();
458                    if !p.is_empty() {
459                        params.push(p);
460                    }
461                }
462                codes.push(ResponseCode {
463                    kind: ResponseCodeKind::from_code(&code, &params),
464                    text,
465                });
466            }
467        }
468        codes
469    }
470
471    /// Raw code string for logging/display.
472    pub fn code(&self) -> &str {
473        match &self.kind {
474            ResponseCodeKind::MessageReceived => "0010",
475            ResponseCodeKind::OrderExecuted => "0020",
476            ResponseCodeKind::TanRequired => "0030",
477            ResponseCodeKind::DialogEnded => "0100",
478            ResponseCodeKind::TanValid => "0900",
479            ResponseCodeKind::Touchdown(_) => "3040",
480            ResponseCodeKind::PartialWarnings => "3060",
481            ResponseCodeKind::ScaExemption => "3076",
482            ResponseCodeKind::AllowedSecurityFunctions(_) => "3920",
483            ResponseCodeKind::DecoupledInitiated => "3955",
484            ResponseCodeKind::DecoupledPending => "3956",
485            ResponseCodeKind::GeneralError => "9010",
486            ResponseCodeKind::AuthenticationMissing => "9040",
487            ResponseCodeKind::PartialErrors => "9050",
488            ResponseCodeKind::UnexpectedInSync => "9110",
489            ResponseCodeKind::DataElementMissing => "9160",
490            ResponseCodeKind::PinWrong => "9340",
491            ResponseCodeKind::DialogAborted => "9800",
492            ResponseCodeKind::AccountLocked => "9942",
493            ResponseCodeKind::OtherSuccess(c)
494            | ResponseCodeKind::OtherWarning(c)
495            | ResponseCodeKind::OtherError(c)
496            | ResponseCodeKind::Unknown(c) => c,
497        }
498    }
499
500    pub fn is_success(&self) -> bool {
501        self.kind.is_success()
502    }
503    pub fn is_warning(&self) -> bool {
504        self.kind.is_warning()
505    }
506    pub fn is_error(&self) -> bool {
507        self.kind.is_error()
508    }
509    pub fn is_tan_required(&self) -> bool {
510        matches!(self.kind, ResponseCodeKind::TanRequired)
511    }
512    pub fn is_touchdown(&self) -> bool {
513        matches!(self.kind, ResponseCodeKind::Touchdown(_))
514    }
515    pub fn is_allowed_tan_methods(&self) -> bool {
516        matches!(self.kind, ResponseCodeKind::AllowedSecurityFunctions(_))
517    }
518    pub fn is_decoupled(&self) -> bool {
519        matches!(self.kind, ResponseCodeKind::DecoupledInitiated)
520    }
521    pub fn is_decoupled_pending(&self) -> bool {
522        matches!(self.kind, ResponseCodeKind::DecoupledPending)
523    }
524    pub fn is_pin_wrong(&self) -> bool {
525        matches!(self.kind, ResponseCodeKind::PinWrong)
526    }
527    pub fn is_general_error(&self) -> bool {
528        matches!(self.kind, ResponseCodeKind::GeneralError)
529    }
530    pub fn is_locked(&self) -> bool {
531        matches!(self.kind, ResponseCodeKind::AccountLocked)
532    }
533}
534
535/// A TAN method as reported by the bank in HITANS.
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct TanMethod {
538    /// Security function code (e.g. "912" for pushTAN)
539    pub security_function: SecurityFunction,
540    /// TAN process type.
541    pub tan_process: TanProcess,
542    /// Human-readable name (e.g. "pushTAN-2.0")
543    pub name: String,
544    /// Whether TAN medium name must be sent
545    pub needs_tan_medium: bool,
546    /// Maximum number of decoupled polls (-1 = unlimited)
547    pub decoupled_max_polls: i32,
548    /// Seconds to wait before first decoupled poll
549    pub wait_before_first_poll: i32,
550    /// Seconds to wait between decoupled polls
551    pub wait_before_next_poll: i32,
552    /// Whether this is a decoupled method
553    pub is_decoupled: bool,
554    /// HKTAN version
555    pub hktan_version: u16,
556}
557
558/// SEPA account info as returned by HISPA / HIUPD.
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct SepaAccount {
561    pub iban: Iban,
562    pub bic: Bic,
563    pub account_number: String,
564    pub sub_account: String,
565    pub blz: Blz,
566    pub owner: Option<String>,
567    pub product_name: Option<String>,
568    pub currency: Option<Currency>,
569}
570
571/// Account balance.
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct AccountBalance {
574    pub amount: Decimal,
575    pub date: NaiveDate,
576    pub currency: Currency,
577    pub credit_line: Option<Decimal>,
578    pub available: Option<Decimal>,
579    /// Pending (vorgemerkter) balance amount, if provided by the bank.
580    pub pending_amount: Option<Decimal>,
581    /// Date of the pending balance, if provided.
582    pub pending_date: Option<NaiveDate>,
583}
584
585/// Whether a transaction is booked or pending (vorgemerkt).
586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587pub enum TransactionStatus {
588    Booked,
589    Pending,
590}
591
592// ── Securities / Depot types ────────────────────────────────────────────────
593
594newtype_string!(/// ISIN (International Securities Identification Number), e.g. "DE0005140008".
595    Isin);
596newtype_string!(/// WKN (Wertpapierkennnummer), e.g. "514000".
597    Wkn);
598
599/// A single securities position in a depot.
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct SecurityHolding {
602    /// ISIN of the security.
603    pub isin: Option<Isin>,
604    /// WKN (German securities identification number).
605    pub wkn: Option<Wkn>,
606    /// Human-readable name of the security.
607    pub name: String,
608    /// Number of shares/units held (can be fractional for funds).
609    pub quantity: Decimal,
610    /// Current market price per unit.
611    pub price: Option<Decimal>,
612    /// Currency of the price.
613    pub price_currency: Option<Currency>,
614    /// Date of the price quote.
615    pub price_date: Option<NaiveDate>,
616    /// Total market value (quantity * price).
617    pub market_value: Option<Decimal>,
618    /// Currency of the market value.
619    pub market_value_currency: Option<Currency>,
620    /// Original purchase value (Einstandswert), if available.
621    pub acquisition_value: Option<Decimal>,
622    /// Profit/loss amount, if available.
623    pub profit_loss: Option<Decimal>,
624    /// Exchange/market where the price was quoted.
625    pub exchange: Option<String>,
626    /// Depot number this holding belongs to.
627    pub depot_id: Option<String>,
628    /// Raw parsed data for debugging/extension.
629    pub raw: serde_json::Value,
630}
631
632/// A parsed transaction.
633#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct Transaction {
635    pub date: NaiveDate,
636    pub valuta_date: Option<NaiveDate>,
637    pub amount: Decimal,
638    pub currency: Currency,
639    pub applicant_name: Option<String>,
640    pub applicant_iban: Option<Iban>,
641    pub applicant_bic: Option<Bic>,
642    pub purpose: Option<String>,
643    pub posting_text: Option<String>,
644    pub reference: Option<String>,
645    pub raw: serde_json::Value,
646    /// Whether this transaction is booked or pending.
647    pub status: TransactionStatus,
648}