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 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
96fn 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
118pub 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 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 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 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 continue;
206 }
207 let abs_amount = format!("{:.2}", amount.abs());
209
210 if let Some(count) = creditor_pool.get_mut(&abs_amount)
212 && *count > 0
213 {
214 *count -= 1;
215 continue;
216 }
217
218 if let Some(count) = archive_pool.get_mut(&abs_amount)
220 && *count > 0
221 {
222 *count -= 1;
223 continue;
224 }
225
226 let counterparty = if tx.contact_name.is_empty() {
228 parse_counterparty(&tx.description)
229 } else {
230 tx.contact_name.clone()
231 };
232
233 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 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
274pub 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
299fn normalize_name(name: &str) -> String {
305 let lower = name.to_lowercase();
306 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
320fn 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
333fn 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}