Skip to main content

yuki_cli/cli/
check.rs

1use crate::cli::setup_domain;
2use crate::client::accounting::AccountingClient;
3use crate::client::archive::ArchiveClient;
4use crate::client::soap_client::SoapClient;
5use crate::client::vat::VatClient;
6use crate::config::Config;
7use crate::error::YukiError;
8use crate::output::{OutputFormat, format_json, format_table, is_tty};
9use crate::period::parse_period;
10
11pub async fn btw(
12    config: &Config,
13    admin: Option<&str>,
14    period: Option<&str>,
15    format: Option<&str>,
16    quiet: bool,
17) -> Result<(), YukiError> {
18    let (start, end) = resolve_period(period)?;
19
20    if !quiet {
21        eprintln!("[1/3] Fetching VAT return list...");
22    }
23    let mut vat_client = VatClient::new();
24    vat_client.authenticate(&config.api_key).await?;
25    let (accounting_client, entry) = setup_domain(config, admin).await?;
26    let vat_returns = vat_client.vat_return_list(&entry.admin_id).await?;
27
28    if !quiet {
29        eprintln!("[2/3] Fetching outstanding debtor items...");
30    }
31    let debtors = accounting_client
32        .outstanding_debtor_items_by_date(&entry.admin_id, &start, &end)
33        .await?;
34
35    if !quiet {
36        eprintln!("[3/3] Fetching outstanding creditor items...");
37    }
38    let creditors = accounting_client
39        .outstanding_creditor_items_by_date(&entry.admin_id, &start, &end)
40        .await?;
41
42    // Build report: VAT returns in period + outstanding items
43    let headers = vec![
44        "Type".into(),
45        "Contact".into(),
46        "Description".into(),
47        "Date".into(),
48        "Amount".into(),
49        "Open".into(),
50    ];
51    let mut rows: Vec<Vec<String>> = Vec::new();
52
53    for r in &vat_returns {
54        if r.start_date >= start && r.end_date <= end {
55            rows.push(vec![
56                "VAT Return".into(),
57                String::new(),
58                format!("Period {} ({})", r.period, r.status),
59                r.start_date.clone(),
60                String::new(),
61                String::new(),
62            ]);
63        }
64    }
65
66    for item in &debtors {
67        rows.push(vec![
68            "Debtor".into(),
69            item.contact_name.clone(),
70            item.description.clone(),
71            item.date.clone(),
72            item.amount.clone(),
73            item.open_amount.clone(),
74        ]);
75    }
76
77    for item in &creditors {
78        rows.push(vec![
79            "Creditor".into(),
80            item.contact_name.clone(),
81            item.description.clone(),
82            item.date.clone(),
83            item.amount.clone(),
84            item.open_amount.clone(),
85        ]);
86    }
87
88    let fmt = OutputFormat::from_flag(format, is_tty());
89    match fmt {
90        OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
91        OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
92    }
93    Ok(())
94}
95
96/// Extract the counterparty name from a SEPA bank transaction description.
97///
98/// SEPA descriptions encode counterparty info in the format:
99/// `/CNTP/<iban>/<bic>/<name>/`
100///
101/// Falls back to the first 50 characters of the description when the field is absent.
102fn parse_counterparty(description: &str) -> String {
103    if let Some(cntp_start) = description.find("/CNTP/") {
104        let after_cntp = &description[cntp_start + 6..];
105        let parts: Vec<&str> = after_cntp.splitn(4, '/').collect();
106        if parts.len() >= 3 {
107            return parts[2].trim().to_string();
108        }
109    }
110    description
111        .chars()
112        .take(50)
113        .collect::<String>()
114        .trim()
115        .to_string()
116}
117
118/// Find bank transactions on a given GL account that have no matching invoice.
119///
120/// Each bank debit (negative amount) is first matched by absolute amount against outstanding
121/// creditor items. Any remaining unmatched debits are then checked against booked invoices in
122/// the archive for the same period. Only transactions that match neither source are reported.
123pub async fn unmatched(
124    config: &Config,
125    admin: Option<&str>,
126    period: Option<&str>,
127    bank_account: &str,
128    format: Option<&str>,
129    quiet: bool,
130) -> Result<(), YukiError> {
131    let (start, end) = resolve_period(period)?;
132
133    if !quiet {
134        eprintln!("[1/3] Fetching bank transactions (GL {bank_account})...");
135    }
136    let mut accounting_client = AccountingClient::new();
137    accounting_client.authenticate(&config.api_key).await?;
138    let (accounting_client, entry) = {
139        let entry = config.resolve_admin(admin)?;
140        accounting_client
141            .set_current_domain(&entry.domain_id)
142            .await?;
143        (accounting_client, entry)
144    };
145
146    let transactions = accounting_client
147        .gl_account_transactions_and_contact(&entry.admin_id, bank_account, &start, &end)
148        .await?;
149
150    if !quiet {
151        eprintln!("[2/3] Fetching outstanding creditor items...");
152    }
153    let creditor_items = accounting_client
154        .outstanding_creditor_items(&entry.admin_id)
155        .await?;
156
157    if !quiet {
158        eprintln!("[3/3] Fetching booked invoices from archive...");
159    }
160    let mut archive_client = ArchiveClient::new();
161    archive_client.authenticate(&config.api_key).await?;
162    let archive_docs = archive_client.search_documents("", &start, &end).await?;
163
164    if !quiet {
165        eprintln!("API calls made: 4");
166    }
167
168    // Build a pool of creditor open amounts for single-pass matching.
169    // Key: canonical amount string (absolute value), value: remaining count.
170    let mut creditor_pool: std::collections::HashMap<String, usize> =
171        std::collections::HashMap::new();
172    for item in &creditor_items {
173        let key = item.open_amount.trim().to_string();
174        *creditor_pool.entry(key).or_insert(0) += 1;
175    }
176
177    // Build a pool of archive document amounts for matching booked invoices.
178    // Only positive amounts (purchase invoices) are relevant.
179    let mut archive_pool: std::collections::HashMap<String, usize> =
180        std::collections::HashMap::new();
181    for doc in &archive_docs {
182        if let Ok(amt) = doc.amount.trim().parse::<f64>()
183            && amt > 0.0
184        {
185            let key = format!("{amt:.2}");
186            *archive_pool.entry(key).or_insert(0) += 1;
187        }
188    }
189
190    // Build a set of normalized contact names from the archive for fallback name matching.
191    // This catches batched or split charges where the amount differs but the supplier is known.
192    let archive_names: std::collections::HashSet<String> = archive_docs
193        .iter()
194        .filter(|d| !d.contact_name.is_empty())
195        .map(|d| normalize_name(&d.contact_name))
196        .filter(|n| !n.is_empty())
197        .collect();
198
199    let mut unmatched_rows: Vec<Vec<String>> = Vec::new();
200
201    for tx in &transactions {
202        let amount: f64 = tx.amount.trim().parse().unwrap_or(0.0);
203        if amount >= 0.0 {
204            // Only debits (payments out) are relevant.
205            continue;
206        }
207        // Represent the absolute value as a rounded-cent string for matching.
208        let abs_amount = format!("{:.2}", amount.abs());
209
210        // Check outstanding creditor items first.
211        if let Some(count) = creditor_pool.get_mut(&abs_amount)
212            && *count > 0
213        {
214            *count -= 1;
215            continue;
216        }
217
218        // Check booked invoices in the archive by amount.
219        if let Some(count) = archive_pool.get_mut(&abs_amount)
220            && *count > 0
221        {
222            *count -= 1;
223            continue;
224        }
225
226        // Use API-provided contact name when available, fall back to SEPA parsing.
227        let counterparty = if tx.contact_name.is_empty() {
228            parse_counterparty(&tx.description)
229        } else {
230            tx.contact_name.clone()
231        };
232
233        // Skip counterparties matching the configured ignore list.
234        let cp_lower = counterparty.to_lowercase();
235        if config
236            .unmatched_ignore
237            .iter()
238            .any(|pat| cp_lower.contains(&pat.to_lowercase()))
239        {
240            continue;
241        }
242
243        // Fallback: check if the counterparty name is known in the archive.
244        // Handles batched or split charges where the amount may differ.
245        if archive_names.iter().any(|n| names_match(&counterparty, n)) {
246            continue;
247        }
248
249        unmatched_rows.push(vec![
250            tx.id.clone(),
251            tx.date.clone(),
252            format!("-{abs_amount}"),
253            counterparty,
254            tx.description.chars().take(80).collect::<String>(),
255        ]);
256    }
257
258    let headers = vec![
259        "ID".into(),
260        "Date".into(),
261        "Amount".into(),
262        "Counterparty".into(),
263        "Description".into(),
264    ];
265
266    let fmt = OutputFormat::from_flag(format, is_tty());
267    match fmt {
268        OutputFormat::Table => println!("{}", format_table(&headers, &unmatched_rows)),
269        OutputFormat::Json => println!("{}", format_json(&headers, &unmatched_rows)),
270    }
271    Ok(())
272}
273
274/// Check if a specific invoice reference is still outstanding.
275pub async fn outstanding(
276    config: &Config,
277    admin: Option<&str>,
278    reference: &str,
279    format: Option<&str>,
280) -> Result<(), YukiError> {
281    let (client, entry) = setup_domain(config, admin).await?;
282    let xml = client
283        .check_outstanding_item_admin(&entry.admin_id, reference)
284        .await?;
285    let result =
286        SoapClient::parse_single_result(&xml, "CheckOutstandingItemAdminResult").unwrap_or(xml);
287
288    let headers = vec!["Reference".into(), "Result".into()];
289    let rows = vec![vec![reference.to_string(), result]];
290
291    let fmt = OutputFormat::from_flag(format, is_tty());
292    match fmt {
293        OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
294        OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
295    }
296    Ok(())
297}
298
299/// Normalize a company name for fuzzy matching.
300///
301/// Lowercases the name, removes "via ..." suffixes, strips common legal suffixes
302/// and punctuation, and trims whitespace. This allows loose matching between bank
303/// counterparty names and Yuki contact names despite formatting differences.
304fn normalize_name(name: &str) -> String {
305    let lower = name.to_lowercase();
306    // Remove "via ..." suffix (e.g. "Vimexx via Mollie" -> "vimexx")
307    let base = lower.split(" via ").next().unwrap_or(&lower);
308    base.replace("b.v.", "")
309        .replace("bv", "")
310        .replace("gmbh", "")
311        .replace("inc", "")
312        .replace("ltd", "")
313        .replace("s.a.", "")
314        .replace(['.', ','], "")
315        .split_whitespace()
316        .collect::<Vec<_>>()
317        .join(" ")
318}
319
320/// Check whether two company names refer to the same entity.
321///
322/// Returns true if one normalized name contains the other, allowing for
323/// abbreviations or partial matches.
324fn names_match(bank_name: &str, archive_name: &str) -> bool {
325    let a = normalize_name(bank_name);
326    let b = normalize_name(archive_name);
327    if a.is_empty() || b.is_empty() {
328        return false;
329    }
330    a.contains(b.as_str()) || b.contains(a.as_str())
331}
332
333/// Resolve an optional period string to (start_date, end_date).
334///
335/// Defaults to the current calendar year when no period is given.
336fn resolve_period(period: Option<&str>) -> Result<(String, String), YukiError> {
337    match period {
338        Some(p) => parse_period(p),
339        None => {
340            let year = current_year();
341            Ok((format!("{year}-01-01"), format!("{year}-12-31")))
342        }
343    }
344}
345
346fn current_year() -> u32 {
347    use std::time::{SystemTime, UNIX_EPOCH};
348    let secs = SystemTime::now()
349        .duration_since(UNIX_EPOCH)
350        .unwrap_or_default()
351        .as_secs();
352    1970 + (secs / 31_557_600) as u32
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn parse_counterparty_extracts_cntp_name() {
361        let desc = "/TRTP/SEPA/CNTP/NL01ABNA0001234567/ABNANL2A/Vimexx B.V./REMI/Hosting";
362        assert_eq!(parse_counterparty(desc), "Vimexx B.V.");
363    }
364
365    #[test]
366    fn parse_counterparty_falls_back_to_description() {
367        assert_eq!(
368            parse_counterparty("ING bankkosten maart"),
369            "ING bankkosten maart"
370        );
371    }
372
373    #[test]
374    fn parse_counterparty_truncates_long() {
375        let desc = "A".repeat(100);
376        assert_eq!(parse_counterparty(&desc).len(), 50);
377    }
378
379    #[test]
380    fn normalize_name_strips_legal_suffixes() {
381        assert_eq!(normalize_name("Vimexx B.V."), "vimexx");
382        assert_eq!(normalize_name("Hetzner GmbH"), "hetzner");
383        assert_eq!(normalize_name("Amazon Inc"), "amazon");
384    }
385
386    #[test]
387    fn normalize_name_removes_via_suffix() {
388        assert_eq!(normalize_name("Vimexx via Mollie"), "vimexx");
389    }
390
391    #[test]
392    fn normalize_name_handles_empty() {
393        assert_eq!(normalize_name(""), "");
394    }
395
396    #[test]
397    fn names_match_bidirectional_substring() {
398        assert!(names_match("Hetzner Online GmbH", "Hetzner"));
399        assert!(names_match("Hetzner", "Hetzner Online GmbH"));
400    }
401
402    #[test]
403    fn names_match_case_insensitive() {
404        assert!(names_match("HETZNER", "hetzner online"));
405    }
406
407    #[test]
408    fn names_match_strips_legal_suffixes() {
409        assert!(names_match("Vimexx B.V.", "Vimexx via Mollie"));
410    }
411
412    #[test]
413    fn names_match_rejects_empty() {
414        assert!(!names_match("", "Hetzner"));
415        assert!(!names_match("Hetzner", ""));
416    }
417
418    #[test]
419    fn names_match_rejects_unrelated() {
420        assert!(!names_match("Hetzner", "Amazon"));
421    }
422}