1use 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#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Share {
27 pub budget: String,
28 pub amount: Money,
29 pub preset: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Place {
35 Budgets(Vec<Share>),
37 Reserve { reserve: String, budget: String },
39 Transfer(String),
41 Known(usize),
43 Nowhere,
45}
46
47#[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 pub account: String,
63 pub movement: Movement,
64 pub place: Place,
65 pub why: Why,
66}
67
68pub fn account_of<'a>(ledger: &'a Ledger, name: &str) -> Option<&'a BankAccount> {
70 ledger.bank_accounts.iter().find(|a| a.owns(name))
71}
72
73fn 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
83pub 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 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 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 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 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 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 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 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 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
194fn spend_from_estimates(ledger: &Ledger, placed: &mut [Placed]) {
198 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 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#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct TopUp {
271 pub account: String,
272 pub reserve: String,
273 pub amount: Money,
274 pub needed_by: Date,
276 pub payment: String,
277 pub ask_by: Date,
279 pub late: bool,
281}
282
283pub 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
293pub 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
303pub 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
310pub 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
316pub 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 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
352pub fn month_of(date: Date) -> Date {
354 Period::Month.bounds(date).0
355}
356
357impl Ledger {
358 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
372use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, value};
375
376fn 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#[derive(Debug, Clone, Default, serde::Deserialize)]
412pub struct BankAccountEdit {
413 pub title: String,
414 #[serde(default)]
415 pub kind: String,
416 #[serde(default)]
418 pub fills: Vec<String>,
419 #[serde(default)]
420 pub floor: f64,
421 #[serde(default)]
423 pub topped_up_by: Vec<String>,
424}
425
426pub fn save_bank_account(path: &std::path::Path, id: &str, edit: &BankAccountEdit) -> Result<String, String> {
428 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
463pub fn remove_bank_account(path: &std::path::Path, id: &str) -> Result<(), String> {
465 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
478pub fn add_export_name(path: &std::path::Path, id: &str, name: &str) -> Result<(), String> {
481 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
496pub fn set_assignment(path: &std::path::Path, account: &str, movement: &str, choice: &str) -> Result<(), String> {
499 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#[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 #[serde(default)]
529 pub direction: String,
530 pub to: String,
531}
532
533#[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 #[serde(default)]
545 pub direction: String,
546 #[serde(default)]
548 pub to: String,
549}
550
551fn 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
566pub fn save_split(path: &std::path::Path, shown: Option<&SplitShown>, edit: &SplitEdit) -> Result<(), String> {
569 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 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 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
609pub fn remove_split(path: &std::path::Path, shown: &SplitShown) -> Result<(), String> {
611 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#[derive(Debug, Clone, Default, serde::Deserialize)]
623pub struct ReserveEdit {
624 pub title: String,
625 pub balance: f64,
626 #[serde(default)]
628 pub as_of: String,
629 #[serde(default)]
630 pub floor: f64,
631 #[serde(default)]
632 pub delay_days: u32,
633}
634
635pub fn save_reserve(path: &std::path::Path, id: &str, edit: &ReserveEdit, today: Date) -> Result<String, String> {
637 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 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 assert_eq!(of("m4"), (Place::Budgets(vec![Share { budget: "duties".into(), amount: Money(-4500), preset: Some("#1".into()) }]), Why::First));
732 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 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 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 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 assert_eq!(with.status(leisure, day("2026-10-10")).so_far, Money(-1200 - 2000 - 2398 - 3000));
758 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 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 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 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 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 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 #[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 #[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}