2018-09-21 02:27:17 -05:00
#!/usr/bin/env python3
2010-06-30 14:26:57 -05:00
# account_analysis.py -- Output all the credits and debits on an account
#
# Copyright (C) 2009, 2010 ParIT Worker Co-operative <transparency@parit.ca>
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation; either version 2 of
# the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, contact:
# Free Software Foundation Voice: +1-617-542-5942
# 51 Franklin Street, Fifth Floor Fax: +1-617-542-2652
# Boston, MA 02110-1301, USA gnu@gnu.org
#
# @author Mark Jenkins, ParIT Worker Co-operative <mark@parit.ca>
2010-12-17 14:36:40 -06:00
## @file
# @brief Output all the credits and debits on an account
# @author Mark Jenkins, ParIT Worker Co-operative <mark@parit.ca>
2010-12-27 09:36:15 -06:00
# @ingroup python_bindings_examples
2010-06-30 14:26:57 -05:00
# python imports
from sys import argv , stdout
from datetime import date , timedelta
from bisect import bisect_right
from decimal import Decimal
from math import log10
import csv
# gnucash imports
2020-06-12 05:24:05 -05:00
from gnucash import Session , GncNumeric , Split , SessionOpenMode
2010-06-30 14:26:57 -05:00
# Invoke this script like the following example
2019-11-30 06:20:08 -06:00
# $ python3 account_analysis.py gnucash_file.gnucash \
2010-06-30 14:26:57 -05:00
# 2010 1 monthly 12 \
# debits-show credits-show Assets 'Test Account'
#
# That will do an analysis on the account 'Assets:Test Account' from
# gnucash_file.xac, all of the debits and all of the credits will be shown
# and summed on for 12 monthly periods starting from January (1st month) 2010
2018-09-21 02:28:41 -05:00
#
2010-06-30 14:26:57 -05:00
# if you just want to see the credit and debit sums for each period, use
# the debits-noshow and credits-noshow argument
#
# The output goes to stdout and is in csv format.
#
2017-01-16 16:03:50 -06:00
# Account path arguments are space separated, so you need to quote parts of
2010-06-30 14:26:57 -05:00
# the account path with spaces in them
#
# available period types are monthly quarterly and yearly
#
# At the moment this script only supports GnuCash files of the sqllite3 type
# its an easy edit to switch to xml: etc...
# a dictionary with a period name as key, and number of months in that
# kind of period as the value
PERIODS = { " monthly " : 1 ,
" quarterly " : 3 ,
" yearly " : 12 }
NUM_MONTHS = 12
ONE_DAY = timedelta ( days = 1 )
DEBITS_SHOW , CREDITS_SHOW = ( " debits-show " , " credits-show " )
ZERO = Decimal ( 0 )
def gnc_numeric_to_python_Decimal ( numeric ) :
negative = numeric . negative_p ( )
if negative :
sign = 1
else :
sign = 0
copy = GncNumeric ( numeric . num ( ) , numeric . denom ( ) )
result = copy . to_decimal ( None )
if not result :
Fix typos for gnuchash/ doc/ bindings/ and misc.
Typos found via `codespell -q 3 -D ~/Projects/codespell/codespell_lib/data/dictionary.txt -S *.po,./po,*.min.js,./ChangeLog*,./NEWS,./doc/README*,./AUTHORS,./libgnucash/tax/us/txf-de*,./data/accounts -L ans,cas,dragable,gae,iff,iif,mut,nd,numer,startd,stoll`
2019-09-13 19:26:03 -05:00
raise Exception ( " gnc numeric value %s can ' t be converted to decimal " %
2010-06-30 14:26:57 -05:00
copy . to_string ( ) )
digit_tuple = tuple ( int ( char )
for char in str ( copy . num ( ) )
if char != ' - ' )
denominator = copy . denom ( )
exponent = int ( log10 ( denominator ) )
assert ( ( 10 * * exponent ) == denominator )
return Decimal ( ( sign , digit_tuple , - exponent ) )
2018-09-21 02:28:41 -05:00
2010-06-30 14:26:57 -05:00
def next_period_start ( start_year , start_month , period_type ) :
# add numbers of months for the period length
end_month = start_month + PERIODS [ period_type ]
# use integer division to find out if the new end month is in a different
# year, what year it is, and what the end month number should be changed
# to.
2017-01-16 16:03:50 -06:00
# Because this depends on modular arithmetic, we have to curvert the month
2010-06-30 14:26:57 -05:00
# values from 1-12 to 0-11 by subtracting 1 and putting it back after
#
# the really cool part is that this whole thing is implemented without
# any branching; if end_month > NUM_MONTHS
#
# A the super nice thing is that you can add all kinds of period lengths
# to PERIODS
end_year = start_year + ( ( end_month - 1 ) / NUM_MONTHS )
end_month = ( ( end_month - 1 ) % NUM_MONTHS ) + 1
return end_year , end_month
2018-09-21 02:28:41 -05:00
2010-06-30 14:26:57 -05:00
def period_end ( start_year , start_month , period_type ) :
if period_type not in PERIODS :
raise Exception ( " %s is not a valid period, should be %s " % (
2019-04-04 10:30:44 -05:00
period_type , str ( list ( PERIODS . keys ( ) ) ) ) )
2010-06-30 14:26:57 -05:00
end_year , end_month = next_period_start ( start_year , start_month ,
period_type )
# last step, the end date is day back from the start of the next period
# so we get a period end like
# 2010-03-31 for period starting 2010-01 instead of 2010-04-01
return date ( end_year , end_month , 1 ) - ONE_DAY
2018-09-21 02:28:41 -05:00
2010-06-30 14:26:57 -05:00
def generate_period_boundaries ( start_year , start_month , period_type , periods ) :
2018-09-22 11:12:11 -05:00
for i in range ( periods ) :
2010-06-30 14:26:57 -05:00
yield ( date ( start_year , start_month , 1 ) ,
period_end ( start_year , start_month , period_type ) )
start_year , start_month = next_period_start ( start_year , start_month ,
period_type )
def account_from_path ( top_account , account_path , original_path = None ) :
if original_path == None : original_path = account_path
account , account_path = account_path [ 0 ] , account_path [ 1 : ]
2012-04-28 08:37:07 -05:00
2010-06-30 14:26:57 -05:00
account = top_account . lookup_by_name ( account )
2014-03-19 11:05:46 -05:00
if account == None :
2010-06-30 14:26:57 -05:00
raise Exception (
" path " + ' ' . join ( original_path ) + " could not be found " )
if len ( account_path ) > 0 :
return account_from_path ( account , account_path , original_path )
else :
return account
def main ( ) :
2012-04-28 08:37:07 -05:00
if len ( argv ) < 10 :
2018-03-22 17:08:22 -05:00
print ( ' not enough parameters ' )
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} ' )
print ( ' examples: \n ' )
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 " )
2019-11-30 06:20:08 -06:00
print ( " python3 account_analysis.py ' /home/username/test.gnucash ' 2010 1 monthly 12 debits-show credits-show Assets ' Test Account ' \n " )
2018-03-22 17:08:22 -05:00
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 " )
2019-11-30 06:20:08 -06:00
print ( " python3 account_analysis.py ' /home/username/test.gnucash ' 2011 3 quarterly 2 debits-noshow credits-show Liabilities ' First Level ' ' Second Level " )
2012-04-28 08:37:07 -05:00
return
try :
( gnucash_file , start_year , start_month , period_type , periods ,
debits_show , credits_show ) = argv [ 1 : 8 ]
start_year , start_month , periods = [ int ( blah )
for blah in ( start_year , start_month ,
periods ) ]
debits_show = debits_show == DEBITS_SHOW
credits_show = credits_show == CREDITS_SHOW
account_path = argv [ 8 : ]
2020-06-12 05:24:05 -05:00
gnucash_session = Session ( gnucash_file , SessionOpenMode . SESSION_NORMAL_OPEN )
2012-04-28 08:37:07 -05:00
root_account = gnucash_session . book . get_root_account ( )
account_of_interest = account_from_path ( root_account , account_path )
# a list of all the periods of interest, for each period
# keep the start date, end date, a list to store debits and credits,
# and sums for tracking the sum of all debits and sum of all credits
period_list = [
[ start_date , end_date ,
[ ] , # debits
[ ] , # credits
ZERO , # debits sum
ZERO , # credits sum
]
for start_date , end_date in generate_period_boundaries (
start_year , start_month , period_type , periods )
]
# a copy of the above list with just the period start dates
period_starts = [ e [ 0 ] for e in period_list ]
2018-09-21 02:28:41 -05:00
2012-04-28 08:37:07 -05:00
# insert and add all splits in the periods of interest
for split in account_of_interest . GetSplitList ( ) :
trans = split . parent
trans_date = date . fromtimestamp ( trans . GetDate ( ) )
# use binary search to find the period that starts before or on
# the transaction date
period_index = bisect_right ( period_starts , trans_date ) - 1
2018-09-21 02:28:41 -05:00
2012-04-28 08:37:07 -05:00
# ignore transactions with a date before the matching period start
# (after subtracting 1 above start_index would be -1)
# and after the last period_end
if period_index > = 0 and \
trans_date < = period_list [ len ( period_list ) - 1 ] [ 1 ] :
2018-09-21 02:28:41 -05:00
2012-04-28 08:37:07 -05:00
# get the period bucket appropriate for the split in question
period = period_list [ period_index ]
# more specifically, we'd expect the transaction date
# to be on or after the period start, and before or on the
# period end, assuming the binary search (bisect_right)
# assumptions from above are are right..
#
# in other words, we assert our use of binary search
# and the filtered results from the above if provide all the
# protection we need
assert ( trans_date > = period [ 0 ] and trans_date < = period [ 1 ] )
2018-09-21 02:28:41 -05:00
2012-04-28 08:37:07 -05:00
split_amount = gnc_numeric_to_python_Decimal ( split . GetAmount ( ) )
# if the amount is negative, this is a credit
if split_amount < ZERO :
debit_credit_offset = 1
# else a debit
else :
debit_credit_offset = 0
# store the debit or credit Split with its transaction, using the
# above offset to get in the right bucket
#
# if we wanted to be really cool we'd keep the transactions
period [ 2 + debit_credit_offset ] . append ( ( trans , split ) )
2018-09-21 02:28:41 -05:00
2012-04-28 08:37:07 -05:00
# add the debit or credit to the sum, using the above offset
# to get in the right bucket
period [ 4 + debit_credit_offset ] + = split_amount
2010-06-30 14:26:57 -05:00
2012-04-28 08:37:07 -05:00
csv_writer = csv . writer ( stdout )
csv_writer . writerow ( ( ' period start ' , ' period end ' , ' debits ' , ' credits ' ) )
2018-09-21 02:28:41 -05:00
2012-04-28 08:37:07 -05:00
def generate_detail_rows ( values ) :
return (
( ' ' , ' ' , ' ' , ' ' , trans . GetDescription ( ) ,
gnc_numeric_to_python_Decimal ( split . GetAmount ( ) ) )
for trans , split in values )
2018-09-21 02:28:41 -05:00
2010-06-30 14:26:57 -05:00
2012-04-28 08:37:07 -05:00
for start_date , end_date , debits , credits , debit_sum , credit_sum in \
period_list :
csv_writer . writerow ( ( start_date , end_date , debit_sum , credit_sum ) )
if debits_show and len ( debits ) > 0 :
csv_writer . writerow (
( ' DEBITS ' , ' ' , ' ' , ' ' , ' description ' , ' value ' ) )
csv_writer . writerows ( generate_detail_rows ( debits ) )
csv_writer . writerow ( ( ) )
if credits_show and len ( credits ) > 0 :
csv_writer . writerow (
( ' CREDITS ' , ' ' , ' ' , ' ' , ' description ' , ' value ' ) )
csv_writer . writerows ( generate_detail_rows ( credits ) )
csv_writer . writerow ( ( ) )
# no save needed, we're just reading..
gnucash_session . end ( )
except :
2012-06-22 10:12:25 -05:00
if " gnucash_session " in locals ( ) :
2012-04-28 08:37:07 -05:00
gnucash_session . end ( )
raise
2010-06-30 14:26:57 -05:00
if __name__ == " __main__ " : main ( )