Skip to main content

sioul_core/
bank.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The money watch (docs/accounting.md, "The bank"): your bank's own export
5//! (OFX, CAMT.053, or its CSV) read into `sioul-bank.toml`, then held against
6//! the recurring payments of your budgets. Nobody looks when it goes badly (the
7//! ostrich effect, Olafsson & Pagel 2017), and a debit that stops does not say
8//! so: Sioul notices, and says it calmly and early.
9//! - **Missed**: a payment expected (rent, a standing order, wages) that did not
10//!   pass in the days around its date.
11//! - **Changed**: one that passed with another amount.
12//! - **Short**: the balance, carried forward with what is expected, would go
13//!   below zero on a day; how much is missing then, and the reserve that covers it.
14//!
15//! No red balance, no "late" badge: a sentence, and what to do about it.
16
17use 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/// One movement of an account: money in positive, out negative.
26#[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    /// The bank's own id (OFX FITID, CAMT reference), else one made from the rest.
33    pub id: String,
34}
35
36/// What an export says.
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct Statement {
39    pub account: String,
40    pub movements: Vec<Movement>,
41    /// The balance and its day, when the export gives one.
42    pub balance: Option<(Date, Money)>,
43}
44
45/// An amount as banks write it: "-62,00", "1 234,56", "1,234.56", "+780.00 EUR", "(62.00)".
46pub 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    // The last separator followed by one or two digits is the decimal one.
57    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    // A run of digits too long for any amount is no amount.
70    let value = whole.checked_mul(100)?.checked_add(cents)?;
71    Some(Money(if negative { -value } else { value }))
72}
73
74/// A date as banks write it: "20261002", "2026-10-02", "02/10/2026", "02.10.2026", "02/10/26".
75pub fn parse_date(text: &str) -> Option<Date> {
76    parse_date_ordered(text, false)
77}
78
79/// The same, the month first when `month_first` ("10/13/2026", as PayPal's American download writes it).
80fn parse_date_ordered(text: &str, month_first: bool) -> Option<Date> {
81    let t = text.trim();
82    // Bytes, not characters: "Total général" in a date column is no date, and no panic.
83    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
105/// Reads an export, whatever its form. `words`: a CSV's column names, the
106/// statuses of rows left out, the lines above its header (`words::BankWords`).
107pub 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    // Ids made where the bank gives none: the same movement read twice keeps its id.
117    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
133/// OFX, SGML (1.x) or XML (2.x): tags read in order, closing tags or not.
134fn 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        // XML's own entities: "M&amp;S" is M&S.
153        let value = rest.lines().next().unwrap_or("").trim().replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&");
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
186/// The element down `names`, by local names (namespaces aside).
187fn 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
191/// The text of the element down `names`; none when it holds only spaces (the
192/// line break before a child, as in `<Sts>` around its `<Cd>` from camt.053.001.08 on).
193fn 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
197/// An amount with its side: a debit (DBIT) negative.
198fn 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
203/// ISO 20022 camt.053: booked entries, and the closing booked balance. A file
204/// may hold several statements (one a day): every one of the first one's
205/// account is read, and the newest closing balance kept.
206fn 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            // A party's name, under `Pty` from camt.053.001.08 on.
235            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/// A CSV's columns, found by their names.
250#[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    /// PayPal's "Type", Stripe's: said after the label.
258    kind: Option<usize>,
259    /// PayPal's "État": what was refused, cancelled or is still pending is left out.
260    status: Option<usize>,
261    /// PayPal's "Impact sur le solde": a "Mémo" row moves nothing (a hold, a note).
262    impact: Option<usize>,
263    /// The balance after each row (PayPal).
264    balance: Option<usize>,
265    time: Option<usize>,
266    currency: Option<usize>,
267    id: Option<usize>,
268}
269
270/// A bank's CSV, PayPal's and Stripe's too: the header row found by its words
271/// (date, label or name, amount or net, or debit and credit), `;` or `,`,
272/// decimal commas; a balance written above it read too (as some French banks
273/// write it: "Solde (EUROS) ;1 234,56" under "Date ;03/10/2026"), or one on each row
274/// (PayPal). Net amounts, fees deducted, when given; rows in another currency
275/// than most of the file's, and those refused, cancelled or pending, left out.
276fn 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            // A column found by the start of its name; `exact`, by its whole name.
303            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            // Net of fees first (PayPal, Stripe), else the amount.
309            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                // Stripe's Description beside its Type; PayPal's Name.
317                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            // Above the header: an account, a balance, its day.
335            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    // Day or month first, from the dates themselves: PayPal's American download
351    // writes 10/13/2026. A first part above 12 is a day; a second, a month's day.
352    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    // The file's currency: the one most rows are in.
364    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    // The day of a balance written above: the day said, else the newest movement's.
414    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/// An account as kept: its last balance known.
424#[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/// What `sioul-bank.toml` holds.
432#[derive(Debug, Clone, Default)]
433pub struct Bank {
434    pub root: PathBuf,
435    pub accounts: Vec<Account>,
436    pub movements: Vec<Movement>,
437}
438
439/// What an import added.
440#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
441pub struct Imported {
442    pub read: usize,
443    pub new: usize,
444}
445
446impl Bank {
447    /// Read, changed by `change` and written back, all under the file's lock,
448    /// which the sharing takes too: a change is set over the file as it is
449    /// then, never over a copy read earlier. Returns what `change` returns.
450    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    /// A statement taken in: new movements added (by id), the balance kept when newer.
498    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    /// Written whole, under the file's lock, which the sharing takes too.
524    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    /// The balance of every account together, and the newest day among them,
564    /// each account once: the names a card gives one account (its number, its
565    /// IBAN, none: `BankAccount::exports`) are one balance, its newest, never
566    /// added up (review of 5 October 2026).
567    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    /// The balance of every account together, and the newest day among them.
582    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/// What the watch found, each said in a sentence by the window.
590#[derive(Debug, Clone, PartialEq, Eq)]
591pub enum Finding {
592    /// Expected around `date`, not seen.
593    Missed { label: String, amount: Money, date: Date },
594    /// Seen on `date` with another amount.
595    Changed { label: String, expected: Money, actual: Money, date: Date },
596    /// The balance would go below zero on `date`, with `label` leaving then;
597    /// `short` is missing; the reserve that covers it, if one does.
598    Short { label: String, amount: Money, date: Date, short: Money, reserve: Option<String> },
599}
600
601/// One payment expected ahead.
602#[derive(Debug, Clone, PartialEq, Eq)]
603pub struct Expected {
604    pub date: Date,
605    pub label: String,
606    pub amount: Money,
607}
608
609/// The watch: its findings, the balance known, the days ahead.
610#[derive(Debug, Clone, Default, PartialEq, Eq)]
611pub struct Watch {
612    pub balance: Option<(Date, Money)>,
613    pub findings: Vec<Finding>,
614    /// The next seven days' payments.
615    pub coming: Vec<Expected>,
616    /// The balance, day by day, until a month from today.
617    pub forecast: Vec<(Date, Money)>,
618}
619
620impl Watch {
621    /// The balance expected on `date`, when the forecast reaches it.
622    pub fn balance_on(&self, date: Date) -> Option<Money> {
623        self.forecast.iter().rev().find(|(d, _)| *d <= date).map(|(_, m)| *m)
624    }
625}
626
627/// The words of a label that can name a payment: folded, three letters at
628/// least, no number, none of `filler`: the words that name nothing, "the",
629/// "pour", and what every bank line says ("PRLV SEPA", "CARTE", "VIREMENT";
630/// `words::BankWords::filler`). "Choir, the year's fee" is not "CB BAKERY OF THE PORT".
631pub(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
643/// Whether a movement stands for a payment expected on `date`: a word of the
644/// payment's name in the movement's, or the same amount within days.
645pub(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
656/// Holds the movements against the ledger's recurring payments and planned
657/// lines, their labels compared without `filler` (`words::BankWords::filler`).
658pub 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    // Past occurrences inside what the exports cover: seen, changed, or missed.
664    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    // Ahead: from the balance known, what is expected, day by day.
685    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    // Bank accounts with their reserves say what tops them up (`accounts::top_ups`).
694    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
704/// What is expected on an account from `as_of` to `horizon`: its movements
705/// read after the balance, then the recurring payments and planned lines of
706/// `budgets` (every budget's when None) not seen yet, daily spending spread
707/// over its days; in date order.
708pub(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            // Spending without a trace, spread over its period's days.
719            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            // Already passed in an export read after the balance: counted there.
737            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
750/// The balance from `start` on `as_of`, carried day by day with `flows`, as
751/// seen from today to `horizon`; and the first day from today it goes below
752/// `floor` with a payment out: that day, the flow that takes it there, the
753/// balance then.
754pub(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        // The horizon's own flows count too.
772        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    /// The readers with the packs built in (French and English).
784    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        // A bank's layout with its balance written above the header.
845        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        // Debit and credit apart, commas.
852        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        // PayPal, in French: net of fees, the name and the type, the balance on each row; a pending one and dollars left out.
856        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        // Stripe's balance history: created, net, description and type.
865        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            // The insurance did not pass in September.
893            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        // €300 on 1 October; EDF (62) on the 5th, insurance (15) on the 10th, wages on the 28th, then rent (780) on 1 November.
901        // Before the wages: 300 − 62 − 15 = 223; after: 1423; the rent leaves 643: no shortfall this month.
902        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        // Without the wages: short on 1 November, the savings cover it.
906        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        // A footer in the date column, a long digit run: no panic, no movement.
916        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        // PayPal's American download: the month first.
921        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        // OFX's entities.
928        let ofx = "OFXHEADER:100\n<OFX><STMTTRN>\n<DTPOSTED>20261001\n<TRNAMT>-12.00\n<FITID>B1\n<NAME>M&amp;S\n</STMTTRN></OFX>";
929        assert_eq!(read(ofx).unwrap().movements[0].label, "M&S");
930        // camt.053.001.08, written with line breaks, two days in one file, names under `Pty`.
931        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    /// A real export, named by SIOUL_TEST_EXPORT, read: how many movements and
986    /// their days, whether a balance was found; no amount, no label. Run by hand.
987    #[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}