Skip to main content

sioul_core/
budget.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Budgets, reserves, and the lines between them.
5//!
6//! A budget is an envelope with a goal: credits come in and debits go out over
7//! a month or a year, and the balance should reach its target by the end of
8//! the period. Lines come from mail (invoices, payment notifications, through
9//! mail rules), from presets (automatic payments that send no mail: rent,
10//! insurance, wages), and by hand. Reserves (savings accounts) cover the
11//! budgets assigned to them: at the end of a period a shortfall is drawn from
12//! the reserve, and a surplus can be swept back into it.
13//!
14//! A budget keeps its balance from one period to the next, as an envelope
15//! keeps what was not spent: money set aside in October for a bill due in
16//! December is still there in December, and no reserve is drawn twice.
17//!
18//! "On track" does not assume money flows evenly: the balance carried in, what
19//! happened so far, and what is known to come before the end of the period
20//! (presets and planned lines) are compared with the target (docs/accounting.md).
21
22use 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
35/// The file's name, at the root of the notes folder.
36pub const LEDGER: &str = "sioul-budgets.toml";
37
38/// TOML writes dates bare (`2026-10-01`) and jiff reads them as text: both are accepted.
39pub(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    /// The first and the last day of the period containing `day`.
70    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    /// The first day of the next period.
78    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/// An envelope with a goal.
88#[derive(Debug, Clone, Deserialize)]
89pub struct Budget {
90    pub id: String,
91    pub title: String,
92    pub period: Period,
93    /// The balance to reach by the end of each period; 0 is breaking even.
94    #[serde(default)]
95    pub target: Money,
96    /// The day the budget's balance starts from; nothing before it counts. Without it,
97    /// the budget starts with the current period.
98    #[serde(default, deserialize_with = "dates::optional")]
99    pub since: Option<Date>,
100    /// Projects, notes, tasks this budget belongs with.
101    #[serde(default)]
102    pub links: Vec<String>,
103    /// "personal" for leisure money: it stays in view in quiet time.
104    #[serde(default)]
105    pub area: Option<String>,
106}
107
108/// A payment that comes back: rent, insurance, wages; or an estimate of what is
109/// spent without a trace (food, cash). Mail can stand for one of its occurrences:
110/// a line linked to it (`preset`) replaces a fixed preset's amount, and is spent
111/// from an estimate's envelope, never added on top.
112#[derive(Debug, Clone, Deserialize)]
113pub struct Preset {
114    /// The name lines and mail rules use to stand for it.
115    #[serde(default)]
116    pub id: Option<String>,
117    pub budget: String,
118    pub label: String,
119    /// Positive for money in, negative for money out.
120    pub amount: Money,
121    pub every: Period,
122    /// The day of the month; 31 means the last day, whatever the month.
123    pub day: i8,
124    /// For yearly presets, the month (1 to 12).
125    #[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    /// An estimate (daily spending), not a fixed payment.
132    #[serde(default)]
133    pub estimate: bool,
134    #[serde(default)]
135    pub links: Vec<String>,
136}
137
138impl Preset {
139    /// The dates this preset falls on, between `from` and `to` included.
140    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    /// This preset's date in the period starting on `period_start`.
154    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/// One credit or debit of a budget.
169#[derive(Debug, Clone, Deserialize)]
170pub struct Line {
171    pub budget: String,
172    #[serde(deserialize_with = "dates::required")]
173    pub date: Date,
174    /// Positive for money in, negative for money out.
175    pub amount: Money,
176    pub label: String,
177    /// Known to come, not happened yet.
178    #[serde(default)]
179    pub planned: bool,
180    /// A transfer with this reserve: positive moves money from the reserve into the budget.
181    #[serde(default)]
182    pub reserve: Option<String>,
183    /// What it comes from or belongs with: `mid:` for a message, a file, a task.
184    #[serde(default)]
185    pub links: Vec<String>,
186    /// The preset this line stands for in its period (see `Preset`).
187    #[serde(default)]
188    pub preset: Option<String>,
189}
190
191/// A savings account that covers budgets: an assurance vie, a Livret A.
192#[derive(Debug, Clone, Deserialize)]
193pub struct Reserve {
194    pub id: String,
195    pub title: String,
196    /// The balance on `as_of`; transfers after that date are counted from it.
197    pub balance: Money,
198    #[serde(deserialize_with = "dates::required")]
199    pub as_of: Date,
200    /// Never planned below this.
201    #[serde(default)]
202    pub floor: Money,
203    /// How many days money asked from it takes to arrive: 0 for a Livret A,
204    /// about ten for an assurance vie.
205    #[serde(default)]
206    pub delay_days: u32,
207    #[serde(default)]
208    pub links: Vec<String>,
209}
210
211/// Where money actually is: a current account, PayPal, Stripe. Its movements,
212/// read from its exports (`bank::Bank`), fill the budgets it pays for
213/// (docs/accounting.md, "Bank accounts").
214#[derive(Debug, Clone, Default, Deserialize)]
215pub struct BankAccount {
216    pub id: String,
217    pub title: String,
218    /// "bank", "paypal", "stripe", "other".
219    #[serde(default)]
220    pub kind: Option<String>,
221    /// The budgets it pays for and is paid into; the first takes what nothing else places.
222    #[serde(default)]
223    pub fills: Vec<String>,
224    /// What its exports call it (an account number, an IBAN), besides its id.
225    #[serde(default)]
226    pub exports: Vec<String>,
227    /// Kept on it at least: below, the reserves top it up, in `topped_up_by` order.
228    #[serde(default)]
229    pub floor: Money,
230    /// The reserves that top it up, the first first: a Livret A (at once), an assurance vie (after its delay).
231    #[serde(default)]
232    pub topped_up_by: Vec<String>,
233    #[serde(default)]
234    pub links: Vec<String>,
235}
236
237impl BankAccount {
238    /// Whether a movement kept under `name` (`bank::Movement::account`) is this account's.
239    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/// Where a bank account's movement goes, when its label holds one of `words`:
249/// a budget (and the recurring payment it stands for), a reserve (money moved
250/// with a savings account), or another bank account (money moved between
251/// yours, counted in no budget). The first rule that holds wins.
252#[derive(Debug, Clone, Default, Deserialize)]
253pub struct Split {
254    /// Its id (`ids`), by which your devices merge it and the window names it;
255    /// empty for a rule an older Sioul made, until the sharing gives it one.
256    #[serde(default)]
257    pub id: String,
258    /// The bank account it reads; empty for all.
259    #[serde(default)]
260    pub account: String,
261    /// Any of these in the label, case and accents aside.
262    #[serde(default)]
263    pub words: Vec<String>,
264    /// Money in, money out, or either.
265    #[serde(default)]
266    pub direction: Option<Direction>,
267    #[serde(default)]
268    pub budget: Option<String>,
269    /// The preset such movements are spent from or stand for: the food envelope.
270    #[serde(default)]
271    pub preset: Option<String>,
272    #[serde(default)]
273    pub reserve: Option<String>,
274    /// Another of your bank accounts: a transfer, in no budget.
275    #[serde(default)]
276    pub transfer: Option<String>,
277}
278
279/// One movement placed by your hand: in a budget, in parts between budgets, or
280/// in none (a transfer between your accounts, a refund already counted).
281#[derive(Debug, Clone, Default, Deserialize)]
282pub struct Assignment {
283    pub account: String,
284    /// The movement's id (`bank::Movement::id`).
285    pub movement: String,
286    #[serde(default)]
287    pub budget: Option<String>,
288    /// In parts: each budget its share; what they leave goes to `budget`.
289    #[serde(default)]
290    pub parts: Vec<Part>,
291    /// In no budget.
292    #[serde(default)]
293    pub none: bool,
294}
295
296/// A movement's share in one budget.
297#[derive(Debug, Clone, Default, Deserialize)]
298pub struct Part {
299    pub budget: String,
300    pub amount: Money,
301}
302
303/// Which reserve covers which budget's shortfall, at the end of each period.
304#[derive(Debug, Clone, Deserialize)]
305pub struct Cover {
306    pub reserve: String,
307    pub budget: String,
308    /// Surpluses go back into the reserve too.
309    #[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/// Mail that becomes a line: an invoice, a payment notification.
321#[derive(Debug, Clone, Deserialize)]
322pub struct MailRule {
323    pub budget: String,
324    pub direction: Direction,
325    /// Which messages: the same conditions as a project's routes (docs/notes-folder.md).
326    #[serde(flatten)]
327    pub route: Route,
328    /// A fixed amount; else the amount the message states.
329    #[serde(default)]
330    pub amount: Option<Money>,
331    #[serde(default)]
332    pub label: Option<String>,
333    /// Added without asking; otherwise proposed.
334    #[serde(default)]
335    pub automatic: bool,
336    /// The preset such a message stands for: the phone bill for the phone preset.
337    #[serde(default)]
338    pub preset: Option<String>,
339}
340
341/// Everything in `sioul-budgets.toml`.
342#[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
364/// "As planned" within this many cents of the target, the same for `sioul
365/// budgets` (`status`) and the window's verdict (`pace`): 5 % of what the
366/// period moves (each of its `flows`, in or out, done or planned, and what it
367/// still `needed` beyond what is scheduled), never closer than 10 units
368/// (docs/accounting.md, "On track, without assuming money flows evenly").
369/// Wide enough that a budget whose money comes later in the month is not
370/// "short" in its first days.
371fn 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
376/// What the period still needs, beyond what is scheduled in it, to reach the
377/// target from `opening` (`pace`, `tolerance`).
378fn 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/// One flow of money in a budget, whatever its source.
384#[derive(Debug, Clone, Copy)]
385struct Flow {
386    amount: Money,
387    planned: bool,
388    /// Known in advance: a preset's occurrence or a planned line. The rest
389    /// (what simply happened) is judged by its pace.
390    scheduled: bool,
391}
392
393/// How a budget's period is going, against its target.
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum Verdict {
396    Better,
397    AsPlanned,
398    Short,
399}
400
401/// A budget's current period, at a glance.
402#[derive(Debug, Clone)]
403pub struct BudgetStatus {
404    pub id: String,
405    pub start: Date,
406    pub end: Date,
407    /// The balance carried in from the previous periods.
408    pub opening: Money,
409    /// What happened so far this period (presets dated up to today count as done).
410    pub so_far: Money,
411    /// What is known to come before the end.
412    pub to_come: Money,
413    /// The balance expected at the end: opening + so far + to come.
414    pub projected: Money,
415    /// The part of a surplus already set aside for planned lines after this period
416    /// (a bill, a tax due later): not "better than planned", only kept.
417    pub earmarked: Money,
418    pub target: Money,
419    /// `projected - target`: positive is better than planned.
420    pub gap: Money,
421    pub verdict: Verdict,
422    /// At the end of the period: drawn from a reserve (positive), or swept into it (negative).
423    pub reserve_transfer: Option<(String, Money)>,
424}
425
426/// A reserve, at a glance. Amounts leaving the reserve are positive.
427#[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    /// How long it lasts above its floor at the coming year's average draw.
437    pub months_left: Option<i64>,
438}
439
440impl Ledger {
441    /// Reads `sioul-budgets.toml` at the root of a notes folder.
442    pub fn load(root: &Path) -> Result<Ledger, String> {
443        Ledger::load_file(&root.join(LEDGER))
444    }
445
446    /// Reads a budget file anywhere.
447    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    /// A budget's title by id, else the id.
453    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    /// A reserve's title by id, else the id.
458    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    /// How a budget's current period is going.
463    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    /// The balance carried into the period starting on `start`: everything since the
469    /// budget's start, as it happened (transfers from reserves included as lines).
470    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    /// What the planned lines of the coming year will take, after `end` (as a positive amount).
502    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    /// Every flow of a budget between two dates: lines and preset occurrences.
514    /// A line that stands for a preset counts through it (see `occurrence`).
515    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    /// The preset occurrence a line stands for, if its preset falls in the line's period.
531    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    /// One occurrence of a preset, with the lines standing for it in its period.
539    /// Without any, a preset dated up to today is assumed done. With some, a
540    /// fixed preset is replaced by them (the real amount); an estimate is an
541    /// envelope they are spent from, and what remains of it is still to come.
542    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    /// A reserve's balance and its planned draws.
563    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        // Under its floor already: it lasts no month, never fewer.
580    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    /// What will leave a reserve after `today`, up to `until`: planned transfers,
594    /// and the covers of every budget period ending in that time.
595    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    /// The reserve transfers of a budget's periods, from the current one to those
613    /// ending by `until`; each period opens with the last one's balance after its transfer.
614    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            // A sweep takes only the surplus beyond what is set aside (`gap` excludes it).
623            status = self.status_for(budget, s, e, today, status.projected + transfer);
624        }
625        total
626    }
627}
628
629/// A message about money, and what becomes of it in the budgets.
630#[derive(Debug, Clone)]
631pub struct MailLine {
632    /// The message: `mid:<Message-ID>`, else `file:<path>`. Lines recorded from it carry it in `links`.
633    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    /// The budget a mail rule gives it.
641    pub budget: Option<String>,
642    /// The preset it stands for, from its rule.
643    pub preset: Option<String>,
644    /// The label its line takes: the rule's, else who was paid or who paid.
645    pub label: String,
646    pub state: MailState,
647    /// From someone new and not verified: a fake "your payment" is a classic trick.
648    pub doubtful: bool,
649}
650
651#[derive(Debug, Clone, PartialEq, Eq)]
652pub enum MailState {
653    /// In the budget file already.
654    Recorded,
655    /// You said it is not a payment.
656    Ignored,
657    /// The same payment as another message (its key): a merchant's order
658    /// confirmation and the processor's payment, a bill and its settlement, one
659    /// message received at two addresses.
660    Duplicate(String),
661    /// Waits for you: add it, or not.
662    Proposed,
663}
664
665impl MailLine {
666    /// The amount with its sign: money in is positive.
667    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    /// The line it becomes in a budget; none without an amount.
673    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
687/// Every message about money among `items`, newest first, with what becomes of
688/// it. Mail set aside (forged, spam) and the review queue's (what your own spam
689/// filter flagged or moved) are never read; a rule gives the budget and
690/// the preset; `ignored` holds the keys you said are not payments.
691pub 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        // The rule knows which way the money goes better than the wording does.
720        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
752/// Days within which a merchant's confirmation and the processor's payment are one payment.
753const SAME_PAYMENT_DAYS: i32 = 3;
754/// Days within which a bill and the message saying it was settled belong together.
755const SETTLED_DAYS: i32 = 40;
756
757/// Marks the second message of each pair that tells the same payment: the same
758/// message at two addresses; an order and the payment for it (same amount, a
759/// few days apart); a bill and its settlement (same sender). What you recorded
760/// stays counted, and its partner becomes the duplicate.
761fn 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
780/// Whether `a` tells the payment `b` tells, `b` being the one that counts.
781fn 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    // An order and a payment are matched by their amounts: both must have one.
790    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    // Two donations of the same amount from two people are two payments: the party must match too.
802    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/// A budget as its form gives it.
812#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize)]
813pub struct BudgetEdit {
814    pub title: String,
815    /// "month" or "year".
816    pub period: String,
817    /// The balance to reach by the end of each period, in the ledger's currency.
818    #[serde(default)]
819    pub target: f64,
820    #[serde(default)]
821    pub personal: bool,
822    /// What it is for: "work", "admin", "leisure", joined by "+" (docs/areas.md); written over `personal` when given.
823    #[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
834/// Makes a budget (`id` empty) or changes one, in place, its comments and
835/// its other fields kept; returns its id.
836pub fn save_budget(path: &Path, id: &str, edit: &BudgetEdit) -> Result<String, String> {
837    // Read, changed and written under the file's lock, which the sharing takes too.
838    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        // The first budget makes the file; a file that cannot be read is never written over.
848        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
881/// Takes a budget out of the file. Its lines stay, written as they were,
882/// and no longer count anywhere.
883pub fn remove_budget(path: &Path, id: &str) -> Result<(), String> {
884    // Read, changed and written under the file's lock, which the sharing takes too.
885    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/// A line of the file as the page showed it, to find it again: lines are named
900/// by their place among the lines (`sioul:budget/<budget>/<place>`), and the
901/// file may have changed since the page was made (a line added or taken out
902/// on another device, or by hand). Never changed by its place alone.
903#[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    /// The line an address names (`sioul:budget/<budget>/<place>`), with what
913    /// the page showed of it (JSON `{"day", "value", "label"}`, as the budget
914    /// page's rows give them): its place, and the line to find there.
915    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    /// Whether `table`, a `[[line]]` of the file, is this line, read as the ledger reads it.
932    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
945/// Where the line `seen` is in the file now: at `place` when it is still
946/// there, else the one line of the file that is it. None when it is gone,
947/// changed, or not told apart from another line just like it.
948fn 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
959/// Takes one line out of the file: the one at `place` among the lines (as
960/// `sioul:budget/<budget>/<place>` names it) when it is still the line
961/// `seen`, else the one line that is (`find_line`). Ok(false) when the line
962/// is not found as it was: nothing is changed, for the page to say so.
963pub fn remove_line(path: &Path, place: usize, seen: &SeenLine) -> Result<bool, String> {
964    // Read, changed and written under the file's lock, which the sharing takes too.
965    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
977/// A line of the file changed in place: its label, amount and date; its links,
978/// its budget and the comment saying where it came from stay. A date to come
979/// makes it planned. The line is found as `remove_line` finds it; Ok(false)
980/// when it is not found as it was (`seen`): nothing is changed.
981pub 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    // Read, changed and written under the file's lock, which the sharing takes too.
983    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
1006/// Writes a line at the end of the budget file, keeping its comments, with a
1007/// comment saying where it came from.
1008pub fn record_line(path: &Path, line: &Line, origin: &str) -> Result<(), String> {
1009    // Read, changed and written under the file's lock, which the sharing takes too.
1010    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        // Its own id, given now (`ids`): your devices merge each line by it, so
1016        // that two lines the same stay two (docs/database.md, "The format of what travels").
1017        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/// How a budget's period is going, at its pace: what is known in advance
1044/// (presets, planned lines) counts in full; the rest is judged against the
1045/// part of the period gone. Ten days into thirty, a third of what the period
1046/// still needs should have come (or no more than a third of what it can
1047/// spend should be spent): then it is on track.
1048#[derive(Debug, Clone, PartialEq)]
1049pub struct Pace {
1050    pub start: Date,
1051    pub end: Date,
1052    /// The part of the period gone, today included: 0 to 1.
1053    pub gone: f64,
1054    pub opening: Money,
1055    /// Presets and planned lines of the whole period, done or to come.
1056    pub scheduled: Money,
1057    /// What else happened so far.
1058    pub unscheduled: Money,
1059    /// What the rest of the period must bring to reach the target; negative: what it may spend.
1060    pub needed: Money,
1061    /// `unscheduled - gone × needed`: positive is ahead of the pace.
1062    pub ahead: Money,
1063    pub verdict: Verdict,
1064    /// The balance expected at the end: at the pace of the past periods when
1065    /// there are some, else the difference of today carried to the end.
1066    pub realistic: Money,
1067    /// Pessimistic and optimistic ends, from the past periods: three at least.
1068    pub range: Option<(Money, Money)>,
1069    /// Past periods with movements, to judge from.
1070    pub history: usize,
1071    pub target: Money,
1072}
1073
1074impl Ledger {
1075    /// Unscheduled movements of each past period (the newest twelve with any).
1076    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    /// The current period at its pace (see `Pace`).
1097    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        // The same tolerance as `status`: one "as planned" everywhere.
1110        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            // Nothing to judge from: what is ahead or behind today stays so at the end.
1127            (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/// One movement of a budget's period, as its ledger lists it.
1134#[derive(Debug, Clone, PartialEq)]
1135pub struct Movement {
1136    pub date: Date,
1137    pub label: String,
1138    pub amount: Money,
1139    /// Still to come.
1140    pub planned: bool,
1141    /// A recurring movement (a preset's occurrence).
1142    pub recurring: bool,
1143    /// Its line in the file, when it is one: `sioul:budget/<budget>/<position>`.
1144    pub line: Option<usize>,
1145}
1146
1147/// The balance on a day, and whether that day is still to come.
1148#[derive(Debug, Clone, PartialEq)]
1149pub struct Point {
1150    pub date: Date,
1151    pub balance: Money,
1152    pub future: bool,
1153}
1154
1155impl Ledger {
1156    /// Every movement of a budget between two dates, oldest first: its lines
1157    /// and its recurring movements.
1158    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            // A bank account's movement is no line of the file: changed where it was placed.
1165            .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                // A line standing for this occurrence is listed itself.
1170                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    /// The balance at the end of each step ("day", "week", "month", "year")
1184    /// from `from` to `to`, planned movements included.
1185    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/// A recurring movement, as its form gives it.
1223#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, Deserialize)]
1224pub struct PresetEdit {
1225    pub budget: String,
1226    pub label: String,
1227    /// Positive in, negative out, in units: -650.0.
1228    pub amount: f64,
1229    /// "month" or "year".
1230    pub every: String,
1231    pub day: i8,
1232    #[serde(default)]
1233    pub month: Option<i8>,
1234    /// "2026-10-01", or "" for from now on.
1235    #[serde(default)]
1236    pub from: String,
1237    #[serde(default)]
1238    pub until: String,
1239    #[serde(default)]
1240    pub estimate: bool,
1241}
1242
1243/// Adds a recurring movement to the budget file, its comments kept.
1244pub fn record_preset(path: &Path, preset: &PresetEdit, origin: &str) -> Result<(), String> {
1245    // Read, changed and written under the file's lock, which the sharing takes too.
1246    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        // Its own id (`ids`): lines and rules name it by it, and your devices merge it by it.
1259        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
1290/// The planned line `link` names (an invoice, "sioul:invoice/2026-002") made
1291/// real: paid on `date`, no longer planned. Returns whether there was one.
1292pub fn settle_line(path: &Path, link: &str, date: Date) -> Result<bool, String> {
1293    // Read, changed and written under the file's lock, which the sharing takes too.
1294    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    /// Lines are named by their place in the file, which may change after the
1335    /// page was made: a line added before it, one taken out, an edit by hand.
1336    /// The line changed or taken out is the one the page showed, found again
1337    /// by its content, or none, and nothing changes.
1338    #[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        // The page showed "Bakery" at place 1; meanwhile a line came before it.
1347        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        // Taken out the same way: the line shown, not the one now at its place.
1352        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        // Changed meanwhile (by hand, or on another device): not found as it was, nothing changes.
1356        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        // Two lines just alike, neither at its place: not told apart, nothing changes.
1362        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        // At its place, one of the two is as good as the other.
1366        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        // As the page names it: its address and what its row showed.
1371        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        // Latin-1, as another editor may save it: not UTF-8.
1381        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        // No file yet: the first budget makes it.
1388        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        // A budget that breaks even; its subscriptions are taken on the 1st; its income comes when it comes.
1396        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        // Halfway with nothing: behind its pace, said once.
1406        assert_eq!(ledger.pace(budget, date("2026-10-16")).verdict, Verdict::Short);
1407        // With three months of income behind it: a realistic end, and a range.
1408        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        // Known so far (-110 + 30), then the median month (120) for the 28 days left.
1413        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        // The ledger of the month, and its balance day by day.
1419        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    /// One tolerance says "as planned" in `sioul budgets` (`status`) and in the
1430    /// window (`pace`): 5 % of what the period moves, never closer than 10
1431    /// (docs/accounting.md). `sioul budgets` used 2 % of what goes out, and
1432    /// called "better" what the window called "as planned".
1433    #[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        // 1,000 out (the rent), 1,200 in, 1,000 still needed beyond the rent: 3,200 moved, 160 tolerated. 200 above: better, in both.
1440        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        // 100 above, within 155: as planned, in both (2 % of what goes out, 20, said "better").
1446        assert_eq!(both(&with_income("1100")), (Verdict::AsPlanned, Verdict::AsPlanned));
1447        // 200 below, beyond 140: short, in both.
1448        assert_eq!(both(&with_income("800")), (Verdict::Short, Verdict::Short));
1449        // Little moving: never closer than 10.
1450        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        // Under its floor already: no month left, never fewer.
1565        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        // October keeps the €1,000; November spends €600 of it; December needs €200 more.
1606        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        // Once in the file, it is counted, not proposed again.
1633        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        // The processor's authorisation is its own wording, added as its users add it.
1647        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        // Newest first.
1654        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        // As in a real file: lines in the middle, reserves after them, notes at the end.
1663        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        // The real phone bill replaces the preset's amount.
1709        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        // Spending within the food envelope does not add to it; beyond it, it does.
1713        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}