Skip to main content

sioul_core/
accounts.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Bank accounts and budgets (docs/accounting.md, "Bank accounts"). Where
5//! money is (a current account, PayPal, Stripe) and what it is for (the
6//! budgets) are two things: each movement an account's exports bring goes to
7//! a budget, in parts between budgets, with a reserve, or to none (money moved
8//! between your own accounts). By your hand for that movement; else the same
9//! payment as a line the budgets hold already (from mail), counted there once;
10//! else money moved to or from PayPal or Stripe, when their own exports cover
11//! the day; else the first rule that names it (or, without words, every
12//! movement its way); else the recurring payment it stands for; else the
13//! account's first budget. A card payment of daily life
14//! is spent from its budget's estimate (food, clothes), never added on top of
15//! it. Reserves top the accounts up, in your order, each with its own delay.
16
17use crate::bank::{self, Bank, Expected, Movement};
18use crate::budget::{BankAccount, Direction, Ledger, Line, Period};
19use crate::money::Money;
20use jiff::ToSpan;
21use jiff::civil::Date;
22use std::collections::BTreeMap;
23
24/// A movement's share in one budget, and the preset it stands for or is spent from.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Share {
27    pub budget: String,
28    pub amount: Money,
29    pub preset: Option<String>,
30}
31
32/// Where a movement goes.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Place {
35    /// Into budgets, each its share.
36    Budgets(Vec<Share>),
37    /// Money moved with a savings account, through a budget.
38    Reserve { reserve: String, budget: String },
39    /// Moved between your own accounts: in no budget.
40    Transfer(String),
41    /// The same payment as this line of the budget file (from mail, by hand): counted there.
42    Known(usize),
43    /// In none: the account fills no budget, or you said so.
44    Nowhere,
45}
46
47/// Why it went there, for the window to say.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Why {
50    Hand,
51    Mail,
52    Rule,
53    Between,
54    Preset,
55    First,
56    Unknown,
57}
58
59#[derive(Debug, Clone)]
60pub struct Placed {
61    /// The bank account's id.
62    pub account: String,
63    pub movement: Movement,
64    pub place: Place,
65    pub why: Why,
66}
67
68/// The bank account a movement kept under `name` belongs to.
69pub fn account_of<'a>(ledger: &'a Ledger, name: &str) -> Option<&'a BankAccount> {
70    ledger.bank_accounts.iter().find(|a| a.owns(name))
71}
72
73/// A preset's name for lines: its id, else its place in the file (`#3`).
74fn key(ledger: &Ledger, index: usize) -> String {
75    ledger.presets[index].id.clone().unwrap_or_else(|| format!("#{index}"))
76}
77
78fn folded(text: &str) -> String {
79    crate::text::fold(text).into_iter().collect()
80}
81
82
83/// Every movement of the declared bank accounts, placed (see the module's
84/// notes), oldest first. Movements of no declared account are left out: they
85/// only feed the money watch.
86/// `looked`: the words of labels that name nothing, and what PayPal and
87/// Stripe write for money moved to or from a bank (`words::AccountWords`).
88pub fn place(ledger: &Ledger, bank: &Bank, looked: &crate::words::Words) -> Vec<Placed> {
89    let mut movements: Vec<(&BankAccount, &Movement)> = bank.movements.iter().filter_map(|m| account_of(ledger, &m.account).map(|a| (a, m))).collect();
90    movements.sort_by(|a, b| (a.1.date, &a.1.id).cmp(&(b.1.date, &b.1.id)));
91    // A movement read under two names of one account (an export first read
92    // without its number, later with it) is one: counted once.
93    let mut seen: std::collections::BTreeSet<(&str, &str)> = std::collections::BTreeSet::new();
94    movements.retain(|(account, m)| m.id.is_empty() || seen.insert((account.id.as_str(), m.id.as_str())));
95    // What each account's exports cover: a movement elsewhere names it only inside.
96    let mut covered: BTreeMap<&str, (Date, Date)> = BTreeMap::new();
97    for (account, m) in &movements {
98        let span = covered.entry(account.id.as_str()).or_insert((m.date, m.date));
99        span.0 = span.0.min(m.date);
100        span.1 = span.1.max(m.date);
101    }
102    let mut known_taken = vec![false; ledger.lines.len()];
103    let mut preset_taken: Vec<(usize, Date)> = Vec::new();
104    let mut out = Vec::new();
105    for (account, m) in movements {
106        let first = account.fills.first().cloned();
107        let whole = |budget: String, preset: Option<String>| Place::Budgets(vec![Share { budget, amount: m.amount, preset }]);
108        let label = folded(&m.label);
109        // 1. Your hand.
110        if let Some(a) = ledger.assignments.iter().find(|a| account.owns(&a.account) && a.movement == m.id) {
111            let place = if a.none {
112                Place::Nowhere
113            } else if a.parts.is_empty() {
114                a.budget.clone().or(first.clone()).map_or(Place::Nowhere, |b| whole(b, None))
115            } else {
116                let mut shares: Vec<Share> = a.parts.iter().map(|p| Share { budget: p.budget.clone(), amount: p.amount, preset: None }).collect();
117                let rest = m.amount - shares.iter().map(|s| s.amount).sum::<Money>();
118                if rest != Money::ZERO
119                    && let Some(budget) = a.budget.clone().or(first.clone())
120                {
121                    shares.push(Share { budget, amount: rest, preset: None });
122                }
123                Place::Budgets(shares)
124            };
125            out.push(Placed { account: account.id.clone(), movement: m.clone(), place, why: Why::Hand });
126            continue;
127        }
128        // 2. A line the budgets hold already: read from mail on the day, the bank a few days after.
129        let from = m.date.checked_sub(7.days()).unwrap_or(m.date);
130        let to = m.date.checked_add(3.days()).unwrap_or(m.date);
131        let known = ledger.lines.iter().enumerate().position(|(i, l)| {
132            !known_taken[i] && !l.planned && l.reserve.is_none() && l.amount == m.amount && l.date >= from && l.date <= to && (account.fills.is_empty() || account.fills.contains(&l.budget))
133        });
134        if let Some(i) = known {
135            known_taken[i] = true;
136            out.push(Placed { account: account.id.clone(), movement: m.clone(), place: Place::Known(i), why: Why::Mail });
137            continue;
138        }
139        // 3. Money moved with PayPal or Stripe, when their own exports cover the day:
140        // both sides read, it counts in no budget, whatever a rule says of it.
141        let partner = ledger.bank_accounts.iter().filter(|other| other.id != account.id).find(|other| {
142            let named = match other.kind() {
143                "paypal" => label.contains("paypal"),
144                "stripe" => label.contains("stripe"),
145                _ => matches!(account.kind(), "paypal" | "stripe") && looked.accounts.between.iter().map(|w| crate::words::folded(w)).any(|w| !w.is_empty() && label.contains(&w)),
146            };
147            named && covered.get(other.id.as_str()).is_some_and(|(a, b)| m.date >= a.checked_sub(10.days()).unwrap_or(*a) && m.date <= b.checked_add(10.days()).unwrap_or(*b))
148        });
149        if let Some(other) = partner {
150            out.push(Placed { account: account.id.clone(), movement: m.clone(), place: Place::Transfer(other.id.clone()), why: Why::Between });
151            continue;
152        }
153        // 4. The first rule that names it; a rule without words takes every movement its way.
154        let rule = ledger.splits.iter().find(|s| {
155            (s.account.is_empty() || account.owns(&s.account))
156                && s.direction.is_none_or(|d| (d == Direction::Credit) == !m.amount.is_negative())
157                && ((s.words.iter().all(|w| w.trim().is_empty()) && s.direction.is_some()) || s.words.iter().any(|w| !w.trim().is_empty() && label.contains(&folded(w.trim()))))
158        });
159        if let Some(rule) = rule {
160            let preset = rule.preset.as_deref().and_then(|id| ledger.presets.iter().position(|p| p.id.as_deref() == Some(id)));
161            let place = if let Some(other) = &rule.transfer {
162                Place::Transfer(other.clone())
163            } else if let Some(reserve) = &rule.reserve {
164                rule.budget.clone().or(first.clone()).map_or(Place::Nowhere, |budget| Place::Reserve { reserve: reserve.clone(), budget })
165            } else {
166                let budget = rule.budget.clone().or_else(|| preset.map(|i| ledger.presets[i].budget.clone())).or(first.clone());
167                budget.map_or(Place::Nowhere, |b| whole(b, preset.map(|i| key(ledger, i))))
168            };
169            out.push(Placed { account: account.id.clone(), movement: m.clone(), place, why: Why::Rule });
170            continue;
171        }
172        // 5. The recurring payment it stands for, in a budget it fills.
173        let preset = ledger.presets.iter().enumerate().filter(|(_, p)| !p.estimate && (account.fills.is_empty() || account.fills.contains(&p.budget))).find_map(|(i, p)| {
174            let words = bank::words(&looked.bank.filler, &p.label);
175            let around = (m.date.checked_sub(10.days()).ok()?, m.date.checked_add(5.days()).ok()?);
176            p.occurrences(around.0, around.1).into_iter().find(|d| !preset_taken.contains(&(i, *d)) && bank::matches(&looked.bank.filler, m, &words, p.amount, *d)).map(|d| (i, d))
177        });
178        if let Some((i, date)) = preset {
179            preset_taken.push((i, date));
180            out.push(Placed { account: account.id.clone(), movement: m.clone(), place: whole(ledger.presets[i].budget.clone(), Some(key(ledger, i))), why: Why::Preset });
181            continue;
182        }
183        // 6. The account's first budget.
184        let (place, why) = match first {
185            Some(budget) => (whole(budget, None), Why::First),
186            None => (Place::Nowhere, Why::Unknown),
187        };
188        out.push(Placed { account: account.id.clone(), movement: m.clone(), place, why });
189    }
190    spend_from_estimates(ledger, &mut out);
191    out
192}
193
194/// Payments out that stand for nothing are spent from their budget's
195/// estimates (food, clothes), the one with the most left in its period first;
196/// past them, they count on top.
197fn spend_from_estimates(ledger: &Ledger, placed: &mut [Placed]) {
198    // What each estimate's envelope holds per period, less what lines of the file spend from it.
199    let mut left: BTreeMap<(usize, Date), i64> = BTreeMap::new();
200    let room = |left: &mut BTreeMap<(usize, Date), i64>, i: usize, date: Date| -> Option<(Date, i64)> {
201        let p = &ledger.presets[i];
202        let (start, end) = p.every.bounds(date);
203        if p.occurrences(start, end).is_empty() {
204            return None;
205        }
206        let entry = left.entry((i, start)).or_insert_with(|| {
207            let spent: i64 = ledger.lines.iter().filter(|l| l.preset.is_some() && l.preset == p.id && l.date >= start && l.date <= end).map(|l| l.amount.cents()).sum();
208            (-p.amount.cents() + spent).max(0)
209        });
210        Some((start, *entry))
211    };
212    for item in placed.iter_mut() {
213        let Place::Budgets(shares) = &mut item.place else { continue };
214        for share in shares.iter_mut().filter(|s| s.preset.is_none() && s.amount.is_negative()) {
215            let date = item.movement.date;
216            let best = (0..ledger.presets.len())
217                .filter(|&i| ledger.presets[i].estimate && ledger.presets[i].budget == share.budget && ledger.presets[i].amount.is_negative())
218                .filter_map(|i| room(&mut left, i, date).map(|(start, cents)| (i, start, cents)))
219                .filter(|(_, _, cents)| *cents > 0)
220                .max_by_key(|(_, _, cents)| *cents);
221            if let Some((i, start, _)) = best {
222                share.preset = Some(key(ledger, i));
223                if let Some(cents) = left.get_mut(&(i, start)) {
224                    *cents -= share.amount.cents().abs();
225                }
226            }
227        }
228    }
229}
230
231impl Ledger {
232    /// The ledger with its bank accounts' movements as lines, done on their
233    /// day, for the budgets to count (`budget::Ledger::status`): the presets
234    /// each stands for are named (`#3` for one without an id). Kept in memory
235    /// only; the budget file is not written.
236    pub fn with_bank(&self, bank: &Bank, looked: &crate::words::Words) -> Ledger {
237        let mut out = self.clone();
238        if self.bank_accounts.is_empty() {
239            return out;
240        }
241        for (i, preset) in out.presets.iter_mut().enumerate() {
242            if preset.id.is_none() {
243                preset.id = Some(format!("#{i}"));
244            }
245        }
246        for item in place(self, bank, looked) {
247            let link = format!("bank:{}/{}", item.account, item.movement.id);
248            let line = |budget: String, amount: Money, preset: Option<String>, reserve: Option<String>| Line {
249                budget,
250                date: item.movement.date,
251                amount,
252                label: item.movement.label.clone(),
253                planned: false,
254                reserve,
255                links: vec![link.clone()],
256                preset,
257            };
258            match item.place {
259                Place::Budgets(shares) => out.lines.extend(shares.into_iter().map(|s| line(s.budget, s.amount, s.preset, None))),
260                Place::Reserve { reserve, budget } => out.lines.push(line(budget, item.movement.amount, None, Some(reserve))),
261                Place::Transfer(_) | Place::Known(_) | Place::Nowhere => {}
262            }
263        }
264        out
265    }
266}
267
268/// Money a reserve should send to a bank account before it runs under its floor.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct TopUp {
271    pub account: String,
272    pub reserve: String,
273    pub amount: Money,
274    /// The day the account would go under its floor, and the payment then.
275    pub needed_by: Date,
276    pub payment: String,
277    /// The last day to ask, the reserve's delay before `needed_by`; today when already past.
278    pub ask_by: Date,
279    /// Asked today, it still arrives after `needed_by`.
280    pub late: bool,
281}
282
283/// A bank account's balance known, and its day: the newest of its exports',
284/// whatever name each gave it (`BankAccount::exports`: its number, its IBAN,
285/// none). Each name is the same account, so their balances are one balance
286/// at different days, never added: an export first read without a number,
287/// later with one, counted the same money twice (review of 5 October 2026).
288/// Two real accounts are two cards.
289pub fn balance_of(bank: &Bank, account: &BankAccount) -> Option<(Date, Money)> {
290    bank.accounts.iter().filter(|a| account.owns(&a.id)).filter_map(|a| a.balance).max_by_key(|(day, _)| *day)
291}
292
293/// Whether a card's names cannot all be one account: two of them with
294/// balances on the same day that differ. One card then covers two real
295/// accounts: the newest balance alone is shown (`balance_of`), and movements
296/// the banks gave equal ids are read as one (review of 10 October 2026, R11).
297/// Said calmly on the card, for you to give each account a card of its own.
298pub fn two_accounts_in_one(bank: &Bank, account: &BankAccount) -> bool {
299    let balances: Vec<(Date, Money)> = bank.accounts.iter().filter(|a| account.owns(&a.id)).filter_map(|a| a.balance).collect();
300    balances.iter().enumerate().any(|(at, (day, money))| balances[at + 1..].iter().any(|(other_day, other)| other_day == day && other != money))
301}
302
303/// A bank account's movements under every name its exports gave it, each
304/// once: the same movement read under two names (the bank's own id) is one.
305pub fn movements_of<'a>(bank: &'a Bank, account: &BankAccount) -> Vec<&'a Movement> {
306    let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
307    bank.movements.iter().filter(|m| account.owns(&m.account)).filter(|m| m.id.is_empty() || seen.insert(m.id.as_str())).collect()
308}
309
310/// The budgets whose payments a bank account carries forward: those it is the
311/// first account to fill (a budget filled by two counts on the first).
312pub fn carried_by<'a>(ledger: &'a Ledger, account: &BankAccount) -> Vec<String> {
313    account.fills.iter().filter(|b| ledger.bank_accounts.iter().find(|a| a.fills.contains(b)).is_some_and(|a| a.id == account.id)).cloned().collect()
314}
315
316/// What tops each bank account up in the coming month: its balance carried
317/// forward with its own movements and its budgets' recurring payments and
318/// planned lines; on the first day it would go under its floor, what is
319/// missing (rounded up to ten), taken from its reserves in order, each as far
320/// as it holds above its own floor, asked its delay ahead.
321pub fn top_ups(ledger: &Ledger, bank: &Bank, today: Date, looked: &crate::words::Words) -> Vec<TopUp> {
322    let horizon = today.checked_add(31.days()).unwrap_or(today);
323    // The reserves' balances count the money already moved, read from the exports.
324    let ledger = &ledger.with_bank(bank, looked);
325    let mut out = Vec::new();
326    for account in ledger.bank_accounts.iter().filter(|a| !a.topped_up_by.is_empty()) {
327        let Some((as_of, start)) = balance_of(bank, account) else { continue };
328        let movements: Vec<&Movement> = movements_of(bank, account);
329        let budgets = carried_by(ledger, account);
330        let flows: Vec<Expected> = bank::expected(&movements, ledger, Some(&budgets), as_of, today, horizon, &looked.bank.filler);
331        let (_, below) = bank::carry(start, as_of, &flows, today, horizon, account.floor);
332        let Some((day, at, balance)) = below else { continue };
333        let missing = account.floor.cents() - balance.cents();
334        let mut missing = (missing + 999) / 1000 * 1000;
335        for id in &account.topped_up_by {
336            let Some(reserve) = ledger.reserves.iter().find(|r| &r.id == id) else { continue };
337            let room = (ledger.reserve_status(reserve, today).balance_now - reserve.floor).cents();
338            if room <= 0 || missing <= 0 {
339                continue;
340            }
341            let amount = missing.min(room);
342            missing -= amount;
343            let delay = i64::from(reserve.delay_days);
344            let ask_by = day.checked_sub(delay.days()).unwrap_or(day).max(today);
345            let late = today.checked_add(delay.days()).unwrap_or(today) > day;
346            out.push(TopUp { account: account.id.clone(), reserve: reserve.id.clone(), amount: Money(amount), needed_by: day, payment: flows[at].label.clone(), ask_by, late });
347        }
348    }
349    out
350}
351
352/// The period a movement's month is in, for the window: "October 2026" groups.
353pub fn month_of(date: Date) -> Date {
354    Period::Month.bounds(date).0
355}
356
357impl Ledger {
358    /// The budget file at the root of a notes folder, with its bank accounts'
359    /// movements counted (`with_bank`) when it declares some.
360    pub fn load_with_bank(root: &std::path::Path, looked: &crate::words::Words) -> Result<Ledger, String> {
361        let ledger = Ledger::load(root)?;
362        if ledger.bank_accounts.is_empty() {
363            return Ok(ledger);
364        }
365        Ok(match Bank::load(root) {
366            Ok(bank) => ledger.with_bank(&bank, looked),
367            Err(_) => ledger,
368        })
369    }
370}
371
372// ---- Written into the budget file, its comments and other fields kept.
373
374use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, value};
375
376/// The budget file, empty when there is none yet; one that cannot be read
377/// is an error, never an empty file written over it.
378fn open(path: &std::path::Path) -> Result<DocumentMut, String> {
379    let text = match std::fs::read_to_string(path) {
380        Ok(text) => text,
381        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
382        Err(e) => return Err(format!("{}: {e}", path.display())),
383    };
384    text.parse().map_err(|e: toml_edit::TomlError| format!("{}: {e}", path.display()))
385}
386
387fn write(path: &std::path::Path, doc: &DocumentMut) -> Result<(), String> {
388    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
389    let temporary = path.with_extension("toml.new");
390    std::fs::write(&temporary, doc.to_string()).map_err(fail)?;
391    std::fs::rename(&temporary, path).map_err(fail)
392}
393
394fn tables<'a>(doc: &'a mut DocumentMut, name: &str) -> Result<&'a mut ArrayOfTables, String> {
395    doc.entry(name).or_insert(Item::ArrayOfTables(ArrayOfTables::new())).as_array_of_tables_mut().ok_or_else(|| format!("`{name}` is not a list of tables"))
396}
397
398fn words_array(list: &[String]) -> Array {
399    list.iter().map(|w| w.trim()).filter(|w| !w.is_empty()).collect()
400}
401
402fn money_value(m: f64) -> Item {
403    value((m * 100.0).round() / 100.0)
404}
405
406fn date_value(d: Date) -> Item {
407    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 })
408}
409
410/// A bank account as the window gives it.
411#[derive(Debug, Clone, Default, serde::Deserialize)]
412pub struct BankAccountEdit {
413    pub title: String,
414    #[serde(default)]
415    pub kind: String,
416    /// The budgets it fills, the one taking what nothing else places first.
417    #[serde(default)]
418    pub fills: Vec<String>,
419    #[serde(default)]
420    pub floor: f64,
421    /// The reserves that top it up, the first first.
422    #[serde(default)]
423    pub topped_up_by: Vec<String>,
424}
425
426/// Makes a bank account (`id` empty) or changes one; returns its id.
427pub fn save_bank_account(path: &std::path::Path, id: &str, edit: &BankAccountEdit) -> Result<String, String> {
428    // Read, changed and written under the file's lock, which the sharing takes too.
429    crate::filelock::with_lock(path, || {
430        let title = edit.title.trim();
431        if title.is_empty() {
432            return Err(format!("{}: a bank account needs a name", path.display()));
433        }
434        let mut doc = open(path)?;
435        let accounts = tables(&mut doc, "bank_account")?;
436        let taken: Vec<String> = accounts.iter().filter_map(|t| t.get("id").and_then(Item::as_str).map(str::to_string)).collect();
437        let id = if id.is_empty() { crate::projects::new_id(title, &taken) } else { id.to_string() };
438        if !taken.contains(&id) {
439            let mut table = Table::new();
440            table["id"] = value(id.as_str());
441            accounts.push(table);
442        }
443        let table = accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id.as_str())).ok_or_else(|| format!("no bank account {id}"))?;
444        table["title"] = value(title);
445        match edit.kind.as_str() {
446            "paypal" | "stripe" | "other" => table["kind"] = value(edit.kind.as_str()),
447            _ => {
448                table.remove("kind");
449            }
450        }
451        table["fills"] = value(words_array(&edit.fills));
452        if edit.floor > 0.0 {
453            table["floor"] = money_value(edit.floor);
454        } else {
455            table.remove("floor");
456        }
457        table["topped_up_by"] = value(words_array(&edit.topped_up_by));
458        write(path, &doc)?;
459        Ok(id)
460    })
461}
462
463/// Takes a bank account out; its movements stay in `sioul-bank.toml`, its rules and choices here.
464pub fn remove_bank_account(path: &std::path::Path, id: &str) -> Result<(), String> {
465    // Read, changed and written under the file's lock, which the sharing takes too.
466    crate::filelock::with_lock(path, || {
467        let mut doc = open(path)?;
468        let accounts = tables(&mut doc, "bank_account")?;
469        let before = accounts.len();
470        accounts.retain(|t| t.get("id").and_then(Item::as_str) != Some(id));
471        if accounts.len() == before {
472            return Err(format!("no bank account {id}"));
473        }
474        write(path, &doc)
475    })
476}
477
478/// Another name the exports give a bank account (an account number), kept so
479/// that its movements read under it are its own.
480pub fn add_export_name(path: &std::path::Path, id: &str, name: &str) -> Result<(), String> {
481    // Read, changed and written under the file's lock, which the sharing takes too.
482    crate::filelock::with_lock(path, || {
483        let mut doc = open(path)?;
484        let accounts = tables(&mut doc, "bank_account")?;
485        let table = accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)).ok_or_else(|| format!("no bank account {id}"))?;
486        let mut names: Vec<String> = table.get("exports").and_then(Item::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
487        if names.iter().any(|n| n == name) || name == id {
488            return Ok(());
489        }
490        names.push(name.to_string());
491        table["exports"] = value(words_array(&names));
492        write(path, &doc)
493    })
494}
495
496/// Where one movement goes, by your hand: `budget:<id>`, `none` (in no budget),
497/// or `""` (as the rules say again).
498pub fn set_assignment(path: &std::path::Path, account: &str, movement: &str, choice: &str) -> Result<(), String> {
499    // Read, changed and written under the file's lock, which the sharing takes too.
500    crate::filelock::with_lock(path, || {
501        let mut doc = open(path)?;
502        let all = tables(&mut doc, "assign")?;
503        all.retain(|t| !(t.get("account").and_then(Item::as_str) == Some(account) && t.get("movement").and_then(Item::as_str) == Some(movement)));
504        if !choice.is_empty() {
505            let mut table = Table::new();
506            table["account"] = value(account);
507            table["movement"] = value(movement);
508            match choice.strip_prefix("budget:") {
509                Some(budget) => table["budget"] = value(budget),
510                None if choice == "none" => table["none"] = value(true),
511                None => return Err(format!("{choice}: a budget or none")),
512            }
513            all.push(table);
514        }
515        write(path, &doc)
516    })
517}
518
519/// A rule as the window gives it: words, which way, and where to: `budget:<id>`,
520/// `reserve:<id>`, `transfer:<bank account>`.
521#[derive(Debug, Clone, Default, serde::Deserialize)]
522pub struct SplitEdit {
523    #[serde(default)]
524    pub account: String,
525    #[serde(default)]
526    pub words: Vec<String>,
527    /// "credit", "debit", or either.
528    #[serde(default)]
529    pub direction: String,
530    pub to: String,
531}
532
533/// A rule as the window showed it: its id (empty for one an older Sioul
534/// made), its place then, and what it held then.
535#[derive(Debug, Clone, Default, serde::Deserialize)]
536pub struct SplitShown {
537    #[serde(default)]
538    pub id: String,
539    #[serde(default)]
540    pub place: usize,
541    #[serde(default)]
542    pub words: Vec<String>,
543    /// "credit", "debit", "".
544    #[serde(default)]
545    pub direction: String,
546    /// `budget:<id>`, `reserve:<id>`, `transfer:<id>`, `preset:<id>`.
547    #[serde(default)]
548    pub to: String,
549}
550
551/// Where the rule the window showed is among the rules now: by its id; one
552/// without (an older Sioul's) at its place, only while it still holds what
553/// was shown (another device's change may have moved the rules since).
554fn split_at(all: &toml_edit::ArrayOfTables, shown: &SplitShown) -> Option<usize> {
555    let text = |t: &Table, key: &str| t.get(key).and_then(toml_edit::Item::as_str).unwrap_or_default().to_string();
556    if !shown.id.is_empty() {
557        return all.iter().position(|t| text(t, "id") == shown.id);
558    }
559    let table = all.get(shown.place)?;
560    let words: Vec<String> = table.get("words").and_then(toml_edit::Item::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default();
561    let to = ["budget", "reserve", "transfer", "preset"].iter().find_map(|key| table.get(key).and_then(toml_edit::Item::as_str).map(|v| format!("{key}:{v}"))).unwrap_or_default();
562    let holds = text(table, "id").is_empty() && words == shown.words && text(table, "direction") == shown.direction && to == shown.to;
563    holds.then_some(shown.place)
564}
565
566/// Makes a rule (`shown` None, at the end) or changes the one the window
567/// showed (`split_at`): read, changed and written under the file's lock.
568pub fn save_split(path: &std::path::Path, shown: Option<&SplitShown>, edit: &SplitEdit) -> Result<(), String> {
569    // Read, changed and written under the file's lock, which the sharing takes too.
570    crate::filelock::with_lock(path, || {
571        let words: Vec<String> = edit.words.iter().map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect();
572        // Without words, a rule takes every movement one way: it needs that way.
573        if words.is_empty() && !matches!(edit.direction.as_str(), "credit" | "debit") {
574            return Err(format!("{}: a rule needs a word, or a way (in or out)", path.display()));
575        }
576        let mut table = Table::new();
577        if !edit.account.is_empty() {
578            table["account"] = value(edit.account.as_str());
579        }
580        table["words"] = value(words_array(&words));
581        if matches!(edit.direction.as_str(), "credit" | "debit") {
582            table["direction"] = value(edit.direction.as_str());
583        }
584        let (key, target) = edit.to.split_once(':').ok_or_else(|| format!("{}: where to?", edit.to))?;
585        match key {
586            "budget" | "reserve" | "transfer" | "preset" => table[key] = value(target),
587            _ => return Err(format!("{}: where to?", edit.to)),
588        }
589        let mut doc = open(path)?;
590        let all = tables(&mut doc, "split")?;
591        let place = match shown {
592            Some(shown) => Some(split_at(all, shown).ok_or("no such rule")?),
593            None => None,
594        };
595        // Its id (`ids`), by which your devices merge it: kept when the rule is
596        // changed, given when it is made (or changed, made by an older Sioul).
597        let id = place.and_then(|at| all.get(at)).and_then(|old| old.get("id").and_then(toml_edit::Item::as_str).map(str::to_string)).unwrap_or_else(crate::ids::new);
598        let mut table = table;
599        table.insert("id", value(id));
600        match place {
601            Some(at) if at < all.len() => *all.get_mut(at).ok_or("no such rule")? = table,
602            Some(_) => return Err("no such rule".into()),
603            None => all.push(table),
604        }
605        write(path, &doc)
606    })
607}
608
609/// Takes out the rule the window showed (`split_at`), under the file's lock.
610pub fn remove_split(path: &std::path::Path, shown: &SplitShown) -> Result<(), String> {
611    // Read, changed and written under the file's lock, which the sharing takes too.
612    crate::filelock::with_lock(path, || {
613        let mut doc = open(path)?;
614        let all = tables(&mut doc, "split")?;
615        let place = split_at(all, shown).ok_or("no such rule")?;
616        all.remove(place);
617        write(path, &doc)
618    })
619}
620
621/// A reserve as the window gives it.
622#[derive(Debug, Clone, Default, serde::Deserialize)]
623pub struct ReserveEdit {
624    pub title: String,
625    pub balance: f64,
626    /// "2026-10-01"; today when empty.
627    #[serde(default)]
628    pub as_of: String,
629    #[serde(default)]
630    pub floor: f64,
631    #[serde(default)]
632    pub delay_days: u32,
633}
634
635/// Makes a reserve (`id` empty) or changes one; returns its id.
636pub fn save_reserve(path: &std::path::Path, id: &str, edit: &ReserveEdit, today: Date) -> Result<String, String> {
637    // Read, changed and written under the file's lock, which the sharing takes too.
638    crate::filelock::with_lock(path, || {
639        let title = edit.title.trim();
640        if title.is_empty() {
641            return Err(format!("{}: a reserve needs a name", path.display()));
642        }
643        let as_of = if edit.as_of.trim().is_empty() { today } else { edit.as_of.trim().parse::<Date>().map_err(|e| format!("{}: {e}", edit.as_of))? };
644        let mut doc = open(path)?;
645        let reserves = tables(&mut doc, "reserve")?;
646        let taken: Vec<String> = reserves.iter().filter_map(|t| t.get("id").and_then(Item::as_str).map(str::to_string)).collect();
647        let id = if id.is_empty() { crate::projects::new_id(title, &taken) } else { id.to_string() };
648        if !taken.contains(&id) {
649            let mut table = Table::new();
650            table["id"] = value(id.as_str());
651            reserves.push(table);
652        }
653        let table = reserves.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id.as_str())).ok_or_else(|| format!("no reserve {id}"))?;
654        table["title"] = value(title);
655        table["balance"] = money_value(edit.balance);
656        table["as_of"] = date_value(as_of);
657        if edit.floor > 0.0 {
658            table["floor"] = money_value(edit.floor);
659        } else {
660            table.remove("floor");
661        }
662        if edit.delay_days > 0 {
663            table["delay_days"] = value(i64::from(edit.delay_days));
664        } else {
665            table.remove("delay_days");
666        }
667        write(path, &doc)?;
668        Ok(id)
669    })
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use crate::bank::Account;
676
677    fn day(text: &str) -> Date {
678        text.parse().unwrap()
679    }
680
681    fn ledger() -> Ledger {
682        toml::from_str(
683            "[[budget]]\nid = 'duties'\ntitle = 'Duties'\nperiod = 'month'\nsince = 2026-09-01\n\
684             [[budget]]\nid = 'leisure'\ntitle = 'Leisure'\nperiod = 'month'\nsince = 2026-09-01\n\
685             [[budget]]\nid = 'work'\ntitle = 'Work'\nperiod = 'month'\nsince = 2026-09-01\n\
686             [[preset]]\nbudget = 'duties'\nlabel = 'Loyer SCI Exemple'\namount = -250\nevery = 'month'\nday = 1\n\
687             [[preset]]\nbudget = 'duties'\nlabel = 'Food'\namount = -300\nevery = 'month'\nday = 15\nestimate = true\n\
688             [[line]]\nbudget = 'leisure'\ndate = 2026-10-01\namount = -23.98\nlabel = 'SHOP'\nlinks = ['mid:x']\n\
689             [[reserve]]\nid = 'livret-a'\ntitle = 'Livret A'\nbalance = 300\nas_of = 2026-10-01\n\
690             [[reserve]]\nid = 'av'\ntitle = 'Assurance vie'\nbalance = 50000\nas_of = 2026-10-01\ndelay_days = 10\n\
691             [[bank_account]]\nid = 'mybank'\ntitle = 'My bank'\nfills = ['duties', 'leisure']\nexports = ['0123456A']\nfloor = 100\ntopped_up_by = ['livret-a', 'av']\n\
692             [[bank_account]]\nid = 'paypal'\ntitle = 'PayPal'\nkind = 'paypal'\nfills = ['work', 'leisure']\n\
693             [[split]]\naccount = 'mybank'\nwords = ['cinema']\nbudget = 'leisure'\n\
694             [[split]]\nwords = ['livret a']\nreserve = 'livret-a'\n\
695             [[split]]\naccount = 'paypal'\ndirection = 'debit'\nbudget = 'leisure'\n\
696             [[split]]\naccount = 'mybank'\nwords = ['paypal']\nbudget = 'work'\n\
697             [[assign]]\naccount = 'mybank'\nmovement = 'm5'\nparts = [{ budget = 'leisure', amount = -20 }]\n",
698        )
699        .unwrap()
700    }
701
702    fn bank() -> Bank {
703        let m = |account: &str, id: &str, date: &str, cents: i64, label: &str| Movement { account: account.into(), date: day(date), amount: Money(cents), label: label.into(), id: id.into() };
704        Bank {
705            root: std::path::PathBuf::new(),
706            accounts: vec![Account { id: "0123456A".into(), title: String::new(), balance: Some((day("2026-10-02"), Money(40000))) }],
707            movements: vec![
708                m("0123456A", "m1", "2026-10-01", -25000, "PRLV SCI EXEMPLE"),
709                m("0123456A", "m2", "2026-10-02", -1200, "CB CINEMA LE PALACE"),
710                m("0123456A", "m3", "2026-10-03", -2398, "CB SHOP"),
711                m("0123456A", "m4", "2026-10-03", -4500, "CB CARREFOUR"),
712                m("0123456A", "m5", "2026-10-04", -6000, "CB FNAC"),
713                m("0123456A", "m6", "2026-10-05", 50000, "VIR LIVRET A"),
714                m("0123456A", "m7", "2026-10-06", -3000, "PAYPAL EUROPE"),
715                m("paypal", "p1", "2026-10-06", 3000, "Approvisionnement · Virement bancaire"),
716                m("paypal", "p2", "2026-10-06", -3000, "Jean Exemple · Paiement envoyé"),
717                m("elsewhere", "x1", "2026-10-06", -999, "NOT DECLARED"),
718            ],
719        }
720    }
721
722    #[test]
723    fn each_movement_finds_its_budget() {
724        let placed = place(&ledger(), &bank(), crate::words::Words::builtin_ref());
725        let of = |id: &str| placed.iter().find(|p| p.movement.id == id).map(|p| (p.place.clone(), p.why)).unwrap();
726        // The rent stands for its preset (#0); the cinema by a rule; the SHOP line from mail counts once.
727        assert_eq!(of("m1"), (Place::Budgets(vec![Share { budget: "duties".into(), amount: Money(-25000), preset: Some("#0".into()) }]), Why::Preset));
728        assert_eq!(of("m2").1, Why::Rule);
729        assert_eq!(of("m3"), (Place::Known(0), Why::Mail));
730        // A card payment with nothing to say: spent from the food envelope, never on top of it.
731        assert_eq!(of("m4"), (Place::Budgets(vec![Share { budget: "duties".into(), amount: Money(-4500), preset: Some("#1".into()) }]), Why::First));
732        // In parts by your hand: 20 for leisure, the rest to the first budget (and its envelope).
733        let Place::Budgets(parts) = of("m5").0 else { panic!() };
734        assert_eq!(parts.iter().map(|s| (s.budget.as_str(), s.amount)).collect::<Vec<_>>(), vec![("leisure", Money(-2000)), ("duties", Money(-4000))]);
735        // Money from the Livret A; money moved to PayPal, both ends.
736        assert_eq!(of("m6").0, Place::Reserve { reserve: "livret-a".into(), budget: "duties".into() });
737        assert_eq!(of("m7"), (Place::Transfer("paypal".into()), Why::Between));
738        assert_eq!(of("p1"), (Place::Transfer("mybank".into()), Why::Between));
739        // Every payment out of PayPal: leisure, by a rule without words; the bank's "PAYPAL"
740        // went to PayPal anyway, both sides read, whatever the rule on that word says.
741        assert_eq!(of("p2"), (Place::Budgets(vec![Share { budget: "leisure".into(), amount: Money(-3000), preset: None }]), Why::Rule));
742        assert!(!placed.iter().any(|p| p.movement.id == "x1"), "an account not declared feeds only the watch");
743    }
744
745    #[test]
746    fn budgets_count_the_movements_once() {
747        let ledger = ledger();
748        let with = ledger.with_bank(&bank(), crate::words::Words::builtin_ref());
749        let duties = with.budgets.iter().find(|b| b.id == "duties").unwrap();
750        let status = with.status(duties, day("2026-10-10"));
751        // Rent 250 (the real one, standing for the preset), food 300 (an envelope: 45 + 40
752        // spent from it, the rest to come), the FNAC's 40 and the Livret A's 500 in.
753        assert_eq!(status.so_far, Money(-25000) + Money(-4500) + Money(-4000) + Money(50000), "{status:?}");
754        assert_eq!(status.to_come, Money(-30000 + 4500 + 4000));
755        let leisure = with.budgets.iter().find(|b| b.id == "leisure").unwrap();
756        // The cinema, the FNAC's 20, the SHOP line once (from mail), and the payment out of PayPal.
757        assert_eq!(with.status(leisure, day("2026-10-10")).so_far, Money(-1200 - 2000 - 2398 - 3000));
758        // Nothing declared: the budgets are as the file says.
759        let mut bare = ledger.clone();
760        bare.bank_accounts.clear();
761        assert_eq!(bare.with_bank(&bank(), crate::words::Words::builtin_ref()).lines.len(), bare.lines.len());
762    }
763
764    #[test]
765    fn written_in_the_file() {
766        let path = std::env::temp_dir().join(format!("sioul-accounts-{}.toml", std::process::id()));
767        std::fs::write(&path, "# My budgets.\n[[budget]]\nid = 'duties'\ntitle = 'Duties'\nperiod = 'month'\n").unwrap();
768        let edit = BankAccountEdit { title: "My bank".into(), kind: "bank".into(), fills: vec!["duties".into()], floor: 100.0, topped_up_by: vec!["livret-a".into()] };
769        let id = save_bank_account(&path, "", &edit).unwrap();
770        assert_eq!(id, "my-bank");
771        add_export_name(&path, &id, "0123456A").unwrap();
772        save_split(&path, None, &SplitEdit { account: id.clone(), words: vec!["cinema".into(), " ".into()], direction: "debit".into(), to: "budget:duties".into() }).unwrap();
773        set_assignment(&path, &id, "m1", "none").unwrap();
774        set_assignment(&path, &id, "m2", "budget:duties").unwrap();
775        set_assignment(&path, &id, "m1", "").unwrap();
776        save_reserve(&path, "", &ReserveEdit { title: "Livret A".into(), balance: 1200.5, as_of: String::new(), floor: 0.0, delay_days: 0 }, day("2026-10-04")).unwrap();
777        let text = std::fs::read_to_string(&path).unwrap();
778        assert!(text.starts_with("# My budgets."), "{text}");
779        let ledger: Ledger = toml::from_str(&text).unwrap();
780        let account = &ledger.bank_accounts[0];
781        assert_eq!((account.fills.clone(), account.exports.clone(), account.floor), (vec!["duties".to_string()], vec!["0123456A".to_string()], Money(10000)));
782        assert_eq!((ledger.splits[0].words.clone(), ledger.splits[0].direction, ledger.splits[0].budget.as_deref()), (vec!["cinema".to_string()], Some(Direction::Debit), Some("duties")));
783        assert_eq!(ledger.assignments.iter().map(|a| a.movement.as_str()).collect::<Vec<_>>(), vec!["m2"]);
784        assert_eq!((ledger.reserves[0].id.as_str(), ledger.reserves[0].balance, ledger.reserves[0].as_of), ("livret-a", Money(120050), day("2026-10-04")));
785        // Named by its id: another rule put before it (another device's) moves nothing.
786        let first = ledger.splits[0].id.clone();
787        assert!(!first.is_empty());
788        let text = std::fs::read_to_string(&path).unwrap().replace("[[split]]", "[[split]]\nid = \"other\"\naccount = \"\"\nwords = [\"rent\"]\nbudget = \"duties\"\n\n[[split]]");
789        std::fs::write(&path, text).unwrap();
790        save_split(&path, Some(&SplitShown { id: first.clone(), place: 0, ..SplitShown::default() }), &SplitEdit { account: id.clone(), words: vec!["cinéma".into()], direction: "debit".into(), to: "budget:duties".into() }).unwrap();
791        let ledger: Ledger = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
792        assert_eq!(ledger.splits.iter().map(|s| (s.id.as_str(), s.words.join(","))).collect::<Vec<_>>(), vec![("other", "rent".to_string()), (first.as_str(), "cinéma".to_string())]);
793        // A rule without an id (an older Sioul's), at its place only while it holds what was shown.
794        let gone = SplitShown { place: 0, words: vec!["rent".into()], to: "budget:duties".into(), ..SplitShown::default() };
795        assert!(remove_split(&path, &gone).is_err(), "the rule there has an id: not the one shown");
796        remove_split(&path, &SplitShown { id: "other".into(), ..SplitShown::default() }).unwrap();
797        remove_split(&path, &SplitShown { id: first, ..SplitShown::default() }).unwrap();
798        remove_bank_account(&path, &id).unwrap();
799        let ledger: Ledger = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
800        assert!(ledger.splits.is_empty() && ledger.bank_accounts.is_empty());
801        let _ = std::fs::remove_file(&path);
802    }
803
804    #[test]
805    fn reserves_top_up_in_order_with_their_delay() {
806        let mut ledger = ledger();
807        ledger.reserves[0].balance = Money(100000);
808        // A tax on 25 October that the account will not hold.
809        ledger.lines.push(toml::from_str::<Ledger>("[[line]]\nbudget = 'duties'\ndate = 2026-10-25\namount = -1500\nlabel = 'Tax'\nplanned = true\n").unwrap().lines.remove(0));
810        let ups = top_ups(&ledger, &bank(), day("2026-10-08"), crate::words::Words::builtin_ref());
811        // The Livret A first, as far as it holds (1,000 less the 500 moved out on the 5th), at once;
812        // then the assurance vie for the rest, asked ten days ahead.
813        assert_eq!(ups.iter().map(|u| u.reserve.as_str()).collect::<Vec<_>>(), vec!["livret-a", "av"], "{ups:#?}");
814        assert_eq!((ups[0].amount, ups[0].ask_by, ups[0].late), (Money(50000), day("2026-10-25"), false));
815        assert_eq!((ups[1].ask_by, ups[1].late, ups[1].payment.as_str()), (day("2026-10-15"), false, "Tax"));
816        assert_eq!(ups[1].amount.cents() % 1000, 0, "rounded up to ten");
817        // Asked on the 20th, the assurance vie arrives after the tax.
818        assert!(top_ups(&ledger, &bank(), day("2026-10-20"), crate::words::Words::builtin_ref()).iter().any(|u| u.reserve == "av" && u.late));
819    }
820
821    /// The review of 5 October 2026 (money): one account read under two names
822    /// (an export first read without its number, kept under its card's id;
823    /// later ones with it) has one balance, the newest, never the sum, and a
824    /// movement read under both is counted once, in the budgets and the top-ups.
825    #[test]
826    fn one_account_under_two_names_counts_its_money_once() {
827        let ledger = ledger();
828        let mut bank = bank();
829        bank.accounts.push(Account { id: "mybank".into(), title: String::new(), balance: Some((day("2026-09-30"), Money(39000))) });
830        let first = bank.movements[0].clone();
831        bank.movements.push(Movement { account: "mybank".into(), ..first });
832        let card = &ledger.bank_accounts[0];
833        assert_eq!(balance_of(&bank, card), Some((day("2026-10-02"), Money(40000))), "the newest, never the sum");
834        assert_eq!(bank.balance_in(&ledger), Some((day("2026-10-02"), Money(40000))), "the money watch's total too");
835        assert_eq!(movements_of(&bank, card).iter().filter(|m| m.id == "m1").count(), 1);
836        assert_eq!(place(&ledger, &bank, crate::words::Words::builtin_ref()).iter().filter(|p| p.movement.id == "m1").count(), 1, "placed once");
837        assert!(!two_accounts_in_one(&bank, card), "one account under two names, balances on other days");
838    }
839
840    /// R11 of the review of 10 October 2026: two names of one card whose
841    /// balances on the same day differ cannot be one account: said, so that
842    /// each real account gets its own card. The same balance under both
843    /// names that day is one account read twice.
844    #[test]
845    fn one_card_for_two_real_accounts_is_noticed() {
846        let ledger = ledger();
847        let card = &ledger.bank_accounts[0];
848        let (day_of, money) = bank().accounts.iter().find(|a| card.owns(&a.id)).and_then(|a| a.balance).unwrap();
849        let mut bank = bank();
850        bank.accounts.push(Account { id: "mybank".into(), title: String::new(), balance: Some((day_of, money)) });
851        assert!(!two_accounts_in_one(&bank, card), "the same balance the same day: one account");
852        bank.accounts.last_mut().unwrap().balance = Some((day_of, Money(money.cents() + 12_345)));
853        assert!(two_accounts_in_one(&bank, card), "two balances the same day: two accounts");
854    }
855}