...
 
Commits (4)
......@@ -5,3 +5,7 @@ authors = ["Bruno Barcarol Guimarães <bbgstb@gmail.com>"]
edition = "2018"
[dependencies]
reqwest = {version = "0.10", features = ["blocking", "json"]}
zip = "0.5.5"
csv = "1.1.3"
chrono = "0.4.11"
nummi
=====
Amateur accounting software.
Database
--------
Transactions are stored in one or more text files. No directory structure is
enforced other than files having the `.txt` extension and being concatenated in
lexicographical order.
Each file contains one transaction per line. The format of each entry is:
```
<date> <amount><currency> <tag> <description>
```
e.g.:
```
2020-04-19 -100.00eur t description
```
- `date`: Date of the transaction in ISO 8601 format (i.e. `%Y-%m-%d`). No
timezone information is encoded, Dates are always assumed to be in the
machine's current timezone.
- `amount`: Monetary value in decimal notation.
- `currency`: Three-letter ISO 4217 code for the currency of the value in
`amount`.
- `tag`: A single character tag used for grouping entries. Values are
arbitrary and have no specific meaning.
- `description`: A textual description attached to the transaction.
Field in the entry should be separated by one space character, except for
`amount` and `currency`, which have no space in between, and `description`,
which consumes the entire rest of the line.
Processing of each file stops at the first blank line. The remaining content
is completely ignored, so extra information can be added.
Commands
--------
See `nummi --help` for a summary of the command line arguments and available
commands.
### `list`
The default command when none is specified is to list all entries in the
database.
### `currencies`
Lists all unique currencies from all entries.
### `update-cache`
Force an update of the currency exchange cache file ([see
below](#currency-conversion)).
### `plot`
Generate a `gnuplot` graphic summarizing the historical values in the database.
Each entry is converted to EUR ([see below](#currency-conversion)) and a
monthly total is calculated. Bars show the monthly income and expense, lines
show the discrete and accumulated net values.
```
2019-12-01 300.00eur a entry0
2019-12-01 -100.00eur a entry1
2020-01-01 700.00usd a entry2
2020-01-02 -500.00eur a entry3
2020-02-01 -200.00eur a entry4
2020-02-01 300.00eur a entry5
2020-03-01 -1200.00brl a entry6
2020-03-01 1800.00brl a entry7
2020-04-01 4000.00czk a entry8
2020-04-01 -250.00usd a entry9
```
![plot.png](./doc/screenshots/plot.png)
Currency conversion
-------------------
In order to summarize entries in different currencies the exchange rates from
the [European Central Bank](https://www.ecb.europa.eu) is downloaded and cached
locally (in `$XDG_CACHE_HOME/nummi/currencies`, TTL: 1d). Values in the
database entries are then converted to EUR (note: this is a gross
simplification and in no way an attempt to be a financially sound tool).
use std::io::prelude::*;
use super::db;
use super::dec;
use super::PROG_NAME;
const CURRENCIES_MAX_AGE: u64 = 24 * 60 * 60;
pub struct Cache {
pub currencies: Vec<db::Currency>,
}
pub fn dir() -> std::path::PathBuf {
std::env::var("XDG_CACHE_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").expect("HOME not set");
std::path::PathBuf::from(home).join(".cache")
})
.join(PROG_NAME)
}
impl Cache {
pub fn new() -> Cache {
Cache {
currencies: Vec::new(),
}
}
pub fn read_currencies(
&mut self,
dir: &std::path::Path,
force: bool,
fetch: impl Fn() -> std::io::Result<Vec<db::Currency>>,
) -> std::io::Result<()> {
let path = dir.join("currencies");
if force || cache_stale(&path, CURRENCIES_MAX_AGE)? {
update_currencies(&path, &fetch()?)?;
}
Ok(self.currencies = self::read_currencies(&path)?)
}
}
fn cache_stale(path: &std::path::Path, max_age: u64) -> std::io::Result<bool> {
match std::fs::metadata(&path) {
Ok(meta) => Ok(std::time::SystemTime::now()
.duration_since(meta.modified().unwrap())
.unwrap()
> std::time::Duration::new(max_age, 0)),
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => Ok(true),
_ => Err(e),
},
}
}
fn update_currencies(
path: &std::path::Path,
v: &Vec<db::Currency>,
) -> std::io::Result<()> {
create_dir(path.parent().unwrap())?;
let mut f = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.create(true)
.open(&path)?;
v.iter()
.map(|x| {
f.write_all(x.name_str().as_bytes())?;
f.write_all(b" ")?;
f.write_all(x.to_eur.to_string().as_bytes())?;
f.write_all(b"\n")
})
.collect()
}
fn read_currencies(
path: &std::path::Path,
) -> std::io::Result<Vec<db::Currency>> {
let s = std::fs::read_to_string(&path)?;
let mut ret = Vec::new();
for line in s.lines() {
let mut it = line.split(" ");
let mut name = it.next().unwrap_or_default().bytes();
let to_eur = it.next().unwrap_or_default();
ret.push(db::Currency {
name: [
name.next().unwrap(),
name.next().unwrap(),
name.next().unwrap(),
],
to_eur: dec::Decimal::from_string(to_eur)
.expect("invalid decimal in currency file"),
});
}
ret.push(db::Currency {
name: [b'e', b'u', b'r'],
to_eur: dec::Decimal::new(1.0),
});
Ok(ret)
}
fn create_dir(path: &std::path::Path) -> std::io::Result<()> {
std::fs::metadata(&path).and(Ok(())).or_else(|e| {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(e);
}
std::fs::create_dir(&path)
})
}
use super::dec;
pub struct Currency {
pub name: [u8; 3],
pub to_eur: dec::Decimal,
}
impl Currency {
pub fn name_str(&self) -> &str {
std::str::from_utf8(&self.name).unwrap()
}
}
pub struct Entry {
pub date: String,
value: dec::Decimal,
currency: [u8; 3],
tag: u8,
text: String,
}
impl Entry {
// TODO more detailed errors
pub fn from_line(l: &str) -> Entry {
let mut fields = l.split(' ');
let date = fields.next().unwrap();
let value = fields.next().unwrap();
let mut currency = value[value.len() - 3..].bytes();
let tag = fields.next().unwrap().bytes().nth(0).unwrap();
Entry {
date: String::from(date),
value: dec::Decimal::from_string(&value[..value.len() - 3])
.expect("invalid decimal in entry"),
currency: [
currency.next().unwrap(),
currency.next().unwrap(),
currency.next().unwrap(),
],
tag,
text: String::from(&l[date.len() + value.len() + 4..]),
}
}
pub fn to_line(&self) -> String {
format!(
"{} {:.2}{} {} {}",
self.date,
self.value,
std::str::from_utf8(&self.currency).unwrap(),
self.tag as char,
self.text,
)
}
pub fn unique_currencies(v: &[Entry]) -> Vec<String> {
v.iter()
.map(|x| std::str::from_utf8(&x.currency).unwrap())
.collect::<std::collections::HashSet<_>>()
.iter()
.map(|x| String::from(*x))
.collect()
}
pub fn total<'a>(
it: impl Iterator<Item = &'a Entry>,
) -> Vec<([u8; 3], dec::Decimal, dec::Decimal)> {
let mut ret = std::collections::HashMap::new();
for x in it {
let (pos, neg) = ret
.entry(x.currency)
.or_insert((dec::Decimal::new(0.0), dec::Decimal::new(0.0)));
if x.value < dec::Decimal::new(0.0) {
*neg += x.value
} else {
*pos += x.value
}
}
ret.iter().map(|(k, v)| (*k, v.0, v.1)).collect()
}
pub fn read_db_file(
path: &std::path::Path,
v: &mut Vec<Entry>,
) -> std::io::Result<()> {
Ok(std::fs::read_to_string(&path)?
.lines()
.take_while(|&x| x != "")
.map(Entry::from_line)
.for_each(|x| v.push(x)))
}
pub fn read_db(path: &std::path::Path) -> std::io::Result<Vec<Entry>> {
use std::io::Result;
let mut ret = Vec::new();
let mut files =
Find::new(path).collect::<Result<Vec<std::path::PathBuf>>>()?;
files.sort();
files
.iter()
.filter(|x| x.extension().unwrap_or_default() == "txt")
.map(|x| Entry::read_db_file(x, &mut ret))
.collect::<Result<()>>()
.and(Ok(ret))
}
}
struct Find {
stack: Vec<std::path::PathBuf>,
}
impl Find {
pub fn new(dir: &std::path::Path) -> Find {
Find {
stack: vec![std::path::PathBuf::from(dir)],
}
}
fn read_dir(&mut self, path: &std::path::Path) -> std::io::Result<()> {
std::fs::read_dir(path)?
.map(|x| x.and_then(|x| Ok(self.stack.push(x.path()))))
.collect()
}
}
impl Iterator for Find {
type Item = std::io::Result<std::path::PathBuf>;
fn next(&mut self) -> Option<Self::Item> {
while let Some(x) = self.stack.pop() {
if !x.is_dir() {
return Some(Ok(x));
}
if let Err(e) = self.read_dir(&x) {
return Some(Err(e));
}
}
None
}
}
#[derive(Debug)]
pub struct Decimal {
// TODO implement a real decimal type
v: f64,
}
impl Decimal {
// XXX
pub fn new(v: f64) -> Decimal {
Decimal { v }
}
pub fn from_string(s: &str) -> Option<Decimal> {
s.parse().ok().and_then(|v| Some(Decimal { v }))
}
}
impl Clone for Decimal {
fn clone(&self) -> Decimal {
Decimal { v: self.v }
}
}
impl Copy for Decimal {}
impl std::fmt::Display for Decimal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match f.precision() {
Some(p) => write!(f, "{1:.*}", p, self.v),
None => write!(f, "{}", self.v),
}
}
}
impl std::cmp::PartialEq for Decimal {
fn eq(&self, o: &Decimal) -> bool {
self.v == o.v
}
}
impl std::cmp::Eq for Decimal {}
impl std::cmp::PartialOrd for Decimal {
fn partial_cmp(&self, o: &Decimal) -> Option<std::cmp::Ordering> {
self.v.partial_cmp(&o.v)
}
}
impl core::ops::Add for Decimal {
type Output = Decimal;
fn add(self, o: Decimal) -> Self::Output {
Decimal { v: self.v + o.v }
}
}
impl core::ops::Sub for Decimal {
type Output = Decimal;
fn sub(self, o: Decimal) -> Self::Output {
Decimal { v: self.v - o.v }
}
}
impl core::ops::AddAssign for Decimal {
fn add_assign(&mut self, o: Decimal) {
self.v += o.v
}
}
impl core::ops::Mul for Decimal {
type Output = Decimal;
fn mul(self, o: Decimal) -> Self::Output {
Decimal { v: self.v * o.v }
}
}
impl core::ops::Div for Decimal {
type Output = Decimal;
fn div(self, o: Decimal) -> Self::Output {
Decimal { v: self.v / o.v }
}
}
mod cache;
mod db;
mod dec;
mod net;
mod plot;
const PROG_NAME: &'static str = "nummi";
fn parse_args() -> Option<(std::path::PathBuf, Vec<String>)> {
let mut dir = std::path::PathBuf::new();
let mut pos = Vec::new();
let mut args = std::env::args();
args.next().unwrap();
loop {
match args.next() {
None => break,
Some(arg) => match arg.as_str() {
"-h" | "--help" => { usage(); return None; },
"-d" | "--db-dir" => dir = std::path::PathBuf::from(
args.next().expect("-d requires an argument")),
_ => pos.push(String::from(arg)),
},
}
}
if dir == std::path::PathBuf::new() {
dir = std::env::var("XDG_DATA_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").expect("HOME not set");
std::path::PathBuf::from(home).join(".local/share")
})
.join(PROG_NAME)
.join("db");
}
Some((dir, pos))
}
fn usage() {
print!(r#"
Usage: {exe} [-d <db_dir>] [<cmd>] [<args>]
-d, --db-dir path path to the database directory
(default: $XDG_DATA_HOME/{prog_name}/db)
Commands:
<none> List all entries.
currencies List all currencies present in the database.
update-cache Force an update of the currency exchange cache
file.
plot Generate a `gnuplot` graphic summarizing with the
monthly historical total.
"#,
exe = std::env::args().next().unwrap(),
prog_name = PROG_NAME,
)
}
fn cmd_list(d: &std::path::Path) {
for x in db::Entry::read_db(&d).unwrap() {
println!("{}", x.to_line());
}
}
fn cmd_currencies(d: &std::path::Path) {
for x in db::Entry::unique_currencies(&db::Entry::read_db(&d).unwrap()) {
println!("{}", x);
}
}
fn cmd_plot(d: &std::path::Path) {
let entries = db::Entry::read_db(&d).unwrap();
let currencies = update_cache(d, false).unwrap().currencies;
let currencies: std::collections::HashMap<_, _> = currencies
.iter()
.map(|x| (x.name, dec::Decimal::new(1.0) / x.to_eur))
.collect();
plot::plot(&entries, &currencies).unwrap();
}
fn update_cache(
d: &std::path::Path,
force: bool,
) -> std::io::Result<cache::Cache> {
let currencies = db::Entry::read_db(&d)
.map(|x| db::Entry::unique_currencies(&x))
.unwrap()
.drain(..)
.collect::<std::collections::HashSet<String>>();
let mut cache = cache::Cache::new();
cache.read_currencies(&cache::dir(), force, ||
net::fetch_currencies(&currencies))?;
Ok(cache)
}
fn main() {
let (dir, args) = match parse_args() {
None => return,
Some(x) => x,
};
let mut args = args.iter();
match args.next().map(|x| x.as_str()).unwrap_or_default() {
"" => cmd_list(&dir),
"currencies" => cmd_currencies(&dir),
"update-cache" => update_cache(&dir, true).and(Ok(())).unwrap(),
"plot" => cmd_plot(&dir),
x => panic!("invalid command: {}", x),
}
}
use super::db;
use super::dec;
const EUR_SERVICE_URL: &'static str =
"https://www.ecb.europa.eu/stats/eurofxref/eurofxref.zip";
pub fn fetch_currencies(
s: &std::collections::HashSet<String>,
) -> std::io::Result<Vec<db::Currency>> {
let resp = reqwest::blocking::get(EUR_SERVICE_URL)
.unwrap()
.bytes()
.unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(resp))?;
let zip = zip.by_name("eurofxref.csv")?;
let mut csv = csv::Reader::from_reader(zip);
let headers: Vec<String> =
csv.headers()?.iter().map(String::from).collect();
let record = csv.records().next().unwrap().unwrap_or_default();
Ok(headers
.iter()
.map(|x| x.trim().to_lowercase())
.zip(record.iter())
.filter(|(x, _)| s.contains(x))
.map(|(k, v)| {
let mut k = k.bytes();
db::Currency {
name: [
k.next().unwrap(),
k.next().unwrap(),
k.next().unwrap(),
],
to_eur: dec::Decimal::from_string(v.trim())
.expect("invalid decimal from currency service"),
}
})
.collect())
}
use std::io::Write;
use chrono::Datelike;
use super::db;
use super::dec;
struct DateSeries {
d: chrono::NaiveDate,
}
impl DateSeries {
fn new(d: &chrono::NaiveDate) -> DateSeries {
DateSeries { d: *d }
}
}
impl Iterator for DateSeries {
type Item = chrono::NaiveDate;
fn next(&mut self) -> Option<Self::Item> {
let ret = self.d;
self.d = Self::Item::from_ymd(
ret.year() + ret.month() as i32 / 12,
ret.month() % 12u32 + 1,
1,
);
Some(ret)
}
}
pub fn plot(
v: &[db::Entry],
to_eur: &std::collections::HashMap<[u8; 3], dec::Decimal>,
) -> std::io::Result<()> {
let mut out = Vec::new();
let series = DateSeries::new(
&chrono::NaiveDate::parse_from_str(&v[0].date, "%Y-%m-%d").unwrap());
let end = chrono::Local::now().naive_local().date();
let mut sum = dec::Decimal::new(0.0);
for d in series.take_while(|x| x <= &end) {
let (y, m) = (d.year(), d.month());
let date = format!("{}-{:02}", y, m);
// TODO entries are ordered, implement `group_by`
let filtered = v.iter().filter(|x| x.date.starts_with(&date));
let eur_total = db::Entry::total(filtered).iter().fold(
(dec::Decimal::new(0.0), dec::Decimal::new(0.0)),
|(acc_pos, acc_neg), (cur, pos, neg)| {
let conv = to_eur[cur];
(acc_pos + *pos * conv, acc_neg + *neg * conv)
},
);
let net = eur_total.0 + eur_total.1;
sum += net;
write!(
&mut out,
"{} {:.2} {:.2} {:.2} {:.2}\n",
date, eur_total.0, eur_total.1, net, sum,
)?;
}
plot_data(&out)
}
// TODO adjust width
fn plot_data(b: &[u8]) -> std::io::Result<()> {
let mut cmd = std::process::Command::new("gnuplot")
.stdin(std::process::Stdio::piped())
.spawn()?;
let stdin = cmd.stdin.as_mut().unwrap();
stdin.write_all(b"$d <<EOD\n")?;
stdin.write_all(b)?;
stdin.write_all(b"EOD\n")?;
stdin.write_all(
br#"
set term png size 4096,1080
set grid
set xtics 3 * 30 * 24 * 60 * 60 rotate
set xdata time
set format x "%Y-%m"
set timefmt "%Y-%m"
w = 15 * 24 * 60 * 60
o(x) = (x + 200 * (x < 0 ? -1 : 1))
plot \
$d using 1:2:(w) with boxes lc "blue" title "in", \
$d using 1:(o($2)):2 with labels tc "blue" notitle, \
$d using 1:3:(w) with boxes lc "red" title "out", \
$d using 1:(o($3)):3 with labels tc "red" notitle, \
$d using 1:4 with lines lc "dark-yellow" title "net", \
$d using 1:4:4 with labels tc "dark-yellow" notitle, \
$d using 1:5 with lines lc "dark-green" title "sum", \
$d using 1:5:5 with labels tc "dark-green" notitle
"#,
)
}