Initial Commit
This commit is contained in:
commit
dcb0212105
|
@ -0,0 +1 @@
|
|||
/target
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "ledgerneo"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
ledger-parser = "5.1.1"
|
||||
rust_decimal = "1.29.0"
|
||||
spreadsheet-ods = "0.15.0"
|
||||
chrono = "0.4.24"
|
||||
icu_locid = "1.1.0"
|
|
@ -0,0 +1,77 @@
|
|||
max_width = 200
|
||||
hard_tabs = false
|
||||
tab_spaces = 4
|
||||
newline_style = "Auto"
|
||||
indent_style = "Block"
|
||||
use_small_heuristics = "Default"
|
||||
fn_call_width = 60
|
||||
attr_fn_like_width = 70
|
||||
struct_lit_width = 18
|
||||
struct_variant_width = 35
|
||||
array_width = 60
|
||||
chain_width = 60
|
||||
single_line_if_else_max_width = 50
|
||||
wrap_comments = false
|
||||
format_code_in_doc_comments = false
|
||||
doc_comment_code_block_width = 100
|
||||
comment_width = 80
|
||||
normalize_comments = false
|
||||
normalize_doc_attributes = false
|
||||
format_strings = false
|
||||
format_macro_matchers = false
|
||||
format_macro_bodies = true
|
||||
skip_macro_invocations = []
|
||||
hex_literal_case = "Preserve"
|
||||
empty_item_single_line = true
|
||||
struct_lit_single_line = true
|
||||
fn_single_line = false
|
||||
where_single_line = false
|
||||
imports_indent = "Block"
|
||||
imports_layout = "Mixed"
|
||||
imports_granularity = "Preserve"
|
||||
group_imports = "Preserve"
|
||||
reorder_imports = true
|
||||
reorder_modules = true
|
||||
reorder_impl_items = false
|
||||
type_punctuation_density = "Wide"
|
||||
space_before_colon = false
|
||||
space_after_colon = true
|
||||
spaces_around_ranges = false
|
||||
binop_separator = "Front"
|
||||
remove_nested_parens = true
|
||||
combine_control_expr = true
|
||||
short_array_element_width_threshold = 10
|
||||
overflow_delimited_expr = false
|
||||
struct_field_align_threshold = 0
|
||||
enum_discrim_align_threshold = 0
|
||||
match_arm_blocks = true
|
||||
match_arm_leading_pipes = "Never"
|
||||
force_multiline_blocks = false
|
||||
fn_params_layout = "Tall"
|
||||
brace_style = "SameLineWhere"
|
||||
control_brace_style = "AlwaysSameLine"
|
||||
trailing_semicolon = true
|
||||
trailing_comma = "Vertical"
|
||||
match_block_trailing_comma = false
|
||||
blank_lines_upper_bound = 1
|
||||
blank_lines_lower_bound = 0
|
||||
edition = "2015"
|
||||
version = "One"
|
||||
inline_attribute_width = 0
|
||||
format_generated_files = true
|
||||
merge_derives = true
|
||||
use_try_shorthand = false
|
||||
use_field_init_shorthand = false
|
||||
force_explicit_abi = true
|
||||
condense_wildcard_suffixes = false
|
||||
color = "Auto"
|
||||
required_version = "1.5.2"
|
||||
unstable_features = false
|
||||
disable_all_formatting = false
|
||||
skip_children = false
|
||||
hide_parse_errors = false
|
||||
error_on_line_overflow = false
|
||||
error_on_unformatted = false
|
||||
ignore = []
|
||||
emit_mode = "Files"
|
||||
make_backup = false
|
|
@ -0,0 +1,189 @@
|
|||
use std::borrow::Borrow;
|
||||
use crate::CommoditiesPriceOracle;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
use chrono::Month::December;
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AssetCommodity {
|
||||
cost_basis: Decimal,
|
||||
quantity: Decimal,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl AssetCommodity {
|
||||
pub fn cost_basis_total(&self) -> Decimal {
|
||||
self.cost_basis * self.quantity
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AssetAccount {
|
||||
name: String,
|
||||
commodities: HashMap<String, Vec<AssetCommodity>>,
|
||||
running_total: HashMap<String, Decimal>,
|
||||
opening: HashMap<String, Decimal>,
|
||||
pub(crate) realized_gains: Decimal,
|
||||
}
|
||||
|
||||
impl AssetAccount {
|
||||
pub fn new(name: String) -> AssetAccount {
|
||||
AssetAccount {
|
||||
name,
|
||||
commodities: HashMap::new(),
|
||||
realized_gains: Decimal::new(0, 0),
|
||||
running_total: HashMap::new(),
|
||||
opening: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unrealized_gains(&self, oracle: &CommoditiesPriceOracle, date: &String) -> Vec<(String, Decimal)> {
|
||||
let mut totals = vec![];
|
||||
for (name, commodities) in self.commodities.iter() {
|
||||
if name != "$" && name != "StickerSheets" {
|
||||
let mut unrealized_gain = Decimal::new(0, 0);
|
||||
|
||||
let historical_cost = commodities.iter().fold(Decimal::new(0,0), |accum, x| {
|
||||
accum + (x.cost_basis * x.quantity)
|
||||
});
|
||||
|
||||
let current_cost = commodities.iter().fold(Decimal::new(0,0), |accum, x| {
|
||||
accum + (oracle.lookup(&x.name, date) * x.quantity)
|
||||
});
|
||||
|
||||
unrealized_gain += current_cost - historical_cost;
|
||||
totals.push((name.clone(), unrealized_gain))
|
||||
}
|
||||
}
|
||||
totals
|
||||
}
|
||||
|
||||
pub fn total_nominal(&self, oracle: &CommoditiesPriceOracle, date: &String) -> f64 {
|
||||
let mut total = 0.0;
|
||||
for (commodity_name, quantity) in self.running_total.iter() {
|
||||
if commodity_name!= "$" && commodity_name != "StickerSheets" {
|
||||
total += (oracle.lookup(commodity_name, date) * quantity.abs()).to_f64().unwrap();
|
||||
} else if commodity_name == "$" {
|
||||
total += quantity.abs().to_f64().unwrap()
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
pub fn opening_nominal(&self, oracle: &CommoditiesPriceOracle, date: &String) -> f64 {
|
||||
let mut total = 0.0;
|
||||
for (commodity_name, quantity) in self.opening.iter() {
|
||||
if commodity_name!= "$" && commodity_name != "StickerSheets" {
|
||||
total += (oracle.lookup(commodity_name, date) * quantity.abs()).to_f64().unwrap();
|
||||
} else if commodity_name == "$" {
|
||||
total += quantity.abs().to_f64().unwrap()
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
|
||||
pub fn withdraw(&mut self, name: String, cost: Decimal, quantity: Decimal, date: &String) {
|
||||
let cost_basis = cost;
|
||||
let asset_commodity = AssetCommodity {
|
||||
cost_basis,
|
||||
quantity,
|
||||
name: name.clone(),
|
||||
};
|
||||
|
||||
match self.commodities.contains_key(&name) {
|
||||
false => {
|
||||
self.commodities.insert(name.clone(), vec![]);
|
||||
self.running_total.insert(name.clone(), Decimal::new(0,0));
|
||||
if date == "2022-02-11" {
|
||||
println!("Opening Total: {} {} {}", quantity, name, cost);
|
||||
self.opening.insert(name.clone(), quantity);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
}
|
||||
}
|
||||
|
||||
*self.running_total.get_mut(&name).unwrap() += quantity;
|
||||
println!("Running Total: {} {} {} {}", quantity, name, cost, *self.running_total.get_mut(&name).unwrap() );
|
||||
|
||||
if self.name.starts_with("Assets") == false {
|
||||
// we don't track cost-basis for liabilities
|
||||
return;
|
||||
}
|
||||
|
||||
match self.commodities.get_mut(&name) {
|
||||
None => {
|
||||
panic!();
|
||||
}
|
||||
Some(current_commodities) => {
|
||||
|
||||
current_commodities.sort_by(|a,b| {
|
||||
a.cost_basis.cmp(&b.cost_basis)
|
||||
});
|
||||
current_commodities.reverse();
|
||||
|
||||
|
||||
let mut quantity_needed = quantity.abs();
|
||||
let mut base_cost = Decimal::new(0,0);
|
||||
if name != "$" {
|
||||
for commodity in current_commodities.iter_mut() {
|
||||
if commodity.quantity <= quantity_needed {
|
||||
// use all of this commodity....
|
||||
println!("Taking {} {} @@ {}", commodity.quantity, commodity.name, commodity.cost_basis);
|
||||
base_cost += (commodity.quantity * commodity.cost_basis);
|
||||
quantity_needed -= commodity.quantity;
|
||||
commodity.quantity = Decimal::new(0, 0);
|
||||
} else {
|
||||
// only use what we need from this...and then we are done...
|
||||
println!("Taking {} {} @@ {}", quantity_needed, commodity.name, commodity.cost_basis);
|
||||
base_cost += (quantity_needed * commodity.cost_basis);
|
||||
commodity.quantity -= quantity_needed;
|
||||
break;
|
||||
}
|
||||
if quantity_needed.is_zero() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (base_cost - (cost * quantity.abs())).is_zero() == false {
|
||||
println!("Realized Gains: {} {} {} {} {} {}", self.name, (cost * quantity.abs()) - base_cost, base_cost, cost * quantity.abs(), date, cost);
|
||||
}
|
||||
self.realized_gains += (cost * quantity.abs()) - base_cost;
|
||||
} else {
|
||||
current_commodities.push(AssetCommodity{
|
||||
cost_basis: Decimal::new(1,0),
|
||||
quantity: quantity,
|
||||
name
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deposit(&mut self, name: String, cost: Decimal, quantity: Decimal, date: &String) {
|
||||
let cost_basis = cost;
|
||||
let asset_commodity = AssetCommodity {
|
||||
cost_basis,
|
||||
quantity,
|
||||
name: name.clone(),
|
||||
};
|
||||
|
||||
match self.commodities.get_mut(&name) {
|
||||
None => {
|
||||
self.commodities.insert(name.clone(), vec![asset_commodity]);
|
||||
self.running_total.insert(name.clone(), Decimal::new(0,0));
|
||||
if date == "2022-02-11" {
|
||||
println!("Opening: {} {} {}", quantity, name, cost);
|
||||
self.opening.insert(name.clone(), quantity);
|
||||
}
|
||||
}
|
||||
Some(account) => account.push(asset_commodity),
|
||||
}
|
||||
|
||||
|
||||
*self.running_total.get_mut(&name).unwrap() += quantity;
|
||||
println!("Running Total: {} {} {} {}", quantity, name, cost, *self.running_total.get_mut(&name).unwrap() );
|
||||
}
|
||||
}
|
|
@ -0,0 +1,249 @@
|
|||
use crate::{AssetAccount, CommoditiesPriceOracle, ExternalAccount};
|
||||
use ledger_parser::{LedgerItem, PostingAmount};
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::read_to_string;
|
||||
use std::str::FromStr;
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
|
||||
pub type TransactionReference = usize;
|
||||
|
||||
pub struct Books {
|
||||
pub(crate) asset_accounts: HashMap<String, AssetAccount>,
|
||||
pub(crate) external_accounts: HashMap<String, ExternalAccount>,
|
||||
pub(crate) commodities_oracle: CommoditiesPriceOracle,
|
||||
pub general_ledger: Vec<(TransactionReference, String, Vec<(String, String, Decimal)>)>,
|
||||
}
|
||||
|
||||
impl Books {
|
||||
pub fn new() -> Books {
|
||||
Books {
|
||||
asset_accounts: HashMap::new(),
|
||||
external_accounts: HashMap::new(),
|
||||
commodities_oracle: CommoditiesPriceOracle::new(),
|
||||
general_ledger: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_ledger(&mut self, ledger_file: &str, to_date: &String) {
|
||||
let ast = ledger_parser::parse(&read_to_string(ledger_file).unwrap()).unwrap();
|
||||
for (id, li) in ast.items.iter().enumerate() {
|
||||
match li {
|
||||
LedgerItem::EmptyLine => {}
|
||||
LedgerItem::LineComment(_) => {}
|
||||
LedgerItem::Transaction(tx) => {
|
||||
let mut final_line = None;
|
||||
let mut total = Decimal::new(0, 0);
|
||||
let mut commodities = HashSet::new();
|
||||
|
||||
let mut general_ledger_entry = vec![];
|
||||
|
||||
if tx.date > NaiveDate::from_str(&to_date).unwrap() {
|
||||
break;
|
||||
}
|
||||
|
||||
if tx.postings.len() == 2 {
|
||||
match (&tx.postings[0].amount, &tx.postings[1].amount) {
|
||||
(Some(a), Some(b)) => {
|
||||
if a.amount.commodity != b.amount.commodity {
|
||||
let a_account = tx.postings[0].account.clone();
|
||||
let b_account = tx.postings[1].account.clone();
|
||||
if a.amount.quantity.is_sign_negative() {
|
||||
// Trading a for b
|
||||
let cost_basis = b.amount.quantity / a.amount.quantity.abs() ;
|
||||
self.asset_accounts.get_mut(&a_account).unwrap().withdraw(a.amount.commodity.name.clone(), cost_basis, a.amount.quantity, &tx.date.to_string());
|
||||
self.asset_accounts.get_mut(&b_account).unwrap().deposit(b.amount.commodity.name.clone(), Decimal::new(1,0), b.amount.quantity, &tx.date.to_string());
|
||||
} else {
|
||||
panic!("Trade order should have selling first {} {}",a,b);
|
||||
}
|
||||
|
||||
general_ledger_entry.push((a_account, a.amount.commodity.name.clone(), a.amount.quantity));
|
||||
general_ledger_entry.push((b_account, b.amount.commodity.name.clone(), b.amount.quantity));
|
||||
|
||||
self.general_ledger.push((
|
||||
id as TransactionReference,
|
||||
tx.date.to_string(),
|
||||
general_ledger_entry,
|
||||
));
|
||||
continue
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for posting in &tx.postings {
|
||||
match &posting.amount {
|
||||
None => {
|
||||
final_line = Some(posting.clone());
|
||||
}
|
||||
Some(amount) => {
|
||||
println!(
|
||||
"{},{},{},{},{}",
|
||||
id, tx.date, posting.account, amount.amount.commodity.name, amount.amount.quantity
|
||||
);
|
||||
total += amount.amount.quantity;
|
||||
commodities.insert(amount.amount.commodity.name.clone());
|
||||
|
||||
general_ledger_entry.push((
|
||||
posting.account.clone(),
|
||||
amount.amount.commodity.name.clone(),
|
||||
amount.amount.quantity.clone(),
|
||||
));
|
||||
|
||||
self.update_account(
|
||||
id as TransactionReference,
|
||||
&posting.account,
|
||||
&tx.date.to_string(),
|
||||
&amount.amount.commodity.name.clone(),
|
||||
amount.amount.quantity,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
match final_line {
|
||||
Some(posting) => {
|
||||
|
||||
if commodities.len() != 1 {
|
||||
panic!("Trades should be handled separately")
|
||||
}
|
||||
|
||||
println!("{},{},{},{}", id, tx.date, posting.account, total);
|
||||
let commodity: Vec<&String> = commodities.iter().collect();
|
||||
self.update_account(
|
||||
id as TransactionReference,
|
||||
&posting.account,
|
||||
&tx.date.to_string(),
|
||||
commodity[0],
|
||||
-total,
|
||||
);
|
||||
|
||||
|
||||
general_ledger_entry.push((posting.account.clone(), commodity[0].clone(), -total));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.general_ledger.push((
|
||||
id as TransactionReference,
|
||||
tx.date.to_string(),
|
||||
general_ledger_entry,
|
||||
))
|
||||
}
|
||||
LedgerItem::CommodityPrice(cprice) => self.commodities_oracle.insert(
|
||||
cprice.commodity_name.clone(),
|
||||
cprice.datetime.date().to_string(),
|
||||
cprice.amount.quantity,
|
||||
),
|
||||
LedgerItem::Include(_) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_account(&mut self, tx_ref: TransactionReference, account_name: &String, tx_date: &String, commodity_name: &String, commodity_quantity: Decimal) {
|
||||
if account_name.starts_with("Assets:") || account_name.starts_with("Liability:"){
|
||||
match self.asset_accounts.contains_key(account_name) {
|
||||
false => {
|
||||
self.asset_accounts.insert(
|
||||
account_name.clone(),
|
||||
AssetAccount::new(account_name.clone()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let asset_account = self.asset_accounts.get_mut(account_name).unwrap();
|
||||
|
||||
if commodity_name != "$" && commodity_name != "StickerSheets" {
|
||||
let cost_basis = self.commodities_oracle.lookup(commodity_name, tx_date);
|
||||
|
||||
if commodity_quantity.is_sign_positive() {
|
||||
asset_account.deposit(commodity_name.clone(), cost_basis, commodity_quantity, tx_date);
|
||||
} else {
|
||||
asset_account.withdraw(
|
||||
commodity_name.clone(),
|
||||
cost_basis,
|
||||
commodity_quantity,
|
||||
tx_date,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if commodity_quantity.is_sign_positive() {
|
||||
asset_account.deposit(
|
||||
commodity_name.clone(),
|
||||
Decimal::new(1, 0),
|
||||
commodity_quantity,
|
||||
tx_date
|
||||
);
|
||||
} else {
|
||||
asset_account.withdraw(
|
||||
commodity_name.clone(),
|
||||
Decimal::new(1, 0),
|
||||
commodity_quantity,
|
||||
tx_date,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This is an external account
|
||||
match self.external_accounts.contains_key(account_name) {
|
||||
false => {
|
||||
let external_account = if account_name.starts_with("Income") {
|
||||
ExternalAccount::new(account_name.clone(), true)
|
||||
} else if account_name.starts_with("Expenses") {
|
||||
ExternalAccount::new(account_name.clone(), false)
|
||||
} else {
|
||||
// panic!("Unable to track {}", account_name)
|
||||
return;
|
||||
};
|
||||
|
||||
self.external_accounts
|
||||
.insert(account_name.clone(), external_account);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let external_account = self.external_accounts.get_mut(account_name).unwrap();
|
||||
|
||||
if !external_account.income_account {
|
||||
external_account.deposit(tx_ref, commodity_name.clone(), commodity_quantity, tx_date)
|
||||
} else {
|
||||
external_account.withdraw(tx_ref, commodity_name.clone(), commodity_quantity, tx_date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn summarize_accounts(&self) {
|
||||
for (account_name, account) in self.external_accounts.iter() {
|
||||
println!("{}", account_name);
|
||||
println!("-----");
|
||||
account.summarize_transactions();
|
||||
println!()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_gains(&self, date: &String) -> f64 {
|
||||
let mut realized_gains = Decimal::new(0, 0);
|
||||
let mut unrealized_gains = Decimal::new(0, 0);
|
||||
for (name, asset_account) in self.asset_accounts.iter() {
|
||||
println!("{} ", name);
|
||||
realized_gains += asset_account.realized_gains;
|
||||
println!(" Realized: {}", asset_account.realized_gains);
|
||||
for (commodity, total) in asset_account.unrealized_gains(&self.commodities_oracle, &String::from(date)) {
|
||||
println!(" Unrealized: {} {} ", commodity, total);
|
||||
unrealized_gains += total;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Total Realized Gains: {}", realized_gains);
|
||||
println!("Total Unrealized Gains: {}", unrealized_gains);
|
||||
println!(
|
||||
"Total Realized + Unrealized Gains: {}",
|
||||
unrealized_gains + realized_gains
|
||||
);
|
||||
return (unrealized_gains+realized_gains).to_f64().unwrap()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
use crate::books::TransactionReference;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
use crate::CommoditiesPriceOracle;
|
||||
|
||||
// For tracking income and expenses...
|
||||
// All Transactions are one way.
|
||||
#[derive(Clone)]
|
||||
pub struct ExternalAccount {
|
||||
name: String,
|
||||
pub(crate) income_account: bool,
|
||||
pub(crate) transactions: Vec<(TransactionReference, String, String, Decimal)>,
|
||||
}
|
||||
|
||||
impl ExternalAccount {
|
||||
pub fn new(name: String, income_account: bool) -> ExternalAccount {
|
||||
ExternalAccount {
|
||||
name,
|
||||
transactions: vec![],
|
||||
income_account,
|
||||
}
|
||||
}
|
||||
|
||||
// We can only deposit commodities to an Income Account.
|
||||
pub fn deposit(&mut self, txref: TransactionReference, commodity: String, quantity: Decimal, date: &String) {
|
||||
if !self.income_account {
|
||||
self.transactions
|
||||
.push((txref, date.clone(), commodity, quantity));
|
||||
return;
|
||||
}
|
||||
panic!(
|
||||
"cannot deposit to an income account...{} {} {} {}",
|
||||
txref, commodity, quantity, date
|
||||
)
|
||||
}
|
||||
|
||||
// We can only withdraw commodities from an Income Account.
|
||||
pub fn withdraw(&mut self, txref: TransactionReference, commodity: String, quantity: Decimal, date: &String) {
|
||||
if self.income_account {
|
||||
self.transactions
|
||||
.push((txref, date.clone(), commodity, quantity));
|
||||
return;
|
||||
}
|
||||
panic!(
|
||||
"cannot withdraw from an expense account...{} {} {} {} {}",
|
||||
self.name, txref, commodity, quantity, date
|
||||
)
|
||||
}
|
||||
|
||||
pub fn total_nominal(&self, oracle: &CommoditiesPriceOracle) -> f64 {
|
||||
let mut total = 0.0;
|
||||
for (txid, date, commodity, quantity) in self.transactions.iter() {
|
||||
if commodity != "$" && commodity != "StickerSheets" {
|
||||
total += (oracle.lookup(commodity, date) * quantity.abs()).to_f64().unwrap();
|
||||
} else if commodity == "$" {
|
||||
total += quantity.abs().to_f64().unwrap()
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
pub fn summarize_transactions(&self) {
|
||||
for (txid, date, commodity, quantity) in self.transactions.iter() {
|
||||
println!("{}, {}, {}, {}", txid, date, commodity, quantity)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
use crate::asset_account::AssetAccount;
|
||||
use crate::books::Books;
|
||||
use crate::external_account::ExternalAccount;
|
||||
use crate::oracle::CommoditiesPriceOracle;
|
||||
use crate::spreadsheet::export_to_spreadsheet;
|
||||
|
||||
pub mod asset_account;
|
||||
pub mod books;
|
||||
pub mod external_account;
|
||||
pub mod oracle;
|
||||
pub mod spreadsheet;
|
||||
|
||||
fn main() {
|
||||
let mut books = Books::new();
|
||||
let stop_date = format!("2023-02-10");
|
||||
books.load_ledger("2022-archive.dat", &stop_date);
|
||||
export_to_spreadsheet("test.ods", &books, &format!("2022-02-11"), &stop_date);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct CommoditiesPriceOracle {
|
||||
commodities: HashMap<String, HashMap<String, Decimal>>,
|
||||
}
|
||||
|
||||
impl CommoditiesPriceOracle {
|
||||
pub fn new() -> CommoditiesPriceOracle {
|
||||
CommoditiesPriceOracle {
|
||||
commodities: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, commodity: String, date: String, price: Decimal) {
|
||||
match self.commodities.contains_key(&commodity) {
|
||||
false => {
|
||||
self.commodities.insert(commodity.clone(), HashMap::new());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
println!("inserting {} {} {}", commodity, date, price);
|
||||
self.commodities
|
||||
.get_mut(&commodity)
|
||||
.unwrap()
|
||||
.insert(date, price);
|
||||
}
|
||||
|
||||
pub fn lookup(&self, commodity: &String, date: &String) -> Decimal {
|
||||
match self.commodities.get(commodity) {
|
||||
None => {
|
||||
panic!("no price history data for commodity: {}", commodity)
|
||||
}
|
||||
Some(price_history) => match price_history.get(date) {
|
||||
None => {
|
||||
panic!("no date history for {} on {}", commodity, date)
|
||||
}
|
||||
Some(price) => price.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,283 @@
|
|||
use std::cmp::Ordering;
|
||||
use std::str::FromStr;
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use rust_decimal::prelude::{ToPrimitive, Zero};
|
||||
use spreadsheet_ods::{CellStyle, format, Length, Sheet, Value, WorkBook};
|
||||
use crate::{AssetAccount, Books, ExternalAccount};
|
||||
use icu_locid::locale;
|
||||
use rust_decimal::Decimal;
|
||||
use spreadsheet_ods::style::units::TextAlign;
|
||||
use spreadsheet_ods::Value::Currency;
|
||||
|
||||
|
||||
pub fn export_to_spreadsheet(name: &str, books: &Books, opening_date: &String, close_date: &String) {
|
||||
let mut wb =WorkBook::new(locale!("en_CA"));
|
||||
|
||||
let mut header_style = CellStyle::new_empty();
|
||||
header_style.set_font_bold();
|
||||
header_style.set_text_align(TextAlign::Center);
|
||||
let header_style_ref = wb.add_cellstyle(header_style);
|
||||
|
||||
let currency_format = format::create_currency_prefix("cad", locale!("en_CA"), format!("$"));
|
||||
let currency_style = wb.add_currency_format(currency_format);
|
||||
|
||||
let mut general_text_style = CellStyle::new_empty();
|
||||
general_text_style.set_text_align(TextAlign::Right);
|
||||
let general_text_style = wb.add_cellstyle(general_text_style);
|
||||
|
||||
let mut currency_pretty_print = CellStyle::new_empty();
|
||||
currency_pretty_print.set_text_align(TextAlign::Right);
|
||||
currency_pretty_print.set_decimal_places(2);
|
||||
currency_pretty_print.set_value_format(¤cy_style);
|
||||
let currency_pretty_print_style = wb.add_cellstyle(currency_pretty_print);
|
||||
|
||||
|
||||
// Create a Balance Statement
|
||||
let mut balance_statement = Sheet::new(format!("BalanceSheet"));
|
||||
balance_statement.set_styled_value(0, 0, Value::Text(format!("Balance Sheet")), &header_style_ref);
|
||||
balance_statement.set_col_width(0, Length::Pc(25.0));
|
||||
balance_statement.set_col_width(1, Length::Pc(25.0));
|
||||
balance_statement.set_col_width(2, Length::Pc(25.0));
|
||||
balance_statement.set_col_width(3, Length::Pc(25.0));
|
||||
balance_statement.set_col_width(4, Length::Pc(25.0));
|
||||
// We first need a list of all of our assets and liabilities
|
||||
let mut asset_accounts : Vec<(String, AssetAccount)> = books.asset_accounts.iter().map(|(a,b)| (a.clone(), b.clone())).collect();
|
||||
asset_accounts.sort_by(|a,b| {
|
||||
if a.0.starts_with("Asset") && b.0.starts_with("Liability") {
|
||||
return Ordering::Less
|
||||
} if a.0.starts_with("Liability") && b.0.starts_with("Asset") {
|
||||
return Ordering::Greater
|
||||
} else {
|
||||
return b.1.total_nominal(&books.commodities_oracle, close_date).total_cmp(&a.1.total_nominal(&books.commodities_oracle, close_date))
|
||||
}
|
||||
});
|
||||
|
||||
// Assets Section
|
||||
balance_statement.set_styled_value(2, 0, Value::Text(format!("Assets")), &header_style_ref);
|
||||
balance_statement.set_styled_value(2, 2, Value::Text(format!("Closing Value")), &header_style_ref);
|
||||
balance_statement.set_styled_value(2, 3, Value::Text(format!("Opening Value")), &header_style_ref);
|
||||
let mut row_index = 3;
|
||||
let start = row_index;
|
||||
for (account_name, account) in asset_accounts.iter() {
|
||||
if account_name.starts_with("Asset") {
|
||||
let total_nominal = account.total_nominal(&books.commodities_oracle, close_date);
|
||||
let total_opening = account.opening_nominal(&books.commodities_oracle, opening_date);
|
||||
balance_statement.set_styled_value(row_index, 1, Value::Text(account_name.clone().replace("Assets:", "")), &general_text_style);
|
||||
balance_statement.set_styled_value(row_index, 2, Value::Currency(total_nominal, format!("CAD")), ¤cy_pretty_print_style);
|
||||
balance_statement.set_styled_value(row_index, 3, Value::Currency(total_opening, format!("CAD")), ¤cy_pretty_print_style);
|
||||
|
||||
row_index+=1;
|
||||
}
|
||||
}
|
||||
balance_statement.set_styled_value(row_index, 0, format!("Total"), &header_style_ref);
|
||||
balance_statement.set_formula(row_index, 2, format!("SUM(C{}:C{})", start+1, row_index));
|
||||
balance_statement.set_cellstyle(row_index,2, ¤cy_pretty_print_style);
|
||||
balance_statement.set_formula(row_index, 3, format!("SUM(D{}:D{})", start+1, row_index));
|
||||
balance_statement.set_cellstyle(row_index,3, ¤cy_pretty_print_style);
|
||||
let mut assets_total_row = row_index;
|
||||
row_index+=2;
|
||||
|
||||
balance_statement.set_styled_value(row_index, 0, Value::Text(format!("Liabilities")), &header_style_ref);
|
||||
let start = row_index;
|
||||
for (account_name, account) in asset_accounts.iter() {
|
||||
if account_name.starts_with("Liability") {
|
||||
let total_nominal = account.total_nominal(&books.commodities_oracle, close_date);
|
||||
let total_opening = account.opening_nominal(&books.commodities_oracle, opening_date);
|
||||
if total_opening.is_zero() && total_nominal.is_zero() {
|
||||
continue
|
||||
}
|
||||
balance_statement.set_styled_value(row_index, 1, Value::Text(account_name.clone().replace("Liability:", "")), &general_text_style);
|
||||
balance_statement.set_styled_value(row_index, 2, Value::Currency(total_nominal, format!("CAD")), ¤cy_pretty_print_style);
|
||||
balance_statement.set_styled_value(row_index, 3, Value::Currency(total_opening, format!("CAD")), ¤cy_pretty_print_style);
|
||||
|
||||
row_index+=1;
|
||||
}
|
||||
}
|
||||
balance_statement.set_styled_value(row_index, 0, format!("Total"), &header_style_ref);
|
||||
balance_statement.set_formula(row_index, 2, format!("SUM(C{}:C{})", start+1, row_index));
|
||||
balance_statement.set_cellstyle(row_index,2, ¤cy_pretty_print_style);
|
||||
balance_statement.set_formula(row_index, 3, format!("SUM(D{}:D{})", start+1, row_index));
|
||||
balance_statement.set_cellstyle(row_index,3, ¤cy_pretty_print_style);
|
||||
let mut liability_total_row = row_index;
|
||||
row_index+=2;
|
||||
|
||||
|
||||
balance_statement.set_styled_value(row_index, 0, format!("Net Assets"), &header_style_ref);
|
||||
balance_statement.set_formula(row_index, 2, format!("C{} - C{}", assets_total_row+1, liability_total_row+1));
|
||||
balance_statement.set_cellstyle(row_index,2, ¤cy_pretty_print_style);
|
||||
|
||||
|
||||
balance_statement.set_formula(row_index, 3, format!("D{} - D{}", assets_total_row+1, liability_total_row+1));
|
||||
balance_statement.set_cellstyle(row_index,3, ¤cy_pretty_print_style);
|
||||
let balance_sheet_opening_assets_row = row_index;
|
||||
|
||||
let balance_sheet_total_net_assets_row = row_index;
|
||||
|
||||
// Create an Income Statement
|
||||
let mut income_statement = Sheet::new(format!("IncomeStatement"));
|
||||
income_statement.set_styled_value(0, 0, Value::Text(format!("Income Statement")), &header_style_ref);
|
||||
income_statement.set_col_width(0, Length::Pc(25.0));
|
||||
income_statement.set_col_width(1, Length::Pc(25.0));
|
||||
|
||||
// We first need a list of all of our incomes and expenses, ordered by value...
|
||||
let mut external_accounts : Vec<(String, ExternalAccount)> = books.external_accounts.iter().map(|(a,b)| (a.clone(), b.clone())).collect();
|
||||
external_accounts.sort_by(|a,b| {
|
||||
if a.0.starts_with("Income") && b.0.starts_with("Expense") {
|
||||
return Ordering::Less
|
||||
} if a.0.starts_with("Expense") && b.0.starts_with("Income") {
|
||||
return Ordering::Greater
|
||||
} else {
|
||||
return b.1.total_nominal(&books.commodities_oracle).total_cmp(&a.1.total_nominal(&books.commodities_oracle))
|
||||
}
|
||||
});
|
||||
|
||||
// Revenue Section
|
||||
income_statement.set_styled_value(2, 0, Value::Text(format!("Revenue")), &header_style_ref);
|
||||
let mut row_index = 3;
|
||||
let start = row_index;
|
||||
for (account_name, account) in external_accounts.iter() {
|
||||
if account_name.starts_with("Income") {
|
||||
income_statement.set_styled_value(row_index, 0, Value::Text(account_name.clone().replace("Income:", "")), &general_text_style);
|
||||
income_statement.set_styled_value(row_index, 1, Value::Currency(account.total_nominal(&books.commodities_oracle), format!("CAD")), ¤cy_pretty_print_style);
|
||||
row_index+=1;
|
||||
}
|
||||
}
|
||||
income_statement.set_styled_value(row_index, 0, format!("Total"), &header_style_ref);
|
||||
income_statement.set_formula(row_index, 1, format!("SUM(B{}:B{})", start+1, row_index));
|
||||
income_statement.set_cellstyle(row_index,1, ¤cy_pretty_print_style);
|
||||
let mut revenue_total_row = row_index;
|
||||
row_index+=2;
|
||||
|
||||
// Now to Expenses...
|
||||
income_statement.set_styled_value(row_index, 0, Value::Text(format!("Expenses")), &general_text_style);
|
||||
row_index+=1;
|
||||
let start = row_index;
|
||||
for (account_name, account) in external_accounts.iter() {
|
||||
if account_name.starts_with("Expense") {
|
||||
income_statement.set_styled_value(row_index, 0, Value::Text(account_name.clone().replace("Expenses:", "")), &general_text_style);
|
||||
income_statement.set_styled_value(row_index, 1, Value::Currency(account.total_nominal(&books.commodities_oracle), format!("CAD")), ¤cy_pretty_print_style);
|
||||
row_index+=1;
|
||||
}
|
||||
}
|
||||
income_statement.set_styled_value(row_index, 0, format!("Total"), &header_style_ref);
|
||||
income_statement.set_formula(row_index, 1, format!("SUM(B{}:B{})", start+1, row_index));
|
||||
income_statement.set_cellstyle(row_index,1, ¤cy_pretty_print_style);
|
||||
let mut expense_total_row = row_index;
|
||||
|
||||
row_index+=2;
|
||||
income_statement.set_styled_value(row_index, 0, format!("Net Income"), &header_style_ref);
|
||||
income_statement.set_formula(row_index, 1, format!("B{} - B{}", revenue_total_row+1, expense_total_row+1));
|
||||
income_statement.set_cellstyle(row_index,1, ¤cy_pretty_print_style);
|
||||
|
||||
|
||||
let balance_sheet_summary_row = balance_sheet_total_net_assets_row + 2;
|
||||
balance_statement.set_styled_value(balance_sheet_summary_row,0, format!("Income statement balance for the year"), &header_style_ref);
|
||||
balance_statement.set_formula(balance_sheet_summary_row,1, format!("IncomeStatement.B{}", row_index+1));
|
||||
|
||||
balance_statement.set_styled_value(balance_sheet_summary_row+1,0, format!("Opening Assets + Income"), &header_style_ref);
|
||||
balance_statement.set_formula(balance_sheet_summary_row+1,1, format!("B{}+D{}", balance_sheet_summary_row+1,balance_sheet_opening_assets_row+1));
|
||||
|
||||
let exchange_gains = books.calculate_gains(close_date);
|
||||
balance_statement.set_styled_value(balance_sheet_summary_row+2,0, format!("Realized Gains/Losses"), &header_style_ref);
|
||||
balance_statement.set_styled_value(balance_sheet_summary_row+2,1, Currency(exchange_gains, format!("CAD")), ¤cy_pretty_print_style);
|
||||
|
||||
balance_statement.set_styled_value(balance_sheet_summary_row+3,0, format!("Balance"), &header_style_ref);
|
||||
balance_statement.set_formula(balance_sheet_summary_row+3,1, format!("B{}+B{}", balance_sheet_summary_row+2,balance_sheet_summary_row+3));
|
||||
|
||||
|
||||
wb.push_sheet(balance_statement);
|
||||
wb.push_sheet(income_statement);
|
||||
|
||||
// Create a General Ledger
|
||||
let mut general_ledger = Sheet::new(format!("GeneralLedger"));
|
||||
general_ledger.set_styled_value(0, 0, Value::Text(format!("General Ledger")), &header_style_ref);
|
||||
|
||||
let start_row = 1;
|
||||
general_ledger.set_styled_value(start_row, 0, Value::Text(String::from("Ref")), &header_style_ref);
|
||||
general_ledger.set_col_width(0, Length::Pc(15.0));
|
||||
|
||||
general_ledger.set_styled_value(start_row, 1, Value::Text(String::from("Date")), &header_style_ref);
|
||||
general_ledger.set_col_width(1, Length::Pc(15.0));
|
||||
|
||||
general_ledger.set_styled_value(start_row, 2, Value::Text(String::from("Account")), &header_style_ref);
|
||||
general_ledger.set_col_width(2, Length::Pc(30.0));
|
||||
|
||||
general_ledger.set_styled_value(start_row, 3, Value::Text(String::from("Recorded CAD Value")), &header_style_ref);
|
||||
general_ledger.set_col_width(3, Length::Pc(15.0));
|
||||
|
||||
general_ledger.set_styled_value(start_row, 4, Value::Text(String::from("Recorded Commodity Value")), &header_style_ref);
|
||||
general_ledger.set_col_width(4, Length::Pc(15.0));
|
||||
|
||||
general_ledger.set_styled_value(start_row, 5, Value::Text(String::from("Commodity")), &header_style_ref);
|
||||
general_ledger.set_col_width(5, Length::Pc(15.0));
|
||||
general_ledger.set_header_rows(start_row,1);
|
||||
|
||||
let mut row_index = 2;
|
||||
// Create a sheet for each tracked account
|
||||
for (txid, date, postings) in books.general_ledger.iter() {
|
||||
general_ledger.set_styled_value(row_index, 0, Value::Number(*txid as f64), &header_style_ref);
|
||||
let recorded_date = NaiveDate::from_str(date).unwrap();
|
||||
general_ledger.set_styled_value(row_index, 1, Value::DateTime(recorded_date.and_time(NaiveTime::default())), &header_style_ref);
|
||||
row_index +=1;
|
||||
for(account, commodity, quantity) in postings.iter() {
|
||||
let nominal_value = if commodity != "$" && commodity != "StickerSheets" {
|
||||
books.commodities_oracle.lookup(commodity, date) * quantity
|
||||
} else {
|
||||
Decimal::new(1,0 ) * quantity
|
||||
};
|
||||
general_ledger.set_styled_value(row_index, 2, Value::Text(account.clone()), &general_text_style);
|
||||
general_ledger.set_styled_value(row_index, 3, Value::Currency(nominal_value.to_f64().unwrap(), String::from("CAD")), ¤cy_pretty_print_style);
|
||||
general_ledger.set_styled_value(row_index, 4, Value::Currency(quantity.to_f64().unwrap(), commodity.clone()),&general_text_style);
|
||||
general_ledger.set_styled_value(row_index, 5, Value::Text(commodity.clone().replace("$", "CAD")), &general_text_style);
|
||||
row_index +=1;
|
||||
}
|
||||
}
|
||||
wb.push_sheet(general_ledger);
|
||||
|
||||
|
||||
// Create a sheet for each tracked account
|
||||
for (external_account, account) in external_accounts.iter() {
|
||||
let mut sheet = Sheet::new(format!("{}", external_account.to_lowercase().replace(":", "")));
|
||||
|
||||
sheet.set_styled_value(0, 0, Value::Text(format!("Account: {}", external_account)), &header_style_ref);
|
||||
|
||||
|
||||
let start_row = 1;
|
||||
sheet.set_styled_value(start_row, 0, Value::Text(String::from("Ref")), &header_style_ref);
|
||||
sheet.set_col_width(0, Length::Pc(15.0));
|
||||
|
||||
sheet.set_styled_value(start_row, 1, Value::Text(String::from("Date")), &header_style_ref);
|
||||
sheet.set_col_width(1, Length::Pc(15.0));
|
||||
|
||||
sheet.set_styled_value(start_row, 2, Value::Text(String::from("Recorded CAD Value")), &header_style_ref);
|
||||
sheet.set_col_width(2, Length::Pc(15.0));
|
||||
|
||||
sheet.set_styled_value(start_row, 3, Value::Text(String::from("Recorded Commodity Value")), &header_style_ref);
|
||||
sheet.set_col_width(3, Length::Pc(15.0));
|
||||
|
||||
sheet.set_styled_value(start_row, 4, Value::Text(String::from("Commodity")), &header_style_ref);
|
||||
sheet.set_col_width(4, Length::Pc(15.0));
|
||||
|
||||
|
||||
sheet.set_header_rows(start_row,1);
|
||||
|
||||
let start_row = 2;
|
||||
for(row, (txref, date, commodity, quantity)) in account.transactions.iter().enumerate() {
|
||||
let index = start_row+(row as u32);
|
||||
let recorded_date = NaiveDate::from_str(date).unwrap();
|
||||
let nominal_value = if commodity != "$" && commodity != "StickerSheets" {
|
||||
books.commodities_oracle.lookup(commodity, date) * quantity
|
||||
} else {
|
||||
Decimal::new(1,0 ) * quantity
|
||||
};
|
||||
sheet.set_styled_value(index, 0, Value::Number(*txref as f64), &header_style_ref);
|
||||
sheet.set_styled_value(index, 1, Value::DateTime(recorded_date.and_time(NaiveTime::default())), &general_text_style);
|
||||
sheet.set_styled_value(index, 2, Value::Currency(nominal_value.to_f64().unwrap(), String::from("CAD")), ¤cy_pretty_print_style);
|
||||
sheet.set_styled_value(index, 3, Value::Currency(quantity.to_f64().unwrap(), commodity.clone()), &general_text_style);
|
||||
sheet.set_styled_value(index, 4, Value::Text(commodity.clone().replace("$", "CAD")), &general_text_style);
|
||||
}
|
||||
wb.push_sheet(sheet);
|
||||
}
|
||||
|
||||
spreadsheet_ods::write_ods(&mut wb, name);
|
||||
}
|
Loading…
Reference in New Issue