summaryrefslogtreecommitdiff
path: root/ugbudget/ugbudget.py
blob: f786e8d9c464e8ec2fb67503e6ee5477c3382a6d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
import argparse
import collections
import csv
import gnucashxml
import os
import sys
from decimal import Decimal

def main():
    parser = argparse.ArgumentParser(description='The Usable GnuCash Budget Tool')

    cmd_group = parser.add_mutually_exclusive_group(required=True)
    cmd_group.add_argument('--create-tags', action='store_true',
                           help='Update tags-file with any unmapped accounts')
    cmd_group.add_argument('--report', action='store_true',
                           help='Produce a budget vs. actuals report')
    parser.add_argument('-t', '--tags', metavar='tags-file', required=True,
                        help='The mapping of GnuCash accounts to budget tags')
    parser.add_argument('data_file', metavar='gnucash-file',
                        help='The GnuCash data file to process')
    parser.add_argument('-b', '--budget', metavar='budget-file',
                        help='A budget to report against.')

    args = parser.parse_args()

    book = gnucashxml.from_filename(args.data_file)

    if args.create_tags:
        create_tags(book, args.tags)
    elif args.report:
        report_actuals(book, args.tags)
        if args.budget:
            report_budget(args.budget, args.tags)

def read_tags(filename):
    '''
    Read a TSV file where each row maps a GnuCash account to a tag. A tag is
    one or more tab-separated values which are treated as budget categories,
    subcategories, etc.
    '''
    tag_header = ('account_type',)
    tags = collections.OrderedDict()
    if os.path.isfile(filename):
        with open(filename, 'rb') as f:
            reader = csv.reader(f, csv.excel_tab)
            for row in reader:
                if row[0] == "account":
                    tag_header = row[1:]
                else:
                    tags[row[0]] = tuple(row[1:])
    # TODO Sanity check: all tags and header must have same length.
    return (tag_header, tags)

def write_tags(filename, tag_header, tags):
    '''
    Write a tags file as described by read_tags().
    '''
    with open(filename, 'wb') as f:
        writer = csv.writer(f, csv.excel_tab)
        writer.writerow(['account'] + list(tag_header))
        for account in tags:
            writer.writerow([account] + list(tags[account]))

def create_tags(book, tags_file):
    '''
    Read a GnuCash data file and add any new, unmapped accounts to tags_file.

    Note: the header 'account_type' is special: it always defaults to the
    account type, one of either INCOME or EXPENSE.
    '''
    (tag_header, tags) = read_tags(tags_file)
    for (acc, children, splits) in book.walk():
        if not children:
            if acc.actype == "INCOME" or acc.actype == "EXPENSE":
                acc_name = gnucash_account_fullname(acc)
                if acc_name not in tags:
                    tags[acc_name] = default_tag(tag_header, acc)
    write_tags(tags_file, tag_header, tags)

def report_header(tag_header):
    return ['month'] + list(tag_header) + ['source', 'value']

def report_row(month, tag, source, value):
    return [month] + list(tag) + [source, value]

def report_actuals(book, tags_file):
    (tag_header, tags) = read_tags(tags_file)
    report = collections.defaultdict(lambda: collections.defaultdict(Decimal))
    for (acc, children, splits) in book.walk():
        acc_name = gnucash_account_fullname(acc)
        if acc_name in tags:
            for split in splits:
                date = split.transaction.date.strftime("%Y-%m-01")
                report[date][tags[acc_name]] += split.value.copy_negate()

    writer = csv.writer(sys.stdout, csv.excel_tab)
    writer.writerow(report_header(tag_header))
    for month in sorted(report):
        for (tag, value) in report[month].iteritems():
            writer.writerow(report_row(month, tag, 'actual', value))

def report_budget(budget_filename, tags_file):
    (tag_header, tags) = read_tags(tags_file)
    tag_set = set(tags.values())
    with open(budget_filename, 'rb') as f:
        reader = csv.reader(f, csv.excel_tab)
        writer = csv.writer(sys.stdout, csv.excel_tab)
        for row in reader:
            if row[0] == "month":
                continue
            else:
                month = row[0]
                tag = tuple(row[1:-1])
                value = row[-1]
                if tag in tag_set:
                    writer.writerow(report_row(month, tag, 'budget', value))

def default_tag(tag_header, acc):
    default = list(tag_header)
    if 'account_type' in default:
        default[default.index('account_type')] = acc.actype
    return tuple(default)

def gnucash_account_fullname(acc, partial=''):
    if acc.parent:
        if partial:
            partial = "%s:%s" % (acc.name, partial)
        else:
            partial = acc.name
        return gnucash_account_fullname(acc.parent, partial)
    else:
        return partial

if __name__ == "__main__":
    main()