Skip to main content

sioul_app/
bank.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The money watch, for the window (docs/accounting.md, "The bank" and "Bank
5//! accounts"): your bank accounts (a current account, PayPal, Stripe), each
6//! with its exports taken in, the budgets it fills, where each movement went
7//! and why, what tops it up; then in words what passed and what did not, the
8//! week's payments, and the balance carried forward a month.
9
10use crate::backend::{json, load_config, say, tr};
11use serde::Serialize;
12use sioul_core::bank::{Bank, Finding};
13use sioul_core::budget::Ledger;
14use sioul_core::money::Money;
15
16#[derive(Serialize, Default)]
17struct View {
18    store: bool,
19    /// Movements or a balance were read once at least.
20    any: bool,
21    /// "€845.10 on Saturday 3 October, from your bank's file."
22    balance: String,
23    findings: Vec<String>,
24    /// The week's payments: "Thursday 8 October · EDF · €62.00".
25    coming: Vec<String>,
26    /// The balance, day by day: {day ("2026-10-03"), cents}.
27    forecast: Vec<Point>,
28    /// For the Porch: the week's payments in one line, and whether the account holds them.
29    week: String,
30    /// What the watch found, for the Porch to point at the Budgets page.
31    attention: usize,
32    problem: String,
33    /// Your bank accounts, as declared in the budget file.
34    accounts: Vec<AccountView>,
35    /// What a movement can go to: the budgets, the reserves, the accounts.
36    budgets: Vec<Named>,
37    reserves: Vec<ReserveView>,
38}
39
40#[derive(Serialize, Clone)]
41struct Named {
42    id: String,
43    title: String,
44}
45
46#[derive(Serialize)]
47struct ReserveView {
48    id: String,
49    title: String,
50    balance: f64,
51    as_of: String,
52    floor: f64,
53    delay_days: u32,
54}
55
56#[derive(Serialize)]
57struct AccountView {
58    id: String,
59    title: String,
60    /// "bank", "paypal", "stripe", "other".
61    kind: String,
62    /// "€845.10 on Saturday 3 October", or "" before its first export.
63    balance: String,
64    /// Two of its exports' balances on one day differ: two real accounts
65    /// under one (`accounts::two_accounts_in_one`), said calmly.
66    two_accounts: bool,
67    /// The budgets it fills, the one taking what nothing else places first.
68    fills: Vec<Named>,
69    floor: f64,
70    /// "€100.00", or "" without a floor.
71    floor_text: String,
72    topped_up_by: Vec<Named>,
73    /// What tops it up this month, in sentences.
74    top_ups: Vec<String>,
75    /// Newest first.
76    movements: Vec<MovementView>,
77    /// Movements nothing placed: the account fills no budget.
78    unplaced: usize,
79    /// The rules that read its movements (its own and those for every account), in file order.
80    rules: Vec<RuleView>,
81}
82
83#[derive(Serialize)]
84struct MovementView {
85    id: String,
86    date: String,
87    label: String,
88    amount: String,
89    cents: i64,
90    /// Where it went, in words.
91    place: String,
92    /// "hand", "mail", "rule", "between", "preset", "first", "unknown".
93    why: String,
94    /// Your choice for it: `budget:<id>`, `none`, or `""` (as the rules say).
95    chosen: String,
96}
97
98#[derive(Serialize)]
99struct RuleView {
100    /// Its id, by which a change names it; empty for a rule an older Sioul made.
101    id: String,
102    /// Its place among the rules of the file (a rule without an id is named by it).
103    place: usize,
104    words: Vec<String>,
105    /// "credit", "debit", "".
106    direction: String,
107    /// `budget:<id>`, `reserve:<id>`, `transfer:<id>`, `preset:<id>`.
108    to: String,
109    to_title: String,
110    /// It reads every account's movements, not this one's alone.
111    everywhere: bool,
112}
113
114#[derive(Serialize)]
115struct Point {
116    day: String,
117    cents: i64,
118}
119
120fn finding_text(finding: &Finding, today: jiff::civil::Date) -> String {
121    let day = |d: jiff::civil::Date| tr().day_in(d, today);
122    let money = |m: Money| tr().money(m.abs());
123    match finding {
124        Finding::Missed { label, amount, date } => {
125            let title = say(if amount.is_negative() { "money-missed-out" } else { "money-missed-in" }, &[("label", label.clone()), ("amount", money(*amount))]);
126            format!("{title}. {}", say("money-missed-why", &[("date", day(*date))]))
127        }
128        Finding::Changed { label, expected, actual, date } => say("money-changed", &[("label", label.clone()), ("actual", money(*actual)), ("date", day(*date)), ("expected", money(*expected))]),
129        Finding::Short { label, amount, date, short, reserve } => {
130            let title = say("money-short", &[("label", label.clone()), ("date", day(*date)), ("amount", money(*amount))]);
131            let body = match reserve {
132                Some(r) => say("money-short-reserve", &[("short", money(*short)), ("reserve", r.clone())]),
133                None => say("money-short-ask", &[("short", money(*short))]),
134            };
135            format!("{title}. {body}")
136        }
137    }
138}
139
140/// The bank's part of the Budgets page, as JSON.
141pub(crate) fn view() -> String {
142    let config = load_config();
143    let looked = sioul_core::words::Words::of(&config);
144    let Some(root) = config.notes_root_path() else { return json(&View { problem: tr().text("papers-no-store", None), ..View::default() }) };
145    let bank = match Bank::load(&root) {
146        Ok(bank) => bank,
147        Err(problem) => return json(&View { store: true, problem, ..View::default() }),
148    };
149    let today = jiff::Zoned::now().date();
150    let ledger = Ledger::load(&root).unwrap_or_default();
151    let watch = sioul_core::bank::watch(&bank, &ledger, today, &looked.bank.filler);
152    // Each account once, whatever names its exports gave it (`Bank::balance_in`).
153    let mut view = View { store: true, any: !bank.movements.is_empty() || watch.balance.is_some(), ..View::default() };
154    if let Some((date, amount)) = watch.balance {
155        view.balance = say("bank-balance", &[("amount", tr().money(amount)), ("date", tr().day_in(date, today))]);
156    }
157    view.findings = watch.findings.iter().map(|f| finding_text(f, today)).collect();
158    view.coming = watch.coming.iter().map(|e| format!("{} · {} · {}", tr().day_in(e.date, today), e.label, tr().money(e.amount.abs()))).collect();
159    view.forecast = watch.forecast.iter().map(|(d, m)| Point { day: d.to_string(), cents: m.cents() }).collect();
160    view.attention = watch.findings.len();
161    accounts_into(&mut view, &bank, &ledger, today, &looked);
162    if !watch.coming.is_empty() {
163        let list = watch.coming.iter().take(4).map(|e| format!("{} {} ({})", e.label, tr().money(e.amount.abs()), tr().weekday_short(e.date))).collect::<Vec<_>>().join(", ");
164        let week = today.checked_add(jiff::Span::new().days(7)).unwrap_or(today);
165        let held = watch.forecast.iter().filter(|(d, _)| *d <= week).all(|(_, m)| !m.is_negative());
166        view.week = say(if held { "bank-week-held" } else { "bank-week-short" }, &[("list", list)]);
167    }
168    json(&view)
169}
170
171/// The bank accounts' part of the view: each with its balance, its budgets,
172/// its movements placed and why, its rules, what tops it up.
173fn accounts_into(view: &mut View, bank: &Bank, ledger: &Ledger, today: jiff::civil::Date, looked: &sioul_core::words::Words) {
174    use sioul_core::accounts::{self, Place, Why};
175    let budget_title = |id: &str| ledger.budgets.iter().find(|b| b.id == id).map_or(id.to_string(), |b| b.title.clone());
176    let reserve_title = |id: &str| ledger.reserves.iter().find(|r| r.id == id).map_or(id.to_string(), |r| r.title.clone());
177    let account_title = |id: &str| ledger.bank_accounts.iter().find(|a| a.id == id).map_or(id.to_string(), |a| a.title.clone());
178    let preset_label = |key: &str| {
179        let index = key.strip_prefix('#').and_then(|i| i.parse::<usize>().ok());
180        ledger.presets.iter().enumerate().find(|(i, p)| p.id.as_deref() == Some(key) || index == Some(*i)).map(|(_, p)| p.label.clone()).unwrap_or_default()
181    };
182    view.budgets = ledger.budgets.iter().map(|b| Named { id: b.id.clone(), title: b.title.clone() }).collect();
183    view.reserves = ledger
184        .reserves
185        .iter()
186        .map(|r| ReserveView { id: r.id.clone(), title: r.title.clone(), balance: r.balance.cents() as f64 / 100.0, as_of: r.as_of.to_string(), floor: r.floor.cents() as f64 / 100.0, delay_days: r.delay_days })
187        .collect();
188    let placed = accounts::place(ledger, bank, looked);
189    let ups = accounts::top_ups(ledger, bank, today, looked);
190    for account in &ledger.bank_accounts {
191        let balance = accounts::balance_of(bank, account).map(|(date, amount)| say("bank-balance", &[("amount", tr().money(amount)), ("date", tr().day_in(date, today))])).unwrap_or_default();
192        let mine: Vec<&accounts::Placed> = placed.iter().filter(|p| p.account == account.id).collect();
193        let movements = mine
194            .iter()
195            .rev()
196            .take(120)
197            .map(|p| {
198                let place = match &p.place {
199                    Place::Budgets(shares) => shares
200                        .iter()
201                        .map(|s| {
202                            let preset = s.preset.as_deref().map(preset_label).filter(|l| !l.is_empty());
203                            let title = budget_title(&s.budget);
204                            let title = match preset {
205                                Some(label) => format!("{title} · {label}"),
206                                None => title,
207                            };
208                            if shares.len() > 1 { format!("{title} {}", tr().money(s.amount.abs())) } else { title }
209                        })
210                        .collect::<Vec<_>>()
211                        .join(", "),
212                    Place::Reserve { reserve, budget } => say("bank-place-reserve", &[("reserve", reserve_title(reserve)), ("budget", budget_title(budget))]),
213                    Place::Transfer(other) => say("bank-place-transfer", &[("account", account_title(other))]),
214                    Place::Known(line) => say("bank-place-known", &[("label", ledger.lines.get(*line).map(|l| l.label.clone()).unwrap_or_default())]),
215                    Place::Nowhere => tr().text("bank-place-none", None),
216                };
217                let why = match p.why {
218                    Why::Hand => "hand",
219                    Why::Mail => "mail",
220                    Why::Rule => "rule",
221                    Why::Between => "between",
222                    Why::Preset => "preset",
223                    Why::First => "first",
224                    Why::Unknown => "unknown",
225                };
226                let chosen = ledger
227                    .assignments
228                    .iter()
229                    .find(|a| account.owns(&a.account) && a.movement == p.movement.id)
230                    .map(|a| if a.none { "none".to_string() } else { a.budget.as_deref().map(|b| format!("budget:{b}")).unwrap_or_default() })
231                    .unwrap_or_default();
232                MovementView {
233                    id: p.movement.id.clone(),
234                    date: tr().day_in(p.movement.date, today),
235                    label: p.movement.label.clone(),
236                    amount: tr().money(p.movement.amount),
237                    cents: p.movement.amount.cents(),
238                    place,
239                    why: why.to_string(),
240                    chosen,
241                }
242            })
243            .collect();
244        let rules = ledger
245            .splits
246            .iter()
247            .enumerate()
248            .filter(|(_, s)| s.account.is_empty() || account.owns(&s.account))
249            .map(|(place, s)| {
250                let (to, to_title) = if let Some(other) = &s.transfer {
251                    (format!("transfer:{other}"), say("bank-place-transfer", &[("account", account_title(other))]))
252                } else if let Some(reserve) = &s.reserve {
253                    (format!("reserve:{reserve}"), reserve_title(reserve))
254                } else if let Some(budget) = &s.budget {
255                    (format!("budget:{budget}"), budget_title(budget))
256                } else if let Some(preset) = &s.preset {
257                    (format!("preset:{preset}"), preset_label(preset))
258                } else {
259                    (String::new(), String::new())
260                };
261                let direction = match s.direction {
262                    Some(sioul_core::budget::Direction::Credit) => "credit",
263                    Some(sioul_core::budget::Direction::Debit) => "debit",
264                    None => "",
265                };
266                RuleView { id: s.id.clone(), place, words: s.words.clone(), direction: direction.to_string(), to, to_title, everywhere: s.account.is_empty() }
267            })
268            .collect();
269        let top_ups = ups
270            .iter()
271            .filter(|u| u.account == account.id)
272            .map(|u| {
273                let delay = ledger.reserves.iter().find(|r| r.id == u.reserve).map_or(0, |r| r.delay_days);
274                let args = [
275                    ("reserve", reserve_title(&u.reserve)),
276                    ("amount", tr().money(u.amount)),
277                    ("date", tr().day_in(u.needed_by, today)),
278                    ("payment", u.payment.clone()),
279                    ("ask", tr().day_in(u.ask_by, today)),
280                    ("days", delay.to_string()),
281                ];
282                say(if u.late { "money-topup-late" } else if delay == 0 { "money-topup-now" } else { "money-topup-ask" }, &args)
283            })
284            .collect();
285        view.accounts.push(AccountView {
286            id: account.id.clone(),
287            title: account.title.clone(),
288            kind: account.kind().to_string(),
289            balance,
290            two_accounts: accounts::two_accounts_in_one(bank, account),
291            fills: account.fills.iter().map(|b| Named { id: b.clone(), title: budget_title(b) }).collect(),
292            floor: account.floor.cents() as f64 / 100.0,
293            floor_text: if account.floor.cents() > 0 { tr().money(account.floor) } else { String::new() },
294            topped_up_by: account.topped_up_by.iter().map(|r| Named { id: r.clone(), title: reserve_title(r) }).collect(),
295            top_ups,
296            movements,
297            unplaced: mine.iter().filter(|p| p.why == Why::Unknown).count(),
298            rules,
299        });
300    }
301}
302
303/// The budget file, where bank accounts, rules and choices are written.
304fn ledger_path() -> Result<std::path::PathBuf, String> {
305    load_config().notes_root_path().map(|root| root.join(sioul_core::budget::LEDGER)).ok_or_else(|| tr().text("papers-no-store", None))
306}
307
308pub(crate) fn save_account(id: &str, edit: &str) -> String {
309    let result = ledger_path().and_then(|path| {
310        let edit: sioul_core::accounts::BankAccountEdit = serde_json::from_str(edit).map_err(|e| e.to_string())?;
311        sioul_core::accounts::save_bank_account(&path, id, &edit)
312    });
313    result.err().unwrap_or_default()
314}
315
316pub(crate) fn remove_account(id: &str) -> String {
317    ledger_path().and_then(|path| sioul_core::accounts::remove_bank_account(&path, id)).err().unwrap_or_default()
318}
319
320/// One movement placed by your hand: `budget:<id>`, `none`, or `""` (as the rules say).
321pub(crate) fn place_movement(account: &str, movement: &str, choice: &str) -> String {
322    ledger_path().and_then(|path| sioul_core::accounts::set_assignment(&path, account, movement, choice)).err().unwrap_or_default()
323}
324
325/// A rule made (`shown` empty) or changed: `shown` the rule as the window
326/// showed it (JSON, `RuleView`), found by its id, or at its place while it
327/// still holds what was shown (`accounts::save_split`).
328pub(crate) fn save_rule(shown: &str, edit: &str) -> String {
329    let result = ledger_path().and_then(|path| {
330        let edit: sioul_core::accounts::SplitEdit = serde_json::from_str(edit).map_err(|e| e.to_string())?;
331        let shown: Option<sioul_core::accounts::SplitShown> = (!shown.trim().is_empty()).then(|| serde_json::from_str(shown)).transpose().map_err(|e| e.to_string())?;
332        sioul_core::accounts::save_split(&path, shown.as_ref(), &edit)
333    });
334    match result {
335        Err(e) if e == "no such rule" => tr().text("bank-rule-gone", None),
336        other => other.err().unwrap_or_default(),
337    }
338}
339
340/// The rule the window showed (JSON, `RuleView`) taken out.
341pub(crate) fn remove_rule(shown: &str) -> String {
342    let Ok(shown) = serde_json::from_str::<sioul_core::accounts::SplitShown>(shown) else { return tr().text("bank-rule-gone", None) };
343    match ledger_path().and_then(|path| sioul_core::accounts::remove_split(&path, &shown)) {
344        Err(e) if e == "no such rule" => tr().text("bank-rule-gone", None),
345        other => other.err().unwrap_or_default(),
346    }
347}
348
349pub(crate) fn save_reserve(id: &str, edit: &str) -> String {
350    let result = ledger_path().and_then(|path| {
351        let edit: sioul_core::accounts::ReserveEdit = serde_json::from_str(edit).map_err(|e| e.to_string())?;
352        sioul_core::accounts::save_reserve(&path, id, &edit, jiff::Zoned::now().date())
353    });
354    result.err().unwrap_or_default()
355}
356
357/// A bank export taken in; returns what it added, in a sentence, or what went wrong.
358pub(crate) fn import(file: &str) -> (bool, String) {
359    import_into(file, "")
360}
361
362/// An export taken in as one bank account's (`account` empty: as the export
363/// names itself). The number the export gives is kept with the account, so
364/// that what was read under it before is the account's too.
365pub(crate) fn import_into(file: &str, account: &str) -> (bool, String) {
366    let path = crate::backend::local_path(file);
367    let config = load_config();
368    let Some(root) = config.notes_root_path() else { return (false, tr().text("papers-no-store", None)) };
369    let bytes = match std::fs::read(&path) {
370        Ok(bytes) => bytes,
371        Err(e) => return (false, format!("{}: {e}", path.display())),
372    };
373    // Some banks still write Latin-1.
374    let text = String::from_utf8(bytes.clone()).unwrap_or_else(|_| bytes.iter().map(|&b| b as char).collect());
375    let statement = match sioul_core::bank::read(&sioul_core::words::Words::of(&config).bank, &text) {
376        Ok(s) => s,
377        Err(_) => return (false, say("bank-unreadable", &[("file", path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())])),
378    };
379    let title = path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
380    let mut statement = statement;
381    if !account.is_empty() {
382        // Kept under its own number when it gives one, which the account learns; else under the account.
383        if statement.account.is_empty() {
384            statement.account = account.to_string();
385        } else if let Err(e) = ledger_path().and_then(|p| sioul_core::accounts::add_export_name(&p, account, &statement.account)) {
386            return (false, e);
387        }
388    }
389    // Taken into the movements as the file holds them now, under its lock, which the sharing takes too.
390    let added = match Bank::change(&root, |bank| bank.import(&statement, &title)) {
391        Ok(added) => added,
392        Err(e) => return (false, e),
393    };
394    let mut args = sioul_core::i18n::args();
395    args.set("read", added.read as i64);
396    args.set("new", added.new as i64);
397    (true, tr().text("bank-imported", Some(&args)))
398}