use tracing::{info, warn};
use crate::banks::BankConfig;
use crate::error::{FinTSError, Result};
use crate::protocol::*;
use crate::types::*;
pub struct InitiateResult {
pub dialog: Dialog<TanPending>,
pub challenge: TanChallenge,
pub tan_methods: Vec<TanMethod>,
pub allowed_security_functions: Vec<SecurityFunction>,
pub no_tan_required: bool,
pub params: BankParams,
pub system_id: SystemId,
}
pub struct InitiateNoTanResult {
pub dialog: Dialog<Open>,
pub params: BankParams,
pub system_id: SystemId,
pub tan_methods: Vec<TanMethod>,
pub allowed_security_functions: Vec<SecurityFunction>,
}
pub enum InitiateOutcome {
NeedTan(InitiateResult),
Authenticated(InitiateNoTanResult),
}
pub struct FetchResult {
pub balance: Option<AccountBalance>,
pub transactions: Vec<Transaction>,
pub holdings: Vec<SecurityHolding>,
}
#[derive(Debug, Clone, Default)]
pub struct FetchOpts {
pub balance: bool,
pub transactions: bool,
pub holdings: bool,
pub days: u32,
}
impl FetchOpts {
pub fn all(days: u32) -> Self {
Self { balance: true, transactions: true, holdings: true, days }
}
pub fn balance_only() -> Self {
Self { balance: true, transactions: false, holdings: false, days: 0 }
}
pub fn no_holdings(days: u32) -> Self {
Self { balance: true, transactions: true, holdings: false, days }
}
}
pub trait BankOps: Send + Sync {
fn config(&self) -> &BankConfig;
fn initiate(
&self,
username: &UserId,
pin: &Pin,
product_id: &ProductId,
system_id: Option<&SystemId>,
target_iban: Option<&Iban>,
target_bic: Option<&Bic>,
) -> impl std::future::Future<Output = Result<InitiateOutcome>> + Send;
fn fetch(
&self,
dialog: &mut Dialog<Open>,
account: &Account,
days: u32,
) -> impl std::future::Future<Output = Result<FetchResult>> + Send;
fn fetch_holdings(
&self,
dialog: &mut Dialog<Open>,
account: &Account,
) -> impl std::future::Future<Output = Result<Vec<SecurityHolding>>> + Send;
}
pub struct Dkb {
bank: BankConfig,
}
impl Dkb {
pub fn new() -> Self {
Self {
bank: crate::banks::bank_by_blz("12030000")
.expect("DKB (BLZ 12030000) must be in bank registry"),
}
}
fn new_dialog(&self, username: &UserId, pin: &Pin, product_id: &ProductId) -> Result<Dialog<New>> {
Dialog::new(
self.bank.url.as_str(),
&self.bank.blz,
username,
pin,
product_id,
)
}
}
impl BankOps for Dkb {
fn config(&self) -> &BankConfig { &self.bank }
async fn initiate(
&self,
username: &UserId,
pin: &Pin,
product_id: &ProductId,
system_id: Option<&SystemId>,
_target_iban: Option<&Iban>,
_target_bic: Option<&Bic>,
) -> Result<InitiateOutcome> {
let mut sync_dialog = self.new_dialog(username, pin, product_id)?;
if let Some(sid) = system_id {
sync_dialog = sync_dialog.with_system_id(sid);
}
let (synced, _resp) = sync_dialog.sync().await?;
let (sync_params, sys_id) = synced.end().await?;
let sys_id = if sys_id.is_assigned() {
sys_id
} else {
system_id.cloned().unwrap_or_else(SystemId::unassigned)
};
let dialog = self.new_dialog(username, pin, product_id)?
.with_system_id(&sys_id)
.with_params(&sync_params);
let init_result = dialog.init().await?;
match init_result {
InitResult::TanRequired(tan_pending, challenge, _resp) => {
info!("[DKB] TAN required: decoupled={}, task_ref='{}'",
challenge.decoupled, challenge.task_reference);
Ok(InitiateOutcome::NeedTan(InitiateResult {
params: tan_pending.bank_params().clone(),
system_id: tan_pending.system_id().clone(),
dialog: tan_pending,
challenge,
tan_methods: sync_params.tan_methods.clone(),
allowed_security_functions: sync_params.allowed_security_functions.clone(),
no_tan_required: false,
}))
}
InitResult::Opened(open, _resp) => {
info!("[DKB] Opened directly (SCA exemption)");
Ok(InitiateOutcome::Authenticated(InitiateNoTanResult {
params: open.bank_params().clone(),
system_id: open.system_id().clone(),
dialog: open,
tan_methods: sync_params.tan_methods.clone(),
allowed_security_functions: sync_params.allowed_security_functions.clone(),
}))
}
}
}
async fn fetch(
&self,
dialog: &mut Dialog<Open>,
account: &Account,
days: u32,
) -> Result<FetchResult> {
info!("[DKB] Fetching IBAN={}, BIC={}", account.iban(), account.bic());
let balance = match dialog.balance(account).await {
Ok(BalanceResult::Success(b)) => {
info!("[DKB] Balance: {}", b.amount);
Some(b)
}
Ok(BalanceResult::NeedTan(_)) => {
warn!("[DKB] Balance requires additional TAN — skipping");
None
}
Ok(BalanceResult::Empty) => {
warn!("[DKB] No balance data in response");
None
}
Err(e) => {
warn!("[DKB] Balance failed: {}", e);
None
}
};
let end_date = chrono::Utc::now().date_naive();
let start_date = end_date - chrono::Duration::days(days as i64);
info!("[DKB] Transactions {} to {}", start_date, end_date);
let mut all_booked = Mt940Data::new();
let mut all_pending = Mt940Data::new();
let mut touchdown: Option<TouchdownPoint> = None;
loop {
let result = dialog.transactions(
account, start_date, end_date, touchdown.as_ref(),
).await?;
match result {
TransactionResult::NeedTan(_) => {
return Err(FinTSError::Dialog(
"DKB erfordert für Transaktionen eine weitere TAN-Freigabe.".into()
));
}
TransactionResult::Success(page) => {
if !page.booked.is_empty() { all_booked.extend(page.booked.0); }
if !page.pending.is_empty() { all_pending.extend(page.pending.0); }
touchdown = page.touchdown;
if touchdown.is_none() { break; }
info!("[DKB] Touchdown: more data...");
}
}
}
let mut transactions = parse_mt940(all_booked.as_bytes(), TransactionStatus::Booked)?;
if !all_pending.is_empty() {
transactions.extend(parse_mt940(all_pending.as_bytes(), TransactionStatus::Pending)?);
}
info!("[DKB] {} transactions", transactions.len());
let holdings = match self.fetch_holdings(dialog, account).await {
Ok(h) => {
info!("[DKB] {} holdings", h.len());
h
}
Err(e) => {
warn!("[DKB] Holdings fetch failed (non-fatal): {}", e);
Vec::new()
}
};
Ok(FetchResult { balance, transactions, holdings })
}
async fn fetch_holdings(
&self,
dialog: &mut Dialog<Open>,
account: &Account,
) -> Result<Vec<SecurityHolding>> {
info!("[DKB] Fetching holdings IBAN={}, BIC={}", account.iban(), account.bic());
let mut all_holdings = Vec::new();
let mut touchdown: Option<TouchdownPoint> = None;
loop {
let result = dialog.holdings(
account, None, touchdown.as_ref(),
).await?;
match result {
HoldingsResult::NeedTan(_) => {
warn!("[DKB] Holdings requires additional TAN — skipping");
return Ok(all_holdings);
}
HoldingsResult::Empty => {
info!("[DKB] No holdings data (depot may be empty or not supported)");
break;
}
HoldingsResult::Success(page) => {
info!("[DKB] Got {} holdings", page.holdings.len());
all_holdings.extend(page.holdings);
touchdown = page.touchdown;
if touchdown.is_none() { break; }
info!("[DKB] Holdings touchdown: more data...");
}
}
}
info!("[DKB] Total: {} holdings", all_holdings.len());
Ok(all_holdings)
}
}
pub struct GenericBank {
bank: BankConfig,
}
impl GenericBank {
pub fn new(config: BankConfig) -> Self {
Self { bank: config }
}
fn new_dialog(&self, username: &UserId, pin: &Pin, product_id: &ProductId) -> Result<Dialog<New>> {
Dialog::new(self.bank.url.as_str(), &self.bank.blz, username, pin, product_id)
}
}
impl BankOps for GenericBank {
fn config(&self) -> &BankConfig { &self.bank }
async fn initiate(
&self,
username: &UserId,
pin: &Pin,
product_id: &ProductId,
system_id: Option<&SystemId>,
_target_iban: Option<&Iban>,
_target_bic: Option<&Bic>,
) -> Result<InitiateOutcome> {
let mut sync_dialog = self.new_dialog(username, pin, product_id)?;
if let Some(sid) = system_id {
sync_dialog = sync_dialog.with_system_id(sid);
}
let (synced, _) = sync_dialog.sync().await?;
let (sync_params, sys_id) = synced.end().await?;
let sys_id = if sys_id.is_assigned() { sys_id }
else { system_id.cloned().unwrap_or_else(SystemId::unassigned) };
let dialog = self.new_dialog(username, pin, product_id)?
.with_system_id(&sys_id)
.with_params(&sync_params);
let init_result = dialog.init().await?;
match init_result {
InitResult::TanRequired(tan_pending, challenge, _) => {
let challenge = crate::protocol::TanChallenge {
decoupled: challenge.decoupled || tan_pending.bank_params().is_decoupled(),
..challenge
};
Ok(InitiateOutcome::NeedTan(InitiateResult {
params: tan_pending.bank_params().clone(),
system_id: tan_pending.system_id().clone(),
dialog: tan_pending, challenge,
tan_methods: sync_params.tan_methods.clone(),
allowed_security_functions: sync_params.allowed_security_functions.clone(),
no_tan_required: false,
}))
}
InitResult::Opened(open, _) => {
Ok(InitiateOutcome::Authenticated(InitiateNoTanResult {
params: open.bank_params().clone(),
system_id: open.system_id().clone(),
dialog: open,
tan_methods: sync_params.tan_methods.clone(),
allowed_security_functions: sync_params.allowed_security_functions.clone(),
}))
}
}
}
async fn fetch(&self, dialog: &mut Dialog<Open>, account: &Account, days: u32) -> Result<FetchResult> {
Dkb::new().fetch(dialog, account, days).await
}
async fn fetch_holdings(&self, dialog: &mut Dialog<Open>, account: &Account) -> Result<Vec<SecurityHolding>> {
Dkb::new().fetch_holdings(dialog, account).await
}
}
pub enum AnyBank {
Dkb(Dkb),
Generic(GenericBank),
}
impl AnyBank {
pub fn config(&self) -> &BankConfig {
match self {
AnyBank::Dkb(b) => b.config(),
AnyBank::Generic(b) => b.config(),
}
}
pub async fn initiate(
&self,
username: &UserId,
pin: &Pin,
product_id: &ProductId,
system_id: Option<&SystemId>,
target_iban: Option<&Iban>,
target_bic: Option<&Bic>,
) -> Result<InitiateOutcome> {
match self {
AnyBank::Dkb(b) => b.initiate(username, pin, product_id, system_id, target_iban, target_bic).await,
AnyBank::Generic(b) => b.initiate(username, pin, product_id, system_id, target_iban, target_bic).await,
}
}
pub async fn fetch(
&self,
dialog: &mut Dialog<Open>,
account: &Account,
days: u32,
) -> Result<FetchResult> {
match self {
AnyBank::Dkb(b) => b.fetch(dialog, account, days).await,
AnyBank::Generic(b) => b.fetch(dialog, account, days).await,
}
}
pub async fn fetch_holdings(
&self,
dialog: &mut Dialog<Open>,
account: &Account,
) -> Result<Vec<SecurityHolding>> {
match self {
AnyBank::Dkb(b) => b.fetch_holdings(dialog, account).await,
AnyBank::Generic(b) => b.fetch_holdings(dialog, account).await,
}
}
pub async fn fetch_with_opts(
&self,
dialog: &mut Dialog<Open>,
account: &Account,
opts: &FetchOpts,
) -> Result<FetchResult> {
use tracing::warn;
use crate::protocol::{BalanceResult, TransactionResult, HoldingsResult};
use crate::types::{Mt940Data, TransactionStatus, TouchdownPoint};
let balance = if opts.balance {
match dialog.balance(account).await {
Ok(BalanceResult::Success(b)) => Some(b),
Ok(BalanceResult::NeedTan(_)) => { warn!("Balance requires TAN — skipping"); None }
Ok(BalanceResult::Empty) => None,
Err(e) => { warn!("Balance failed: {}", e); None }
}
} else {
None
};
let transactions = if opts.transactions {
let end_date = chrono::Utc::now().date_naive();
let start_date = end_date - chrono::Duration::days(opts.days.max(1) as i64);
let mut all_booked = Mt940Data::new();
let mut all_pending = Mt940Data::new();
let mut td: Option<TouchdownPoint> = None;
loop {
match dialog.transactions(account, start_date, end_date, td.as_ref()).await? {
TransactionResult::NeedTan(_) => break,
TransactionResult::Success(page) => {
if !page.booked.is_empty() { all_booked.extend(page.booked.0); }
if !page.pending.is_empty() { all_pending.extend(page.pending.0); }
td = page.touchdown;
if td.is_none() { break; }
}
}
}
let mut txns = parse_mt940(all_booked.as_bytes(), TransactionStatus::Booked)
.unwrap_or_default();
if !all_pending.is_empty() {
txns.extend(parse_mt940(all_pending.as_bytes(), TransactionStatus::Pending)
.unwrap_or_default());
}
txns
} else {
Vec::new()
};
let holdings = if opts.holdings {
match self.fetch_holdings(dialog, account).await {
Ok(h) => h,
Err(e) => { warn!("Holdings fetch failed: {}", e); Vec::new() }
}
} else {
Vec::new()
};
Ok(FetchResult { balance, transactions, holdings })
}
}
pub fn bank_ops(blz: &str) -> Result<AnyBank> {
let config = crate::banks::bank_by_blz(blz)
.ok_or_else(|| FinTSError::Dialog(format!("Unknown BLZ: {}", blz)))?;
match blz {
"12030000" => Ok(AnyBank::Dkb(Dkb::new())),
_ => Ok(AnyBank::Generic(GenericBank::new(config))),
}
}
pub fn bank_ops_with_config(config: BankConfig) -> AnyBank {
AnyBank::Generic(GenericBank::new(config))
}
fn parse_mt940(data: &[u8], status: TransactionStatus) -> Result<Vec<Transaction>> {
if data.is_empty() { return Ok(Vec::new()); }
let (cow, _, had_errors) = encoding_rs::WINDOWS_1252.decode(data);
if had_errors { warn!("MT940 encoding errors"); }
let mt940_text = cow.into_owned();
let cleaned: String = mt940_text.lines()
.filter(|l| { let t = l.trim(); !t.is_empty() && t != "-" && t != "--" })
.collect::<Vec<_>>().join("\r\n") + "\r\n";
let sanitized = mt940::sanitizers::to_swift_charset(&cleaned);
let messages = mt940::parse_mt940(&sanitized)
.map_err(|e| FinTSError::Mt940(format!("MT940 parse error: {}", e)))?;
let mut transactions = Vec::new();
for msg in messages {
for line in msg.statement_lines {
let is_debit = matches!(line.ext_debit_credit_indicator, mt940::ExtDebitOrCredit::Debit);
let amount = if is_debit { -line.amount } else { line.amount };
let (applicant_name, applicant_iban, applicant_bic, purpose, posting_text) =
match &line.information_to_account_owner {
Some(mt940::InformationToAccountOwner::Structured {
applicant_name, applicant_iban, applicant_bin, purpose, posting_text, ..
}) => (applicant_name.clone(), applicant_iban.clone(), applicant_bin.clone(), purpose.clone(), posting_text.clone()),
Some(mt940::InformationToAccountOwner::Plain(text)) => (None, None, None, Some(text.clone()), None),
None => (None, None, None, None, None),
};
let raw = serde_json::json!({
"date": line.value_date.to_string(),
"entry_date": line.entry_date.map(|d| d.to_string()),
"amount": amount.to_string(),
"currency": msg.opening_balance.iso_currency_code,
"customer_ref": line.customer_ref,
"bank_ref": line.bank_ref,
"applicant_name": applicant_name,
"applicant_iban": applicant_iban,
"applicant_bic": applicant_bic,
"purpose": purpose,
"posting_text": posting_text,
});
transactions.push(Transaction {
date: line.value_date, valuta_date: line.entry_date,
amount,
currency: Currency::new(&msg.opening_balance.iso_currency_code),
applicant_name,
applicant_iban: applicant_iban.map(|s| Iban::new(s)),
applicant_bic: applicant_bic.map(|s| Bic::new(s)),
purpose, posting_text,
reference: Some(line.customer_ref.clone()),
raw, status: status.clone(),
});
}
}
Ok(transactions)
}