Skip to main content

fints/
workflow.rs

1//! Bank workflow trait and DKB implementation.
2//!
3//! Each bank defines its own complete workflow via the `BankOps` trait.
4//! The workflows compose typed Dialog transitions from `protocol.rs`.
5//!
6//! Compile-time safety: workflow methods take typed dialog states.
7//! `fetch()` takes `Dialog<Open>` — you can't call it without authentication.
8
9use tracing::{info, warn};
10
11use crate::banks::BankConfig;
12use crate::error::{FinTSError, Result};
13use crate::protocol::*;
14use crate::types::*;
15
16// ═══════════════════════════════════════════════════════════════════════════════
17// Workflow result types
18// ═══════════════════════════════════════════════════════════════════════════════
19
20/// Result of initiating a connection.
21pub struct InitiateResult {
22    pub dialog: Dialog<TanPending>,
23    pub challenge: TanChallenge,
24    pub tan_methods: Vec<TanMethod>,
25    pub allowed_security_functions: Vec<SecurityFunction>,
26    pub no_tan_required: bool,
27    pub params: BankParams,
28    pub system_id: SystemId,
29}
30
31/// Result when no TAN is required (SCA exemption).
32pub struct InitiateNoTanResult {
33    pub dialog: Dialog<Open>,
34    pub params: BankParams,
35    pub system_id: SystemId,
36    pub tan_methods: Vec<TanMethod>,
37    pub allowed_security_functions: Vec<SecurityFunction>,
38}
39
40/// Either we need TAN or we're already authenticated.
41pub enum InitiateOutcome {
42    NeedTan(InitiateResult),
43    Authenticated(InitiateNoTanResult),
44}
45
46/// Result of fetching data from an open dialog.
47pub struct FetchResult {
48    pub balance: Option<AccountBalance>,
49    pub transactions: Vec<Transaction>,
50    pub holdings: Vec<SecurityHolding>,
51}
52
53/// Options controlling what data to fetch in a single authenticated dialog.
54#[derive(Debug, Clone, Default)]
55pub struct FetchOpts {
56    /// Fetch balance (HKSAL). Default: true.
57    pub balance: bool,
58    /// Fetch transactions (HKKAZ). Default: true.
59    pub transactions: bool,
60    /// Fetch securities holdings (HKWPD). Default: true.
61    pub holdings: bool,
62    /// Days of transaction history to fetch. Default: 90.
63    pub days: u32,
64}
65
66impl FetchOpts {
67    /// Fetch everything: balance, transactions, and holdings.
68    pub fn all(days: u32) -> Self {
69        Self { balance: true, transactions: true, holdings: true, days }
70    }
71    /// Fetch only balance (single request, fast).
72    pub fn balance_only() -> Self {
73        Self { balance: true, transactions: false, holdings: false, days: 0 }
74    }
75    /// Skip holdings (for accounts without a depot).
76    pub fn no_holdings(days: u32) -> Self {
77        Self { balance: true, transactions: true, holdings: false, days }
78    }
79}
80
81// ═══════════════════════════════════════════════════════════════════════════════
82// BankOps trait
83// ═══════════════════════════════════════════════════════════════════════════════
84
85/// Each bank implements its own workflow as typed Dialog transitions.
86///
87/// `fetch()` takes `&mut Dialog<Open>` and `&Account` — compile-time proof that:
88/// 1. Authentication has been completed (Dialog<Open>)
89/// 2. Account has valid IBAN + BIC (Account)
90pub trait BankOps: Send + Sync {
91    fn config(&self) -> &BankConfig;
92
93    /// Phase 1: sync + init, return TAN challenge or authenticated dialog.
94    fn initiate(
95        &self,
96        username: &UserId,
97        pin: &Pin,
98        product_id: &ProductId,
99        system_id: Option<&SystemId>,
100        target_iban: Option<&Iban>,
101        target_bic: Option<&Bic>,
102    ) -> impl std::future::Future<Output = Result<InitiateOutcome>> + Send;
103
104    /// Phase 2: fetch balance + transactions from an open dialog.
105    /// Takes `&Account` — IBAN and BIC are guaranteed present.
106    fn fetch(
107        &self,
108        dialog: &mut Dialog<Open>,
109        account: &Account,
110        days: u32,
111    ) -> impl std::future::Future<Output = Result<FetchResult>> + Send;
112
113    /// Fetch securities holdings from an open dialog.
114    /// Takes `&Account` — IBAN and BIC are guaranteed present.
115    /// Returns an empty Vec if the bank does not support depot queries.
116    fn fetch_holdings(
117        &self,
118        dialog: &mut Dialog<Open>,
119        account: &Account,
120    ) -> impl std::future::Future<Output = Result<Vec<SecurityHolding>>> + Send;
121}
122
123// ═══════════════════════════════════════════════════════════════════════════════
124// DKB implementation
125// ═══════════════════════════════════════════════════════════════════════════════
126
127/// DKB (Deutsche Kreditbank) FinTS workflow.
128///
129/// DKB message flow per spec + empirical discovery:
130/// ```text
131///   Sync dialog:
132///     Msg 1: HKIDN + HKVVB + HKSYN   → BPD, system_id
133///     Msg 2: HKEND
134///
135///   Business dialog:
136///     Msg 1: HKIDN + HKVVB + HKTAN:4(ref=HKIDN)  → InitResult
137///       → TanRequired: push sent (3955)
138///       → Opened: SCA exemption (3076)
139///     Msg 2: HKTAN:S(task_ref)                     → PollResult
140///       → Confirmed: 0020
141///       → Pending: 3955/3956
142///     Msg 3: HKSAL [+ HKTAN:4(ref=HKSAL)]         → SendResult
143///       → Success: balance data (HISAL)
144///       → NeedTan: additional TAN for balance
145///     Msg 4: HKKAZ [+ HKTAN:4(ref=HKKAZ)]         → SendResult
146///       → Success: transaction data (HIKAZ)
147///       → Touchdown: more data, fetch again
148///     Msg 5: HKEND
149/// ```
150pub struct Dkb {
151    bank: BankConfig,
152}
153
154impl Dkb {
155    pub fn new() -> Self {
156        Self {
157            bank: crate::banks::bank_by_blz("12030000")
158                .expect("DKB (BLZ 12030000) must be in bank registry"),
159        }
160    }
161
162    fn new_dialog(&self, username: &UserId, pin: &Pin, product_id: &ProductId) -> Result<Dialog<New>> {
163        Dialog::new(
164            self.bank.url.as_str(),
165            &self.bank.blz,
166            username,
167            pin,
168            product_id,
169        )
170    }
171}
172
173impl BankOps for Dkb {
174    fn config(&self) -> &BankConfig { &self.bank }
175
176    async fn initiate(
177        &self,
178        username: &UserId,
179        pin: &Pin,
180        product_id: &ProductId,
181        system_id: Option<&SystemId>,
182        _target_iban: Option<&Iban>,
183        _target_bic: Option<&Bic>,
184    ) -> Result<InitiateOutcome> {
185        // ── Phase 1: Sync dialog (get system_id + BPD) ──
186        let mut sync_dialog = self.new_dialog(username, pin, product_id)?;
187        if let Some(sid) = system_id {
188            sync_dialog = sync_dialog.with_system_id(sid);
189        }
190        let (synced, _resp) = sync_dialog.sync().await?;
191        let (sync_params, sys_id) = synced.end().await?;
192
193        let sys_id = if sys_id.is_assigned() {
194            sys_id
195        } else {
196            system_id.cloned().unwrap_or_else(SystemId::unassigned)
197        };
198
199        // ── Phase 2: Normal dialog init (triggers TAN or opens directly) ──
200        let dialog = self.new_dialog(username, pin, product_id)?
201            .with_system_id(&sys_id)
202            .with_params(&sync_params);
203
204        let init_result = dialog.init().await?;
205
206        match init_result {
207            InitResult::TanRequired(tan_pending, challenge, _resp) => {
208                info!("[DKB] TAN required: decoupled={}, task_ref='{}'",
209                    challenge.decoupled, challenge.task_reference);
210                Ok(InitiateOutcome::NeedTan(InitiateResult {
211                    params: tan_pending.bank_params().clone(),
212                    system_id: tan_pending.system_id().clone(),
213                    dialog: tan_pending,
214                    challenge,
215                    tan_methods: sync_params.tan_methods.clone(),
216                    allowed_security_functions: sync_params.allowed_security_functions.clone(),
217                    no_tan_required: false,
218                }))
219            }
220            InitResult::Opened(open, _resp) => {
221                info!("[DKB] Opened directly (SCA exemption)");
222                Ok(InitiateOutcome::Authenticated(InitiateNoTanResult {
223                    params: open.bank_params().clone(),
224                    system_id: open.system_id().clone(),
225                    dialog: open,
226                    tan_methods: sync_params.tan_methods.clone(),
227                    allowed_security_functions: sync_params.allowed_security_functions.clone(),
228                }))
229            }
230        }
231    }
232
233    async fn fetch(
234        &self,
235        dialog: &mut Dialog<Open>,
236        account: &Account,
237        days: u32,
238    ) -> Result<FetchResult> {
239        info!("[DKB] Fetching IBAN={}, BIC={}", account.iban(), account.bic());
240
241        // ── Balance (HKSAL) ──
242        let balance = match dialog.balance(account).await {
243            Ok(BalanceResult::Success(b)) => {
244                info!("[DKB] Balance: {}", b.amount);
245                Some(b)
246            }
247            Ok(BalanceResult::NeedTan(_)) => {
248                warn!("[DKB] Balance requires additional TAN — skipping");
249                None
250            }
251            Ok(BalanceResult::Empty) => {
252                warn!("[DKB] No balance data in response");
253                None
254            }
255            Err(e) => {
256                warn!("[DKB] Balance failed: {}", e);
257                None
258            }
259        };
260
261        // ── Transactions (HKKAZ) with pagination ──
262        let end_date = chrono::Utc::now().date_naive();
263        let start_date = end_date - chrono::Duration::days(days as i64);
264        info!("[DKB] Transactions {} to {}", start_date, end_date);
265
266        let mut all_booked = Mt940Data::new();
267        let mut all_pending = Mt940Data::new();
268        let mut touchdown: Option<TouchdownPoint> = None;
269
270        loop {
271            let result = dialog.transactions(
272                account, start_date, end_date, touchdown.as_ref(),
273            ).await?;
274
275            match result {
276                TransactionResult::NeedTan(_) => {
277                    return Err(FinTSError::Dialog(
278                        "DKB erfordert für Transaktionen eine weitere TAN-Freigabe.".into()
279                    ));
280                }
281                TransactionResult::Success(page) => {
282                    if !page.booked.is_empty() { all_booked.extend(page.booked.0); }
283                    if !page.pending.is_empty() { all_pending.extend(page.pending.0); }
284                    touchdown = page.touchdown;
285                    if touchdown.is_none() { break; }
286                    info!("[DKB] Touchdown: more data...");
287                }
288            }
289        }
290
291        let mut transactions = parse_mt940(all_booked.as_bytes(), TransactionStatus::Booked)?;
292        if !all_pending.is_empty() {
293            transactions.extend(parse_mt940(all_pending.as_bytes(), TransactionStatus::Pending)?);
294        }
295        info!("[DKB] {} transactions", transactions.len());
296
297        // ── Holdings (HKWPD) — best-effort, non-fatal ──
298        let holdings = match self.fetch_holdings(dialog, account).await {
299            Ok(h) => {
300                info!("[DKB] {} holdings", h.len());
301                h
302            }
303            Err(e) => {
304                warn!("[DKB] Holdings fetch failed (non-fatal): {}", e);
305                Vec::new()
306            }
307        };
308
309        Ok(FetchResult { balance, transactions, holdings })
310    }
311
312    async fn fetch_holdings(
313        &self,
314        dialog: &mut Dialog<Open>,
315        account: &Account,
316    ) -> Result<Vec<SecurityHolding>> {
317        info!("[DKB] Fetching holdings IBAN={}, BIC={}", account.iban(), account.bic());
318
319        let mut all_holdings = Vec::new();
320        let mut touchdown: Option<TouchdownPoint> = None;
321
322        loop {
323            let result = dialog.holdings(
324                account, None, touchdown.as_ref(),
325            ).await?;
326
327            match result {
328                HoldingsResult::NeedTan(_) => {
329                    warn!("[DKB] Holdings requires additional TAN — skipping");
330                    return Ok(all_holdings);
331                }
332                HoldingsResult::Empty => {
333                    info!("[DKB] No holdings data (depot may be empty or not supported)");
334                    break;
335                }
336                HoldingsResult::Success(page) => {
337                    info!("[DKB] Got {} holdings", page.holdings.len());
338                    all_holdings.extend(page.holdings);
339                    touchdown = page.touchdown;
340                    if touchdown.is_none() { break; }
341                    info!("[DKB] Holdings touchdown: more data...");
342                }
343            }
344        }
345
346        info!("[DKB] Total: {} holdings", all_holdings.len());
347        Ok(all_holdings)
348    }
349}
350
351// ═══════════════════════════════════════════════════════════════════════════════
352// Bank registry
353// ═══════════════════════════════════════════════════════════════════════════════
354
355// ═══════════════════════════════════════════════════════════════════════════════
356// Generic bank — any FinTS endpoint (used for custom/unknown banks)
357// ═══════════════════════════════════════════════════════════════════════════════
358
359/// A generic FinTS bank implementation that works with any BankConfig.
360/// Used when the bank ID is not in the registry (e.g. custom URL + BLZ).
361pub struct GenericBank {
362    bank: BankConfig,
363}
364
365impl GenericBank {
366    pub fn new(config: BankConfig) -> Self {
367        Self { bank: config }
368    }
369
370    fn new_dialog(&self, username: &UserId, pin: &Pin, product_id: &ProductId) -> Result<Dialog<New>> {
371        Dialog::new(self.bank.url.as_str(), &self.bank.blz, username, pin, product_id)
372    }
373}
374
375impl BankOps for GenericBank {
376    fn config(&self) -> &BankConfig { &self.bank }
377
378    async fn initiate(
379        &self,
380        username: &UserId,
381        pin: &Pin,
382        product_id: &ProductId,
383        system_id: Option<&SystemId>,
384        _target_iban: Option<&Iban>,
385        _target_bic: Option<&Bic>,
386    ) -> Result<InitiateOutcome> {
387        let mut sync_dialog = self.new_dialog(username, pin, product_id)?;
388        if let Some(sid) = system_id {
389            sync_dialog = sync_dialog.with_system_id(sid);
390        }
391        let (synced, _) = sync_dialog.sync().await?;
392        let (sync_params, sys_id) = synced.end().await?;
393
394        let sys_id = if sys_id.is_assigned() { sys_id }
395            else { system_id.cloned().unwrap_or_else(SystemId::unassigned) };
396
397        let dialog = self.new_dialog(username, pin, product_id)?
398            .with_system_id(&sys_id)
399            .with_params(&sync_params);
400
401        let init_result = dialog.init().await?;
402
403        match init_result {
404            InitResult::TanRequired(tan_pending, challenge, _) => {
405                let challenge = crate::protocol::TanChallenge {
406                    decoupled: challenge.decoupled || tan_pending.bank_params().is_decoupled(),
407                    ..challenge
408                };
409                Ok(InitiateOutcome::NeedTan(InitiateResult {
410                    params: tan_pending.bank_params().clone(),
411                    system_id: tan_pending.system_id().clone(),
412                    dialog: tan_pending, challenge,
413                    tan_methods: sync_params.tan_methods.clone(),
414                    allowed_security_functions: sync_params.allowed_security_functions.clone(),
415                    no_tan_required: false,
416                }))
417            }
418            InitResult::Opened(open, _) => {
419                Ok(InitiateOutcome::Authenticated(InitiateNoTanResult {
420                    params: open.bank_params().clone(),
421                    system_id: open.system_id().clone(),
422                    dialog: open,
423                    tan_methods: sync_params.tan_methods.clone(),
424                    allowed_security_functions: sync_params.allowed_security_functions.clone(),
425                }))
426            }
427        }
428    }
429
430    async fn fetch(&self, dialog: &mut Dialog<Open>, account: &Account, days: u32) -> Result<FetchResult> {
431        // Reuse DKB fetch logic (it's generic enough — just uses typed Dialog<Open> methods)
432        Dkb::new().fetch(dialog, account, days).await
433    }
434
435    async fn fetch_holdings(&self, dialog: &mut Dialog<Open>, account: &Account) -> Result<Vec<SecurityHolding>> {
436        Dkb::new().fetch_holdings(dialog, account).await
437    }
438}
439
440// ═══════════════════════════════════════════════════════════════════════════════
441// Bank registry
442// ═══════════════════════════════════════════════════════════════════════════════
443
444/// Enum dispatch for bank implementations — zero-cost, no dynamic dispatch.
445///
446/// New banks are added here as enum variants. This avoids `Box<dyn BankOps>`
447/// which is incompatible with native async fn in traits.
448pub enum AnyBank {
449    Dkb(Dkb),
450    Generic(GenericBank),
451}
452
453impl AnyBank {
454    pub fn config(&self) -> &BankConfig {
455        match self {
456            AnyBank::Dkb(b) => b.config(),
457            AnyBank::Generic(b) => b.config(),
458        }
459    }
460
461    pub async fn initiate(
462        &self,
463        username: &UserId,
464        pin: &Pin,
465        product_id: &ProductId,
466        system_id: Option<&SystemId>,
467        target_iban: Option<&Iban>,
468        target_bic: Option<&Bic>,
469    ) -> Result<InitiateOutcome> {
470        match self {
471            AnyBank::Dkb(b) => b.initiate(username, pin, product_id, system_id, target_iban, target_bic).await,
472            AnyBank::Generic(b) => b.initiate(username, pin, product_id, system_id, target_iban, target_bic).await,
473        }
474    }
475
476    pub async fn fetch(
477        &self,
478        dialog: &mut Dialog<Open>,
479        account: &Account,
480        days: u32,
481    ) -> Result<FetchResult> {
482        match self {
483            AnyBank::Dkb(b) => b.fetch(dialog, account, days).await,
484            AnyBank::Generic(b) => b.fetch(dialog, account, days).await,
485        }
486    }
487
488    pub async fn fetch_holdings(
489        &self,
490        dialog: &mut Dialog<Open>,
491        account: &Account,
492    ) -> Result<Vec<SecurityHolding>> {
493        match self {
494            AnyBank::Dkb(b) => b.fetch_holdings(dialog, account).await,
495            AnyBank::Generic(b) => b.fetch_holdings(dialog, account).await,
496        }
497    }
498
499    /// Fetch data with fine-grained control via `FetchOpts`.
500    /// This gives callers a single authenticated dialog for all operations.
501    pub async fn fetch_with_opts(
502        &self,
503        dialog: &mut Dialog<Open>,
504        account: &Account,
505        opts: &FetchOpts,
506    ) -> Result<FetchResult> {
507        use tracing::warn;
508        use crate::protocol::{BalanceResult, TransactionResult, HoldingsResult};
509        use crate::types::{Mt940Data, TransactionStatus, TouchdownPoint};
510
511        // ── Balance ──
512        let balance = if opts.balance {
513            match dialog.balance(account).await {
514                Ok(BalanceResult::Success(b)) => Some(b),
515                Ok(BalanceResult::NeedTan(_)) => { warn!("Balance requires TAN — skipping"); None }
516                Ok(BalanceResult::Empty) => None,
517                Err(e) => { warn!("Balance failed: {}", e); None }
518            }
519        } else {
520            None
521        };
522
523        // ── Transactions ──
524        let transactions = if opts.transactions {
525            let end_date = chrono::Utc::now().date_naive();
526            let start_date = end_date - chrono::Duration::days(opts.days.max(1) as i64);
527            let mut all_booked = Mt940Data::new();
528            let mut all_pending = Mt940Data::new();
529            let mut td: Option<TouchdownPoint> = None;
530            loop {
531                match dialog.transactions(account, start_date, end_date, td.as_ref()).await? {
532                    TransactionResult::NeedTan(_) => break,
533                    TransactionResult::Success(page) => {
534                        if !page.booked.is_empty() { all_booked.extend(page.booked.0); }
535                        if !page.pending.is_empty() { all_pending.extend(page.pending.0); }
536                        td = page.touchdown;
537                        if td.is_none() { break; }
538                    }
539                }
540            }
541            let mut txns = parse_mt940(all_booked.as_bytes(), TransactionStatus::Booked)
542                .unwrap_or_default();
543            if !all_pending.is_empty() {
544                txns.extend(parse_mt940(all_pending.as_bytes(), TransactionStatus::Pending)
545                    .unwrap_or_default());
546            }
547            txns
548        } else {
549            Vec::new()
550        };
551
552        // ── Holdings ──
553        let holdings = if opts.holdings {
554            match self.fetch_holdings(dialog, account).await {
555                Ok(h) => h,
556                Err(e) => { warn!("Holdings fetch failed: {}", e); Vec::new() }
557            }
558        } else {
559            Vec::new()
560        };
561
562        Ok(FetchResult { balance, transactions, holdings })
563    }
564}
565
566/// Look up a bank implementation by its BLZ (Bankleitzahl).
567///
568/// The BLZ is the canonical bank identifier — banks are dispatched based on it.
569/// BLZ `12030000` → DKB implementation; all others → GenericBank.
570pub fn bank_ops(blz: &str) -> Result<AnyBank> {
571    let config = crate::banks::bank_by_blz(blz)
572        .ok_or_else(|| FinTSError::Dialog(format!("Unknown BLZ: {}", blz)))?;
573    match blz {
574        "12030000" => Ok(AnyBank::Dkb(Dkb::new())),
575        _ => Ok(AnyBank::Generic(GenericBank::new(config))),
576    }
577}
578
579/// Create a bank implementation from a custom BankConfig (for non-registry banks).
580pub fn bank_ops_with_config(config: BankConfig) -> AnyBank {
581    AnyBank::Generic(GenericBank::new(config))
582}
583
584// ═══════════════════════════════════════════════════════════════════════════════
585// MT940 parsing
586// ═══════════════════════════════════════════════════════════════════════════════
587
588fn parse_mt940(data: &[u8], status: TransactionStatus) -> Result<Vec<Transaction>> {
589    if data.is_empty() { return Ok(Vec::new()); }
590
591    let (cow, _, had_errors) = encoding_rs::WINDOWS_1252.decode(data);
592    if had_errors { warn!("MT940 encoding errors"); }
593    let mt940_text = cow.into_owned();
594
595    let cleaned: String = mt940_text.lines()
596        .filter(|l| { let t = l.trim(); !t.is_empty() && t != "-" && t != "--" })
597        .collect::<Vec<_>>().join("\r\n") + "\r\n";
598
599    let sanitized = mt940::sanitizers::to_swift_charset(&cleaned);
600    let messages = mt940::parse_mt940(&sanitized)
601        .map_err(|e| FinTSError::Mt940(format!("MT940 parse error: {}", e)))?;
602
603    let mut transactions = Vec::new();
604    for msg in messages {
605        for line in msg.statement_lines {
606            let is_debit = matches!(line.ext_debit_credit_indicator, mt940::ExtDebitOrCredit::Debit);
607            let amount = if is_debit { -line.amount } else { line.amount };
608
609            let (applicant_name, applicant_iban, applicant_bic, purpose, posting_text) =
610                match &line.information_to_account_owner {
611                    Some(mt940::InformationToAccountOwner::Structured {
612                        applicant_name, applicant_iban, applicant_bin, purpose, posting_text, ..
613                    }) => (applicant_name.clone(), applicant_iban.clone(), applicant_bin.clone(), purpose.clone(), posting_text.clone()),
614                    Some(mt940::InformationToAccountOwner::Plain(text)) => (None, None, None, Some(text.clone()), None),
615                    None => (None, None, None, None, None),
616                };
617
618            let raw = serde_json::json!({
619                "date": line.value_date.to_string(),
620                "entry_date": line.entry_date.map(|d| d.to_string()),
621                "amount": amount.to_string(),
622                "currency": msg.opening_balance.iso_currency_code,
623                "customer_ref": line.customer_ref,
624                "bank_ref": line.bank_ref,
625                "applicant_name": applicant_name,
626                "applicant_iban": applicant_iban,
627                "applicant_bic": applicant_bic,
628                "purpose": purpose,
629                "posting_text": posting_text,
630            });
631
632            transactions.push(Transaction {
633                date: line.value_date, valuta_date: line.entry_date,
634                amount,
635                currency: Currency::new(&msg.opening_balance.iso_currency_code),
636                applicant_name,
637                applicant_iban: applicant_iban.map(|s| Iban::new(s)),
638                applicant_bic: applicant_bic.map(|s| Bic::new(s)),
639                purpose, posting_text,
640                reference: Some(line.customer_ref.clone()),
641                raw, status: status.clone(),
642            });
643        }
644    }
645    Ok(transactions)
646}