Skip to main content

fints/
protocol.rs

1//! FinTS 3.0 Protocol State Machine (spec-aligned).
2//!
3//! Implements the dialog lifecycle as defined in FinTS 3.0 Formals (2017-10-06)
4//! and PIN/TAN Security (2020-07-10).
5//!
6//! ## Dialog types (spec Chapter C)
7//!
8//! - **Synchronization dialog**: HKIDN + HKVVB + HKSYN → get system_id → HKEND.
9//!   Marked by presence of HKSYN. Business segments are forbidden (banks return 9110).
10//! - **Normal dialog**: HKIDN + HKVVB [+ HKTAN:4] → [TAN confirmation] → business ops → HKEND.
11//!
12//! ## State machine (spec Chapter C, Section 6)
13//!
14//! ```text
15//!   Dialog<New>
16//!       │
17//!       ├── sync()           → (Dialog<Synced>, Response)      [sync dialog]
18//!       │
19//!       └── init()           → InitResult                      [normal dialog]
20//!             ├── Opened(Dialog<Open>)                            response 0010/0020
21//!             └── TanRequired(Dialog<TanPending>, TanChallenge)   response 0030/3955
22//!
23//!   Dialog<Synced>
24//!       └── end()            → (BankParams, String)             [get params + system_id]
25//!
26//!   Dialog<Open>
27//!       ├── send()           → SendResult                       [business segment]
28//!       │     ├── Success(Response)                               response 0020
29//!       │     ├── NeedTan(Dialog<TanPending>, TanChallenge)       response 0030/3955
30//!       │     └── Touchdown(Response, String)                     response 3040
31//!       └── end()            → ()                               [HKEND]
32//!
33//!   Dialog<TanPending>
34//!       ├── poll()           → PollResult                       [HKTAN process S]
35//!       │     ├── Confirmed(Dialog<Open>, Response)               response 0020
36//!       │     └── Pending(Dialog<TanPending>)                     response 3955/3956
37//!       ├── submit_tan()     → (Dialog<Open>, Response)         [HKTAN process 2]
38//!       └── cancel()         → ()                               [HKEND]
39//! ```
40
41use std::collections::HashMap;
42use std::marker::PhantomData;
43use tracing::{debug, info, warn};
44
45use chrono::NaiveDate;
46
47use crate::error::{FinTSError, Result};
48use crate::message;
49use crate::parser::{self, RawSegment, DEG};
50use crate::segments::response::*;
51use crate::transport::FinTSConnection;
52use crate::types::*;
53
54// ═══════════════════════════════════════════════════════════════════════════════
55// Typestate markers (per spec dialog states)
56// ═══════════════════════════════════════════════════════════════════════════════
57
58/// Dialog not yet started. Can transition to `Synced` or `Open`/`TanPending`.
59#[derive(Debug)]
60pub struct New;
61/// Synchronization dialog: system_id obtained, BPD/UPD cached. No business ops allowed.
62#[derive(Debug)]
63pub struct Synced;
64/// Dialog is open and authenticated. Business segments can be sent.
65#[derive(Debug)]
66pub struct Open;
67/// A TAN challenge is pending (either from init or from a business segment).
68#[derive(Debug)]
69pub struct TanPending;
70
71// ═══════════════════════════════════════════════════════════════════════════════
72// Typed business segments — replaces raw Vec<DEG> at all internal boundaries
73// ═══════════════════════════════════════════════════════════════════════════════
74
75/// A typed FinTS business segment. Every field is validated at construction.
76/// This is the ONLY way to construct segments within the crate — raw DEG
77/// builders are never called directly from protocol or workflow code.
78#[derive(Debug, Clone)]
79pub(crate) enum Segment {
80    /// HKIDN: Identifikation (dialog init)
81    Identify { blz: Blz, user_id: UserId, system_id: SystemId },
82    /// HKVVB: Verarbeitungsvorbereitung
83    ProcessPrep { bpd_version: u16, upd_version: u16, product_id: ProductId },
84    /// HKSYN: Synchronisierung (get system_id)
85    Sync,
86    /// HKSAL: Saldenabfrage (balance request)
87    Balance { account: Account },
88    /// HKKAZ: Kontoumsätze (transaction request)
89    Transactions {
90        account: Account,
91        start_date: NaiveDate,
92        end_date: NaiveDate,
93        touchdown: Option<TouchdownPoint>,
94    },
95    /// HKWPD: Wertpapierdepotaufstellung (securities holdings request)
96    Holdings {
97        account: Account,
98        currency: Option<Currency>,
99        touchdown: Option<TouchdownPoint>,
100    },
101    /// HKTAN process 4: initiate TAN for a referenced segment
102    TanProcess4 { reference_seg: SegmentRef, tan_medium: Option<TanMediumName> },
103    /// HKTAN process S: poll decoupled TAN status
104    TanPollDecoupled { task_reference: TaskReference, tan_medium: Option<TanMediumName> },
105    /// HKTAN process 2: submit TAN response
106    TanProcess2 { task_reference: TaskReference, tan_medium: Option<TanMediumName> },
107    /// HKEND: dialog end
108    End { dialog_id: DialogId },
109}
110
111impl Segment {
112    /// Convert to raw DEGs using bank parameters for version selection.
113    pub(crate) fn to_degs(&self, params: &BankParams) -> Vec<DEG> {
114        use crate::segments::builder::*;
115        match self {
116            Segment::Identify { blz, user_id, system_id } => {
117                hkidn(0, blz.as_str(), user_id.as_str(), system_id.as_str())
118            }
119            Segment::ProcessPrep { bpd_version, upd_version, product_id } => {
120                hkvvb(0, *bpd_version, *upd_version, product_id.as_str())
121            }
122            Segment::Sync => {
123                hksyn(0)
124            }
125            Segment::Balance { account } => {
126                let version = params.supported_version("HISALS", 7).max(5);
127                hksal(0, version, account.iban(), account.bic(), None)
128            }
129            Segment::Transactions { account, start_date, end_date, touchdown } => {
130                let version = params.supported_version("HIKAZS", 7).max(5);
131                hkkaz(0, version, account.iban(), account.bic(), *start_date, *end_date, touchdown.as_ref().map(|t| t.as_str()))
132            }
133            Segment::Holdings { account, currency, touchdown } => {
134                let version = params.supported_version("HIWPDS", 7).max(1);
135                hkwpd(0, version, account.iban(), account.bic(), currency.as_ref().map(|c| c.as_str()), touchdown.as_ref().map(|t| t.as_str()))
136            }
137            Segment::TanProcess4 { reference_seg, tan_medium } => {
138                let version = params.hktan_version();
139                hktan_process4(0, version, reference_seg.as_str(), tan_medium.as_ref().map(|t| t.as_str()))
140            }
141            Segment::TanPollDecoupled { task_reference, tan_medium } => {
142                let version = params.hktan_version();
143                hktan_process_s(0, version, task_reference.as_str(), tan_medium.as_ref().map(|t| t.as_str()))
144            }
145            Segment::TanProcess2 { task_reference, tan_medium } => {
146                let version = params.hktan_version();
147                hktan_process2(0, version, task_reference.as_str(), tan_medium.as_ref().map(|t| t.as_str()))
148            }
149            Segment::End { dialog_id } => {
150                hkend(0, dialog_id.as_str())
151            }
152        }
153    }
154}
155
156// ═══════════════════════════════════════════════════════════════════════════════
157// Result of dialog init — response-driven transition
158// ═══════════════════════════════════════════════════════════════════════════════
159
160/// Result of `Dialog::init()`. Per the spec, the bank either accepts the dialog
161/// (0010/0020) or requires SCA (0030/3955).
162pub enum InitResult {
163    /// Dialog opened, no TAN needed. Ready for business segments.
164    Opened(Dialog<Open>, Response),
165    /// Bank requires TAN on init (SCA). Must confirm before business ops.
166    TanRequired(Dialog<TanPending>, TanChallenge, Response),
167}
168
169// ═══════════════════════════════════════════════════════════════════════════════
170// Result of sending a business segment — response-driven transition
171// ═══════════════════════════════════════════════════════════════════════════════
172
173/// Result of `Dialog<Open>::send()`. Per the spec, three outcomes are possible.
174pub enum SendResult {
175    /// 0020: Order executed. Response contains the result data (HISAL, HIKAZ, etc.).
176    Success(Response),
177    /// 0030/3955: TAN required for this operation. Dialog transitions to TanPending.
178    NeedTan(Dialog<TanPending>, TanChallenge, Response),
179    /// 3040: More data available (pagination). Response contains partial data
180    /// plus a touchdown point string for the next request.
181    Touchdown(Response, String),
182}
183
184// ═══════════════════════════════════════════════════════════════════════════════
185// Result of polling TAN — response-driven transition
186// ═══════════════════════════════════════════════════════════════════════════════
187
188/// Result of `Dialog<TanPending>::poll()`.
189pub enum PollResult {
190    /// TAN confirmed. Dialog returns to Open state.
191    Confirmed(Dialog<Open>, Response),
192    /// TAN still pending (3955/3956). Dialog remains in TanPending.
193    Pending(Dialog<TanPending>),
194}
195
196// ═══════════════════════════════════════════════════════════════════════════════
197// Parsed bank response
198// ═══════════════════════════════════════════════════════════════════════════════
199
200/// Parsed response from the bank after sending a message.
201#[derive(Debug)]
202pub struct Response {
203    /// All segments from the response (including inner HNVSD segments).
204    pub segments: Vec<RawSegment>,
205    /// Global response codes (from HIRMG).
206    pub global_codes: Vec<ResponseCode>,
207    /// Segment-specific response codes (from HIRMS).
208    pub segment_codes: Vec<ResponseCode>,
209}
210
211impl Response {
212    pub fn find_segments(&self, seg_type: &str) -> Vec<&RawSegment> {
213        self.segments.iter().filter(|s| s.segment_type() == seg_type).collect()
214    }
215
216    pub fn find_segment(&self, seg_type: &str) -> Option<&RawSegment> {
217        self.segments.iter().find(|s| s.segment_type() == seg_type)
218    }
219
220    pub fn all_codes(&self) -> impl Iterator<Item = &ResponseCode> {
221        self.global_codes.iter().chain(self.segment_codes.iter())
222    }
223
224    /// 0030 = order received, TAN required.
225    pub fn needs_tan(&self) -> bool {
226        self.all_codes().any(|c| c.is_tan_required() || c.is_decoupled())
227    }
228
229    /// 3955 = decoupled TAN (pushTAN initiated).
230    pub fn is_decoupled(&self) -> bool {
231        self.all_codes().any(|c| c.is_decoupled())
232    }
233
234    /// 3955/3956 = decoupled TAN still pending.
235    pub fn is_decoupled_pending(&self) -> bool {
236        self.all_codes().any(|c| c.is_decoupled_pending())
237    }
238
239    /// 3076 = no strong authentication required (SCA exemption).
240    pub fn has_sca_exemption(&self) -> bool {
241        self.all_codes().any(|c| c.kind == ResponseCodeKind::ScaExemption)
242    }
243
244    /// 3040 = more data available (touchdown/pagination).
245    /// Returns the touchdown point if found.
246    pub fn touchdown(&self) -> Option<TouchdownPoint> {
247        find_touchdown(&self.segment_codes)
248            .or_else(|| find_touchdown(&self.global_codes))
249    }
250
251    /// Extract HITAN challenge from response.
252    pub fn get_tan_challenge(&self) -> Option<TanChallenge> {
253        if let Some(hitan) = self.find_segment("HITAN") {
254            let (task_ref, challenge, hhduc) = parse_hitan(hitan);
255            if !task_ref.is_empty() || !challenge.is_empty() {
256                return Some(TanChallenge {
257                    challenge: ChallengeText::new(challenge),
258                    challenge_hhduc: hhduc.map(HhdUcData),
259                    task_reference: TaskReference::new(task_ref),
260                    decoupled: self.is_decoupled(),
261                });
262            }
263        }
264        None
265    }
266
267    /// Check for fatal errors. Returns `Ok(())` if no errors.
268    pub fn check_errors(&self) -> Result<()> {
269        for code in self.all_codes() {
270            match &code.kind {
271                ResponseCodeKind::PinWrong => return Err(FinTSError::PinWrong),
272                ResponseCodeKind::AccountLocked => return Err(FinTSError::AccountLocked),
273                k if k.is_error() => return Err(FinTSError::BankError {
274                    kind: code.kind.clone(),
275                    message: code.text.clone(),
276                }),
277                _ => {}
278            }
279        }
280        Ok(())
281    }
282
283    /// Extract allowed TAN security functions from code 3920.
284    pub fn allowed_security_functions(&self) -> Vec<SecurityFunction> {
285        extract_allowed_security_functions(&self.segment_codes)
286            .into_iter()
287            .chain(extract_allowed_security_functions(&self.global_codes))
288            .collect()
289    }
290}
291
292// ═══════════════════════════════════════════════════════════════════════════════
293// TAN challenge
294// ═══════════════════════════════════════════════════════════════════════════════
295
296#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
297pub struct TanChallenge {
298    pub challenge: ChallengeText,
299    pub challenge_hhduc: Option<HhdUcData>,
300    pub task_reference: TaskReference,
301    pub decoupled: bool,
302}
303
304// ═══════════════════════════════════════════════════════════════════════════════
305// Bank parameters
306// ═══════════════════════════════════════════════════════════════════════════════
307
308/// Cached bank and user parameters discovered during sync/init.
309#[derive(Debug, Clone)]
310pub struct BankParams {
311    pub bpd_version: u16,
312    pub upd_version: u16,
313    pub bpd_segments: Vec<RawSegment>,
314    pub upd_segments: Vec<RawSegment>,
315    pub tan_methods: Vec<TanMethod>,
316    pub selected_security_function: SecurityFunction,
317    pub selected_tan_medium: Option<TanMediumName>,
318    pub accounts_from_upd: Vec<SepaAccount>,
319    pub operation_tan_required: HashMap<SegmentType, bool>,
320    pub allowed_security_functions: Vec<SecurityFunction>,
321    pub preferred_security_function: Option<SecurityFunction>,
322}
323
324impl BankParams {
325    pub fn new() -> Self {
326        Self {
327            bpd_version: 0, upd_version: 0,
328            bpd_segments: Vec::new(), upd_segments: Vec::new(),
329            tan_methods: Vec::new(),
330            selected_security_function: SecurityFunction::pin_only(),
331            selected_tan_medium: None,
332            accounts_from_upd: Vec::new(),
333            operation_tan_required: HashMap::new(),
334            allowed_security_functions: Vec::new(),
335            preferred_security_function: None,
336        }
337    }
338
339    /// Ingest BPD/UPD/HITANS/HIPINS/HISYN from a response.
340    pub fn ingest_response(&mut self, response: &Response, system_id: &mut SystemId) {
341        for seg in &response.segments {
342            let stype = seg.segment_type();
343            match stype {
344                "HIBPA" => self.bpd_version = parse_hibpa_version(seg),
345                "HITANS" => self.tan_methods.extend(parse_hitans(seg)),
346                "HIPINS" => {
347                    let m = parse_hipins(seg);
348                    if !m.is_empty() {
349                        info!("[FinTS] HIPINS: {} operation rules", m.len());
350                        self.operation_tan_required.extend(m);
351                    }
352                }
353                "HIUPA" => self.upd_version = parse_hiupa_version(seg),
354                "HIUPD" => {
355                    self.upd_segments.push(seg.clone());
356                    if let Some(acc) = parse_hiupd(seg) {
357                        self.accounts_from_upd.push(acc);
358                    }
359                }
360                "HISYN" => {
361                    let sid = parse_hisyn_system_id(seg);
362                    if !sid.is_empty() {
363                        info!("[FinTS] System ID: {}", sid);
364                        *system_id = SystemId::new(sid);
365                    }
366                }
367                _ => {
368                    if stype.starts_with("HI") && stype.len() >= 5 && stype.ends_with('S') {
369                        self.bpd_segments.push(seg.clone());
370                    }
371                }
372            }
373        }
374        let allowed = response.allowed_security_functions();
375        if !allowed.is_empty() {
376            self.allowed_security_functions = allowed;
377        }
378    }
379
380    /// Does the given operation require TAN (per HIPINS)? Default: true (safe).
381    pub fn needs_tan(&self, segment_type: &SegmentType) -> bool {
382        self.operation_tan_required.get(segment_type).copied().unwrap_or(true)
383    }
384
385    /// HKTAN version for the selected TAN method.
386    pub fn hktan_version(&self) -> u16 {
387        self.tan_methods.iter()
388            .find(|m| m.security_function == self.selected_security_function)
389            .map(|m| m.hktan_version)
390            .unwrap_or(7)
391    }
392
393    /// Highest supported version for a segment type in BPD.
394    pub fn supported_version(&self, param_segment_type: &str, max_version: u16) -> u16 {
395        let v = find_highest_segment_version(&self.bpd_segments, param_segment_type, max_version);
396        let result = if v == 0 { max_version } else { v };
397        info!("[FinTS] BPD lookup: {} → found v{} (BPD has v{}, max={})",
398            param_segment_type, result, v, max_version);
399        result
400    }
401
402    /// Select the best security function from 3920 allowed list.
403    pub fn select_security_function(&mut self) {
404        let allowed = &self.allowed_security_functions;
405        if allowed.is_empty() { return; }
406
407        if let Some(ref pref) = self.preferred_security_function {
408            if allowed.contains(pref) {
409                self.selected_security_function = pref.clone();
410                return;
411            }
412        }
413
414        let pin_only = SecurityFunction::pin_only();
415        let methods = &self.tan_methods;
416        let chosen = allowed.iter()
417            .filter(|sf| *sf != &pin_only)
418            .max_by_key(|sf| {
419                methods.iter().find(|m| &m.security_function == *sf)
420                    .map(|m| if m.is_decoupled { 2i32 } else { 1 })
421                    .unwrap_or(0)
422            });
423
424        if let Some(sf) = chosen {
425            info!("[FinTS] Selected security function: {}", sf);
426            self.selected_security_function = sf.clone();
427        }
428    }
429
430    pub fn is_decoupled(&self) -> bool {
431        self.tan_methods.iter()
432            .find(|m| m.security_function == self.selected_security_function)
433            .map(|m| m.is_decoupled)
434            .unwrap_or(false)
435    }
436
437    pub fn needs_tan_medium(&self) -> bool {
438        self.tan_methods.iter()
439            .find(|m| m.security_function == self.selected_security_function)
440            .map(|m| m.needs_tan_medium)
441            .unwrap_or(false)
442    }
443
444    pub fn decoupled_params(&self) -> (u64, u64, u32) {
445        self.tan_methods.iter()
446            .find(|m| m.security_function == self.selected_security_function)
447            .map(|m| {
448                let first = if m.wait_before_first_poll > 0 { m.wait_before_first_poll as u64 } else { 5 };
449                let next = if m.wait_before_next_poll > 0 { m.wait_before_next_poll as u64 } else { 5 };
450                let max = if m.decoupled_max_polls > 0 { m.decoupled_max_polls as u32 } else { 20 };
451                (first, next, max)
452            })
453            .unwrap_or((5, 5, 20))
454    }
455}
456
457// ═══════════════════════════════════════════════════════════════════════════════
458// Dialog<S> — the typestate dialog
459// ═══════════════════════════════════════════════════════════════════════════════
460
461pub struct Dialog<S: std::fmt::Debug> {
462    connection: FinTSConnection,
463    blz: Blz,
464    user_id: UserId,
465    pin: Pin,
466    system_id: SystemId,
467    product_id: ProductId,
468    dialog_id: DialogId,
469    message_number: u16,
470    pub params: BankParams,
471    _state: PhantomData<S>,
472}
473
474impl<S: std::fmt::Debug> std::fmt::Debug for Dialog<S> {
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        f.debug_struct("Dialog")
477            .field("blz", &self.blz)
478            .field("user_id", &self.user_id)
479            .field("system_id", &self.system_id)
480            .field("dialog_id", &self.dialog_id)
481            .field("message_number", &self.message_number)
482            .field("state", &std::any::type_name::<S>())
483            .finish()
484    }
485}
486
487// Note: Pin intentionally Debug-redacted (shows "Pin(****)")
488
489// ── Shared internals (all states) ───────────────────────────────────────────
490
491impl<S: std::fmt::Debug> Dialog<S> {
492    pub fn system_id(&self) -> &SystemId { &self.system_id }
493    pub fn bank_params(&self) -> &BankParams { &self.params }
494    pub fn bank_params_mut(&mut self) -> &mut BankParams { &mut self.params }
495
496    /// Build the standard HKIDN segment for this dialog.
497    fn identify_segment(&self) -> Segment {
498        Segment::Identify {
499            blz: self.blz.clone(),
500            user_id: self.user_id.clone(),
501            system_id: self.system_id.clone(),
502        }
503    }
504
505    /// Build the standard HKVVB segment for this dialog.
506    fn process_prep_segment(&self) -> Segment {
507        Segment::ProcessPrep {
508            bpd_version: self.params.bpd_version,
509            upd_version: self.params.upd_version,
510            product_id: self.product_id.clone(),
511        }
512    }
513
514    /// Send typed segments with PIN but no TAN.
515    async fn send_segments(&mut self, segments: &[Segment]) -> Result<Response> {
516        let msg_bytes = message::build_message_from_typed(
517            &self.dialog_id, self.message_number,
518            &self.blz, &self.user_id, &self.system_id, &self.pin,
519            &self.params.selected_security_function,
520            segments, &self.params,
521        )?;
522
523        let msg_str = String::from_utf8_lossy(&msg_bytes);
524        let redacted = msg_str.replace(self.pin.as_str(), "***PIN***");
525        info!("[FinTS] Outgoing ({} bytes): {}", msg_bytes.len(), &redacted[..redacted.len().min(500)]);
526
527        self.message_number += 1;
528        let response_bytes = self.connection.send(&msg_bytes).await?;
529        parse_response(&response_bytes, self.message_number - 1)
530    }
531
532    /// Send typed segments with an explicit TAN value in HNSHA.
533    async fn send_segments_with_tan(&mut self, segments: &[Segment], tan: &str) -> Result<Response> {
534        let msg_bytes = message::build_message_from_typed_with_tan(
535            &self.dialog_id, self.message_number,
536            &self.blz, &self.user_id, &self.system_id, &self.pin,
537            tan, &self.params.selected_security_function,
538            segments, &self.params,
539        )?;
540        self.message_number += 1;
541        let response_bytes = self.connection.send(&msg_bytes).await?;
542        parse_response(&response_bytes, self.message_number - 1)
543    }
544
545    async fn send_end(&mut self) -> Result<()> {
546        if !self.dialog_id.is_assigned() { return Ok(()); }
547        debug!("Ending dialog {}", self.dialog_id);
548        let msg_bytes = message::build_end_message(
549            &self.dialog_id, self.message_number,
550            &self.blz, &self.user_id, &self.system_id, &self.pin,
551            &self.params.selected_security_function,
552            &self.params,
553        )?;
554        self.message_number += 1;
555        let _ = self.connection.send(&msg_bytes).await;
556        self.dialog_id = DialogId::unassigned();
557        Ok(())
558    }
559
560    fn extract_dialog_id(&mut self, response: &Response) {
561        if let Some(hnhbk) = response.find_segment("HNHBK") {
562            let new_id = hnhbk.deg(3).get_str(0);
563            if !new_id.is_empty() && new_id != "0" {
564                self.dialog_id = DialogId::new(new_id);
565            }
566        }
567    }
568
569    fn transition<T: std::fmt::Debug>(self) -> Dialog<T> {
570        Dialog {
571            connection: self.connection, blz: self.blz, user_id: self.user_id,
572            pin: self.pin, system_id: self.system_id, product_id: self.product_id,
573            dialog_id: self.dialog_id, message_number: self.message_number,
574            params: self.params, _state: PhantomData,
575        }
576    }
577}
578
579// ── Dialog<New> ─────────────────────────────────────────────────────────────
580
581impl Dialog<New> {
582    pub fn new(url: &str, blz: &Blz, user_id: &UserId, pin: &Pin, product_id: &ProductId) -> Result<Self> {
583        Ok(Self {
584            connection: FinTSConnection::new(url)?,
585            blz: blz.clone(), user_id: user_id.clone(),
586            pin: pin.clone(), system_id: SystemId::unassigned(),
587            product_id: product_id.clone(),
588            dialog_id: DialogId::unassigned(), message_number: 1,
589            params: BankParams::new(), _state: PhantomData,
590        })
591    }
592
593    pub fn with_system_id(mut self, system_id: &SystemId) -> Self {
594        self.system_id = system_id.clone(); self
595    }
596
597    pub fn with_params(mut self, params: &BankParams) -> Self {
598        self.params = params.clone(); self
599    }
600
601    pub fn with_tan_medium(mut self, medium: &TanMediumName) -> Self {
602        self.params.selected_tan_medium = Some(medium.clone()); self
603    }
604
605    /// Synchronization dialog (spec: Initialisierung mit Synchronisierung).
606    ///
607    /// Sends HKIDN + HKVVB + HKSYN (mode=0 for new system_id).
608    /// The bank responds with BPD, UPD, HITANS, HIPINS, and HISYN(system_id).
609    /// This dialog is ONLY for synchronization — no business segments allowed.
610    pub async fn sync(mut self) -> Result<(Dialog<Synced>, Response)> {
611        info!("[FinTS] Sync dialog: BLZ={} user={} system_id={}", self.blz, self.user_id, self.system_id);
612
613        let segments = [
614            self.identify_segment(),
615            self.process_prep_segment(),
616            Segment::Sync,
617        ];
618
619        let response = self.send_segments(&segments).await?;
620        self.extract_dialog_id(&response);
621        self.params.ingest_response(&response, &mut self.system_id);
622        self.params.select_security_function();
623
624        // Check for fatal errors (sync dialog should not require TAN)
625        if !response.needs_tan() {
626            response.check_errors()?;
627        }
628
629        // Log all BPD parameter segments for diagnostics
630        let bpd_summary: Vec<String> = self.params.bpd_segments.iter()
631            .map(|s| format!("{}:v{}", s.segment_type(), s.segment_version()))
632            .collect();
633        info!("[FinTS] Sync complete: BPD v{}, {} TAN methods, system_id={}",
634            self.params.bpd_version, self.params.tan_methods.len(), self.system_id);
635        info!("[FinTS] BPD segments ({}): {}", bpd_summary.len(), bpd_summary.join(", "));
636
637        Ok((self.transition(), response))
638    }
639
640    /// Normal dialog initialization (spec: Dialoginitialisierung).
641    ///
642    /// Sends HKIDN + HKVVB + HKTAN(process=4, ref=HKIDN).
643    /// Response-driven: returns `InitResult::Opened` or `InitResult::TanRequired`
644    /// based on the bank's response codes.
645    pub async fn init(mut self) -> Result<InitResult> {
646        let medium = self.params.selected_tan_medium.clone();
647        info!("[FinTS] Init dialog: BLZ={} security_fn={}", self.blz, self.params.selected_security_function);
648
649        let segments = [
650            self.identify_segment(),
651            self.process_prep_segment(),
652            Segment::TanProcess4 { reference_seg: SegmentRef::new("HKIDN"), tan_medium: medium },
653        ];
654
655        let response = self.send_segments(&segments).await?;
656        self.extract_dialog_id(&response);
657        self.params.ingest_response(&response, &mut self.system_id);
658
659        let allowed = response.allowed_security_functions();
660        if !allowed.is_empty() {
661            self.params.allowed_security_functions = allowed;
662            self.params.select_security_function();
663        }
664
665        for c in response.all_codes() {
666            info!("[FinTS] Init: {} - {}", c.code(), c.text);
667        }
668
669        // Response-driven transition per spec:
670        if response.needs_tan() {
671            if let Some(challenge) = response.get_tan_challenge() {
672                // 0030/3955: TAN required on init → TanPending
673                let challenge = TanChallenge {
674                    decoupled: challenge.decoupled || self.params.is_decoupled(),
675                    ..challenge
676                };
677                info!("[FinTS] Init requires TAN: decoupled={}", challenge.decoupled);
678                return Ok(InitResult::TanRequired(self.transition(), challenge, response));
679            }
680        }
681
682        // 0010/0020 or 3076: dialog opened, no TAN needed → Open
683        response.check_errors()?;
684        info!("[FinTS] Init opened without TAN");
685        Ok(InitResult::Opened(self.transition(), response))
686    }
687
688    /// Initialize WITHOUT HKTAN — PIN-only. Used when bank doesn't require SCA.
689    pub async fn init_no_tan(mut self) -> Result<(Dialog<Open>, Response)> {
690        info!("[FinTS] Init (no HKTAN)");
691        let segments = [
692            self.identify_segment(),
693            self.process_prep_segment(),
694        ];
695
696        let response = self.send_segments(&segments).await?;
697        self.extract_dialog_id(&response);
698        self.params.ingest_response(&response, &mut self.system_id);
699        response.check_errors()?;
700        Ok((self.transition(), response))
701    }
702}
703
704// ── Dialog<Synced> ──────────────────────────────────────────────────────────
705
706impl Dialog<Synced> {
707    /// End the sync dialog. Returns bank params and system_id for use in normal dialogs.
708    pub async fn end(mut self) -> Result<(BankParams, SystemId)> {
709        self.send_end().await.ok();
710        Ok((self.params, self.system_id))
711    }
712}
713
714// ═══════════════════════════════════════════════════════════════════════════════
715// Account — validated account identifier (IBAN + BIC, both required)
716// ═══════════════════════════════════════════════════════════════════════════════
717
718/// A validated bank account identifier (Kontoverbindung International).
719///
720/// Both IBAN and BIC are required and non-empty. This is enforced at
721/// construction time — you cannot create an `Account` with a missing BIC.
722/// All typed business operations on `Dialog<Open>` take `&Account`,
723/// making it a compile error to pass raw strings that might be empty.
724///
725/// ```
726/// use fints::protocol::Account;
727///
728/// // This works:
729/// let acc = Account::new("DE89370400440532013000", "COBADEFFXXX").unwrap();
730///
731/// // This fails at construction time:
732/// let bad = Account::new("DE89370400440532013000", "");
733/// assert!(bad.is_err());
734/// ```
735#[derive(Debug, Clone)]
736pub struct Account {
737    iban: Iban,
738    bic: Bic,
739}
740
741impl Account {
742    /// Create a validated account. Returns `Err` if IBAN or BIC is empty.
743    pub fn new(iban: &str, bic: &str) -> Result<Self> {
744        if iban.is_empty() {
745            return Err(FinTSError::Dialog("IBAN must not be empty".into()));
746        }
747        if bic.is_empty() {
748            return Err(FinTSError::Dialog("BIC must not be empty. Please set the BIC in the account settings.".into()));
749        }
750        Ok(Self { iban: Iban::new(iban), bic: Bic::new(bic) })
751    }
752
753    pub fn iban(&self) -> &str { self.iban.as_str() }
754    pub fn bic(&self) -> &str { self.bic.as_str() }
755}
756
757// ═══════════════════════════════════════════════════════════════════════════════
758// Typed business operation results
759// ═══════════════════════════════════════════════════════════════════════════════
760
761/// Result of a balance request.
762pub enum BalanceResult {
763    /// Balance retrieved successfully.
764    Success(AccountBalance),
765    /// Bank requires TAN for this operation.
766    NeedTan(TanChallenge),
767    /// No balance data in response (unexpected but not fatal).
768    Empty,
769}
770
771/// Result of a transaction request (single page).
772pub struct TransactionPage {
773    /// MT940 booked transaction data.
774    pub booked: Mt940Data,
775    /// MT940 pending transaction data.
776    pub pending: Mt940Data,
777    /// If Some, more data is available — call `transactions()` again with this value.
778    pub touchdown: Option<TouchdownPoint>,
779}
780
781/// Result of a transaction request.
782pub enum TransactionResult {
783    /// Transaction data retrieved (may need more pages via touchdown).
784    Success(TransactionPage),
785    /// Bank requires TAN for this operation.
786    NeedTan(TanChallenge),
787}
788
789/// Result of a single securities holdings request page.
790pub struct HoldingsPage {
791    /// Parsed securities positions.
792    pub holdings: Vec<SecurityHolding>,
793    /// If Some, more data is available — call `holdings()` again with this value.
794    pub touchdown: Option<TouchdownPoint>,
795}
796
797/// Result of a holdings request.
798pub enum HoldingsResult {
799    /// Holdings data retrieved (may need more pages via touchdown).
800    Success(HoldingsPage),
801    /// Bank requires TAN for this operation.
802    NeedTan(TanChallenge),
803    /// No holdings data in response (depot may be empty or segment not supported).
804    Empty,
805}
806
807// ── Dialog<Open> ─────────────────────────────────────────────────────────────
808
809impl Dialog<Open> {
810    /// Request account balance (HKSAL).
811    ///
812    /// Takes a validated `Account` — IBAN and BIC are guaranteed non-empty.
813    /// Automatically selects the correct HKSAL version from BPD and bundles
814    /// HKTAN:4 when HIPINS says TAN is required for this operation.
815    pub async fn balance(&mut self, account: &Account) -> Result<BalanceResult> {
816        let hksal = SegmentType::new("HKSAL");
817        let needs_tan = self.params.needs_tan(&hksal);
818        let mut segments = vec![
819            Segment::Balance { account: account.clone() },
820        ];
821        if needs_tan {
822            info!("[FinTS] balance: HKSAL + HKTAN:4 (HIPINS: TAN required)");
823            segments.push(Segment::TanProcess4 {
824                reference_seg: SegmentRef::new("HKSAL"),
825                tan_medium: self.params.selected_tan_medium.clone(),
826            });
827        } else {
828            info!("[FinTS] balance: HKSAL (HIPINS: PIN-only)");
829        }
830
831        let response = self.send_segments(&segments).await?;
832
833        for c in response.all_codes() {
834            if c.is_error() || c.is_warning() {
835                info!("[FinTS] HKSAL: {} - {}", c.code(), c.text);
836            }
837        }
838
839        // TAN required but no exemption
840        if response.needs_tan() && !response.has_sca_exemption() {
841            if let Some(challenge) = response.get_tan_challenge() {
842                return Ok(BalanceResult::NeedTan(challenge));
843            }
844        }
845
846        // Check errors
847        response.check_errors()?;
848
849        // Parse HISAL
850        if let Some(hisal) = response.find_segment("HISAL") {
851            if let Some(balance) = parse_hisal(hisal) {
852                return Ok(BalanceResult::Success(balance));
853            }
854        }
855
856        Ok(BalanceResult::Empty)
857    }
858
859    /// Request account transactions (HKKAZ) — single page.
860    ///
861    /// Takes a validated `Account`. For pagination, pass the `touchdown` value
862    /// from a previous `TransactionPage` — pass `None` for the first request.
863    /// HKTAN:4 is only bundled on the first request (not on touchdown pages).
864    pub async fn transactions(
865        &mut self,
866        account: &Account,
867        start_date: NaiveDate,
868        end_date: NaiveDate,
869        touchdown: Option<&TouchdownPoint>,
870    ) -> Result<TransactionResult> {
871        let is_first = touchdown.is_none();
872        let hkkaz = SegmentType::new("HKKAZ");
873        let needs_tan = self.params.needs_tan(&hkkaz);
874
875        let mut segments = vec![
876            Segment::Transactions {
877                account: account.clone(),
878                start_date,
879                end_date,
880                touchdown: touchdown.cloned(),
881            },
882        ];
883
884        if is_first && needs_tan {
885            info!("[FinTS] transactions: HKKAZ + HKTAN:4 (HIPINS: TAN required)");
886            segments.push(Segment::TanProcess4 {
887                reference_seg: SegmentRef::new("HKKAZ"),
888                tan_medium: self.params.selected_tan_medium.clone(),
889            });
890        } else if is_first {
891            info!("[FinTS] transactions: HKKAZ (HIPINS: PIN-only)");
892        }
893
894        let response = self.send_segments(&segments).await?;
895
896        for c in response.all_codes() {
897            if c.is_error() || c.is_warning() {
898                info!("[FinTS] HKKAZ: {} - {}", c.code(), c.text);
899            }
900        }
901
902        // TAN required but no exemption
903        if response.needs_tan() && !response.has_sca_exemption() {
904            if let Some(challenge) = response.get_tan_challenge() {
905                return Ok(TransactionResult::NeedTan(challenge));
906            }
907        }
908
909        // Check errors
910        response.check_errors()?;
911
912        // Extract MT940 data
913        let mt940 = extract_mt940_data(&response.segments);
914        let td = response.touchdown();
915
916        Ok(TransactionResult::Success(TransactionPage {
917            booked: Mt940Data(mt940.booked),
918            pending: Mt940Data(mt940.pending),
919            touchdown: td,
920        }))
921    }
922
923    /// Request securities holdings (HKWPD).
924    ///
925    /// Takes a validated `Account` — IBAN and BIC are guaranteed non-empty.
926    /// Automatically selects the correct HKWPD version from BPD.
927    /// Pass `touchdown` from a previous `HoldingsPage` for pagination,
928    /// or `None` for the first request.
929    pub async fn holdings(
930        &mut self,
931        account: &Account,
932        currency: Option<&Currency>,
933        touchdown: Option<&TouchdownPoint>,
934    ) -> Result<HoldingsResult> {
935        let is_first = touchdown.is_none();
936        let hkwpd = SegmentType::new("HKWPD");
937        let needs_tan = self.params.needs_tan(&hkwpd);
938
939        let mut segments = vec![
940            Segment::Holdings {
941                account: account.clone(),
942                currency: currency.cloned(),
943                touchdown: touchdown.cloned(),
944            },
945        ];
946
947        if is_first && needs_tan {
948            info!("[FinTS] holdings: HKWPD + HKTAN:4 (HIPINS: TAN required)");
949            segments.push(Segment::TanProcess4 {
950                reference_seg: SegmentRef::new("HKWPD"),
951                tan_medium: self.params.selected_tan_medium.clone(),
952            });
953        } else if is_first {
954            info!("[FinTS] holdings: HKWPD (HIPINS: PIN-only)");
955        }
956
957        let response = self.send_segments(&segments).await?;
958
959        for c in response.all_codes() {
960            if c.is_error() || c.is_warning() {
961                info!("[FinTS] HKWPD: {} - {}", c.code(), c.text);
962            }
963        }
964
965        // TAN required but no exemption
966        if response.needs_tan() && !response.has_sca_exemption() {
967            if let Some(challenge) = response.get_tan_challenge() {
968                return Ok(HoldingsResult::NeedTan(challenge));
969            }
970        }
971
972        // Check errors
973        response.check_errors()?;
974
975        // Parse HIWPD segments
976        let holdings = parse_hiwpd(&response.segments);
977        let td = response.touchdown();
978
979        if holdings.is_empty() && td.is_none() {
980            return Ok(HoldingsResult::Empty);
981        }
982
983        Ok(HoldingsResult::Success(HoldingsPage {
984            holdings,
985            touchdown: td,
986        }))
987    }
988
989    /// End the dialog.
990    pub async fn end(mut self) -> Result<()> {
991        self.send_end().await
992    }
993}
994
995// ── Dialog<TanPending> ──────────────────────────────────────────────────────
996
997impl Dialog<TanPending> {
998    /// Poll decoupled TAN status (HKTAN process S).
999    /// Per spec: sends HKTAN alone, no business segments.
1000    /// Returns `Confirmed` (→ Open) or `Pending` (→ still TanPending).
1001    pub async fn poll(mut self, task_reference: &TaskReference) -> Result<PollResult> {
1002        let segments = [
1003            Segment::TanPollDecoupled {
1004                task_reference: task_reference.clone(),
1005                tan_medium: self.params.selected_tan_medium.clone(),
1006            },
1007        ];
1008
1009        let response = self.send_segments(&segments).await?;
1010
1011        for c in response.all_codes() {
1012            info!("[FinTS] Poll: {} - {}", c.code(), c.text);
1013        }
1014
1015        // 3955/3956: still pending
1016        if response.is_decoupled_pending() {
1017            return Ok(PollResult::Pending(self));
1018        }
1019
1020        // Check for errors (9xxx)
1021        response.check_errors()?;
1022
1023        // 0020: confirmed → Open
1024        self.params.ingest_response(&response, &mut self.system_id);
1025        Ok(PollResult::Confirmed(self.transition(), response))
1026    }
1027
1028    /// Submit TAN for process 2 (non-decoupled: chipTAN, SMS-TAN).
1029    /// TAN value is included in HNSHA.
1030    pub async fn submit_tan(mut self, task_reference: &TaskReference, tan: &str) -> Result<(Dialog<Open>, Response)> {
1031        let segments = [
1032            Segment::TanProcess2 {
1033                task_reference: task_reference.clone(),
1034                tan_medium: self.params.selected_tan_medium.clone(),
1035            },
1036        ];
1037
1038        let response = self.send_segments_with_tan(&segments, tan).await?;
1039        response.check_errors()?;
1040        self.params.ingest_response(&response, &mut self.system_id);
1041        Ok((self.transition(), response))
1042    }
1043
1044    /// Cancel: end dialog without completing TAN.
1045    pub async fn cancel(mut self) -> Result<()> {
1046        self.send_end().await
1047    }
1048}
1049
1050// ═══════════════════════════════════════════════════════════════════════════════
1051// Response parsing
1052// ═══════════════════════════════════════════════════════════════════════════════
1053
1054fn parse_response(data: &[u8], expected_msg_num: u16) -> Result<Response> {
1055    let outer_segments = parser::parse_message(data)?;
1056
1057    if let Some(hnhbk) = outer_segments.iter().find(|s| s.segment_type() == "HNHBK") {
1058        let resp_num = hnhbk.deg(4).get_str(0);
1059        let expected = expected_msg_num.to_string();
1060        if resp_num != expected && !resp_num.is_empty() {
1061            warn!("Message number mismatch: expected {}, got {}", expected, resp_num);
1062        }
1063    }
1064
1065    let mut all_segments = Vec::new();
1066    for seg in &outer_segments {
1067        if seg.segment_type() == "HNVSD" {
1068            if let Some(binary) = seg.deg(1).get(0).as_bytes() {
1069                match parser::parse_inner_segments(binary) {
1070                    Ok(inner) => all_segments.extend(inner),
1071                    Err(e) => warn!("Failed to parse HNVSD: {}", e),
1072                }
1073            }
1074        } else {
1075            all_segments.push(seg.clone());
1076        }
1077    }
1078
1079    let mut global_codes = Vec::new();
1080    let mut segment_codes = Vec::new();
1081    for seg in &all_segments {
1082        match seg.segment_type() {
1083            "HIRMG" => global_codes.extend(ResponseCode::parse_from_segment(seg)),
1084            "HIRMS" => segment_codes.extend(ResponseCode::parse_from_segment(seg)),
1085            _ => {}
1086        }
1087    }
1088
1089    Ok(Response { segments: all_segments, global_codes, segment_codes })
1090}