1use crate::budget::Ledger;
18use crate::money::Money;
19use jiff::ToSpan;
20use jiff::civil::Date;
21use std::path::{Path, PathBuf};
22
23pub const MANIFEST: &str = "sioul-bank.toml";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Movement {
28 pub account: String,
29 pub date: Date,
30 pub amount: Money,
31 pub label: String,
32 pub id: String,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct Statement {
39 pub account: String,
40 pub movements: Vec<Movement>,
41 pub balance: Option<(Date, Money)>,
43}
44
45pub fn parse_amount(text: &str) -> Option<Money> {
47 let mut t: String = text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{a0}' | '\u{202f}' | '€' | '$' | '£' | '\'')).collect();
48 for unit in ["EUR", "eur", "EUROS", "euros"] {
49 t = t.replace(unit, "");
50 }
51 let negative = t.starts_with('-') || (t.starts_with('(') && t.ends_with(')')) || t.ends_with('-');
52 let t = t.trim_matches(|c| matches!(c, '-' | '+' | '(' | ')'));
53 if t.is_empty() || !t.chars().all(|c| c.is_ascii_digit() || c == ',' || c == '.') {
54 return None;
55 }
56 let last = t.rfind([',', '.']);
58 let (whole, cents) = match last {
59 Some(at) if t.len() - at - 1 <= 2 => (&t[..at], &t[at + 1..]),
60 _ => (t, ""),
61 };
62 let whole: String = whole.chars().filter(char::is_ascii_digit).collect();
63 let whole: i64 = if whole.is_empty() { 0 } else { whole.parse().ok()? };
64 let cents: i64 = match cents.len() {
65 0 => 0,
66 1 => cents.parse::<i64>().ok()? * 10,
67 _ => cents.parse().ok()?,
68 };
69 let value = whole.checked_mul(100)?.checked_add(cents)?;
71 Some(Money(if negative { -value } else { value }))
72}
73
74pub fn parse_date(text: &str) -> Option<Date> {
76 parse_date_ordered(text, false)
77}
78
79fn parse_date_ordered(text: &str, month_first: bool) -> Option<Date> {
81 let t = text.trim();
82 if t.len() >= 8 && t.as_bytes()[..8].iter().all(u8::is_ascii_digit) {
84 return Date::new(t[..4].parse().ok()?, t[4..6].parse().ok()?, t[6..8].parse().ok()?).ok();
85 }
86 if let Ok(date) = t.get(..10).unwrap_or(t).parse::<Date>() {
87 return Some(date);
88 }
89 let parts: Vec<&str> = t.split(['/', '.', '-']).collect();
90 if parts.len() == 3 {
91 let first: i8 = parts[0].trim().parse().ok()?;
92 let second: i8 = parts[1].trim().parse().ok()?;
93 let (day, month) = if month_first { (second, first) } else { (first, second) };
94 let year: i16 = parts[2].trim().get(..4.min(parts[2].trim().len()))?.parse().ok()?;
95 let year = if year < 100 { 2000 + year } else { year };
96 return Date::new(year, month, day).ok();
97 }
98 None
99}
100
101fn fnv(text: &str) -> u64 {
102 text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
103}
104
105pub fn read(words: &crate::words::BankWords, text: &str) -> Result<Statement, String> {
108 let head: String = text.chars().take(2000).collect::<String>().to_ascii_uppercase();
109 let mut statement = if head.contains("<OFX>") || head.contains("OFXHEADER") {
110 read_ofx(text)
111 } else if head.contains("CAMT.053") || head.contains("<BKTOCSTMR") {
112 read_camt(text)?
113 } else {
114 read_csv(words, text)?
115 };
116 let mut seen: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
118 for m in &mut statement.movements {
119 m.account = statement.account.clone();
120 if m.id.is_empty() {
121 let base = format!("{}|{}|{}", m.date, m.amount.cents(), m.label);
122 let n = seen.entry(base.clone()).or_insert(0);
123 *n += 1;
124 m.id = format!("{:016x}", fnv(&format!("{base}|{n}")));
125 }
126 }
127 if statement.movements.is_empty() && statement.balance.is_none() {
128 return Err("no movement".into());
129 }
130 Ok(statement)
131}
132
133fn read_ofx(text: &str) -> Statement {
135 let mut statement = Statement::default();
136 let mut current: Option<(Option<Date>, Option<Money>, String, String, String)> = None;
137 let (mut balance_amount, mut balance_date) = (None, None);
138 let mut in_ledger = false;
139 let finish = |current: &mut Option<(Option<Date>, Option<Money>, String, String, String)>, out: &mut Vec<Movement>| {
140 if let Some((Some(date), Some(amount), name, memo, id)) = current.take() {
141 let label = match (name.is_empty(), memo.is_empty()) {
142 (false, false) if !name.contains(&memo) => format!("{name} {memo}"),
143 (false, _) => name,
144 _ => memo,
145 };
146 out.push(Movement { account: String::new(), date, amount, label, id });
147 }
148 };
149 for piece in text.split('<').skip(1) {
150 let Some((tag, rest)) = piece.split_once('>') else { continue };
151 let tag = tag.trim().to_ascii_uppercase();
152 let value = rest.lines().next().unwrap_or("").trim().replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&");
154 match tag.as_str() {
155 "STMTTRN" => {
156 finish(&mut current, &mut statement.movements);
157 current = Some((None, None, String::new(), String::new(), String::new()));
158 }
159 "/STMTTRN" => finish(&mut current, &mut statement.movements),
160 "LEDGERBAL" => in_ledger = true,
161 "/LEDGERBAL" | "AVAILBAL" => in_ledger = false,
162 "BALAMT" if in_ledger => balance_amount = parse_amount(&value),
163 "DTASOF" if in_ledger => balance_date = parse_date(&value),
164 "ACCTID" if statement.account.is_empty() => statement.account = value,
165 _ => {
166 if let Some((date, amount, name, memo, id)) = current.as_mut() {
167 match tag.as_str() {
168 "DTPOSTED" => *date = parse_date(&value),
169 "TRNAMT" => *amount = parse_amount(&value),
170 "NAME" => *name = value,
171 "MEMO" => *memo = value,
172 "FITID" => *id = value,
173 _ => {}
174 }
175 }
176 }
177 }
178 }
179 finish(&mut current, &mut statement.movements);
180 statement.balance = balance_date.zip(balance_amount);
181 statement
182}
183
184type XmlNode<'a, 'input> = roxmltree::Node<'a, 'input>;
185
186fn path<'a, 'input>(node: XmlNode<'a, 'input>, names: &[&str]) -> Option<XmlNode<'a, 'input>> {
188 names.iter().try_fold(node, |n, name| n.children().find(|c| c.tag_name().name() == *name))
189}
190
191fn text_at(node: XmlNode, names: &[&str]) -> Option<String> {
194 path(node, names).and_then(|n| n.text()).map(|t| t.trim().to_string()).filter(|t| !t.is_empty())
195}
196
197fn signed(node: XmlNode) -> Option<Money> {
199 let amount = parse_amount(&text_at(node, &["Amt"])?)?;
200 Some(if text_at(node, &["CdtDbtInd"]).as_deref() == Some("DBIT") { Money(-amount.cents().abs()) } else { amount })
201}
202
203fn read_camt(text: &str) -> Result<Statement, String> {
207 let doc = roxmltree::Document::parse(text).map_err(|e| e.to_string())?;
208 let mut statement = Statement::default();
209 let account_of = |stmt: XmlNode| text_at(stmt, &["Acct", "Id", "IBAN"]).or_else(|| text_at(stmt, &["Acct", "Id", "Othr", "Id"])).unwrap_or_default();
210 let statements: Vec<XmlNode> = doc.descendants().filter(|n| n.tag_name().name() == "Stmt").collect();
211 let Some(first) = statements.first() else { return Err("no statement".into()) };
212 statement.account = account_of(*first);
213 for stmt in statements.iter().copied().filter(|s| account_of(*s) == statement.account) {
214 let mut closing: Option<(Date, Money)> = None;
215 for balance in stmt.children().filter(|c| c.tag_name().name() == "Bal") {
216 let kind = text_at(balance, &["Tp", "CdOrPrtry", "Cd"]).unwrap_or_default();
217 if kind == "CLBD" || (kind == "CLAV" && closing.is_none()) {
218 let date = text_at(balance, &["Dt", "Dt"]).or_else(|| text_at(balance, &["Dt", "DtTm"])).and_then(|d| parse_date(&d));
219 closing = date.zip(signed(balance));
220 }
221 }
222 if let Some((date, _)) = closing
223 && statement.balance.is_none_or(|(newest, _)| date >= newest)
224 {
225 statement.balance = closing;
226 }
227 for entry in stmt.children().filter(|c| c.tag_name().name() == "Ntry") {
228 if text_at(entry, &["Sts"]).or_else(|| text_at(entry, &["Sts", "Cd"])).is_some_and(|s| s != "BOOK") {
229 continue;
230 }
231 let Some(date) = text_at(entry, &["BookgDt", "Dt"]).or_else(|| text_at(entry, &["ValDt", "Dt"])).and_then(|d| parse_date(&d)) else { continue };
232 let Some(amount) = signed(entry) else { continue };
233 let details = path(entry, &["NtryDtls", "TxDtls"]);
234 let name = |d: XmlNode, side: &str| text_at(d, &["RltdPties", side, "Nm"]).or_else(|| text_at(d, &["RltdPties", side, "Pty", "Nm"]));
236 let label = details
237 .and_then(|d| text_at(d, &["RmtInf", "Ustrd"]).or_else(|| name(d, "Cdtr")).or_else(|| name(d, "Dbtr")))
238 .or_else(|| text_at(entry, &["AddtlNtryInf"]))
239 .unwrap_or_default();
240 let party = details.and_then(|d| name(d, if amount.is_negative() { "Cdtr" } else { "Dbtr" })).unwrap_or_default();
241 let label = if !party.is_empty() && !label.contains(&party) { format!("{party} {label}") } else { label };
242 let id = text_at(entry, &["AcctSvcrRef"]).or_else(|| text_at(entry, &["NtryRef"])).unwrap_or_default();
243 statement.movements.push(Movement { account: String::new(), date, amount, label, id });
244 }
245 }
246 Ok(statement)
247}
248
249#[derive(Debug, Clone, Copy, Default)]
251struct Columns {
252 date: usize,
253 label: usize,
254 amount: Option<usize>,
255 debit: Option<usize>,
256 credit: Option<usize>,
257 kind: Option<usize>,
259 status: Option<usize>,
261 impact: Option<usize>,
263 balance: Option<usize>,
265 time: Option<usize>,
266 currency: Option<usize>,
267 id: Option<usize>,
268}
269
270fn read_csv(words: &crate::words::BankWords, text: &str) -> Result<Statement, String> {
277 let text = text.trim_start_matches('\u{feff}');
278 let separator = if text.lines().take(15).map(|l| l.matches(';').count()).sum::<usize>() >= text.lines().take(15).map(|l| l.matches(',').count()).sum::<usize>() / 2 { ';' } else { ',' };
279 let cells = |line: &str| -> Vec<String> {
280 let mut out = Vec::new();
281 let mut cell = String::new();
282 let mut quoted = false;
283 for c in line.chars() {
284 match c {
285 '"' => quoted = !quoted,
286 c if c == separator && !quoted => out.push(std::mem::take(&mut cell).trim().to_string()),
287 c => cell.push(c),
288 }
289 }
290 out.push(cell.trim().to_string());
291 out
292 };
293 let fold = |s: &str| crate::text::fold(s).into_iter().collect::<String>();
294 let mut statement = Statement::default();
295 let (mut pre_date, mut pre_balance) = (None, None);
296 let mut header: Option<Columns> = None;
297 let mut rows: Vec<(Vec<String>, Columns)> = Vec::new();
298 for line in text.lines().filter(|l| !l.trim().is_empty()) {
299 let row = cells(line);
300 let Some(columns) = header else {
301 let names: Vec<String> = row.iter().map(|c| fold(c)).collect();
302 let find = |list: &[String]| names.iter().position(|n| list.iter().map(|w| crate::words::folded(w)).any(|w| !w.is_empty() && n.starts_with(&w)));
304 let exact = |list: &[String]| names.iter().position(|n| list.iter().any(|w| crate::words::folded(w) == *n));
305 let columns = &words.columns;
306 let date = find(&columns.date);
307 let label = find(&columns.label).or_else(|| exact(&columns.name));
308 let amount = exact(&columns.net).or_else(|| find(&columns.amount));
310 let (debit, credit) = (find(&columns.debit), find(&columns.credit));
311 if let (Some(date), Some(label)) = (date, label)
312 && (amount.is_some() || debit.is_some() || credit.is_some())
313 && row.len() >= 3
314 {
315 let kind = exact(&columns.kind).filter(|k| *k != label);
316 let label = if names.get(label).is_some_and(|n| n.starts_with("description")) { label } else { exact(&columns.name).unwrap_or(label) };
318 header = Some(Columns {
319 date,
320 label,
321 amount,
322 debit,
323 credit,
324 kind,
325 status: exact(&columns.status),
326 impact: exact(&columns.impact),
327 balance: exact(&columns.balance),
328 time: exact(&columns.time),
329 currency: exact(&columns.currency),
330 id: exact(&columns.id),
331 });
332 continue;
333 }
334 let first = names.first().cloned().unwrap_or_default();
336 let second = row.get(1).cloned().unwrap_or_default();
337 let starts = |list: &[String]| list.iter().map(|w| crate::words::folded(w)).any(|w| !w.is_empty() && first.starts_with(&w));
338 if starts(&words.above.balance) {
339 pre_balance = parse_amount(&second);
340 } else if starts(&words.above.date) {
341 pre_date = parse_date(&second);
342 } else if starts(&words.above.account) && statement.account.is_empty() {
343 statement.account = second;
344 }
345 continue;
346 };
347 rows.push((row, columns));
348 }
349 let Some(columns) = header else { return Err("no header".into()) };
350 let month_first = {
353 let (mut day_first, mut month_first) = (false, false);
354 for (row, c) in &rows {
355 let parts: Vec<u8> = row.get(c.date).map(|d| d.split(['/', '.']).take(2).filter_map(|p| p.trim().parse().ok()).collect()).unwrap_or_default();
356 if let [first, second] = parts[..] {
357 day_first |= first > 12;
358 month_first |= second > 12;
359 }
360 }
361 month_first && !day_first
362 };
363 let main_currency = columns.currency.and_then(|c| {
365 let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
366 for (row, _) in &rows {
367 *counts.entry(row.get(c).cloned().unwrap_or_default().to_uppercase()).or_insert(0) += 1;
368 }
369 counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c)
370 });
371 let mut latest: Option<((Date, String), Money)> = None;
372 for (row, c) in rows {
373 let Some(date) = row.get(c.date).and_then(|d| parse_date_ordered(d, month_first)) else { continue };
374 if let (Some(col), Some(main)) = (c.currency, &main_currency)
375 && row.get(col).map(|v| v.to_uppercase()).as_ref() != Some(main)
376 {
377 continue;
378 }
379 if let Some(col) = c.status {
380 let status = fold(row.get(col).map_or("", String::as_str));
381 if words.skipped.iter().map(|w| crate::words::folded(w)).any(|w| !w.is_empty() && status.starts_with(&w)) {
382 continue;
383 }
384 }
385 if c.impact.and_then(|i| row.get(i)).is_some_and(|v| words.memo.iter().map(|w| crate::words::folded(w)).any(|w| !w.is_empty() && fold(v).starts_with(&w))) {
386 continue;
387 }
388 let amount = match (c.amount, c.debit, c.credit) {
389 (Some(a), _, _) => row.get(a).and_then(|v| parse_amount(v)),
390 (None, debit, credit) => {
391 let debit = debit.and_then(|d| row.get(d)).and_then(|v| parse_amount(v)).map(|m| Money(-m.cents().abs()));
392 let credit = credit.and_then(|c| row.get(c)).and_then(|v| parse_amount(v)).map(|m| Money(m.cents().abs()));
393 debit.or(credit)
394 }
395 };
396 let Some(amount) = amount else { continue };
397 let name = row.get(c.label).cloned().unwrap_or_default();
398 let kind = c.kind.and_then(|k| row.get(k)).cloned().unwrap_or_default();
399 let label = match (name.is_empty(), kind.is_empty()) {
400 (false, false) => format!("{name} · {kind}"),
401 (true, _) => kind,
402 (false, true) => name,
403 };
404 if let Some(b) = c.balance.and_then(|b| row.get(b)).and_then(|v| parse_amount(v)) {
405 let when = (date, c.time.and_then(|t| row.get(t)).cloned().unwrap_or_default());
406 if latest.as_ref().is_none_or(|(w, _)| when >= *w) {
407 latest = Some((when, b));
408 }
409 }
410 let id = c.id.and_then(|i| row.get(i)).cloned().unwrap_or_default();
411 statement.movements.push(Movement { account: String::new(), date, amount, label, id });
412 }
413 if let Some(amount) = pre_balance {
415 let day = pre_date.or_else(|| statement.movements.iter().map(|m| m.date).max());
416 statement.balance = day.map(|d| (d, amount));
417 } else if let Some(((date, _), amount)) = latest {
418 statement.balance = Some((date, amount));
419 }
420 Ok(statement)
421}
422
423#[derive(Debug, Clone, Default, PartialEq, Eq)]
425pub struct Account {
426 pub id: String,
427 pub title: String,
428 pub balance: Option<(Date, Money)>,
429}
430
431#[derive(Debug, Clone, Default)]
433pub struct Bank {
434 pub root: PathBuf,
435 pub accounts: Vec<Account>,
436 pub movements: Vec<Movement>,
437}
438
439#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
441pub struct Imported {
442 pub read: usize,
443 pub new: usize,
444}
445
446impl Bank {
447 pub fn change<R>(root: &Path, change: impl FnOnce(&mut Bank) -> R) -> Result<R, String> {
451 crate::filelock::with_lock(&root.join(MANIFEST), || {
452 let mut bank = Bank::load(root)?;
453 let out = change(&mut bank);
454 bank.save()?;
455 Ok(out)
456 })
457 }
458
459 pub fn load(root: &Path) -> Result<Bank, String> {
460 let path = root.join(MANIFEST);
461 let text = match std::fs::read_to_string(&path) {
462 Ok(text) => text,
463 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Bank { root: root.to_path_buf(), ..Bank::default() }),
464 Err(e) => return Err(format!("{}: {e}", path.display())),
465 };
466 let table: toml::Table = text.parse().map_err(|e: toml::de::Error| format!("{}: {e}", path.display()))?;
467 let date_of = |v: Option<&toml::Value>| v.and_then(|v| match v {
468 toml::Value::Datetime(d) => parse_date(&d.to_string()),
469 toml::Value::String(s) => parse_date(s),
470 _ => None,
471 });
472 let money_of = |v: Option<&toml::Value>| v.and_then(|v| match v {
473 toml::Value::Float(f) => Some(Money::from_units(*f)),
474 toml::Value::Integer(i) => Some(Money(i * 100)),
475 toml::Value::String(s) => parse_amount(s),
476 _ => None,
477 });
478 let text_of = |v: Option<&toml::Value>| v.and_then(toml::Value::as_str).unwrap_or("").to_string();
479 let list = |name: &str| table.get(name).and_then(toml::Value::as_array).cloned().unwrap_or_default();
480 let accounts = list("account")
481 .iter()
482 .filter_map(|a| {
483 let a = a.as_table()?;
484 Some(Account { id: text_of(a.get("id")), title: text_of(a.get("title")), balance: date_of(a.get("as_of")).zip(money_of(a.get("balance"))) })
485 })
486 .collect();
487 let movements = list("movement")
488 .iter()
489 .filter_map(|m| {
490 let m = m.as_table()?;
491 Some(Movement { account: text_of(m.get("account")), date: date_of(m.get("date"))?, amount: money_of(m.get("amount"))?, label: text_of(m.get("label")), id: text_of(m.get("id")) })
492 })
493 .collect();
494 Ok(Bank { root: root.to_path_buf(), accounts, movements })
495 }
496
497 pub fn import(&mut self, statement: &Statement, title: &str) -> Imported {
499 let account = if statement.account.is_empty() { "account".to_string() } else { statement.account.clone() };
500 let mut new = 0;
501 for m in &statement.movements {
502 if !self.movements.iter().any(|old| old.account == account && old.id == m.id) {
503 self.movements.push(Movement { account: account.clone(), ..m.clone() });
504 new += 1;
505 }
506 }
507 self.movements.sort_by(|a, b| (a.date, &a.account, &a.id).cmp(&(b.date, &b.account, &b.id)));
508 let place = match self.accounts.iter().position(|a| a.id == account) {
509 Some(at) => at,
510 None => {
511 self.accounts.push(Account { id: account.clone(), title: title.to_string(), balance: None });
512 self.accounts.len() - 1
513 }
514 };
515 if let Some((date, amount)) = statement.balance
516 && self.accounts[place].balance.is_none_or(|(old, _)| date >= old)
517 {
518 self.accounts[place].balance = Some((date, amount));
519 }
520 Imported { read: statement.movements.len(), new }
521 }
522
523 pub fn save(&self) -> Result<(), String> {
525 crate::filelock::with_lock(&self.root.join(MANIFEST), || self.write())
526 }
527
528 fn write(&self) -> Result<(), String> {
529 let mut doc = toml_edit::DocumentMut::new();
530 doc.decor_mut().set_prefix("# Your bank's movements, read from its exports (docs/accounting.md). Kept on your devices only.\n\n");
531 let date = |d: Date| toml_edit::value(toml_edit::Datetime { date: Some(toml_edit::Date { year: d.year() as u16, month: d.month() as u8, day: d.day() as u8 }), time: None, offset: None });
532 let money = |m: Money| toml_edit::value(m.cents() as f64 / 100.0);
533 let mut accounts = toml_edit::ArrayOfTables::new();
534 for a in &self.accounts {
535 let mut t = toml_edit::Table::new();
536 t["id"] = toml_edit::value(&a.id);
537 if !a.title.is_empty() {
538 t["title"] = toml_edit::value(&a.title);
539 }
540 if let Some((d, m)) = a.balance {
541 t["balance"] = money(m);
542 t["as_of"] = date(d);
543 }
544 accounts.push(t);
545 }
546 doc.insert("account", toml_edit::Item::ArrayOfTables(accounts));
547 let mut movements = toml_edit::ArrayOfTables::new();
548 for m in &self.movements {
549 let mut t = toml_edit::Table::new();
550 t["account"] = toml_edit::value(&m.account);
551 t["date"] = date(m.date);
552 t["amount"] = money(m.amount);
553 t["label"] = toml_edit::value(&m.label);
554 t["id"] = toml_edit::value(&m.id);
555 movements.push(t);
556 }
557 doc.insert("movement", toml_edit::Item::ArrayOfTables(movements));
558 let path = self.root.join(MANIFEST);
559 let temporary = self.root.join(format!("{MANIFEST}.new"));
560 std::fs::write(&temporary, doc.to_string()).and_then(|()| std::fs::rename(&temporary, &path)).map_err(|e| format!("{}: {e}", path.display()))
561 }
562
563 pub fn balance_in(&self, ledger: &Ledger) -> Option<(Date, Money)> {
568 let mut by: std::collections::BTreeMap<String, (Date, Money)> = std::collections::BTreeMap::new();
569 for account in &self.accounts {
570 let Some((day, amount)) = account.balance else { continue };
571 let owner = ledger.bank_accounts.iter().find(|card| card.owns(&account.id)).map_or_else(|| format!("\u{1f}{}", account.id), |card| card.id.clone());
572 let kept = by.entry(owner).or_insert((day, amount));
573 if day > kept.0 {
574 *kept = (day, amount);
575 }
576 }
577 let day = by.values().map(|(d, _)| *d).max()?;
578 Some((day, by.values().map(|(_, m)| *m).sum()))
579 }
580
581 pub fn balance(&self) -> Option<(Date, Money)> {
583 let known: Vec<(Date, Money)> = self.accounts.iter().filter_map(|a| a.balance).collect();
584 let day = known.iter().map(|(d, _)| *d).max()?;
585 Some((day, known.iter().map(|(_, m)| *m).sum()))
586 }
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
591pub enum Finding {
592 Missed { label: String, amount: Money, date: Date },
594 Changed { label: String, expected: Money, actual: Money, date: Date },
596 Short { label: String, amount: Money, date: Date, short: Money, reserve: Option<String> },
599}
600
601#[derive(Debug, Clone, PartialEq, Eq)]
603pub struct Expected {
604 pub date: Date,
605 pub label: String,
606 pub amount: Money,
607}
608
609#[derive(Debug, Clone, Default, PartialEq, Eq)]
611pub struct Watch {
612 pub balance: Option<(Date, Money)>,
613 pub findings: Vec<Finding>,
614 pub coming: Vec<Expected>,
616 pub forecast: Vec<(Date, Money)>,
618}
619
620impl Watch {
621 pub fn balance_on(&self, date: Date) -> Option<Money> {
623 self.forecast.iter().rev().find(|(d, _)| *d <= date).map(|(_, m)| *m)
624 }
625}
626
627pub(crate) fn words(filler: &[String], text: &str) -> Vec<String> {
632 let filler: Vec<String> = filler.iter().map(|w| crate::words::folded(w)).collect();
633 crate::text::fold(text)
634 .into_iter()
635 .map(|c| if c.is_alphanumeric() { c } else { ' ' })
636 .collect::<String>()
637 .split_whitespace()
638 .filter(|w| w.len() >= 3 && !w.chars().all(|c| c.is_ascii_digit()) && !filler.iter().any(|f| f == w))
639 .map(str::to_string)
640 .collect()
641}
642
643pub(crate) fn matches(filler: &[String], movement: &Movement, label: &[String], amount: Money, date: Date) -> bool {
646 let near = movement.date >= date.checked_sub(3.days()).unwrap_or(date) && movement.date <= date.checked_add(7.days()).unwrap_or(date);
647 if !near || movement.amount.is_negative() != amount.is_negative() {
648 return false;
649 }
650 let theirs = words(filler, &movement.label);
651 let named = label.iter().any(|w| theirs.contains(w));
652 let same = (movement.amount.cents() - amount.cents()).abs() <= amount.cents().abs() / 100 + 1;
653 named || (same && (movement.date.since(date).map_or(99, |s| s.get_days().abs())) <= 3)
654}
655
656pub fn watch(bank: &Bank, ledger: &Ledger, today: Date, filler: &[String]) -> Watch {
659 let mut out = Watch { balance: bank.balance_in(ledger), ..Watch::default() };
660 let first = bank.movements.iter().map(|m| m.date).min();
661 let last = bank.movements.iter().map(|m| m.date).max();
662 let mut used: Vec<bool> = vec![false; bank.movements.len()];
663 if let (Some(first), Some(last)) = (first, last) {
665 let from = first.checked_add(3.days()).unwrap_or(first);
666 let to = last.checked_sub(7.days()).unwrap_or(last);
667 for preset in ledger.presets.iter().filter(|p| !p.estimate) {
668 let label = words(filler, &preset.label);
669 for date in preset.occurrences(from, to) {
670 let found = bank.movements.iter().enumerate().find(|(i, m)| !used[*i] && matches(filler, m, &label, preset.amount, date));
671 match found {
672 Some((i, m)) => {
673 used[i] = true;
674 let gap = (m.amount.cents() - preset.amount.cents()).abs();
675 if gap > preset.amount.cents().abs() / 20 && gap > 200 {
676 out.findings.push(Finding::Changed { label: preset.label.clone(), expected: preset.amount, actual: m.amount, date: m.date });
677 }
678 }
679 None => out.findings.push(Finding::Missed { label: preset.label.clone(), amount: preset.amount, date }),
680 }
681 }
682 }
683 }
684 let Some((as_of, start)) = out.balance else { return out };
686 let horizon = today.checked_add(31.days()).unwrap_or(today);
687 let movements: Vec<&Movement> = bank.movements.iter().collect();
688 let flows = expected(&movements, ledger, None, as_of, today, horizon, filler);
689 let week = today.checked_add(7.days()).unwrap_or(today);
690 out.coming = flows.iter().filter(|f| f.date >= today && f.date <= week && f.amount.is_negative() && !ledger.presets.iter().any(|p| p.estimate && p.label == f.label)).cloned().collect();
691 let (forecast, below) = carry(start, as_of, &flows, today, horizon, Money::ZERO);
692 out.forecast = forecast;
693 if let Some((date, at, balance)) = below
695 && ledger.bank_accounts.iter().all(|a| a.topped_up_by.is_empty())
696 {
697 let short = Money(-balance.cents());
698 let reserve = ledger.reserves.iter().find(|r| (r.balance - r.floor).cents() >= short.cents()).map(|r| r.title.clone());
699 out.findings.push(Finding::Short { label: flows[at].label.clone(), amount: flows[at].amount, date, short, reserve });
700 }
701 out
702}
703
704pub(crate) fn expected(movements: &[&Movement], ledger: &Ledger, budgets: Option<&[String]>, as_of: Date, today: Date, horizon: Date, filler: &[String]) -> Vec<Expected> {
709 let ours = |budget: &str| budgets.is_none_or(|list| list.iter().any(|b| b == budget));
710 let last = movements.iter().map(|m| m.date).max();
711 let seen_until = last.unwrap_or(as_of).max(as_of);
712 let mut flows: Vec<Expected> = Vec::new();
713 for m in movements.iter().filter(|m| m.date > as_of) {
714 flows.push(Expected { date: m.date, label: m.label.clone(), amount: m.amount });
715 }
716 for preset in ledger.presets.iter().filter(|p| ours(&p.budget)) {
717 if preset.estimate {
718 let per_day = |d: Date| {
720 let (a, b) = preset.every.bounds(d);
721 let days = b.since(a).map_or(30, |s| i64::from(s.get_days()) + 1).max(1);
722 Money(preset.amount.cents() / days)
723 };
724 let mut d = as_of.tomorrow().unwrap_or(as_of);
725 while d <= horizon {
726 if preset.from.is_none_or(|f| d >= f) && preset.until.is_none_or(|u| d <= u) {
727 flows.push(Expected { date: d, label: preset.label.clone(), amount: per_day(d) });
728 }
729 let Ok(next) = d.tomorrow() else { break };
730 d = next;
731 }
732 continue;
733 }
734 let label = words(filler, &preset.label);
735 for date in preset.occurrences(as_of.tomorrow().unwrap_or(as_of), horizon) {
736 if date <= seen_until && movements.iter().any(|m| m.date > as_of && matches(filler, m, &label, preset.amount, date)) {
738 continue;
739 }
740 flows.push(Expected { date: date.max(today), label: preset.label.clone(), amount: preset.amount });
741 }
742 }
743 for line in ledger.lines.iter().filter(|l| l.planned && l.date > as_of && l.date <= horizon && l.preset.is_none() && ours(&l.budget)) {
744 flows.push(Expected { date: line.date.max(today), label: line.label.clone(), amount: line.amount });
745 }
746 flows.sort_by(|a, b| (a.date, a.amount.cents()).cmp(&(b.date, b.amount.cents())));
747 flows
748}
749
750pub(crate) fn carry(start: Money, as_of: Date, flows: &[Expected], today: Date, horizon: Date, floor: Money) -> (Vec<(Date, Money)>, Option<(Date, usize, Money)>) {
755 let mut forecast = Vec::new();
756 let mut below = None;
757 let mut balance = start;
758 let mut day = as_of;
759 let mut at = 0;
760 while day <= horizon {
761 while at < flows.len() && flows[at].date <= day {
762 balance += flows[at].amount;
763 if below.is_none() && balance.cents() < floor.cents() && flows[at].amount.is_negative() && day >= today {
764 below = Some((day, at, balance));
765 }
766 at += 1;
767 }
768 if day >= today {
769 forecast.push((day, balance));
770 }
771 let Ok(next) = day.tomorrow() else { break };
773 day = next;
774 }
775 (forecast, below)
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781 use crate::words::Words;
782
783 fn read(text: &str) -> Result<Statement, String> {
785 super::read(&Words::builtin().bank, text)
786 }
787
788 fn words(text: &str) -> Vec<String> {
789 super::words(&Words::builtin().bank.filler, text)
790 }
791
792 fn watch(bank: &Bank, ledger: &Ledger, today: Date) -> Watch {
793 super::watch(bank, ledger, today, &Words::builtin().bank.filler)
794 }
795
796 #[test]
797 fn filler_words_name_nothing() {
798 let choir = words("Choir, the year's fee");
799 assert_eq!(choir, vec!["choir".to_string(), "year".to_string(), "fee".to_string()]);
800 assert!(!words("CB BAKERY OF THE PORT").iter().any(|w| choir.contains(w)));
801 assert_eq!(words("PRLV SEPA EDF 4417"), vec!["edf".to_string()]);
802 }
803
804 fn day(text: &str) -> Date {
805 text.parse().unwrap()
806 }
807
808 #[test]
809 fn amounts_and_dates_as_banks_write_them() {
810 assert_eq!(parse_amount("-62,00"), Some(Money(-6200)));
811 assert_eq!(parse_amount("1 234,56"), Some(Money(123456)));
812 assert_eq!(parse_amount("1\u{a0}234,56 €"), Some(Money(123456)));
813 assert_eq!(parse_amount("1,234.56"), Some(Money(123456)));
814 assert_eq!(parse_amount("+780.00 EUR"), Some(Money(78000)));
815 assert_eq!(parse_amount("(62.00)"), Some(Money(-6200)));
816 assert_eq!(parse_amount("12"), Some(Money(1200)));
817 assert_eq!(parse_amount("1.250"), Some(Money(125000)), "a thousands dot");
818 assert_eq!(parse_amount("abc"), None);
819 assert_eq!(parse_date("20261002120000[+1:CET]"), Some(day("2026-10-02")));
820 assert_eq!(parse_date("02/10/2026"), Some(day("2026-10-02")));
821 assert_eq!(parse_date("02.10.26"), Some(day("2026-10-02")));
822 assert_eq!(parse_date("2026-10-02T00:00:00"), Some(day("2026-10-02")));
823 }
824
825 #[test]
826 fn three_exports_read() {
827 let ofx = "OFXHEADER:100\nDATA:OFXSGML\n<OFX><BANKMSGSRSV1><STMTTRNRS><STMTRS><BANKACCTFROM><ACCTID>0123456A</BANKACCTFROM>\n<BANKTRANLIST>\n<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>20261001\n<TRNAMT>-780.00\n<FITID>A1\n<NAME>PRLV SEPA AGENCE LOYERS\n</STMTTRN>\n<STMTTRN>\n<TRNTYPE>CREDIT\n<DTPOSTED>20261002\n<TRNAMT>1200.00\n<FITID>A2\n<NAME>VIR SALAIRE\n<MEMO>OCTOBRE\n</STMTTRN>\n</BANKTRANLIST>\n<LEDGERBAL><BALAMT>845.10<DTASOF>20261003</LEDGERBAL>\n</STMTRS></STMTTRNRS></BANKMSGSRSV1></OFX>";
828 let s = read(ofx).unwrap();
829 assert_eq!(s.account, "0123456A");
830 assert_eq!(s.movements.len(), 2);
831 assert_eq!((s.movements[1].amount, s.movements[1].label.as_str(), s.movements[1].id.as_str()), (Money(120000), "VIR SALAIRE OCTOBRE", "A2"));
832 assert_eq!(s.balance, Some((day("2026-10-03"), Money(84510))));
833
834 let camt = r#"<?xml version="1.0"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02"><BkToCstmrStmt><Stmt><Acct><Id><IBAN>FR7600000000000000000000000</IBAN></Id></Acct>
835 <Bal><Tp><CdOrPrtry><Cd>CLBD</Cd></CdOrPrtry></Tp><Amt Ccy="EUR">845.10</Amt><CdtDbtInd>CRDT</CdtDbtInd><Dt><Dt>2026-10-03</Dt></Dt></Bal>
836 <Ntry><Amt Ccy="EUR">62.00</Amt><CdtDbtInd>DBIT</CdtDbtInd><Sts>BOOK</Sts><BookgDt><Dt>2026-10-05</Dt></BookgDt><AcctSvcrRef>R9</AcctSvcrRef>
837 <NtryDtls><TxDtls><RltdPties><Cdtr><Nm>EDF</Nm></Cdtr></RltdPties><RmtInf><Ustrd>Facture 42</Ustrd></RmtInf></TxDtls></NtryDtls></Ntry>
838 </Stmt></BkToCstmrStmt></Document>"#;
839 let s = read(camt).unwrap();
840 assert_eq!(s.movements[0].amount, Money(-6200));
841 assert_eq!(s.movements[0].label, "EDF Facture 42");
842 assert_eq!(s.balance, Some((day("2026-10-03"), Money(84510))));
843
844 let csv = "\u{feff}Numéro Compte ;0123456A\nType ;CCP\nCompte tenu en : ;euros\nDate ;03/10/2026\nSolde (EUROS) ;845,10\n\nDate;Libellé;Montant(EUROS)\n01/10/2026;\"PRLV SEPA AGENCE LOYERS\";-780,00\n02/10/2026;VIREMENT DE SALAIRE;1 200,00\n";
846 let s = read(csv).unwrap();
847 assert_eq!(s.account, "0123456A");
848 assert_eq!(s.movements.len(), 2);
849 assert_eq!(s.movements[0].amount, Money(-78000));
850 assert_eq!(s.balance, Some((day("2026-10-03"), Money(84510))));
851 let csv = "Date,Description,Debit,Credit\n2026-10-01,Rent,780.00,\n2026-10-02,Wages,,1200.00\n";
853 let s = read(csv).unwrap();
854 assert_eq!(s.movements.iter().map(|m| m.amount).collect::<Vec<_>>(), vec![Money(-78000), Money(120000)]);
855 let paypal = "\u{feff}\"Date\",\"Heure\",\"Fuseau horaire\",\"Nom\",\"Type\",\"État\",\"Devise\",\"Avant commission\",\"Commission\",\"Net\",\"Solde\",\"Numéro de transaction\"\n\
857 \"01/10/2026\",\"10:02:11\",\"Europe/Paris\",\"Jean Exemple\",\"Paiement de don\",\"Terminé\",\"EUR\",\"12,00\",\"-0,77\",\"11,23\",\"111,23\",\"T1\"\n\
858 \"02/10/2026\",\"09:00:00\",\"Europe/Paris\",\"Web Exemple\",\"Paiement Express Checkout\",\"Terminé\",\"EUR\",\"-76,50\",\"0,00\",\"-76,50\",\"34,73\",\"T2\"\n\
859 \"02/10/2026\",\"09:30:00\",\"Europe/Paris\",\"Someone\",\"Paiement envoyé\",\"En attente\",\"EUR\",\"-5,00\",\"0,00\",\"-5,00\",\"34,73\",\"T3\"\n\
860 \"02/10/2026\",\"09:40:00\",\"Europe/Paris\",\"Shop US\",\"Paiement\",\"Terminé\",\"USD\",\"-10,00\",\"0,00\",\"-10,00\",\"0,00\",\"T4\"\n";
861 let s = read(paypal).unwrap();
862 assert_eq!(s.movements.iter().map(|m| (m.amount, m.label.as_str(), m.id.as_str())).collect::<Vec<_>>(), vec![(Money(1123), "Jean Exemple · Paiement de don", "T1"), (Money(-7650), "Web Exemple · Paiement Express Checkout", "T2")]);
863 assert_eq!(s.balance, Some((day("2026-10-02"), Money(3473))));
864 let stripe = "id,Type,Source,Amount,Fee,Net,Currency,Created (UTC),Available On (UTC),Description\n\
866 txn_1,charge,ch_1,25.00,-1.03,23.97,eur,2026-10-01 10:00:00,2026-10-08 00:00:00,Support\n\
867 txn_2,payout,po_1,-100.00,0.00,-100.00,eur,2026-10-03 06:00:00,2026-10-03 06:00:00,STRIPE PAYOUT\n";
868 let s = read(stripe).unwrap();
869 assert_eq!(s.movements.iter().map(|m| (m.amount, m.label.as_str(), m.id.as_str())).collect::<Vec<_>>(), vec![(Money(2397), "Support · charge", "txn_1"), (Money(-10000), "STRIPE PAYOUT · payout", "txn_2")]);
870 }
871
872 #[test]
873 fn missed_changed_and_short() {
874 let ledger: Ledger = toml::from_str(
875 "[[budget]]\nid = 'home'\ntitle = 'Home'\nperiod = 'month'\n\
876 [[preset]]\nid = 'rent'\nbudget = 'home'\nlabel = 'Loyer agence'\namount = -780\nevery = 'month'\nday = 1\n\
877 [[preset]]\nid = 'edf'\nbudget = 'home'\nlabel = 'EDF'\namount = -62\nevery = 'month'\nday = 5\n\
878 [[preset]]\nid = 'insurance'\nbudget = 'home'\nlabel = 'Accident insurance'\namount = -15\nevery = 'month'\nday = 10\n\
879 [[preset]]\nid = 'wages'\nbudget = 'home'\nlabel = 'Salaire'\namount = 1200\nevery = 'month'\nday = 28\n\
880 [[reserve]]\nid = 'savings'\ntitle = 'Livret A'\nbalance = 2000\nas_of = 2026-09-30\n",
881 )
882 .unwrap();
883 let mut bank = Bank::default();
884 let movement = |date: &str, amount: i64, label: &str| Movement { account: "a".into(), date: day(date), amount: Money(amount), label: label.into(), id: format!("{date}{amount}") };
885 bank.movements = vec![
886 movement("2026-08-01", -78000, "PRLV LOYER AGENCE"),
887 movement("2026-08-05", -6200, "PRLV EDF"),
888 movement("2026-08-10", -1500, "PRLV ACCIDENT INSURANCE"),
889 movement("2026-08-28", 120000, "VIR SALAIRE"),
890 movement("2026-09-01", -78000, "PRLV LOYER AGENCE"),
891 movement("2026-09-05", -7140, "PRLV EDF"),
892 movement("2026-09-28", 120000, "VIR SALAIRE"),
894 movement("2026-10-01", -78000, "PRLV LOYER AGENCE"),
895 ];
896 bank.accounts = vec![Account { id: "a".into(), title: String::new(), balance: Some((day("2026-10-01"), Money(30000))) }];
897 let watch = watch(&bank, &ledger, day("2026-10-03"));
898 assert!(watch.findings.contains(&Finding::Missed { label: "Accident insurance".into(), amount: Money(-1500), date: day("2026-09-10") }), "{:#?}", watch.findings);
899 assert!(watch.findings.contains(&Finding::Changed { label: "EDF".into(), expected: Money(-6200), actual: Money(-7140), date: day("2026-09-05") }), "{:#?}", watch.findings);
900 assert!(!watch.findings.iter().any(|f| matches!(f, Finding::Short { .. })), "{:#?}", watch.findings);
903 assert_eq!(watch.balance_on(day("2026-10-12")), Some(Money(22300)));
904 assert_eq!(watch.coming.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), vec!["EDF", "Accident insurance"], "the next seven days, the 10th included");
905 let mut poorer = ledger.clone();
907 poorer.presets.retain(|p| p.label != "Salaire");
908 let watch = self::watch(&bank, &poorer, day("2026-10-03"));
909 let short = watch.findings.iter().find(|f| matches!(f, Finding::Short { .. })).expect("short");
910 assert_eq!(short, &Finding::Short { label: "Loyer agence".into(), amount: Money(-78000), date: day("2026-11-01"), short: Money(55700), reserve: Some("Livret A".into()) });
911 }
912
913 #[test]
914 fn odd_exports_read_safely() {
915 assert_eq!((parse_date("Total général"), parse_date("Date d’opération")), (None, None));
917 assert_eq!((parse_amount("99999999999999999"), parse_amount("99999999999999999999")), (None, None));
918 let csv = "Date;Libellé;Montant\n01/10/2026;LOYER;-780,00\nTotal général;;-780,00\n";
919 assert_eq!(read(csv).unwrap().movements.len(), 1);
920 let paypal = "\"Date\",\"Time\",\"TimeZone\",\"Name\",\"Type\",\"Status\",\"Currency\",\"Gross\",\"Fee\",\"Net\",\"Balance\",\"Transaction ID\"\n\
922 \"10/01/2026\",\"10:02:11\",\"PDT\",\"Jane Example\",\"Donation Payment\",\"Completed\",\"USD\",\"12.00\",\"-0.77\",\"11.23\",\"111.23\",\"U1\"\n\
923 \"10/13/2026\",\"09:00:00\",\"PDT\",\"Web Example\",\"Express Checkout Payment\",\"Completed\",\"USD\",\"-76.50\",\"0.00\",\"-76.50\",\"34.73\",\"U2\"\n";
924 let s = read(paypal).unwrap();
925 assert_eq!(s.movements.iter().map(|m| (m.date, m.amount)).collect::<Vec<_>>(), vec![(day("2026-10-01"), Money(1123)), (day("2026-10-13"), Money(-7650))]);
926 assert_eq!(s.balance, Some((day("2026-10-13"), Money(3473))));
927 let ofx = "OFXHEADER:100\n<OFX><STMTTRN>\n<DTPOSTED>20261001\n<TRNAMT>-12.00\n<FITID>B1\n<NAME>M&S\n</STMTTRN></OFX>";
929 assert_eq!(read(ofx).unwrap().movements[0].label, "M&S");
930 let camt = r#"<?xml version="1.0" encoding="UTF-8"?>
932<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.08">
933 <BkToCstmrStmt>
934 <Stmt>
935 <Acct><Id><IBAN>FR7600000000000000000000000</IBAN></Id></Acct>
936 <Bal>
937 <Tp><CdOrPrtry><Cd>CLBD</Cd></CdOrPrtry></Tp>
938 <Amt Ccy="EUR">900.00</Amt>
939 <CdtDbtInd>CRDT</CdtDbtInd>
940 <Dt><Dt>2026-10-01</Dt></Dt>
941 </Bal>
942 <Ntry>
943 <Amt Ccy="EUR">62.00</Amt>
944 <CdtDbtInd>DBIT</CdtDbtInd>
945 <Sts>
946 <Cd>BOOK</Cd>
947 </Sts>
948 <BookgDt><Dt>2026-10-01</Dt></BookgDt>
949 <NtryDtls><TxDtls><RltdPties><Cdtr><Pty><Nm>EDF</Nm></Pty></Cdtr></RltdPties><RmtInf><Ustrd>Facture 42</Ustrd></RmtInf></TxDtls></NtryDtls>
950 </Ntry>
951 </Stmt>
952 <Stmt>
953 <Acct><Id><IBAN>FR7600000000000000000000000</IBAN></Id></Acct>
954 <Bal>
955 <Tp><CdOrPrtry><Cd>CLBD</Cd></CdOrPrtry></Tp>
956 <Amt Ccy="EUR">858.00</Amt>
957 <CdtDbtInd>CRDT</CdtDbtInd>
958 <Dt><Dt>2026-10-02</Dt></Dt>
959 </Bal>
960 <Ntry>
961 <Amt Ccy="EUR">5.00</Amt>
962 <CdtDbtInd>DBIT</CdtDbtInd>
963 <Sts>
964 <Cd>PDNG</Cd>
965 </Sts>
966 <BookgDt><Dt>2026-10-02</Dt></BookgDt>
967 </Ntry>
968 <Ntry>
969 <Amt Ccy="EUR">20.00</Amt>
970 <CdtDbtInd>CRDT</CdtDbtInd>
971 <Sts>
972 <Cd>BOOK</Cd>
973 </Sts>
974 <BookgDt><Dt>2026-10-02</Dt></BookgDt>
975 <AddtlNtryInf>VIR EXEMPLE</AddtlNtryInf>
976 </Ntry>
977 </Stmt>
978 </BkToCstmrStmt>
979</Document>"#;
980 let s = read(camt).unwrap();
981 assert_eq!(s.movements.iter().map(|m| (m.amount, m.label.as_str())).collect::<Vec<_>>(), vec![(Money(-6200), "EDF Facture 42"), (Money(2000), "VIR EXEMPLE")]);
982 assert_eq!(s.balance, Some((day("2026-10-02"), Money(85800))));
983 }
984
985 #[test]
988 #[ignore]
989 fn a_real_export_reads() {
990 let path = std::env::var("SIOUL_TEST_EXPORT").expect("SIOUL_TEST_EXPORT");
991 let bytes = std::fs::read(&path).unwrap();
992 let text = String::from_utf8(bytes.clone()).unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect());
993 let s = read(&text).unwrap();
994 let first = s.movements.iter().map(|m| m.date).min();
995 let last = s.movements.iter().map(|m| m.date).max();
996 let ids = s.movements.iter().filter(|m| !m.id.is_empty()).count();
997 println!("movements: {}, from {first:?} to {last:?}, with their own id: {ids}, balance found: {}", s.movements.len(), s.balance.is_some());
998 assert!(!s.movements.is_empty());
999 }
1000
1001 #[test]
1002 fn imported_once() {
1003 let root = std::env::temp_dir().join(format!("sioul-bank-{}", std::process::id()));
1004 let _ = std::fs::remove_dir_all(&root);
1005 std::fs::create_dir_all(&root).unwrap();
1006 let csv = "Date;Libellé;Montant\n01/10/2026;LOYER;-780,00\n01/10/2026;LOYER;-780,00\n02/10/2026;SALAIRE;1200,00\n";
1007 let statement = read(csv).unwrap();
1008 let mut bank = Bank::load(&root).unwrap();
1009 assert_eq!(bank.import(&statement, "Courant"), Imported { read: 3, new: 3 }, "two same movements on one day are two");
1010 assert_eq!(bank.import(&statement, "Courant"), Imported { read: 3, new: 0 });
1011 bank.save().unwrap();
1012 let again = Bank::load(&root).unwrap();
1013 assert_eq!(again.movements, bank.movements);
1014 assert_eq!(again.accounts, bank.accounts);
1015 let _ = std::fs::remove_dir_all(&root);
1016 }
1017}