1mod account;
2mod charts;
3mod script;
4mod transaction;
5
6use account::Account;
7use clap::Subcommand;
8use transaction::{Item, Transaction, TransactionRhai};
9
10const TIME_FORMAT: &[time::format_description::FormatItem<'_>] =
11 time_macros::format_description!("[year]-[month]-[day]");
12const TIME_FORMAT_MONTH: &[time::format_description::FormatItem<'_>] =
13 time_macros::format_description!("[month]");
14const TIME_FORMAT_YEAR: &[time::format_description::FormatItem<'_>] =
15 time_macros::format_description!("[year]");
16const TIME_FORMAT_DAY: &[time::format_description::FormatItem<'_>] =
17 time_macros::format_description!("[day]");
18
19#[derive(Debug)]
21pub enum Error {
22 Account(String, account::Error),
23 Operation(transaction::Error),
24 InvalidDate(time::error::Parse),
25 ScriptEvaluation(Box<rhai::EvalAltResult>),
26}
27
28impl From<transaction::Error> for Error {
29 fn from(value: transaction::Error) -> Self {
30 Self::Operation(value)
31 }
32}
33
34#[derive(Clone, serde::Deserialize)]
35pub struct ScriptAccountBalance {
36 pub amount: rhai::FLOAT,
37 pub currency: String,
38}
39
40#[derive(Subcommand)]
41pub enum Commands {
42 New {
44 #[arg(short, long)]
46 name: String,
47 #[arg(short, long)]
49 currency: String,
50 },
51 Spend {
53 #[arg(long, value_name = "ACCOUNT-NAME")]
55 account: String,
56 #[arg(long, value_name = "amount")]
58 amount: f64,
59 #[arg(short, long, value_name = "DESCRIPTION")]
61 description: String,
62 #[arg(short, long, value_name = "TAGS", value_parser = Commands::parse_tags)]
65 tags: std::collections::HashSet<String>,
66 },
67 Income {
69 #[arg(long, value_name = "ACCOUNT-NAME")]
71 account: String,
72 #[arg(long, value_name = "amount")]
74 amount: f64,
75 #[arg(short, long, value_name = "DESCRIPTION")]
77 description: String,
78 #[arg(short, long, value_name = "TAGS", value_parser = Commands::parse_tags)]
81 tags: std::collections::HashSet<String>,
82 },
83 Balance {
85 #[arg(short, long, value_name = "ACCOUNT-NAME")]
88 account: Option<String>,
89 #[arg(short, long, value_name = "START-DATE", value_parser = Commands::parse_date)]
91 from: Option<time::Date>,
92 #[arg(short, long, value_name = "END-DATE", value_parser = Commands::parse_date)]
94 to: Option<time::Date>,
95 #[arg(short, long)]
97 chart: bool,
98 #[arg(short, long)]
100 script: Option<std::path::PathBuf>,
101 },
102}
103
104impl Commands {
105 fn parse_tags(
106 s: &str,
107 ) -> Result<std::collections::HashSet<String>, Box<dyn std::error::Error + Send + Sync + 'static>>
108 {
109 Ok(s.split(',').map(|s| s.to_string()).collect())
110 }
111
112 fn parse_date(
113 s: &str,
114 ) -> Result<time::Date, Box<dyn std::error::Error + Send + Sync + 'static>> {
115 time::Date::parse(s, TIME_FORMAT).map_err(|error| error.into())
116 }
117
118 pub fn run(self, accounts_path: &str) -> Result<(), Error> {
119 match self {
120 Commands::New { name, currency } => {
121 Commands::new_account(accounts_path, &name, ¤cy)
122 }
123 Commands::Income {
124 account,
125 amount,
126 description,
127 tags,
128 } => Commands::write_transaction(
129 accounts_path,
130 &account,
131 Transaction::Income(Item {
132 date: time::OffsetDateTime::now_utc().date(),
133 amount,
134 description: description.to_string(),
135 tags: tags.clone(),
136 }),
137 ),
138 Commands::Spend {
139 account,
140 amount,
141 description,
142 tags,
143 } => Commands::write_transaction(
144 accounts_path,
145 &account,
146 Transaction::Spending(Item {
147 date: time::OffsetDateTime::now_utc().date(),
148 amount,
149 description,
150 tags,
151 }),
152 ),
153 Commands::Balance {
154 account,
155 from,
156 to,
157 chart,
158 script,
159 } => Commands::balance(
160 accounts_path,
161 account.as_ref(),
162 from.as_ref(),
163 to.as_ref(),
164 chart,
165 script.as_ref(),
166 ),
167 }
168 }
169
170 fn list_accounts_paths(accounts_path: &str) -> Vec<std::path::PathBuf> {
171 std::fs::read_dir(accounts_path)
172 .map(|dir| {
173 dir.filter_map(|entry| {
174 let account = entry.expect("entry must be valid").path();
175 if account.is_file() {
176 Some(account)
177 } else {
178 None
179 }
180 })
181 .collect()
182 })
183 .unwrap_or_default()
184 }
185
186 fn new_account(accounts_path: &str, name: &str, currency: &str) -> Result<(), Error> {
187 let path = std::path::PathBuf::from_iter([accounts_path, name]);
188
189 if path.exists() {
190 return Err(Error::Account(
191 name.to_string(),
192 account::Error::AlreadyExists,
193 ));
194 }
195
196 Account::open(path, currency).map_err(|error| Error::Account(name.to_string(), error))
197 }
198
199 fn write_transaction(
200 accounts_path: &str,
201 name: &str,
202 transaction: Transaction,
203 ) -> Result<(), Error> {
204 Account::from_file(std::path::PathBuf::from_iter([accounts_path, name]))
205 .map_err(|error| Error::Account(name.to_string(), error))?
206 .push_transaction(transaction)
207 .write()
208 .map_err(|error| Error::Account(name.to_string(), error))
209 .map(|_| ())
210 }
211
212 fn balance(
213 accounts_path: &str,
214 account: Option<&String>,
215 from: Option<&time::Date>,
216 to: Option<&time::Date>,
217 chart: bool,
218 script: Option<&std::path::PathBuf>,
219 ) -> Result<(), Error> {
220 let totals = if let Some(script) = script {
221 let engine = script::build_engine(script);
222 let accounts = Self::get_accounts(accounts_path, account);
223
224 let ast = engine
225 .compile_file(script.into())
226 .map_err(Error::ScriptEvaluation)?;
227 let mut totals = std::collections::HashMap::<String, f64>::new();
228
229 for account in accounts {
230 let fn_name = format!("on_{}", account.name());
231 if ast.iter_functions().any(|func| func.name == fn_name) {
232 let transactions = account
233 .transactions_between(from, to)
234 .map_err(|error| Error::Account(account.name().to_string(), error))?;
235
236 let parameters: rhai::Dynamic = transactions
237 .iter()
238 .map(TransactionRhai::from)
239 .collect::<Vec<TransactionRhai>>()
240 .into();
241
242 let account_balance = engine
243 .call_fn::<rhai::Dynamic>(
244 &mut rhai::Scope::new(),
245 &ast,
246 fn_name,
247 (parameters,),
248 )
249 .map_err(Error::ScriptEvaluation)?;
250
251 let (balance, currency) = if account_balance.is_map() {
252 let balance: ScriptAccountBalance =
253 rhai::serde::from_dynamic(&account_balance)
254 .map_err(Error::ScriptEvaluation)?;
255
256 totals
257 .entry(balance.currency.clone())
258 .and_modify(|entry| *entry += balance.amount)
259 .or_insert(balance.amount);
260
261 (balance.amount, balance.currency)
262 } else if account_balance.is_float() {
263 let balance = account_balance.cast::<rhai::FLOAT>();
264 totals
265 .entry(account.currency().to_string())
266 .and_modify(|entry| *entry += balance)
267 .or_insert(balance);
268
269 (balance, account.currency().to_string())
270 } else {
271 return Err(Error::ScriptEvaluation(Box::new(
273 rhai::EvalAltResult::ErrorRuntime(
274 rhai::Dynamic::from("return value must be a map or float"),
275 rhai::Position::NONE,
276 ),
277 )));
278 };
279
280 match (transactions.first(), transactions.last()) {
281 (Some(from), Some(to)) => {
282 println!(
283 "[{}/{}] balance for '{}': {:.2} {}",
284 from.date(),
285 to.date(),
286 account.name(),
287 balance,
288 currency
289 );
290
291 if chart {
292 charts::build(transactions);
293 }
294 }
295 _ => {
296 println!(
297 "balance for '{}': 0.00 {}",
298 account.name(),
299 account.currency()
300 );
301 }
302 }
303 } else {
304 let account_balance = Self::list_between(&account, from, to, chart)?;
305
306 totals
307 .entry(account.currency().to_string())
308 .and_modify(|entry| *entry += account_balance)
309 .or_insert(account_balance);
310 }
311 }
312
313 totals
314 } else {
315 let mut totals = std::collections::HashMap::<String, f64>::new();
316 let accounts = Self::get_accounts(accounts_path, account);
317
318 for account in accounts {
319 let account_balance = Self::list_between(&account, from, to, chart)?;
320
321 totals
322 .entry(account.currency().to_string())
323 .and_modify(|entry| *entry += account_balance)
324 .or_insert(account_balance);
325 }
326
327 totals
328 };
329
330 let mut totals: Vec<(String, f64)> = totals
331 .into_iter()
332 .map(|(currency, total)| (currency.to_string(), total))
333 .collect();
334 totals.sort_by(|(c1, _), (c2, _)| c1.cmp(c2));
335
336 println!("\nTotals:");
337
338 for (currency, total) in totals {
339 println!(" {total:.2} {currency}");
340 }
341
342 Ok(())
343 }
344
345 fn get_accounts(accounts_path: &str, account: Option<&String>) -> Vec<Account> {
346 let mut accounts = if let Some(account) = account {
347 match Account::from_file(std::path::PathBuf::from_iter([accounts_path, account])) {
348 Ok(account) => vec![account],
349 Err(error) => {
350 println!("failed to open {account:?}: {error:?}");
351 vec![]
352 }
353 }
354 } else {
355 Self::list_accounts_paths(accounts_path)
356 .into_iter()
357 .filter_map(|path| match Account::from_file(&path) {
358 Ok(account) => Some(account),
359 Err(error) => {
360 println!("failed to open {path:?}: {error:?}");
361 None
362 }
363 })
364 .collect::<Vec<_>>()
365 };
366 accounts.sort_by(|a, b| a.name().cmp(b.name()));
367 accounts
368 }
369
370 fn list_between(
371 account: &Account,
372 from: Option<&time::Date>,
373 to: Option<&time::Date>,
374 chart: bool,
375 ) -> Result<f64, Error> {
376 let transactions = account
377 .transactions_between(from, to)
378 .map_err(|error| Error::Account(account.name().to_string(), error))?;
379
380 match (transactions.first(), transactions.last()) {
381 (Some(from), Some(to)) => {
382 let balance: f64 = transactions.iter().map(|op| op.amount()).sum();
383
384 println!(
385 "[{}/{}] balance for '{}': {:.2} {}",
386 from.date(),
387 to.date(),
388 account.name(),
389 balance,
390 account.currency()
391 );
392
393 if chart {
394 charts::build(transactions);
395 }
396
397 Ok(balance)
398 }
399 _ => {
400 println!(
401 "balance for '{}': 0.00 {}",
402 account.name(),
403 account.currency()
404 );
405 Ok(0.0)
406 }
407 }
408 }
409}