1use 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 any: bool,
21 balance: String,
23 findings: Vec<String>,
24 coming: Vec<String>,
26 forecast: Vec<Point>,
28 week: String,
30 attention: usize,
32 problem: String,
33 accounts: Vec<AccountView>,
35 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 kind: String,
62 balance: String,
64 two_accounts: bool,
67 fills: Vec<Named>,
69 floor: f64,
70 floor_text: String,
72 topped_up_by: Vec<Named>,
73 top_ups: Vec<String>,
75 movements: Vec<MovementView>,
77 unplaced: usize,
79 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 place: String,
92 why: String,
94 chosen: String,
96}
97
98#[derive(Serialize)]
99struct RuleView {
100 id: String,
102 place: usize,
104 words: Vec<String>,
105 direction: String,
107 to: String,
109 to_title: String,
110 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
140pub(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 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
171fn 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
303fn 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
320pub(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
325pub(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
340pub(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
357pub(crate) fn import(file: &str) -> (bool, String) {
359 import_into(file, "")
360}
361
362pub(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 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 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 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}