GnuCash  5.6-150-g038405b370+
account_analysis.py
Go to the documentation of this file.
1 #!/usr/bin/env python3
2 
3 # account_analysis.py -- Output all the credits and debits on an account
4 #
5 # Copyright (C) 2009, 2010 ParIT Worker Co-operative <transparency@parit.ca>
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License as
8 # published by the Free Software Foundation; either version 2 of
9 # the License, or (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, contact:
18 # Free Software Foundation Voice: +1-617-542-5942
19 # 51 Franklin Street, Fifth Floor Fax: +1-617-542-2652
20 # Boston, MA 02110-1301, USA gnu@gnu.org
21 #
22 # @author Mark Jenkins, ParIT Worker Co-operative <mark@parit.ca>
23 
24 
28 
29 # python imports
30 from sys import argv, stdout
31 from datetime import date, timedelta
32 from bisect import bisect_right
33 from decimal import Decimal
34 from math import log10
35 import csv
36 
37 # gnucash imports
38 from gnucash import Session, GncNumeric, Split, SessionOpenMode
39 
40 # Invoke this script like the following example
41 # $ python3 account_analysis.py gnucash_file.gnucash \
42 # 2010 1 monthly 12 \
43 # debits-show credits-show Assets 'Test Account'
44 #
45 # That will do an analysis on the account 'Assets:Test Account' from
46 # gnucash_file.xac, all of the debits and all of the credits will be shown
47 # and summed on for 12 monthly periods starting from January (1st month) 2010
48 #
49 # if you just want to see the credit and debit sums for each period, use
50 # the debits-noshow and credits-noshow argument
51 #
52 # The output goes to stdout and is in csv format.
53 #
54 # Account path arguments are space separated, so you need to quote parts of
55 # the account path with spaces in them
56 #
57 # available period types are monthly quarterly and yearly
58 #
59 # At the moment this script only supports GnuCash files of the sqllite3 type
60 # its an easy edit to switch to xml: etc...
61 
62 
63 # a dictionary with a period name as key, and number of months in that
64 # kind of period as the value
65 PERIODS = {"monthly": 1,
66  "quarterly": 3,
67  "yearly": 12 }
68 
69 NUM_MONTHS = 12
70 
71 ONE_DAY = timedelta(days=1)
72 
73 DEBITS_SHOW, CREDITS_SHOW = ("debits-show", "credits-show")
74 
75 ZERO = Decimal(0)
76 
77 def gnc_numeric_to_python_Decimal(numeric):
78  negative = numeric.negative_p()
79  if negative:
80  sign = 1
81  else:
82  sign = 0
83  copy = GncNumeric(numeric.num(), numeric.denom())
84  result = copy.to_decimal(None)
85  if not result:
86  raise Exception("gnc numeric value %s can't be converted to decimal" %
87  copy.to_string() )
88  digit_tuple = tuple( int(char)
89  for char in str(copy.num())
90  if char != '-' )
91  denominator = copy.denom()
92  exponent = int(log10(denominator))
93  assert( (10 ** exponent) == denominator )
94  return Decimal( (sign, digit_tuple, -exponent) )
95 
96 
97 def next_period_start(start_year, start_month, period_type):
98  # add numbers of months for the period length
99  end_month = start_month + PERIODS[period_type]
100  # use integer division to find out if the new end month is in a different
101  # year, what year it is, and what the end month number should be changed
102  # to.
103  # Because this depends on modular arithmetic, we have to curvert the month
104  # values from 1-12 to 0-11 by subtracting 1 and putting it back after
105  #
106  # the really cool part is that this whole thing is implemented without
107  # any branching; if end_month > NUM_MONTHS
108  #
109  # A the super nice thing is that you can add all kinds of period lengths
110  # to PERIODS
111  end_year = start_year + ( (end_month-1) / NUM_MONTHS )
112  end_month = ( (end_month-1) % NUM_MONTHS ) + 1
113 
114  return end_year, end_month
115 
116 
117 def period_end(start_year, start_month, period_type):
118  if period_type not in PERIODS:
119  raise Exception("%s is not a valid period, should be %s" % (
120  period_type, str(list(PERIODS.keys())) ) )
121 
122  end_year, end_month = next_period_start(start_year, start_month,
123  period_type)
124 
125  # last step, the end date is day back from the start of the next period
126  # so we get a period end like
127  # 2010-03-31 for period starting 2010-01 instead of 2010-04-01
128  return date(end_year, end_month, 1) - ONE_DAY
129 
130 
131 def generate_period_boundaries(start_year, start_month, period_type, periods):
132  for i in range(periods):
133  yield ( date(start_year, start_month, 1),
134  period_end(start_year, start_month, period_type) )
135  start_year, start_month = next_period_start(start_year, start_month,
136  period_type)
137 
138 def account_from_path(top_account, account_path, original_path=None):
139  if original_path==None: original_path = account_path
140  account, account_path = account_path[0], account_path[1:]
141 
142  account = top_account.lookup_by_name(account)
143  if account == None:
144  raise Exception(
145  "path " + ''.join(original_path) + " could not be found")
146  if len(account_path) > 0 :
147  return account_from_path(account, account_path, original_path)
148  else:
149  return account
150 
151 
152 def main():
153 
154  if len(argv) < 10:
155  print('not enough parameters')
156  print('usage: account_analysis.py {book url} {start year} {start month, numeric} {period type: monthly, quarterly, or yearly} {number of periods to show, from start year and month} {whether to show debits: debits-show for true, all other values false} {whether to show credits: credits-show for true, all other values false} {space separated account path, as many nested levels as desired} ')
157  print('examples:\n')
158  print("The following example analyzes 12 months of 'Assets:Test Account' from /home/username/test.gnucash, starting in January of 2010, and shows both credits and debits")
159  print("python3 account_analysis.py '/home/username/test.gnucash' 2010 1 monthly 12 debits-show credits-show Assets 'Test Account'\n")
160  print("The following example analyzes 2 quarters of 'Liabilities:First Level:Second Level' from /home/username/test.gnucash, starting March 2011, and shows credits but not debits")
161  print("python3 account_analysis.py '/home/username/test.gnucash' 2011 3 quarterly 2 debits-noshow credits-show Liabilities 'First Level' 'Second Level")
162  return
163 
164  try:
165  (gnucash_file, start_year, start_month, period_type, periods,
166  debits_show, credits_show) = argv[1:8]
167  start_year, start_month, periods = [int(blah)
168  for blah in (start_year, start_month,
169  periods) ]
170 
171  debits_show = debits_show == DEBITS_SHOW
172  credits_show = credits_show == CREDITS_SHOW
173 
174  account_path = argv[8:]
175 
176  gnucash_session = Session(gnucash_file, SessionOpenMode.SESSION_NORMAL_OPEN)
177  root_account = gnucash_session.book.get_root_account()
178  account_of_interest = account_from_path(root_account, account_path)
179 
180  # a list of all the periods of interest, for each period
181  # keep the start date, end date, a list to store debits and credits,
182  # and sums for tracking the sum of all debits and sum of all credits
183  period_list = [
184  [start_date, end_date,
185  [], # debits
186  [], # credits
187  ZERO, # debits sum
188  ZERO, # credits sum
189  ]
190  for start_date, end_date in generate_period_boundaries(
191  start_year, start_month, period_type, periods)
192  ]
193  # a copy of the above list with just the period start dates
194  period_starts = [e[0] for e in period_list ]
195 
196  # insert and add all splits in the periods of interest
197  for split in account_of_interest.GetSplitList():
198  trans = split.parent
199  trans_date = date.fromtimestamp(trans.GetDate())
200 
201  # use binary search to find the period that starts before or on
202  # the transaction date
203  period_index = bisect_right( period_starts, trans_date ) - 1
204 
205  # ignore transactions with a date before the matching period start
206  # (after subtracting 1 above start_index would be -1)
207  # and after the last period_end
208  if period_index >= 0 and \
209  trans_date <= period_list[len(period_list)-1][1]:
210 
211  # get the period bucket appropriate for the split in question
212  period = period_list[period_index]
213 
214  # more specifically, we'd expect the transaction date
215  # to be on or after the period start, and before or on the
216  # period end, assuming the binary search (bisect_right)
217  # assumptions from above are are right..
218  #
219  # in other words, we assert our use of binary search
220  # and the filtered results from the above if provide all the
221  # protection we need
222  assert( trans_date>= period[0] and trans_date <= period[1] )
223 
224  split_amount = gnc_numeric_to_python_Decimal(split.GetAmount())
225 
226  # if the amount is negative, this is a credit
227  if split_amount < ZERO:
228  debit_credit_offset = 1
229  # else a debit
230  else:
231  debit_credit_offset = 0
232 
233  # store the debit or credit Split with its transaction, using the
234  # above offset to get in the right bucket
235  #
236  # if we wanted to be really cool we'd keep the transactions
237  period[2+debit_credit_offset].append( (trans, split) )
238 
239  # add the debit or credit to the sum, using the above offset
240  # to get in the right bucket
241  period[4+debit_credit_offset] += split_amount
242 
243  csv_writer = csv.writer(stdout)
244  csv_writer.writerow( ('period start', 'period end', 'debits', 'credits') )
245 
246  def generate_detail_rows(values):
247  return (
248  ('', '', '', '', trans.GetDescription(),
249  gnc_numeric_to_python_Decimal(split.GetAmount()))
250  for trans, split in values )
251 
252 
253  for start_date, end_date, debits, credits, debit_sum, credit_sum in \
254  period_list:
255  csv_writer.writerow( (start_date, end_date, debit_sum, credit_sum) )
256 
257  if debits_show and len(debits) > 0:
258  csv_writer.writerow(
259  ('DEBITS', '', '', '', 'description', 'value') )
260  csv_writer.writerows( generate_detail_rows(debits) )
261  csv_writer.writerow( () )
262  if credits_show and len(credits) > 0:
263  csv_writer.writerow(
264  ('CREDITS', '', '', '', 'description', 'value') )
265  csv_writer.writerows( generate_detail_rows(credits) )
266  csv_writer.writerow( () )
267 
268  # no save needed, we're just reading..
269  gnucash_session.end()
270  except:
271  if "gnucash_session" in locals():
272  gnucash_session.end()
273 
274  raise
275 
276 if __name__ == "__main__": main()
277 
278 
The primary numeric class for representing amounts and values.
Definition: gnc-numeric.hpp:60