use crate::cli::setup_domain;
use crate::client::accounting::AccountingClient;
use crate::client::archive::ArchiveClient;
use crate::client::soap_client::SoapClient;
use crate::client::vat::VatClient;
use crate::config::Config;
use crate::error::YukiError;
use crate::output::{OutputFormat, format_json, format_table, is_tty};
use crate::period::parse_period;
pub async fn btw(
config: &Config,
admin: Option<&str>,
period: Option<&str>,
format: Option<&str>,
quiet: bool,
) -> Result<(), YukiError> {
let (start, end) = resolve_period(period)?;
if !quiet {
eprintln!("[1/3] Fetching VAT return list...");
}
let mut vat_client = VatClient::new();
vat_client.authenticate(&config.api_key).await?;
let (accounting_client, entry) = setup_domain(config, admin).await?;
let vat_returns = vat_client.vat_return_list(&entry.admin_id).await?;
if !quiet {
eprintln!("[2/3] Fetching outstanding debtor items...");
}
let debtors = accounting_client
.outstanding_debtor_items_by_date(&entry.admin_id, &start, &end)
.await?;
if !quiet {
eprintln!("[3/3] Fetching outstanding creditor items...");
}
let creditors = accounting_client
.outstanding_creditor_items_by_date(&entry.admin_id, &start, &end)
.await?;
let headers = vec![
"Type".into(),
"Contact".into(),
"Description".into(),
"Date".into(),
"Amount".into(),
"Open".into(),
];
let mut rows: Vec<Vec<String>> = Vec::new();
for r in &vat_returns {
if r.start_date >= start && r.end_date <= end {
rows.push(vec![
"VAT Return".into(),
String::new(),
format!("Period {} ({})", r.period, r.status),
r.start_date.clone(),
String::new(),
String::new(),
]);
}
}
for item in &debtors {
rows.push(vec![
"Debtor".into(),
item.contact_name.clone(),
item.description.clone(),
item.date.clone(),
item.amount.clone(),
item.open_amount.clone(),
]);
}
for item in &creditors {
rows.push(vec![
"Creditor".into(),
item.contact_name.clone(),
item.description.clone(),
item.date.clone(),
item.amount.clone(),
item.open_amount.clone(),
]);
}
let fmt = OutputFormat::from_flag(format, is_tty());
match fmt {
OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
}
Ok(())
}
fn parse_counterparty(description: &str) -> String {
if let Some(cntp_start) = description.find("/CNTP/") {
let after_cntp = &description[cntp_start + 6..];
let parts: Vec<&str> = after_cntp.splitn(4, '/').collect();
if parts.len() >= 3 {
return parts[2].trim().to_string();
}
}
description
.chars()
.take(50)
.collect::<String>()
.trim()
.to_string()
}
pub async fn unmatched(
config: &Config,
admin: Option<&str>,
period: Option<&str>,
bank_account: &str,
format: Option<&str>,
quiet: bool,
) -> Result<(), YukiError> {
let (start, end) = resolve_period(period)?;
if !quiet {
eprintln!("[1/3] Fetching bank transactions (GL {bank_account})...");
}
let mut accounting_client = AccountingClient::new();
accounting_client.authenticate(&config.api_key).await?;
let (accounting_client, entry) = {
let entry = config.resolve_admin(admin)?;
accounting_client
.set_current_domain(&entry.domain_id)
.await?;
(accounting_client, entry)
};
let transactions = accounting_client
.gl_account_transactions_and_contact(&entry.admin_id, bank_account, &start, &end)
.await?;
if !quiet {
eprintln!("[2/3] Fetching outstanding creditor items...");
}
let creditor_items = accounting_client
.outstanding_creditor_items(&entry.admin_id)
.await?;
if !quiet {
eprintln!("[3/3] Fetching booked invoices from archive...");
}
let mut archive_client = ArchiveClient::new();
archive_client.authenticate(&config.api_key).await?;
let archive_docs = archive_client.search_documents("", &start, &end).await?;
if !quiet {
eprintln!("API calls made: 4");
}
let mut creditor_pool: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for item in &creditor_items {
let key = item.open_amount.trim().to_string();
*creditor_pool.entry(key).or_insert(0) += 1;
}
let mut archive_pool: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for doc in &archive_docs {
if let Ok(amt) = doc.amount.trim().parse::<f64>()
&& amt > 0.0
{
let key = format!("{amt:.2}");
*archive_pool.entry(key).or_insert(0) += 1;
}
}
let archive_names: std::collections::HashSet<String> = archive_docs
.iter()
.filter(|d| !d.contact_name.is_empty())
.map(|d| normalize_name(&d.contact_name))
.filter(|n| !n.is_empty())
.collect();
let mut unmatched_rows: Vec<Vec<String>> = Vec::new();
for tx in &transactions {
let amount: f64 = tx.amount.trim().parse().unwrap_or(0.0);
if amount >= 0.0 {
continue;
}
let abs_amount = format!("{:.2}", amount.abs());
if let Some(count) = creditor_pool.get_mut(&abs_amount)
&& *count > 0
{
*count -= 1;
continue;
}
if let Some(count) = archive_pool.get_mut(&abs_amount)
&& *count > 0
{
*count -= 1;
continue;
}
let counterparty = if tx.contact_name.is_empty() {
parse_counterparty(&tx.description)
} else {
tx.contact_name.clone()
};
let cp_lower = counterparty.to_lowercase();
if config
.unmatched_ignore
.iter()
.any(|pat| cp_lower.contains(&pat.to_lowercase()))
{
continue;
}
if archive_names.iter().any(|n| names_match(&counterparty, n)) {
continue;
}
unmatched_rows.push(vec![
tx.id.clone(),
tx.date.clone(),
format!("-{abs_amount}"),
counterparty,
tx.description.chars().take(80).collect::<String>(),
]);
}
let headers = vec![
"ID".into(),
"Date".into(),
"Amount".into(),
"Counterparty".into(),
"Description".into(),
];
let fmt = OutputFormat::from_flag(format, is_tty());
match fmt {
OutputFormat::Table => println!("{}", format_table(&headers, &unmatched_rows)),
OutputFormat::Json => println!("{}", format_json(&headers, &unmatched_rows)),
}
Ok(())
}
pub async fn outstanding(
config: &Config,
admin: Option<&str>,
reference: &str,
format: Option<&str>,
) -> Result<(), YukiError> {
let (client, entry) = setup_domain(config, admin).await?;
let xml = client
.check_outstanding_item_admin(&entry.admin_id, reference)
.await?;
let result =
SoapClient::parse_single_result(&xml, "CheckOutstandingItemAdminResult").unwrap_or(xml);
let headers = vec!["Reference".into(), "Result".into()];
let rows = vec![vec![reference.to_string(), result]];
let fmt = OutputFormat::from_flag(format, is_tty());
match fmt {
OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
}
Ok(())
}
fn normalize_name(name: &str) -> String {
let lower = name.to_lowercase();
let base = lower.split(" via ").next().unwrap_or(&lower);
base.replace("b.v.", "")
.replace("bv", "")
.replace("gmbh", "")
.replace("inc", "")
.replace("ltd", "")
.replace("s.a.", "")
.replace(['.', ','], "")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn names_match(bank_name: &str, archive_name: &str) -> bool {
let a = normalize_name(bank_name);
let b = normalize_name(archive_name);
if a.is_empty() || b.is_empty() {
return false;
}
a.contains(b.as_str()) || b.contains(a.as_str())
}
fn resolve_period(period: Option<&str>) -> Result<(String, String), YukiError> {
match period {
Some(p) => parse_period(p),
None => {
let year = current_year();
Ok((format!("{year}-01-01"), format!("{year}-12-31")))
}
}
}
fn current_year() -> u32 {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
1970 + (secs / 31_557_600) as u32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_counterparty_extracts_cntp_name() {
let desc = "/TRTP/SEPA/CNTP/NL01ABNA0001234567/ABNANL2A/Vimexx B.V./REMI/Hosting";
assert_eq!(parse_counterparty(desc), "Vimexx B.V.");
}
#[test]
fn parse_counterparty_falls_back_to_description() {
assert_eq!(
parse_counterparty("ING bankkosten maart"),
"ING bankkosten maart"
);
}
#[test]
fn parse_counterparty_truncates_long() {
let desc = "A".repeat(100);
assert_eq!(parse_counterparty(&desc).len(), 50);
}
#[test]
fn normalize_name_strips_legal_suffixes() {
assert_eq!(normalize_name("Vimexx B.V."), "vimexx");
assert_eq!(normalize_name("Hetzner GmbH"), "hetzner");
assert_eq!(normalize_name("Amazon Inc"), "amazon");
}
#[test]
fn normalize_name_removes_via_suffix() {
assert_eq!(normalize_name("Vimexx via Mollie"), "vimexx");
}
#[test]
fn normalize_name_handles_empty() {
assert_eq!(normalize_name(""), "");
}
#[test]
fn names_match_bidirectional_substring() {
assert!(names_match("Hetzner Online GmbH", "Hetzner"));
assert!(names_match("Hetzner", "Hetzner Online GmbH"));
}
#[test]
fn names_match_case_insensitive() {
assert!(names_match("HETZNER", "hetzner online"));
}
#[test]
fn names_match_strips_legal_suffixes() {
assert!(names_match("Vimexx B.V.", "Vimexx via Mollie"));
}
#[test]
fn names_match_rejects_empty() {
assert!(!names_match("", "Hetzner"));
assert!(!names_match("Hetzner", ""));
}
#[test]
fn names_match_rejects_unrelated() {
assert!(!names_match("Hetzner", "Amazon"));
}
}