Newer
Older
money / money.py
#!/usr/bin/python3
'''
A small commandline application to help me keep track of my
monthly finances.

Copyright (c) 2015 Daniel Vedder
Licensed under the terms of the GNU General Public License version 3.
'''

#FIXME Specifying non-existent files as budget files results in an error.
#TODO Add a function to create new budget files from scratch

import sys
import os.path
import time
import calendar
# We have to use decimal to avoid binary/decimal arithmetic issues
from decimal import *

global VERSION
VERSION = "0.2.2"

def str_join(str_list, sep=" "):
    'Syntactic sugar for str.join()'
    return sep.join(str_list)

class Budget(object):
    
    def __init__(self, budget_text):
        'Initialise a budget from a string representation'
        self.balance = Decimal(0)
        self.received = []
        self.spent = []
        budget_text = budget_text.strip().splitlines()
        self.name = budget_text[0].strip()
        if len(budget_text) < 2: return
        for line in budget_text[1:-1]:
            line = line.split()
            if len(line) < 2:
                pass #ignore malformed lines
            elif line[0] == '+':
                self.receive(Decimal(float(line[1])), str_join(line[2:]))
            elif line[0] == '-':
                self.spend(Decimal(float(line[1])), str_join(line[2:]))
    
    def spend(self, amount, comment=""):
        'Enter an amount spent'
        amount = round(amount, 2)
        s = 0
        while s < len(self.spent) and self.spent[s][0] > amount:
            s = s + 1 # keep the self.spent list sorted
        self.spent = self.spent[:s] + [(amount, comment)] + self.spent[s:]
        self.balance = self.balance - amount

    def receive(self, amount, comment=""):
        'Enter an amount received'
        amount = round(amount, 2)
        r = 0
        while r < len(self.received) and self.received[r][0] > amount:
            r = r + 1 # keep the self.received list sorted
        self.received = self.received[:r] + [(amount, comment)] + self.received[r:]
        self.balance = self.balance + amount

    def string_representation(self):
        'Return a string representation of this budget (pretty-printed)'
        budget_text = [self.name]
        for r in self.received:
            budget_text.append("\t+ "+str(r[0]).rjust(13)+" "+r[1])
        for s in self.spent:
            budget_text.append("\t- "+str(s[0]).rjust(13)+" "+s[1])
        budget_text.append("\t===============")
        budget_text.append("\tBalance: "+str(self.balance).rjust(6))
        return str_join(budget_text, "\n")


def load(budget_file_name):
    '''
    Load a specified budget file
    Returns a list of the budgets in this file
    '''
    budget_file = open(budget_file_name)
    budget_text = budget_file.readlines()
    budget_file.close()
    budgets = []
    if not budget_text:
        raise Exception("Empty budget file "+budget_file_name)
    next_budget_text = ""
    for line in budget_text:
        line = line.strip()
        if line != '':
            next_budget_text = next_budget_text + "\n" + line
        elif line == "" and next_budget_text != "":
            budgets.append(Budget(next_budget_text))
            next_budget_text = ""
        else:
            next_budget_text = ""
    if budget_text[-1].strip() != "":
        budgets.append(Budget(next_budget_text))
    return budgets

def save(budget_list, file_name):
    'Save a list of budgets to file'
    budget_file = open(file_name, 'w')
    budget_text = ""
    for b in budget_list:
        budget_text = budget_text+b.string_representation()+"\n\n"
    budget_file.write(budget_text)
    budget_file.close()

def find_budget(month, budget_list):
    '''
    Check if the given month's budget is in the budget list, else create it.
    Returns the desired budget and True if it was newly created or False if not.
    '''
    for b in budget_list:
        if b.name == month:
            return b, False
    new_budget = Budget(month)
    monthly_budget = budget_list[0]
    if monthly_budget.balance > 0:
        new_budget.receive(monthly_budget.balance, "Monthly budget")
    else: new_budget.spend(-monthly_budget.balance, "Monthly budget")
    return new_budget, True

def balance(budget):
    balance = budget.balance
    print(str(budget.name+" balance: ").ljust(24)+str(balance))

def convert_amount(amount):
    #FIXME Decimal() does something weird with dollar signs in a string...
    try:
        dec_amount = Decimal(amount)
    except InvalidOperation:
        print("Error: invalid number "+str(amount))
        exit()
    return dec_amount
    
def spent(budget, options):
    amount = convert_amount(options[0])
    comment = str_join(options[1:])
    budget.spend(amount, comment)
    return budget

def received(budget, options):
    amount = convert_amount(options[0])
    comment = str_join(options[1:])
    budget.receive(amount, comment)
    return budget

def find_entries(budget, search_terms):
    '''
    Search the budget for entries matching the search term(s).
    Prints a new budget containing only those entries.
    '''
    search = str_join(search_terms).lower()
    search_budget = Budget("Entries matching '"+search+"':")
    for s in budget.spent:
        if search in s[1].lower():
            search_budget.spend(s[0], s[1])
    for r in budget.received:
        if search in r[1].lower():
            search_budget.receive(s[0], s[1])
    print(search_budget.string_representation())

def show_history(budgets, current_budget_name):
    '''
    Print out the end-of-month balances of all budgets to date.
    '''
    for b in budgets:
        if b.name != current_budget_name and b.name != "Monthly budget":
            balance(b)

def print_version():
    global VERSION
    print("money "+VERSION)
    print("Copyright (c) 2015 Daniel Vedder")
    print("Licensed under the terms of the GNU General Public License v3")

def print_help():
    help_text = """
Usage: money [options] <command> [parameters]
\nOptions:
\t--version -v\tPrint the version number.
\t--help -h\tPrint this help text.
\t--file <file>\tSpecify a budget file other than ~/.money
\t--month -m <month>\tSpecify another month instead of the current one
\t--monthly\tShorthand for '--month "Monthly budget"'
\nCommands:
\tbalance\t\tPrint the current month's balance
\treport\t\tPrint all transactions this month
\thistory\t\tPrint a history of previous end-of-month balances
\tfind <string>\tDisplay all budget entries containing this string
\tspent <amount> [comment]\tLog a spending
\treceived <amount> [comment]\tLog an amount received
\nRead the source!"""
    print_version()
    print(help_text)

def main():
    '''
    Process commandline arguments
    '''
    budget_file = os.path.expanduser("~/.money")
    current_budget = None
    month = str_join([calendar.month_name[time.localtime().tm_mon],
                      str(time.localtime().tm_year)])
    if len(sys.argv) < 2:
        sys.argv.append('balance') #DWIM
    a = 1
    while a < len(sys.argv):
        arg = sys.argv[a]
        if arg == '--monthly': month = "Monthly budget"
        elif arg == '-m' or arg == '--month':
            a = a + 1
            if len(sys.argv[a].split()) < 2:
                # Try to prevent the user from shooting himself in the foot
                raise Exception("Budget name too short! Did you forget to add the year?")
            month = sys.argv[a]
        elif arg == '--file':
            a = a + 1
            budget_file = sys.argv[a]
        elif arg == '-v' or arg == '--version':
            print_version()
            return
        elif arg == '-h' or arg == '--help':
            print_help()
            return
        elif arg in ('balance', 'report', 'spent',
                     'received', 'find', 'history'):
            budgets = load(budget_file)
            if month == "Monthly budget": current_budget = budgets[0]
            else:
                current_budget, is_new = find_budget(month, budgets)
                if is_new: budgets.append(current_budget)
            if arg == 'report':
                print(current_budget.string_representation())
            elif arg == 'spent':
                current_budget = spent(current_budget, sys.argv[a+1:])
            elif arg == 'received':
                current_budget = received(current_budget, sys.argv[a+1:])
            elif arg == 'find':
                find_entries(current_budget, sys.argv[a+1:])
            elif arg == 'history':
                show_history(budgets, current_budget.name)
            if arg != 'report': balance(current_budget)
            save(budgets, budget_file)
            return
        else:
            print("Bad syntax! To check up on syntax, use 'money --help'")
        a = a + 1


if __name__ == '__main__':
    main()