├── LICENSE ├── README.md ├── budget.py ├── requirements.txt └── sample_plot.png /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Cameron Sun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This script generates a simple budget report for GnuCash accounts. The reports can be configured to use different accounts and will look like this: 2 | 3 | ![](sample_plot.png) 4 | 5 | # Description 6 | This script queries a SQLite GnuCash file for a given list of accounts, then plots them in a cumulative manner (so the plot line for Expenses will be its balance plus the sum of all of its child balances, Expenses:Food will be the sum of *its* balance plus child account balances, etc.) 7 | 8 | In addition, this script plots a line on each report that shows a target "budget" value. Please note that the concept of a budget as used in this script is quite different from that used by GnuCash. In this script's case, each account has a single floating point budget, representing the target balance of that account each month. 9 | 10 | This script will plot a year-to-date graph for each account for each year >= 2020, as well as separate graphs for each month of each plotted year, up to the current month. These can be dumped to a configurable `--output` directory, which defaults to the script directory. 11 | 12 | # Setup 13 | I have only tested this on python 3.8.1, so be warned. I believe it requires python 3.x or newer, but I could be wrong. 14 | 15 | 1. Make sure that your GnuCash file is saved in SQLite format. 16 | 2. Run `pip install -r requirements.txt`. I recommend using a virtualenv or similar. 17 | 18 | # Running 19 | To run the command, provide it with a path to your gnucash file, the accounts you want to use, and the budgets for each account. Use `python3 budget.py --help` for more info on the desired format. Here's a sample command: 20 | 21 | ``` 22 | python budget.py ../gnucash/main.gnucash \ 23 | --ignored_accounts="Expenses:Taxes,Expenses:401k Management,Expenses:Rent" \ 24 | --accounts="Expenses,Expenses:Food,Expenses:Leisure" \ 25 | --budgets="0,0,0" 26 | ``` 27 | 28 | If you are using a version of GnuCash >3.7, you may need to use the `--unsupported_table_hotfix` flag to get this to run (until piecash pushes a release that fixes that bug) 29 | -------------------------------------------------------------------------------- /budget.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import calendar 3 | import datetime 4 | import heapq 5 | import os 6 | import os.path 7 | import matplotlib.pyplot as plt 8 | import numpy as np 9 | import pandas as pd 10 | import piecash 11 | import re 12 | 13 | from piecash import open_book 14 | from piecash.core import Transaction 15 | 16 | 17 | class CumulativeTree(object): 18 | 19 | def __init__(self, name, buckets_count): 20 | self.name = name 21 | self.buckets = np.zeros(buckets_count) 22 | self.latest_bucket = 0 23 | self.children = {} 24 | self.sorted_children = None 25 | 26 | def ingest_split(self, path, bucket, split): 27 | assert path[0] == self.name 28 | assert bucket >= self.latest_bucket, 'Must ingest splits in date order' 29 | 30 | if len(path) == 1: 31 | # base case - add the value of transaction to this node 32 | if bucket != self.latest_bucket: 33 | for i in range(self.latest_bucket + 1, bucket + 1): 34 | self.buckets[i] = self.buckets[self.latest_bucket] 35 | self.latest_bucket = bucket 36 | 37 | self.buckets[bucket] += float(split.value) 38 | else: 39 | # recursive case 40 | if path[1] not in self.children: 41 | self.children[path[1]] = CumulativeTree(path[1], self.buckets.size) 42 | self.children[path[1]].ingest_split(path[1:], bucket, split) 43 | 44 | def finalize(self, name_prefix=None): 45 | if name_prefix is None: 46 | name_prefix = self.name 47 | else: 48 | name_prefix = name_prefix + ':' + self.name 49 | 50 | for i in range(self.latest_bucket + 1, self.buckets.size): 51 | self.buckets[i] = self.buckets[self.latest_bucket] 52 | 53 | self.sorted_children = [] 54 | for _, child in self.children.items(): 55 | if (name_prefix + ':' + child.name) in global_ignored_accounts: 56 | continue 57 | 58 | child.finalize(name_prefix=name_prefix) 59 | self.sorted_children.append(child) 60 | self.buckets = np.add(self.buckets, child.buckets) 61 | 62 | self.sorted_children.sort(key=lambda child : -child.buckets[-1]) 63 | 64 | 65 | def to_dataframe(self, depth=1, name_prefix=None, index=None): 66 | assert self.sorted_children is not None, 'Please run finalize() first' 67 | dataframe = pd.DataFrame(index=index) 68 | 69 | if name_prefix is None: 70 | name_prefix = self.name 71 | else: 72 | name_prefix += ':' + self.name 73 | 74 | # Actual work of converting to dataframe 75 | dataframe.insert(0, name_prefix, self.buckets) 76 | if depth > 0: 77 | for child in self.sorted_children: 78 | child_dataframe = child.to_dataframe(depth - 1, name_prefix=name_prefix, index=index) 79 | dataframe = dataframe.join(child_dataframe) 80 | 81 | return dataframe 82 | 83 | def get_node(self, path): 84 | assert self.name == path[0] 85 | 86 | if len(path) == 1: 87 | return self 88 | elif path[1] in self.children: 89 | return self.children[path[1]].get_node(path[1:]) 90 | else: 91 | return None 92 | 93 | 94 | class CumulativeAccountsIngester(object): 95 | 96 | def __init__(self, book, start_date, end_date): 97 | self.book = book 98 | self.start_date = start_date 99 | self.end_date = end_date 100 | self.total_buckets = (self.end_date - self.start_date).days + 1 101 | self.trees = {} 102 | self.has_started = False 103 | 104 | def start(self): 105 | self.has_started = True 106 | transactions = (self.book.session.query(Transaction).filter(Transaction.post_date>=self.start_date) 107 | .filter(Transaction.post_date<=self.end_date) 108 | .order_by(Transaction.post_date).all()) 109 | 110 | for transaction in transactions: 111 | bucket = (transaction.post_date - self.start_date).days 112 | for split in transaction.splits: 113 | path = split.account.fullname.split(':') 114 | base_acc = path[0] 115 | 116 | if base_acc not in self.trees: 117 | self.trees[base_acc] = CumulativeTree(base_acc, self.total_buckets) 118 | 119 | self.trees[base_acc].ingest_split(path, bucket, split) 120 | 121 | for _, tree in self.trees.items(): 122 | tree.finalize() 123 | 124 | def get_dataframe_for_account(self, account, depth=1): 125 | path = account.split(':') 126 | 127 | if path[0] not in self.trees: 128 | return None 129 | 130 | index = pd.date_range(start=self.start_date, periods=self.total_buckets, freq='D') 131 | node = self.trees[path[0]].get_node(path) 132 | if node is None: 133 | return None 134 | 135 | return node.to_dataframe(depth, index=index) 136 | 137 | 138 | def last_day_of_month(any_day): 139 | next_month = any_day.replace(day=28) + datetime.timedelta(days=4) 140 | return next_month - datetime.timedelta(days=next_month.day) 141 | 142 | 143 | def plot_dataframe(dataframe, title, filename, budget=None): 144 | if dataframe is None: 145 | return 146 | 147 | plt.style.use('fivethirtyeight') 148 | 149 | total_rows = len(dataframe.index) 150 | budget_col = np.zeros(total_rows) 151 | budget /= total_rows 152 | for i in range(total_rows): 153 | budget_col[i] = budget * (i + 1) 154 | dataframe.insert(0, 'BUDGET', budget_col) 155 | 156 | fig = dataframe.plot(title=title, linewidth=2).figure 157 | fig.set_size_inches(19.2, 10.8) 158 | fig.savefig(filename, dpi=300) 159 | 160 | 161 | def plot_ingester(ingester, accounts, budgets, title_prefix='', filename_prefix='', monthly_budget_multiplier=1.0): 162 | def plot_ingester_single(account, filename_suffix, monthly_budget): 163 | output = filename_prefix + filename_suffix 164 | print('== Plotting {}'.format(output)) 165 | 166 | try: 167 | modified_date = datetime.datetime.fromtimestamp(os.path.getmtime(output)).date() 168 | file_needs_update = modified_date < ingester.end_date 169 | except OSError: 170 | file_needs_update = True 171 | 172 | if not file_needs_update: 173 | print('\tPlot has been updated since end date of range. Skipping.') 174 | return 175 | 176 | if not ingester.has_started: 177 | ingester.start() 178 | 179 | plot_dataframe(ingester.get_dataframe_for_account(account), title_prefix + account, 180 | output, budget=monthly_budget_multiplier * monthly_budget) 181 | 182 | for i in range(len(accounts)): 183 | plot_ingester_single( 184 | accounts[i], 185 | re.sub(r':| ', '_', accounts[i].lower()) + '.svg', 186 | budgets[i]) 187 | 188 | 189 | def main(): 190 | this_folder = os.path.dirname(os.path.realpath(__file__)) 191 | 192 | parser = argparse.ArgumentParser(description='Generate monthly and yearly budget plots for GnuCash accounts') 193 | parser.add_argument('gnucash_file', help='Path to a gnucash file stored in SQLite format') 194 | parser.add_argument('--output_folder', '-o',default=this_folder, help='Path to reports output folder') 195 | parser.add_argument('--accounts', type=str, default='Expenses', 196 | help='Comma-separated list of account names to plot') 197 | parser.add_argument('--budgets', type=str, default='0', 198 | help=('Comma-separated list of monthly budgets. Each element should be the ' 199 | 'budget for the account with the corresponding index')) 200 | parser.add_argument('--ignored_accounts', type=str, default='Expenses', 201 | help='Comma-separated list of account names to ignore') 202 | # TODO remove this flag when piecash fixes the bug 203 | parser.add_argument('--unsupported_table_hotfix', action='store_true', 204 | help='Hotfix for unsupported table versions error') 205 | args = parser.parse_args() 206 | 207 | global global_ignored_accounts 208 | global_ignored_accounts = set(args.ignored_accounts.split(',')) 209 | args.accounts = args.accounts.split(',') 210 | args.budgets = list(map(float, args.budgets.split(','))) 211 | 212 | if args.unsupported_table_hotfix: 213 | piecash.core.session.version_supported['3.0']['Gnucash'] = 3000001 214 | piecash.core.session.version_supported['3.0']['splits'] = 5 215 | 216 | book = open_book(args.gnucash_file, open_if_lock=True) 217 | 218 | # TODO make dates more configurable 219 | for year in range(2020, datetime.date.today().year + 1): 220 | reports_folder = os.path.join(args.output_folder, str(year)) 221 | os.makedirs(reports_folder, exist_ok=True) 222 | 223 | ytd_start_date = datetime.date(year, 1, 1) 224 | ytd_end_date = datetime.date.today() 225 | cur_year_end = datetime.date(year, 12, 31) 226 | if cur_year_end < ytd_end_date: 227 | ytd_end_date = cur_year_end 228 | 229 | cur_month = ytd_end_date.month 230 | cur_month_days = calendar.monthrange(year, cur_month)[1] 231 | ytd_budget_multiplier = cur_month - ((cur_month_days - ytd_end_date.day) / cur_month_days) 232 | 233 | ingester = CumulativeAccountsIngester(book, ytd_start_date, ytd_end_date) 234 | plot_ingester(ingester, 235 | args.accounts, 236 | args.budgets, 237 | title_prefix='YTD ', 238 | filename_prefix=os.path.join(reports_folder, 'ytd_'), 239 | monthly_budget_multiplier=ytd_budget_multiplier) 240 | 241 | for month in range(1, ytd_end_date.month + 1): 242 | month_start_date = datetime.date(year, month, 1) 243 | month_end_date = last_day_of_month(month_start_date) 244 | 245 | ingester = CumulativeAccountsIngester(book, month_start_date, month_end_date) 246 | plot_ingester(ingester, 247 | args.accounts, 248 | args.budgets, 249 | title_prefix='{} '.format(month_start_date.strftime('%B')), 250 | filename_prefix=os.path.join(reports_folder, 'month_{}_'.format(month))) 251 | 252 | 253 | if __name__=='__main__': 254 | main() 255 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | piecash 2 | requests 3 | numpy 4 | pandas 5 | matplotlib -------------------------------------------------------------------------------- /sample_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/csun/simple_gnucash_budget_plots/e7abe7ae25af3c9b0138d36c67e9cc5ceb783740/sample_plot.png --------------------------------------------------------------------------------