1use tracing::{info, warn};
10
11use crate::banks::BankConfig;
12use crate::error::{FinTSError, Result};
13use crate::protocol::*;
14use crate::types::*;
15
16pub 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
31pub 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
40pub enum InitiateOutcome {
42 NeedTan(InitiateResult),
43 Authenticated(InitiateNoTanResult),
44}
45
46pub struct FetchResult {
48 pub balance: Option<AccountBalance>,
49 pub transactions: Vec<Transaction>,
50 pub holdings: Vec<SecurityHolding>,
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct FetchOpts {
56 pub balance: bool,
58 pub transactions: bool,
60 pub holdings: bool,
62 pub days: u32,
64}
65
66impl FetchOpts {
67 pub fn all(days: u32) -> Self {
69 Self { balance: true, transactions: true, holdings: true, days }
70 }
71 pub fn balance_only() -> Self {
73 Self { balance: true, transactions: false, holdings: false, days: 0 }
74 }
75 pub fn no_holdings(days: u32) -> Self {
77 Self { balance: true, transactions: true, holdings: false, days }
78 }
79}
80
81pub trait BankOps: Send + Sync {
91 fn config(&self) -> &BankConfig;
92
93 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 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 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
123pub 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 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 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 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 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 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
351pub 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 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
440pub 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 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 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 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 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
566pub 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
579pub fn bank_ops_with_config(config: BankConfig) -> AnyBank {
581 AnyBank::Generic(GenericBank::new(config))
582}
583
584fn 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}