1use crate::projects::Route;
23use crate::money::{Amount, Money};
24use crate::payments::{self, Payment, PaymentKind};
25use crate::porch::{Lane, Triaged};
26use crate::trust::Trust;
27use jiff::civil::Date;
28use jiff::tz::TimeZone;
29use jiff::{Span, Timestamp};
30use serde::Deserialize;
31use std::collections::BTreeSet;
32use std::path::{Path, PathBuf};
33use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, value};
34
35pub const LEDGER: &str = "sioul-budgets.toml";
37
38pub(crate) mod dates {
40 use jiff::civil::Date;
41 use serde::{Deserialize, Deserializer, de::Error};
42
43 fn parse(value: toml::Value) -> Result<Date, String> {
44 let text = match value {
45 toml::Value::Datetime(d) => d.to_string(),
46 toml::Value::String(s) => s,
47 other => return Err(format!("not a date: {other}")),
48 };
49 text.parse::<Date>().map_err(|e| e.to_string())
50 }
51
52 pub fn required<'de, D: Deserializer<'de>>(d: D) -> Result<Date, D::Error> {
53 parse(toml::Value::deserialize(d)?).map_err(D::Error::custom)
54 }
55
56 pub fn optional<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Date>, D::Error> {
57 Option::<toml::Value>::deserialize(d)?.map(parse).transpose().map_err(D::Error::custom)
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum Period {
64 Month,
65 Year,
66}
67
68impl Period {
69 pub fn bounds(self, day: Date) -> (Date, Date) {
71 match self {
72 Period::Month => (day.first_of_month(), day.last_of_month()),
73 Period::Year => (day.first_of_year(), day.last_of_year()),
74 }
75 }
76
77 fn next(self, start: Date) -> Option<Date> {
79 let step = match self {
80 Period::Month => Span::new().months(1),
81 Period::Year => Span::new().years(1),
82 };
83 start.checked_add(step).ok()
84 }
85}
86
87#[derive(Debug, Clone, Deserialize)]
89pub struct Budget {
90 pub id: String,
91 pub title: String,
92 pub period: Period,
93 #[serde(default)]
95 pub target: Money,
96 #[serde(default, deserialize_with = "dates::optional")]
99 pub since: Option<Date>,
100 #[serde(default)]
102 pub links: Vec<String>,
103 #[serde(default)]
105 pub area: Option<String>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
113pub struct Preset {
114 #[serde(default)]
116 pub id: Option<String>,
117 pub budget: String,
118 pub label: String,
119 pub amount: Money,
121 pub every: Period,
122 pub day: i8,
124 #[serde(default)]
126 pub month: Option<i8>,
127 #[serde(default, deserialize_with = "dates::optional")]
128 pub from: Option<Date>,
129 #[serde(default, deserialize_with = "dates::optional")]
130 pub until: Option<Date>,
131 #[serde(default)]
133 pub estimate: bool,
134 #[serde(default)]
135 pub links: Vec<String>,
136}
137
138impl Preset {
139 pub fn occurrences(&self, from: Date, to: Date) -> Vec<Date> {
141 let mut dates = Vec::new();
142 let mut period_start = self.every.bounds(from).0;
143 while period_start <= to {
144 if let Some(date) = self.date_in(period_start).filter(|d| *d >= from && *d <= to && self.active(*d)) {
145 dates.push(date);
146 }
147 let Some(next) = self.every.next(period_start) else { break };
148 period_start = next;
149 }
150 dates
151 }
152
153 fn date_in(&self, period_start: Date) -> Option<Date> {
155 let month = match self.every {
156 Period::Month => period_start.month(),
157 Period::Year => self.month.unwrap_or(1),
158 };
159 let first = Date::new(period_start.year(), month, 1).ok()?;
160 Date::new(first.year(), month, self.day.clamp(1, first.days_in_month())).ok()
161 }
162
163 fn active(&self, date: Date) -> bool {
164 self.from.is_none_or(|f| date >= f) && self.until.is_none_or(|u| date <= u)
165 }
166}
167
168#[derive(Debug, Clone, Deserialize)]
170pub struct Line {
171 pub budget: String,
172 #[serde(deserialize_with = "dates::required")]
173 pub date: Date,
174 pub amount: Money,
176 pub label: String,
177 #[serde(default)]
179 pub planned: bool,
180 #[serde(default)]
182 pub reserve: Option<String>,
183 #[serde(default)]
185 pub links: Vec<String>,
186 #[serde(default)]
188 pub preset: Option<String>,
189}
190
191#[derive(Debug, Clone, Deserialize)]
193pub struct Reserve {
194 pub id: String,
195 pub title: String,
196 pub balance: Money,
198 #[serde(deserialize_with = "dates::required")]
199 pub as_of: Date,
200 #[serde(default)]
202 pub floor: Money,
203 #[serde(default)]
206 pub delay_days: u32,
207 #[serde(default)]
208 pub links: Vec<String>,
209}
210
211#[derive(Debug, Clone, Default, Deserialize)]
215pub struct BankAccount {
216 pub id: String,
217 pub title: String,
218 #[serde(default)]
220 pub kind: Option<String>,
221 #[serde(default)]
223 pub fills: Vec<String>,
224 #[serde(default)]
226 pub exports: Vec<String>,
227 #[serde(default)]
229 pub floor: Money,
230 #[serde(default)]
232 pub topped_up_by: Vec<String>,
233 #[serde(default)]
234 pub links: Vec<String>,
235}
236
237impl BankAccount {
238 pub fn owns(&self, name: &str) -> bool {
240 self.id == name || self.exports.iter().any(|e| e == name)
241 }
242
243 pub fn kind(&self) -> &str {
244 self.kind.as_deref().unwrap_or("bank")
245 }
246}
247
248#[derive(Debug, Clone, Default, Deserialize)]
253pub struct Split {
254 #[serde(default)]
257 pub id: String,
258 #[serde(default)]
260 pub account: String,
261 #[serde(default)]
263 pub words: Vec<String>,
264 #[serde(default)]
266 pub direction: Option<Direction>,
267 #[serde(default)]
268 pub budget: Option<String>,
269 #[serde(default)]
271 pub preset: Option<String>,
272 #[serde(default)]
273 pub reserve: Option<String>,
274 #[serde(default)]
276 pub transfer: Option<String>,
277}
278
279#[derive(Debug, Clone, Default, Deserialize)]
282pub struct Assignment {
283 pub account: String,
284 pub movement: String,
286 #[serde(default)]
287 pub budget: Option<String>,
288 #[serde(default)]
290 pub parts: Vec<Part>,
291 #[serde(default)]
293 pub none: bool,
294}
295
296#[derive(Debug, Clone, Default, Deserialize)]
298pub struct Part {
299 pub budget: String,
300 pub amount: Money,
301}
302
303#[derive(Debug, Clone, Deserialize)]
305pub struct Cover {
306 pub reserve: String,
307 pub budget: String,
308 #[serde(default)]
310 pub sweep: bool,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
314#[serde(rename_all = "lowercase")]
315pub enum Direction {
316 Credit,
317 Debit,
318}
319
320#[derive(Debug, Clone, Deserialize)]
322pub struct MailRule {
323 pub budget: String,
324 pub direction: Direction,
325 #[serde(flatten)]
327 pub route: Route,
328 #[serde(default)]
330 pub amount: Option<Money>,
331 #[serde(default)]
332 pub label: Option<String>,
333 #[serde(default)]
335 pub automatic: bool,
336 #[serde(default)]
338 pub preset: Option<String>,
339}
340
341#[derive(Debug, Clone, Default, Deserialize)]
343pub struct Ledger {
344 #[serde(rename = "budget", default)]
345 pub budgets: Vec<Budget>,
346 #[serde(rename = "preset", default)]
347 pub presets: Vec<Preset>,
348 #[serde(rename = "line", default)]
349 pub lines: Vec<Line>,
350 #[serde(rename = "reserve", default)]
351 pub reserves: Vec<Reserve>,
352 #[serde(rename = "cover", default)]
353 pub covers: Vec<Cover>,
354 #[serde(rename = "mail_rule", default)]
355 pub mail_rules: Vec<MailRule>,
356 #[serde(rename = "bank_account", default)]
357 pub bank_accounts: Vec<BankAccount>,
358 #[serde(rename = "split", default)]
359 pub splits: Vec<Split>,
360 #[serde(rename = "assign", default)]
361 pub assignments: Vec<Assignment>,
362}
363
364fn tolerance(flows: &[Flow], needed: Money) -> i64 {
372 let moved: i64 = flows.iter().map(|f| f.amount.cents().abs()).sum::<i64>() + needed.cents().abs();
373 (moved / 20).max(1000)
374}
375
376fn still_needed(budget: &Budget, opening: Money, flows: &[Flow]) -> Money {
379 let scheduled: Money = flows.iter().filter(|f| f.scheduled).map(|f| f.amount).sum();
380 budget.target - opening - scheduled
381}
382
383#[derive(Debug, Clone, Copy)]
385struct Flow {
386 amount: Money,
387 planned: bool,
388 scheduled: bool,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum Verdict {
396 Better,
397 AsPlanned,
398 Short,
399}
400
401#[derive(Debug, Clone)]
403pub struct BudgetStatus {
404 pub id: String,
405 pub start: Date,
406 pub end: Date,
407 pub opening: Money,
409 pub so_far: Money,
411 pub to_come: Money,
413 pub projected: Money,
415 pub earmarked: Money,
418 pub target: Money,
419 pub gap: Money,
421 pub verdict: Verdict,
422 pub reserve_transfer: Option<(String, Money)>,
424}
425
426#[derive(Debug, Clone)]
428pub struct ReserveStatus {
429 pub id: String,
430 pub balance_now: Money,
431 pub month_done: Money,
432 pub month_planned: Money,
433 pub year_done: Money,
434 pub year_planned: Money,
435 pub year_end: Money,
436 pub months_left: Option<i64>,
438}
439
440impl Ledger {
441 pub fn load(root: &Path) -> Result<Ledger, String> {
443 Ledger::load_file(&root.join(LEDGER))
444 }
445
446 pub fn load_file(path: &Path) -> Result<Ledger, String> {
448 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
449 toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
450 }
451
452 pub fn budget_title<'a>(&'a self, id: &'a str) -> &'a str {
454 self.budgets.iter().find(|b| b.id == id).map_or(id, |b| b.title.as_str())
455 }
456
457 pub fn reserve_title<'a>(&'a self, id: &'a str) -> &'a str {
459 self.reserves.iter().find(|r| r.id == id).map_or(id, |r| r.title.as_str())
460 }
461
462 pub fn status(&self, budget: &Budget, today: Date) -> BudgetStatus {
464 let (start, end) = budget.period.bounds(today);
465 self.status_for(budget, start, end, today, self.opening(budget, start, today))
466 }
467
468 fn opening(&self, budget: &Budget, start: Date, today: Date) -> Money {
471 let since = budget.since.unwrap_or(start);
472 let before = start.yesterday().unwrap_or(start);
473 if since > before {
474 return Money::ZERO;
475 }
476 self.flows(budget, since, before, today).iter().map(|f| f.amount).sum()
477 }
478
479 fn status_for(&self, budget: &Budget, start: Date, end: Date, today: Date, opening: Money) -> BudgetStatus {
480 let from = budget.since.map_or(start, |s| s.max(start));
481 let flows = self.flows(budget, from, end, today);
482 let so_far: Money = flows.iter().filter(|f| !f.planned).map(|f| f.amount).sum();
483 let to_come: Money = flows.iter().filter(|f| f.planned).map(|f| f.amount).sum();
484 let projected = opening + so_far + to_come;
485 let surplus = Money((projected - budget.target).cents().max(0));
486 let earmarked = Money(surplus.cents().min(self.planned_later(budget, end).cents()));
487 let gap = projected - earmarked - budget.target;
488 let tolerance = tolerance(&flows, still_needed(budget, opening, &flows));
489 let verdict = match gap.cents() {
490 g if g > tolerance => Verdict::Better,
491 g if g < -tolerance => Verdict::Short,
492 _ => Verdict::AsPlanned,
493 };
494 let reserve_transfer = self.covers.iter().find(|c| c.budget == budget.id).and_then(|c| {
495 let draw = gap.is_negative() || (c.sweep && gap.cents() > 0);
496 draw.then(|| (c.reserve.clone(), -gap))
497 });
498 BudgetStatus { id: budget.id.clone(), start, end, opening, so_far, to_come, projected, earmarked, target: budget.target, gap, verdict, reserve_transfer }
499 }
500
501 fn planned_later(&self, budget: &Budget, end: Date) -> Money {
503 let horizon = end.checked_add(Span::new().years(1)).unwrap_or(end);
504 let out: Money = self
505 .lines
506 .iter()
507 .filter(|l| l.budget == budget.id && l.planned && l.date > end && l.date <= horizon)
508 .map(|l| l.amount)
509 .sum();
510 Money((-out.cents()).max(0))
511 }
512
513 fn flows(&self, budget: &Budget, from: Date, to: Date, today: Date) -> Vec<Flow> {
516 let budget = budget.id.as_str();
517 let lines = self
518 .lines
519 .iter()
520 .filter(|l| l.budget == budget && l.date >= from && l.date <= to && self.absorbing(l).is_none())
521 .map(|l| Flow { amount: l.amount, planned: l.planned, scheduled: l.planned });
522 let presets = self
523 .presets
524 .iter()
525 .filter(|p| p.budget == budget)
526 .flat_map(|p| p.occurrences(from, to).into_iter().flat_map(move |d| self.occurrence(p, d, today)));
527 lines.chain(presets).collect()
528 }
529
530 fn absorbing(&self, line: &Line) -> Option<&Preset> {
532 let id = line.preset.as_deref()?;
533 let preset = self.presets.iter().find(|p| p.id.as_deref() == Some(id) && p.budget == line.budget)?;
534 let (start, end) = preset.every.bounds(line.date);
535 (!preset.occurrences(start, end).is_empty()).then_some(preset)
536 }
537
538 fn occurrence(&self, preset: &Preset, date: Date, today: Date) -> Vec<Flow> {
543 let (start, end) = preset.every.bounds(date);
544 let actual: Option<Money> = preset.id.as_deref().and_then(|id| {
545 let linked: Vec<&Line> = self
546 .lines
547 .iter()
548 .filter(|l| l.preset.as_deref() == Some(id) && l.budget == preset.budget && l.date >= start && l.date <= end)
549 .collect();
550 (!linked.is_empty()).then(|| linked.iter().map(|l| l.amount).sum())
551 });
552 let Some(actual) = actual else { return vec![Flow { amount: preset.amount, planned: date > today, scheduled: true }] };
553 let mut flows = vec![Flow { amount: actual, planned: false, scheduled: true }];
554 let remaining = preset.amount - actual;
555 let same_way = remaining.cents().signum() == preset.amount.cents().signum() && remaining != Money::ZERO;
556 if preset.estimate && same_way {
557 flows.push(Flow { amount: remaining, planned: date > today, scheduled: true });
558 }
559 flows
560 }
561
562 pub fn reserve_status(&self, reserve: &Reserve, today: Date) -> ReserveStatus {
564 let (month_start, month_end) = Period::Month.bounds(today);
565 let (year_start, year_end) = Period::Year.bounds(today);
566 let done_since = |from: Date| -> Money {
567 self.lines
568 .iter()
569 .filter(|l| l.reserve.as_deref() == Some(&reserve.id) && !l.planned && l.date >= from && l.date <= today)
570 .map(|l| l.amount)
571 .sum()
572 };
573 let after_as_of = reserve.as_of.tomorrow().unwrap_or(reserve.as_of);
574 let balance_now = reserve.balance - done_since(after_as_of);
575 let in_a_year = today.checked_add(Span::new().years(1)).unwrap_or(year_end);
576 let year_planned = self.planned_draws(reserve, today, year_end);
577 let draw_12 = self.planned_draws(reserve, today, in_a_year);
578 let available = balance_now - reserve.floor;
579 let months_left = (draw_12.cents() > 0).then(|| (available.cents().saturating_mul(12) / draw_12.cents()).max(0));
581 ReserveStatus {
582 id: reserve.id.clone(),
583 balance_now,
584 month_done: done_since(month_start),
585 month_planned: self.planned_draws(reserve, today, month_end),
586 year_done: done_since(year_start),
587 year_planned,
588 year_end: balance_now - year_planned,
589 months_left,
590 }
591 }
592
593 fn planned_draws(&self, reserve: &Reserve, today: Date, until: Date) -> Money {
596 let transfers: Money = self
597 .lines
598 .iter()
599 .filter(|l| l.reserve.as_deref() == Some(&reserve.id) && l.planned && l.date > today && l.date <= until)
600 .map(|l| l.amount)
601 .sum();
602 let covers: Money = self
603 .covers
604 .iter()
605 .filter(|c| c.reserve == reserve.id)
606 .filter_map(|c| self.budgets.iter().find(|b| b.id == c.budget))
607 .map(|b| self.covers_ending(b, today, until))
608 .sum();
609 transfers + covers
610 }
611
612 fn covers_ending(&self, budget: &Budget, today: Date, until: Date) -> Money {
615 let mut total = Money::ZERO;
616 let mut status = self.status(budget, today);
617 while status.end <= until {
618 let transfer = status.reserve_transfer.as_ref().map_or(Money::ZERO, |(_, m)| *m);
619 total += transfer;
620 let Some(next) = budget.period.next(status.start) else { break };
621 let (s, e) = budget.period.bounds(next);
622 status = self.status_for(budget, s, e, today, status.projected + transfer);
624 }
625 total
626 }
627}
628
629#[derive(Debug, Clone)]
631pub struct MailLine {
632 pub key: String,
634 pub path: Option<PathBuf>,
635 pub date: Date,
636 pub payment: Payment,
637 pub subject: String,
638 pub sender: String,
639 pub sender_domain: Option<String>,
640 pub budget: Option<String>,
642 pub preset: Option<String>,
644 pub label: String,
646 pub state: MailState,
647 pub doubtful: bool,
649}
650
651#[derive(Debug, Clone, PartialEq, Eq)]
652pub enum MailState {
653 Recorded,
655 Ignored,
657 Duplicate(String),
661 Proposed,
663}
664
665impl MailLine {
666 pub fn signed(&self) -> Option<Money> {
668 let money = self.payment.amount.as_ref()?.money;
669 Some(if self.payment.kind.is_credit() { money } else { -money })
670 }
671
672 pub fn line(&self, budget: &str) -> Option<Line> {
674 Some(Line {
675 budget: budget.to_string(),
676 date: self.date,
677 amount: self.signed()?,
678 label: self.label.clone(),
679 planned: false,
680 reserve: None,
681 links: vec![self.key.clone()],
682 preset: self.preset.clone(),
683 })
684 }
685}
686
687pub fn mail_lines(ledger: &Ledger, items: &[Triaged], ignored: &BTreeSet<String>, words: &crate::words::Words) -> Vec<MailLine> {
692 let recorded: BTreeSet<&str> = ledger.lines.iter().flat_map(|l| l.links.iter().map(String::as_str)).collect();
693 let mut lines: Vec<MailLine> = items.iter().filter(|t| t.lane != Lane::SetAside && t.lane != Lane::Review).filter_map(|t| mail_line(ledger, t, words)).collect();
694 lines.sort_by_key(|l| l.date);
695 for line in &mut lines {
696 line.state = if recorded.contains(line.key.as_str()) {
697 MailState::Recorded
698 } else if ignored.contains(&line.key) {
699 MailState::Ignored
700 } else {
701 MailState::Proposed
702 };
703 }
704 mark_duplicates(&mut lines);
705 lines.reverse();
706 lines
707}
708
709fn mail_line(ledger: &Ledger, t: &Triaged, words: &crate::words::Words) -> Option<MailLine> {
710 let card = &t.card;
711 let rule = ledger.mail_rules.iter().find(|r| r.route.explain(card).is_some());
712 let detected = payments::detect(words, card);
713 let mut payment = match (detected, rule) {
714 (Some(payment), _) => payment,
715 (None, Some(rule)) => payments::read(words, card, kind_of(rule.direction), &payments::start(card)),
716 (None, None) => return None,
717 };
718 if let Some(rule) = rule {
719 if payment.kind.is_credit() != (rule.direction == Direction::Credit) {
721 payment.kind = kind_of(rule.direction);
722 }
723 if let Some(fixed) = rule.amount {
724 payment.amount = Some(Amount { money: fixed.abs(), currency: "EUR" });
725 }
726 }
727 let date = Timestamp::from_second(card.date?).ok()?.to_zoned(TimeZone::system()).date();
728 let key = card.message_id.as_ref().map(|id| format!("mid:{id}")).or_else(|| card.path.as_ref().map(|p| format!("file:{}", p.display())))?;
729 Some(MailLine {
730 key,
731 path: card.path.clone(),
732 date,
733 label: rule.and_then(|r| r.label.clone()).unwrap_or_else(|| payment.party.clone()),
734 subject: card.subject.clone(),
735 sender: card.sender().to_string(),
736 sender_domain: card.sender_domain().map(str::to_ascii_lowercase),
737 budget: rule.map(|r| r.budget.clone()),
738 preset: rule.and_then(|r| r.preset.clone()),
739 doubtful: t.lane == Lane::Screener && t.trust != Trust::Verified,
740 payment,
741 state: MailState::Proposed,
742 })
743}
744
745fn kind_of(direction: Direction) -> PaymentKind {
746 match direction {
747 Direction::Credit => PaymentKind::Received,
748 Direction::Debit => PaymentKind::Paid,
749 }
750}
751
752const SAME_PAYMENT_DAYS: i32 = 3;
754const SETTLED_DAYS: i32 = 40;
756
757fn mark_duplicates(lines: &mut [MailLine]) {
762 let n = lines.len();
763 for i in 0..n {
764 for j in 0..n {
765 if i == j || lines[i].state != MailState::Proposed || lines[j].state == MailState::Ignored {
766 continue;
767 }
768 if matches!(&lines[j].state, MailState::Duplicate(_)) {
769 continue;
770 }
771 if same_payment(&lines[i], &lines[j]) {
772 let other = lines[j].key.clone();
773 lines[i].state = MailState::Duplicate(other);
774 break;
775 }
776 }
777 }
778}
779
780fn same_payment(a: &MailLine, b: &MailLine) -> bool {
782 let days = (b.date - a.date).get_days().abs();
783 let amounts = |x: &MailLine, y: &MailLine| match (x.signed(), y.signed()) {
784 (Some(x), Some(y)) => x == y,
785 _ => true,
786 };
787 let b_counts = b.state == MailState::Recorded || b.date < a.date || (b.date == a.date && b.key < a.key);
788 let same_message = a.key == b.key && b_counts;
789 let order_paid = a.payment.kind == PaymentKind::Order
791 && b.payment.kind == PaymentKind::Paid
792 && days <= SAME_PAYMENT_DAYS
793 && a.signed().is_some()
794 && a.signed() == b.signed();
795 let bill_settled = a.payment.kind == PaymentKind::Bill
796 && b.payment.kind == PaymentKind::Paid
797 && a.sender_domain.is_some()
798 && a.sender_domain == b.sender_domain
799 && days <= SETTLED_DAYS
800 && amounts(a, b);
801 let recorded_twin = b.state == MailState::Recorded
803 && a.payment.kind == b.payment.kind
804 && a.payment.party == b.payment.party
805 && days <= SAME_PAYMENT_DAYS
806 && a.signed().is_some()
807 && amounts(a, b);
808 same_message || order_paid || bill_settled || recorded_twin
809}
810
811#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize)]
813pub struct BudgetEdit {
814 pub title: String,
815 pub period: String,
817 #[serde(default)]
819 pub target: f64,
820 #[serde(default)]
821 pub personal: bool,
822 #[serde(default)]
824 pub area: Option<String>,
825}
826
827fn write_ledger(path: &Path, doc: &DocumentMut) -> Result<(), String> {
828 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
829 let temporary = path.with_extension("toml.new");
830 std::fs::write(&temporary, doc.to_string()).map_err(fail)?;
831 std::fs::rename(&temporary, path).map_err(fail)
832}
833
834pub fn save_budget(path: &Path, id: &str, edit: &BudgetEdit) -> Result<String, String> {
837 crate::filelock::with_lock(path, || {
839 let fail = |e: String| format!("{}: {e}", path.display());
840 let title = edit.title.trim();
841 if title.is_empty() {
842 return Err(fail("a budget needs a name".into()));
843 }
844 if !edit.target.is_finite() {
845 return Err(fail("a target is a number".into()));
846 }
847 let text = match std::fs::read_to_string(path) {
849 Ok(text) => text,
850 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
851 Err(e) => return Err(fail(e.to_string())),
852 };
853 let mut doc: DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))?;
854 let budgets = doc.entry("budget").or_insert(Item::ArrayOfTables(ArrayOfTables::new())).as_array_of_tables_mut().ok_or_else(|| fail("`budget` is not a list of tables".into()))?;
855 let taken: Vec<String> = budgets.iter().filter_map(|t| t.get("id").and_then(Item::as_str).map(str::to_string)).collect();
856 let id = if id.is_empty() { crate::projects::new_id(title, &taken) } else { id.to_string() };
857 if !budgets.iter().any(|t| t.get("id").and_then(Item::as_str) == Some(id.as_str())) {
858 let mut table = Table::new();
859 table["id"] = value(id.as_str());
860 budgets.push(table);
861 }
862 let table = budgets.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id.as_str())).ok_or_else(|| fail(format!("no budget {id}")))?;
863 table["title"] = value(title);
864 table["period"] = value(if edit.period == "year" { "year" } else { "month" });
865 table["target"] = value((edit.target * 100.0).round() / 100.0);
866 match edit.area.as_deref().map(str::trim) {
867 Some(area) if !area.is_empty() => table["area"] = value(crate::areas::Area::parse(area).map_or(area.to_string(), |a| a.id())),
868 Some(_) => {
869 table.remove("area");
870 }
871 None if edit.personal => table["area"] = value("personal"),
872 None => {
873 table.remove("area");
874 }
875 }
876 write_ledger(path, &doc)?;
877 Ok(id)
878 })
879}
880
881pub fn remove_budget(path: &Path, id: &str) -> Result<(), String> {
884 crate::filelock::with_lock(path, || {
886 let fail = |e: String| format!("{}: {e}", path.display());
887 let text = std::fs::read_to_string(path).map_err(|e| fail(e.to_string()))?;
888 let mut doc: DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))?;
889 let budgets = doc.get_mut("budget").and_then(Item::as_array_of_tables_mut).ok_or_else(|| fail(format!("no budget {id}")))?;
890 let before = budgets.len();
891 budgets.retain(|t| t.get("id").and_then(Item::as_str) != Some(id));
892 if budgets.len() == before {
893 return Err(fail(format!("no budget {id}")));
894 }
895 write_ledger(path, &doc)
896 })
897}
898
899#[derive(Debug, Clone, PartialEq)]
904pub struct SeenLine {
905 pub budget: String,
906 pub date: Date,
907 pub amount: Money,
908 pub label: String,
909}
910
911impl SeenLine {
912 pub fn from_page(uri: &str, shown: &str) -> Option<(usize, SeenLine)> {
916 #[derive(Deserialize)]
917 struct Shown {
918 day: String,
919 value: f64,
920 label: String,
921 }
922 let (budget, place) = uri.strip_prefix("sioul:budget/")?.rsplit_once('/')?;
923 let shown: Shown = serde_json::from_str(shown).ok()?;
924 if !shown.value.is_finite() {
925 return None;
926 }
927 let line = SeenLine { budget: crate::notes::decode(budget), date: shown.day.parse().ok()?, amount: Money((shown.value * 100.0).round() as i64), label: shown.label };
928 Some((place.parse().ok()?, line))
929 }
930
931 fn is(&self, table: &Table) -> bool {
933 #[derive(Deserialize)]
934 struct One {
935 line: Vec<Line>,
936 }
937 let mut doc = DocumentMut::new();
938 let mut one = ArrayOfTables::new();
939 one.push(table.clone());
940 doc.insert("line", Item::ArrayOfTables(one));
941 toml::from_str::<One>(&doc.to_string()).is_ok_and(|one| one.line.first().is_some_and(|l| l.budget == self.budget && l.date == self.date && l.amount == self.amount && l.label == self.label))
942 }
943}
944
945fn find_line(lines: &ArrayOfTables, place: usize, seen: &SeenLine) -> Option<usize> {
949 if lines.get(place).is_some_and(|t| seen.is(t)) {
950 return Some(place);
951 }
952 let mut found = lines.iter().enumerate().filter(|(_, t)| seen.is(t)).map(|(i, _)| i);
953 match (found.next(), found.next()) {
954 (Some(i), None) => Some(i),
955 _ => None,
956 }
957}
958
959pub fn remove_line(path: &Path, place: usize, seen: &SeenLine) -> Result<bool, String> {
964 crate::filelock::with_lock(path, || {
966 let fail = |e: String| format!("{}: {e}", path.display());
967 let text = std::fs::read_to_string(path).map_err(|e| fail(e.to_string()))?;
968 let mut doc: DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))?;
969 let Some(lines) = doc.get_mut("line").and_then(Item::as_array_of_tables_mut) else { return Ok(false) };
970 let Some(place) = find_line(lines, place, seen) else { return Ok(false) };
971 lines.remove(place);
972 write_ledger(path, &doc)?;
973 Ok(true)
974 })
975}
976
977pub fn change_line(path: &Path, place: usize, seen: &SeenLine, label: &str, amount: f64, date: jiff::civil::Date, today: jiff::civil::Date) -> Result<bool, String> {
982 crate::filelock::with_lock(path, || {
984 let fail = |e: String| format!("{}: {e}", path.display());
985 if amount == 0.0 || !amount.is_finite() {
986 return Err(fail("an amount is needed".into()));
987 }
988 let text = std::fs::read_to_string(path).map_err(|e| fail(e.to_string()))?;
989 let mut doc: DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))?;
990 let Some(lines) = doc.get_mut("line").and_then(Item::as_array_of_tables_mut) else { return Ok(false) };
991 let Some(place) = find_line(lines, place, seen) else { return Ok(false) };
992 let Some(line) = lines.get_mut(place) else { return Ok(false) };
993 line["label"] = value(label.trim());
994 line["amount"] = value((amount * 100.0).round() / 100.0);
995 line["date"] = value(toml_edit::Datetime { date: Some(toml_edit::Date { year: date.year() as u16, month: date.month() as u8, day: date.day() as u8 }), time: None, offset: None });
996 if date > today {
997 line["planned"] = value(true);
998 } else {
999 line.remove("planned");
1000 }
1001 write_ledger(path, &doc)?;
1002 Ok(true)
1003 })
1004}
1005
1006pub fn record_line(path: &Path, line: &Line, origin: &str) -> Result<(), String> {
1009 crate::filelock::with_lock(path, || {
1011 let fail = |e: String| format!("{}: {e}", path.display());
1012 let text = std::fs::read_to_string(path).map_err(|e| fail(e.to_string()))?;
1013 let mut doc: DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))?;
1014 let mut table = Table::new();
1015 table["id"] = value(crate::ids::new());
1018 table["budget"] = value(line.budget.as_str());
1019 let date = toml_edit::Date { year: line.date.year() as u16, month: line.date.month() as u8, day: line.date.day() as u8 };
1020 table["date"] = value(toml_edit::Datetime { date: Some(date), time: None, offset: None });
1021 table["amount"] = value(line.amount.cents() as f64 / 100.0);
1022 table["label"] = value(line.label.as_str());
1023 if let Some(preset) = &line.preset {
1024 table["preset"] = value(preset.as_str());
1025 }
1026 if line.planned {
1027 table["planned"] = value(true);
1028 }
1029 table["links"] = value(line.links.iter().map(String::as_str).collect::<Array>());
1030 let origin: String = origin.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
1031 let trailing = doc.trailing().as_str().unwrap_or("").trim_end().to_string();
1032 let lead = if trailing.trim().is_empty() { String::new() } else { format!("{trailing}\n") };
1033 table.decor_mut().set_prefix(format!("{lead}\n# {origin}\n"));
1034 doc.set_trailing("");
1035 let lines = doc.entry("line").or_insert(Item::ArrayOfTables(ArrayOfTables::new()));
1036 lines.as_array_of_tables_mut().ok_or_else(|| fail("`line` is not a list of tables".into()))?.push(table);
1037 let temporary = path.with_extension("toml.new");
1038 std::fs::write(&temporary, doc.to_string()).map_err(|e| fail(e.to_string()))?;
1039 std::fs::rename(&temporary, path).map_err(|e| fail(e.to_string()))
1040 })
1041}
1042
1043#[derive(Debug, Clone, PartialEq)]
1049pub struct Pace {
1050 pub start: Date,
1051 pub end: Date,
1052 pub gone: f64,
1054 pub opening: Money,
1055 pub scheduled: Money,
1057 pub unscheduled: Money,
1059 pub needed: Money,
1061 pub ahead: Money,
1063 pub verdict: Verdict,
1064 pub realistic: Money,
1067 pub range: Option<(Money, Money)>,
1069 pub history: usize,
1071 pub target: Money,
1072}
1073
1074impl Ledger {
1075 fn unscheduled_history(&self, budget: &Budget, start: Date, today: Date) -> Vec<Money> {
1077 let mut out = Vec::new();
1078 let mut period_end = start.yesterday().ok();
1079 while let Some(end) = period_end {
1080 if out.len() >= 12 || budget.since.is_some_and(|s| end < s) {
1081 break;
1082 }
1083 let (from, _) = budget.period.bounds(end);
1084 let flows = self.flows(budget, budget.since.map_or(from, |s| s.max(from)), end, today);
1085 let lines = self.lines.iter().filter(|l| l.budget == budget.id && !l.planned && l.date >= from && l.date <= end).count();
1086 if lines > 0 {
1087 out.push(flows.iter().filter(|f| !f.scheduled).map(|f| f.amount).sum());
1088 } else if budget.since.is_none() && from < today.checked_sub(Span::new().years(2)).unwrap_or(today) {
1089 break;
1090 }
1091 period_end = from.yesterday().ok();
1092 }
1093 out
1094 }
1095
1096 pub fn pace(&self, budget: &Budget, today: Date) -> Pace {
1098 let (start, end) = budget.period.bounds(today);
1099 let opening = self.opening(budget, start, today);
1100 let from = budget.since.map_or(start, |s| s.max(start));
1101 let flows = self.flows(budget, from, end, today);
1102 let scheduled: Money = flows.iter().filter(|f| f.scheduled).map(|f| f.amount).sum();
1103 let unscheduled: Money = flows.iter().filter(|f| !f.scheduled).map(|f| f.amount).sum();
1104 let days = |a: Date, b: Date| (b - a).get_days() as f64 + 1.0;
1105 let gone = (days(start, today) / days(start, end)).clamp(0.0, 1.0);
1106 let needed = still_needed(budget, opening, &flows);
1107 let expected_now = Money((needed.cents() as f64 * gone).round() as i64);
1108 let ahead = unscheduled - expected_now;
1109 let tolerance = tolerance(&flows, needed);
1111 let verdict = match ahead.cents() {
1112 g if g > tolerance => Verdict::Better,
1113 g if g < -tolerance => Verdict::Short,
1114 _ => Verdict::AsPlanned,
1115 };
1116 let history = self.unscheduled_history(budget, start, today);
1117 let rest = |per_period: Money| Money((per_period.cents() as f64 * (1.0 - gone)).round() as i64);
1118 let known = opening + scheduled + unscheduled;
1119 let (realistic, range) = if history.len() >= 3 {
1120 let mut sorted: Vec<i64> = history.iter().map(|m| m.cents()).collect();
1121 sorted.sort_unstable();
1122 let at = |q: f64| Money(sorted[((sorted.len() - 1) as f64 * q).round() as usize]);
1123 let (low, middle, high) = if sorted.len() >= 10 { (at(0.1), at(0.5), at(0.9)) } else { (at(0.0), at(0.5), at(1.0)) };
1124 (known + rest(middle), Some((known + rest(low), known + rest(high))))
1125 } else {
1126 (budget.target + ahead, None)
1128 };
1129 Pace { start, end, gone, opening, scheduled, unscheduled, needed, ahead, verdict, realistic, range, history: history.len(), target: budget.target }
1130 }
1131}
1132
1133#[derive(Debug, Clone, PartialEq)]
1135pub struct Movement {
1136 pub date: Date,
1137 pub label: String,
1138 pub amount: Money,
1139 pub planned: bool,
1141 pub recurring: bool,
1143 pub line: Option<usize>,
1145}
1146
1147#[derive(Debug, Clone, PartialEq)]
1149pub struct Point {
1150 pub date: Date,
1151 pub balance: Money,
1152 pub future: bool,
1153}
1154
1155impl Ledger {
1156 pub fn movements(&self, budget: &Budget, from: Date, to: Date, today: Date) -> Vec<Movement> {
1159 let mut out: Vec<Movement> = self
1160 .lines
1161 .iter()
1162 .enumerate()
1163 .filter(|(_, l)| l.budget == budget.id && l.date >= from && l.date <= to)
1164 .map(|(i, l)| Movement { date: l.date, label: l.label.clone(), amount: l.amount, planned: l.planned, recurring: false, line: (!l.links.iter().any(|k| k.starts_with("bank:"))).then_some(i) })
1166 .collect();
1167 for preset in self.presets.iter().filter(|p| p.budget == budget.id) {
1168 for date in preset.occurrences(from, to) {
1169 let stood_for = preset.id.as_deref().is_some_and(|id| {
1171 let (start, end) = preset.every.bounds(date);
1172 self.lines.iter().any(|l| l.preset.as_deref() == Some(id) && l.budget == budget.id && l.date >= start && l.date <= end)
1173 });
1174 if !stood_for {
1175 out.push(Movement { date, label: preset.label.clone(), amount: preset.amount, planned: date > today, recurring: true, line: None });
1176 }
1177 }
1178 }
1179 out.sort_by(|a, b| a.date.cmp(&b.date).then(a.label.cmp(&b.label)));
1180 out
1181 }
1182
1183 pub fn balance_series(&self, budget: &Budget, from: Date, to: Date, step: &str, today: Date) -> Vec<Point> {
1186 let since = budget.since.unwrap_or(from);
1187 let start_balance = if since < from {
1188 let before = from.yesterday().unwrap_or(from);
1189 self.flows(budget, since, before, today).iter().map(|f| f.amount).sum()
1190 } else {
1191 Money::ZERO
1192 };
1193 let moves = self.movements(budget, since.max(from), to, today);
1194 let next = |d: Date| -> Option<Date> {
1195 match step {
1196 "week" => d.checked_add(Span::new().weeks(1)).ok(),
1197 "month" => d.checked_add(Span::new().months(1)).ok(),
1198 "year" => d.checked_add(Span::new().years(1)).ok(),
1199 _ => d.tomorrow().ok(),
1200 }
1201 };
1202 let mut points = Vec::new();
1203 let mut balance = start_balance;
1204 let mut taken = 0;
1205 let mut day = from;
1206 loop {
1207 let end = next(day).and_then(|n| n.yesterday().ok()).unwrap_or(to).min(to);
1208 while taken < moves.len() && moves[taken].date <= end {
1209 balance += moves[taken].amount;
1210 taken += 1;
1211 }
1212 points.push(Point { date: end, balance, future: end > today });
1213 match next(day) {
1214 Some(n) if n <= to => day = n,
1215 _ => break,
1216 }
1217 }
1218 points
1219 }
1220}
1221
1222#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, Deserialize)]
1224pub struct PresetEdit {
1225 pub budget: String,
1226 pub label: String,
1227 pub amount: f64,
1229 pub every: String,
1231 pub day: i8,
1232 #[serde(default)]
1233 pub month: Option<i8>,
1234 #[serde(default)]
1236 pub from: String,
1237 #[serde(default)]
1238 pub until: String,
1239 #[serde(default)]
1240 pub estimate: bool,
1241}
1242
1243pub fn record_preset(path: &Path, preset: &PresetEdit, origin: &str) -> Result<(), String> {
1245 crate::filelock::with_lock(path, || {
1247 let fail = |e: String| format!("{}: {e}", path.display());
1248 if !preset.amount.is_finite() {
1249 return Err(fail("an amount is needed".into()));
1250 }
1251 let text = std::fs::read_to_string(path).map_err(|e| fail(e.to_string()))?;
1252 let mut doc: DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))?;
1253 let day_value = |text: &str| -> Option<toml_edit::Datetime> {
1254 let d: Date = text.parse().ok()?;
1255 Some(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 })
1256 };
1257 let mut table = Table::new();
1258 table["id"] = value(crate::ids::new());
1260 table["budget"] = value(preset.budget.as_str());
1261 table["label"] = value(preset.label.trim());
1262 table["amount"] = value((preset.amount * 100.0).round() / 100.0);
1263 table["every"] = value(if preset.every == "year" { "year" } else { "month" });
1264 table["day"] = value(i64::from(preset.day.clamp(1, 31)));
1265 if preset.every == "year" {
1266 table["month"] = value(i64::from(preset.month.unwrap_or(1).clamp(1, 12)));
1267 }
1268 if let Some(from) = day_value(&preset.from) {
1269 table["from"] = value(from);
1270 }
1271 if let Some(until) = day_value(&preset.until) {
1272 table["until"] = value(until);
1273 }
1274 if preset.estimate {
1275 table["estimate"] = value(true);
1276 }
1277 let origin: String = origin.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
1278 let trailing = doc.trailing().as_str().unwrap_or("").trim_end().to_string();
1279 let lead = if trailing.trim().is_empty() { String::new() } else { format!("{trailing}\n") };
1280 table.decor_mut().set_prefix(format!("{lead}\n# {origin}\n"));
1281 doc.set_trailing("");
1282 let presets = doc.entry("preset").or_insert(Item::ArrayOfTables(ArrayOfTables::new()));
1283 presets.as_array_of_tables_mut().ok_or_else(|| fail("`preset` is not a list of tables".into()))?.push(table);
1284 let temporary = path.with_extension("toml.new");
1285 std::fs::write(&temporary, doc.to_string()).map_err(|e| fail(e.to_string()))?;
1286 std::fs::rename(&temporary, path).map_err(|e| fail(e.to_string()))
1287 })
1288}
1289
1290pub fn settle_line(path: &Path, link: &str, date: Date) -> Result<bool, String> {
1293 crate::filelock::with_lock(path, || {
1295 let fail = |e: String| format!("{}: {e}", path.display());
1296 let text = std::fs::read_to_string(path).map_err(|e| fail(e.to_string()))?;
1297 let mut doc: DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| fail(e.to_string()))?;
1298 let Some(lines) = doc.get_mut("line").and_then(Item::as_array_of_tables_mut) else { return Ok(false) };
1299 let Some(line) = lines.iter_mut().find(|t| t.get("links").and_then(Item::as_array).is_some_and(|a| a.iter().any(|v| v.as_str() == Some(link)))) else { return Ok(false) };
1300 line.remove("planned");
1301 let day = toml_edit::Date { year: date.year() as u16, month: date.month() as u8, day: date.day() as u8 };
1302 line["date"] = value(toml_edit::Datetime { date: Some(day), time: None, offset: None });
1303 let temporary = path.with_extension("toml.new");
1304 std::fs::write(&temporary, doc.to_string()).map_err(|e| fail(e.to_string()))?;
1305 std::fs::rename(&temporary, path).map_err(|e| fail(e.to_string()))?;
1306 Ok(true)
1307 })
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313
1314 #[test]
1315 fn budgets_made_changed_and_taken_out() {
1316 let path = std::env::temp_dir().join(format!("sioul-budget-edit-{}.toml", std::process::id()));
1317 std::fs::write(&path, "# mine\n[[budget]]\nid = \"food\"\ntitle = \"Food\"\nperiod = \"month\"\n\n[[line]]\nbudget = \"food\"\ndate = 2026-10-01\namount = -12.5\nlabel = \"Market\"\n").unwrap();
1318 let id = save_budget(&path, "", &BudgetEdit { title: "Holidays".into(), period: "year".into(), target: 300.0, personal: true, area: None }).unwrap();
1319 assert_eq!(id, "holidays");
1320 let ledger = Ledger::load_file(&path).unwrap();
1321 let holidays = ledger.budgets.iter().find(|b| b.id == "holidays").unwrap();
1322 assert_eq!((holidays.period, holidays.target.cents(), holidays.area.as_deref()), (Period::Year, 30_000, Some("personal")));
1323 save_budget(&path, "food", &BudgetEdit { title: "Groceries".into(), period: "month".into(), target: 0.0, personal: false, area: None }).unwrap();
1324 assert_eq!(Ledger::load_file(&path).unwrap().budget_title("food"), "Groceries");
1325 let market = SeenLine { budget: "food".into(), date: date("2026-10-01"), amount: Money(-1250), label: "Market".into() };
1326 assert!(remove_line(&path, 0, &market).unwrap());
1327 remove_budget(&path, "holidays").unwrap();
1328 let ledger = Ledger::load_file(&path).unwrap();
1329 assert!(ledger.lines.is_empty() && ledger.budgets.len() == 1);
1330 assert!(std::fs::read_to_string(&path).unwrap().starts_with("# mine"));
1331 std::fs::remove_file(&path).unwrap();
1332 }
1333
1334 #[test]
1339 fn a_line_is_changed_only_when_found_as_it_was() {
1340 let path = std::env::temp_dir().join(format!("sioul-budget-lines-{}.toml", std::process::id()));
1341 let line = |date: &str, amount: &str, label: &str| format!("\n[[line]]\nbudget = \"food\"\ndate = {date}\namount = {amount}\nlabel = \"{label}\"\n");
1342 let head = "# mine\n[[budget]]\nid = \"food\"\ntitle = \"Food\"\nperiod = \"month\"\n";
1343 let seen = |date: &str, cents: i64, label: &str| SeenLine { budget: "food".into(), date: date.parse().unwrap(), amount: Money(cents), label: label.into() };
1344 let today = date("2026-10-09");
1345 let labels = || Ledger::load_file(&path).unwrap().lines.iter().map(|l| (l.label.clone(), l.amount.cents())).collect::<Vec<_>>();
1346 std::fs::write(&path, format!("{head}{}{}{}", line("2026-10-01", "-3", "Demo early"), line("2026-10-02", "-12.5", "Market"), line("2026-10-03", "-4.2", "Bakery"))).unwrap();
1348 let bakery = seen("2026-10-03", -420, "Bakery");
1349 assert!(change_line(&path, 1, &bakery, "Bakery, bread", -4.4, date("2026-10-03"), today).unwrap());
1350 assert_eq!(labels(), [("Demo early".to_string(), -300), ("Market".into(), -1250), ("Bakery, bread".into(), -440)], "Market, now at place 1, is left as it was");
1351 let market = seen("2026-10-02", -1250, "Market");
1353 assert!(remove_line(&path, 0, &market).unwrap());
1354 assert_eq!(labels(), [("Demo early".to_string(), -300), ("Bakery, bread".into(), -440)]);
1355 let before = std::fs::read_to_string(&path).unwrap();
1357 assert!(!change_line(&path, 1, &bakery, "Anything", -1.0, date("2026-10-03"), today).unwrap());
1358 assert!(!remove_line(&path, 1, &bakery).unwrap());
1359 assert!(!remove_line(&path, 5, &market).unwrap(), "gone");
1360 assert_eq!(std::fs::read_to_string(&path).unwrap(), before, "nothing written");
1361 std::fs::write(&path, format!("{head}{}{}{}", line("2026-10-01", "-3", "Demo early"), line("2026-10-05", "-2", "Coffee"), line("2026-10-05", "-2", "Coffee"))).unwrap();
1363 let coffee = seen("2026-10-05", -200, "Coffee");
1364 assert!(!remove_line(&path, 0, &coffee).unwrap());
1365 assert!(remove_line(&path, 2, &coffee).unwrap());
1367 assert_eq!(labels().len(), 2);
1368 assert!(std::fs::read_to_string(&path).unwrap().starts_with("# mine"));
1369 std::fs::remove_file(&path).unwrap();
1370 let uri = crate::links::budget_uri("food & drink", 3);
1372 assert_eq!(SeenLine::from_page(&uri, r#"{"day": "2026-10-05", "value": -2.1, "label": "Coffee"}"#), Some((3, SeenLine { budget: "food & drink".into(), date: date("2026-10-05"), amount: Money(-210), label: "Coffee".into() })));
1373 assert_eq!(SeenLine::from_page("sioul:budget/food", r#"{"day": "2026-10-05", "value": -2, "label": "Coffee"}"#), None, "a budget, not a line");
1374 assert_eq!(SeenLine::from_page(&uri, "{}"), None, "nothing shown: never by its place alone");
1375 }
1376
1377 #[test]
1378 fn an_unreadable_file_is_never_written_over() {
1379 let path = std::env::temp_dir().join(format!("sioul-budget-unreadable-{}.toml", std::process::id()));
1380 let latin1: &[u8] = b"# Mes budgets\n[[budget]]\nid = \"d\"\ntitle = \"D\xe9penses\"\nperiod = \"month\"\n";
1382 std::fs::write(&path, latin1).unwrap();
1383 assert!(save_budget(&path, "", &BudgetEdit { title: "Holidays".into(), period: "year".into(), ..BudgetEdit::default() }).is_err());
1384 assert_eq!(std::fs::read(&path).unwrap(), latin1, "left as it was");
1385 assert!(save_budget(&path, "", &BudgetEdit { title: "Holidays".into(), period: "year".into(), target: f64::INFINITY, ..BudgetEdit::default() }).is_err());
1386 std::fs::remove_file(&path).unwrap();
1387 assert_eq!(save_budget(&path, "", &BudgetEdit { title: "Holidays".into(), period: "year".into(), target: 300.004, ..BudgetEdit::default() }).unwrap(), "holidays");
1389 assert_eq!(Ledger::load_file(&path).unwrap().budgets[0].target, Money(30000));
1390 std::fs::remove_file(&path).unwrap();
1391 }
1392
1393 #[test]
1394 fn on_track_at_its_pace() {
1395 let text = "[[budget]]\nid = \"work\"\ntitle = \"Work\"\nperiod = \"month\"\n\n[[preset]]\nbudget = \"work\"\nlabel = \"Subscriptions\"\namount = -110\nevery = \"month\"\nday = 1\n";
1397 let ledger: Ledger = toml::from_str(text).unwrap();
1398 let budget = &ledger.budgets[0];
1399 let third = date("2026-10-03");
1400 let pace = ledger.pace(budget, third);
1401 assert_eq!((pace.scheduled, pace.unscheduled, pace.needed), (Money(-11_000), Money::ZERO, Money(11_000)));
1402 assert_eq!(pace.verdict, Verdict::AsPlanned, "on the 3rd, nothing has come yet and that is on track: {pace:?}");
1403 assert_eq!(ledger.status(budget, third).verdict, Verdict::Short, "the plain projection said short");
1404 assert!(pace.range.is_none());
1405 assert_eq!(ledger.pace(budget, date("2026-10-16")).verdict, Verdict::Short);
1407 let lines = "\n[[line]]\nbudget = \"work\"\ndate = 2026-07-10\namount = 150\nlabel = \"Client A\"\n\n[[line]]\nbudget = \"work\"\ndate = 2026-08-12\namount = 90\nlabel = \"Client B\"\n\n[[line]]\nbudget = \"work\"\ndate = 2026-09-20\namount = 120\nlabel = \"Client A\"\n\n[[line]]\nbudget = \"work\"\ndate = 2026-10-02\namount = 30\nlabel = \"Client C\"\n";
1409 let ledger: Ledger = toml::from_str(&format!("{text}{lines}")).unwrap();
1410 let pace = ledger.pace(&ledger.budgets[0], third);
1411 assert_eq!((pace.history, pace.unscheduled), (3, Money(3_000)));
1412 let left: f64 = 1.0 - 3.0 / 31.0;
1414 assert_eq!(pace.realistic, Money(((-80.0 + 120.0 * left) * 100.0).round() as i64));
1415 let (low, high) = pace.range.unwrap();
1416 assert!(low < pace.realistic && pace.realistic < high, "{low:?} {:?} {high:?}", pace.realistic);
1417 assert_eq!(pace.verdict, Verdict::Better, "30 in on the 3rd is ahead of the pace");
1418 let moves = ledger.movements(&ledger.budgets[0], date("2026-10-01"), date("2026-10-31"), third);
1420 assert_eq!(moves.iter().map(|m| (m.label.as_str(), m.recurring)).collect::<Vec<_>>(), vec![("Subscriptions", true), ("Client C", false)]);
1421 let days = ledger.balance_series(&ledger.budgets[0], date("2026-10-01"), date("2026-10-31"), "day", third);
1422 assert_eq!(days.len(), 31);
1423 let opening = ledger.pace(&ledger.budgets[0], third).opening;
1424 assert_eq!((days[0].balance, days[1].balance), (opening + Money(-11_000), opening + Money(-8_000)));
1425 assert!(!days[2].future && days[3].future);
1426 assert_eq!(ledger.balance_series(&ledger.budgets[0], date("2026-01-01"), date("2026-12-31"), "month", third).len(), 12);
1427 }
1428
1429 #[test]
1434 fn one_tolerance_says_as_planned_everywhere() {
1435 let rent = "[[budget]]\nid = \"home\"\ntitle = \"Home\"\nperiod = \"month\"\n\n[[preset]]\nbudget = \"home\"\nlabel = \"Rent\"\namount = -1000\nevery = \"month\"\nday = 1\n";
1436 let with_income = |amount: &str| -> Ledger { toml::from_str(&format!("{rent}\n[[line]]\nbudget = \"home\"\ndate = 2026-10-10\namount = {amount}\nlabel = \"Demo income\"\n")).unwrap() };
1437 let end = date("2026-10-31");
1438 let both = |ledger: &Ledger| (ledger.pace(&ledger.budgets[0], end).verdict, ledger.status(&ledger.budgets[0], end).verdict);
1439 let ahead = with_income("1200");
1441 let budget = &ahead.budgets[0];
1442 let flows = ahead.flows(budget, date("2026-10-01"), end, end);
1443 assert_eq!(tolerance(&flows, still_needed(budget, Money::ZERO, &flows)), 16_000);
1444 assert_eq!(both(&ahead), (Verdict::Better, Verdict::Better));
1445 assert_eq!(both(&with_income("1100")), (Verdict::AsPlanned, Verdict::AsPlanned));
1447 assert_eq!(both(&with_income("800")), (Verdict::Short, Verdict::Short));
1449 let small: Ledger = toml::from_str("[[budget]]\nid = \"b\"\ntitle = \"B\"\nperiod = \"month\"\n\n[[line]]\nbudget = \"b\"\ndate = 2026-10-02\namount = -50\nlabel = \"Demo\"\n").unwrap();
1451 let flows = small.flows(&small.budgets[0], date("2026-10-01"), end, end);
1452 assert_eq!(tolerance(&flows, still_needed(&small.budgets[0], Money::ZERO, &flows)), 1_000);
1453 }
1454
1455 #[test]
1456 fn recurring_movements_by_hand() {
1457 let dir = std::env::temp_dir().join(format!("sioul-presets-{}", std::process::id()));
1458 std::fs::create_dir_all(&dir).unwrap();
1459 let path = dir.join(LEDGER);
1460 std::fs::write(&path, "# Budgets\n[[budget]]\nid = \"home\"\ntitle = \"Home\"\nperiod = \"month\"\n").unwrap();
1461 record_preset(&path, &PresetEdit { budget: "home".into(), label: "Rent".into(), amount: -650.0, every: "month".into(), day: 5, from: "2026-11-01".into(), ..PresetEdit::default() }, "Added in Sioul").unwrap();
1462 let ledger = Ledger::load_file(&path).unwrap();
1463 let rent = &ledger.presets[0];
1464 assert_eq!((rent.amount, rent.day, rent.from), (Money(-65_000), 5, Some(date("2026-11-01"))));
1465 assert!(std::fs::read_to_string(&path).unwrap().starts_with("# Budgets"));
1466 let _ = std::fs::remove_dir_all(&dir);
1467 }
1468
1469 fn date(text: &str) -> Date {
1470 text.parse().unwrap()
1471 }
1472
1473 #[test]
1474 fn presets_fall_on_real_days() {
1475 let rent: Preset = toml::from_str("budget='b'\nlabel='rent'\namount=-500\nevery='month'\nday=31").unwrap();
1476 let days = rent.occurrences(date("2027-01-01"), date("2027-04-30"));
1477 let days: Vec<String> = days.iter().map(ToString::to_string).collect();
1478 assert_eq!(days, vec!["2027-01-31", "2027-02-28", "2027-03-31", "2027-04-30"]);
1479 let water: Preset = toml::from_str("budget='b'\nlabel='water'\namount=-500\nevery='year'\nmonth=12\nday=15\nfrom=2026-11-01").unwrap();
1480 let days: Vec<String> = water.occurrences(date("2026-01-01"), date("2027-12-31")).iter().map(ToString::to_string).collect();
1481 assert_eq!(days, vec!["2026-12-15", "2027-12-15"]);
1482 }
1483
1484 const LEDGER_TEXT: &str = r#"
1485 [[budget]]
1486 id = "home"
1487 title = "Home"
1488 period = "month"
1489 target = 0
1490
1491 [[budget]]
1492 id = "life"
1493 title = "Life"
1494 period = "month"
1495
1496 [[preset]]
1497 budget = "home"
1498 label = "Wages"
1499 amount = 1500
1500 every = "month"
1501 day = 25
1502
1503 [[preset]]
1504 budget = "home"
1505 label = "Rent"
1506 amount = -600
1507 every = "month"
1508 day = 1
1509
1510 [[line]]
1511 budget = "home"
1512 date = 2026-10-10
1513 amount = -200
1514 label = "Groceries"
1515
1516 [[preset]]
1517 budget = "life"
1518 label = "Rent"
1519 amount = -500
1520 every = "month"
1521 day = 1
1522
1523 [[preset]]
1524 budget = "life"
1525 label = "Daily spending"
1526 amount = -300
1527 every = "month"
1528 day = 15
1529 estimate = true
1530
1531 [[reserve]]
1532 id = "savings"
1533 title = "Savings"
1534 balance = 10000
1535 as_of = 2026-10-01
1536
1537 [[cover]]
1538 reserve = "savings"
1539 budget = "life"
1540 "#;
1541
1542 #[test]
1543 fn a_budget_on_its_way() {
1544 let ledger: Ledger = toml::from_str(LEDGER_TEXT).unwrap();
1545 let today = date("2026-10-15");
1546 let home = ledger.status(&ledger.budgets[0], today);
1547 assert_eq!((home.so_far, home.to_come, home.projected), (Money(-80000), Money(150000), Money(70000)));
1548 assert_eq!(home.verdict, Verdict::Better);
1549 assert!(home.reserve_transfer.is_none());
1550 let life = ledger.status(&ledger.budgets[1], today);
1551 assert_eq!(life.verdict, Verdict::Short);
1552 assert_eq!(life.reserve_transfer, Some(("savings".to_string(), Money(80000))));
1553 }
1554
1555 #[test]
1556 fn a_reserve_that_covers() {
1557 let ledger: Ledger = toml::from_str(LEDGER_TEXT).unwrap();
1558 let status = ledger.reserve_status(&ledger.reserves[0], date("2026-10-15"));
1559 assert_eq!(status.balance_now, Money(1_000_000));
1560 assert_eq!(status.month_planned, Money(80000));
1561 assert_eq!(status.year_planned, Money(240000));
1562 assert_eq!(status.year_end, Money(760000));
1563 assert_eq!(status.months_left, Some(12));
1564 let mut low = ledger.reserves[0].clone();
1566 low.floor = Money(2_000_000);
1567 assert_eq!(ledger.reserve_status(&low, date("2026-10-15")).months_left, Some(0));
1568 }
1569
1570 #[test]
1571 fn money_set_aside_is_kept() {
1572 let ledger: Ledger = toml::from_str(
1573 r#"
1574 [[budget]]
1575 id = "home"
1576 title = "Home"
1577 period = "month"
1578 since = 2026-10-01
1579 [[line]]
1580 budget = "home"
1581 date = 2026-10-20
1582 amount = 1000
1583 label = "Withdrawal"
1584 reserve = "savings"
1585 planned = true
1586 [[preset]]
1587 budget = "home"
1588 label = "Rent"
1589 amount = -600
1590 every = "month"
1591 day = 1
1592 from = 2026-11-01
1593 [[reserve]]
1594 id = "savings"
1595 title = "Savings"
1596 balance = 5000
1597 as_of = 2026-10-01
1598 [[cover]]
1599 reserve = "savings"
1600 budget = "home"
1601 "#,
1602 )
1603 .unwrap();
1604 let today = date("2026-10-15");
1605 let reserve = ledger.reserve_status(&ledger.reserves[0], today);
1607 assert_eq!(reserve.year_planned, Money(120000));
1608 assert_eq!(reserve.year_end, Money(380000));
1609 let november = ledger.status(&ledger.budgets[0], date("2026-11-15"));
1610 assert_eq!(november.opening, Money(100000));
1611 }
1612
1613 fn triaged(from: &str, subject: &str, date: &str, id: &str, body: &str) -> Triaged {
1614 use crate::porch::{self, Context, KnownSenders};
1615 let raw = format!("From: {from}\r\nSubject: {subject}\r\nDate: {date}\r\nMessage-ID: <{id}>\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{body}\r\n");
1616 let known = KnownSenders::default();
1617 porch::triage(crate::card::Card::from_bytes(raw.as_bytes()).unwrap(), &Context { projects: None, known: &known, senders: &crate::porch::Senders::default(), trusted_ids: &[], now: None, priority: Default::default(), own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &[], spam: None })
1618 }
1619
1620 const PAY: &str = "Pay Exemple <service@pay.example>";
1621
1622 #[test]
1623 fn mail_becomes_a_line() {
1624 let ledger: Ledger = toml::from_str("[[mail_rule]]\nbudget = 'projects'\ndirection = 'credit'\nfrom_domains = ['pay.example']\nsubject_contains = ['paiement reçu']").unwrap();
1625 let mail = [triaged(PAY, "Notification de paiement reçu", "Fri, 02 Oct 2026 10:00:00 +0200", "p1@pay.example", "Vous avez reçu un paiement de 25,00 € EUR de Jean Exemple (jean@example.org).")];
1626 let lines = mail_lines(&ledger, &mail, &BTreeSet::new(), crate::words::Words::builtin_ref());
1627 assert_eq!(lines.len(), 1);
1628 let line = lines[0].line("projects").unwrap();
1629 assert_eq!((line.amount, line.label.as_str()), (Money(2500), "Jean Exemple"));
1630 assert_eq!(line.links, vec!["mid:p1@pay.example".to_string()]);
1631 assert_eq!((lines[0].budget.as_deref(), &lines[0].state), (Some("projects"), &MailState::Proposed));
1632 let recorded = Ledger { lines: vec![line], ..ledger };
1634 assert_eq!(mail_lines(&recorded, &mail, &BTreeSet::new(), crate::words::Words::builtin_ref())[0].state, MailState::Recorded);
1635 assert_eq!(mail_lines(&Ledger::default(), &mail, &BTreeSet::from(["mid:p1@pay.example".to_string()]), crate::words::Words::builtin_ref())[0].state, MailState::Ignored);
1636 }
1637
1638 #[test]
1639 fn one_payment_told_twice_counts_once() {
1640 let mail = [
1641 triaged(PAY, "SHOP: 23,98 € EUR", "Wed, 30 Sep 2026 22:36:00 +0200", "pay@pay.example", "Vous avez autorisé un paiement de 23,98 € EUR en faveur de SHOP"),
1642 triaged("Shop <noreply@shop.example>", "Votre commande est validée !", "Wed, 30 Sep 2026 23:35:00 +0200", "order@shop.example", "Article 19,99 €\r\nLivraison 3,99 €\r\nTotal 23,98 €"),
1643 triaged("Telecom <factures@telecom.example>", "Votre facture mobile est disponible", "Sat, 26 Sep 2026 00:08:00 +0200", "bill@telecom.example", "Rendez-vous dans votre espace."),
1644 triaged("Telecom <factures@telecom.example>", "Le règlement de votre facture a bien été effectué", "Fri, 02 Oct 2026 17:30:00 +0200", "settled@telecom.example", "Merci."),
1645 ];
1646 let lines = mail_lines(&Ledger::default(), &mail, &BTreeSet::new(), &crate::words::with_processor());
1648 let state = |key: &str| lines.iter().find(|l| l.key == key).unwrap().state.clone();
1649 assert_eq!(state("mid:pay@pay.example"), MailState::Proposed);
1650 assert_eq!(state("mid:order@shop.example"), MailState::Duplicate("mid:pay@pay.example".into()));
1651 assert_eq!(state("mid:bill@telecom.example"), MailState::Duplicate("mid:settled@telecom.example".into()));
1652 assert_eq!(state("mid:settled@telecom.example"), MailState::Proposed);
1653 assert_eq!(lines[0].key, "mid:settled@telecom.example");
1655 }
1656
1657 #[test]
1658 fn recorded_lines_keep_the_file() {
1659 let dir = std::env::temp_dir().join(format!("sioul-ledger-{}", std::process::id()));
1660 std::fs::create_dir_all(&dir).unwrap();
1661 let path = dir.join(LEDGER);
1662 let text = "# My budgets.\n[[budget]]\nid = 'home'\ntitle = 'Home'\nperiod = 'month'\n\n\
1664 [[line]]\nbudget = 'home'\ndate = 2026-10-20\namount = -100\nlabel = 'Planned'\nplanned = true\n\n\
1665 [[reserve]]\nid = 'savings'\ntitle = 'Savings'\nbalance = 1000\nas_of = 2026-10-01\n\n# Notes at the end.\n";
1666 std::fs::write(&path, text).unwrap();
1667 let line = Line {
1668 budget: "home".into(),
1669 date: date("2026-10-02"),
1670 amount: Money(-2999),
1671 label: "Telecom".into(),
1672 planned: false,
1673 reserve: None,
1674 links: vec!["mid:settled@telecom.example".into()],
1675 preset: Some("phone".into()),
1676 };
1677 record_line(&path, &line, "From mail: “Le règlement de votre facture” from Telecom.").unwrap();
1678 let text = std::fs::read_to_string(&path).unwrap();
1679 assert!(text.starts_with("# My budgets.\n"), "{text}");
1680 assert!(text.find("# Notes at the end.").unwrap() < text.find("# From mail").unwrap(), "{text}");
1681 let ledger = Ledger::load_file(&path).unwrap();
1682 assert_eq!((ledger.lines.len(), ledger.reserves.len()), (2, 1));
1683 let back = &ledger.lines[1];
1684 assert_eq!((back.amount, back.date, back.preset.as_deref()), (Money(-2999), date("2026-10-02"), Some("phone")));
1685 assert_eq!(back.links, vec!["mid:settled@telecom.example".to_string()]);
1686 std::fs::remove_dir_all(&dir).unwrap();
1687 }
1688
1689 #[test]
1690 fn a_line_stands_for_its_preset() {
1691 let text = "[[budget]]\nid = 'home'\ntitle = 'Home'\nperiod = 'month'\n\
1692 [[preset]]\nid = 'phone'\nbudget = 'home'\nlabel = 'Phone'\namount = -20\nevery = 'month'\nday = 10\n\
1693 [[preset]]\nid = 'food'\nbudget = 'home'\nlabel = 'Food'\namount = -300\nevery = 'month'\nday = 28\nestimate = true\n";
1694 let mut ledger: Ledger = toml::from_str(text).unwrap();
1695 let today = date("2026-10-20");
1696 let home = ledger.budgets[0].clone();
1697 assert_eq!(ledger.status(&home, today).projected, Money(-32000));
1698 let line = |amount: f64, preset: &str| Line {
1699 budget: "home".into(),
1700 date: date("2026-10-12"),
1701 amount: Money::from_units(amount),
1702 label: preset.into(),
1703 planned: false,
1704 reserve: None,
1705 links: Vec::new(),
1706 preset: Some(preset.into()),
1707 };
1708 ledger.lines.push(line(-29.99, "phone"));
1710 let status = ledger.status(&home, today);
1711 assert_eq!((status.so_far, status.projected), (Money(-2999), Money(-32999)));
1712 ledger.lines.push(line(-50.0, "food"));
1714 assert_eq!(ledger.status(&home, today).projected, Money(-32999));
1715 ledger.lines.push(line(-400.0, "food"));
1716 assert_eq!(ledger.status(&home, today).projected, Money(-2999 - 45000));
1717 }
1718}