--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/bttests.py Sun Jul 14 22:47:29 2013 -0400
@@ -0,0 +1,127 @@
+from budgettracker import Budget, Transaction, Decimal
+
+class Expect:
+ def __init__(self, *cmds, verbose = False):
+ self._cmds = cmds
+ self._index = 0
+ self.verbose = verbose
+ def __call__(self, prompt):
+ assert(self._index < len(self._cmds))
+ idx = self._index
+ self._index += 1
+ if self.verbose:
+ print("{:<20} => {}".format(prompt, self._cmds[idx]))
+ return self._cmds[idx]
+
+class Test:
+ def run(self):
+ self.test_load_save()
+ self.test_report(True)
+ self.test_envelops()
+ self.test_read_transaction()
+
+ def test_read_transaction(self, verbose = False):
+ cmds = [ "Salary", "400",
+ "Taxes", "100",
+ "Taxes", "-100",
+ "Grocery", "200" ]
+ b = self.create_budget()
+ t = b.read_transaction(Expect(verbose = verbose, *(cmds + ["", "n"])))
+ assert(t is None)
+ t = b.read_transaction(Expect(verbose = verbose, *(cmds + ["", "y"])))
+ assert(t.amount == 400)
+ assert(t.budget["Taxes"] == 100)
+ assert(t.budget["Grocery"] == 200)
+ assert(len(t.budget) == 2)
+ assert(t.expenses["Taxes"] == 100)
+ assert(len(t.expenses) == 1)
+ assert(t.balance == 200)
+ t = b.read_transaction(Expect(verbose = verbose, *(cmds + ["Hobbies", "", "y"])))
+ assert(t.amount == 400)
+ assert(t.budget["Taxes"] == 100)
+ assert(t.budget["Grocery"] == 200)
+ assert(t.budget["Hobbies"] == 200)
+ assert(len(t.budget) == 3)
+ assert(t.expenses["Taxes"] == 100)
+ assert(len(t.expenses) == 1)
+ assert(t.balance == 0)
+
+ def create_budget(self):
+ b = Budget()
+ salary = Decimal(500)
+ taxes = salary * Decimal('0.20')
+ # Salary: 500 - 100 in taxes = 400
+ # Budget: 200$ in Grocery, 100$ for Taxes
+ # Expenses: Taxes 100$
+ # Unassigned: 200$
+ t = Transaction(name="Salary", amount=salary-taxes,
+ budget = [("Grocery", Decimal(200)),
+ ("Taxes", taxes)],
+ expenses = [("Taxes", taxes)])
+ b.add_transaction(t)
+ # Gift: 100$
+ # Budget: 100$ in Hobbies
+ t = Transaction(name = "Gift", amount = Decimal(100), envelop = "Hobbies")
+ b.add_transaction(t)
+ # Costco: -75$
+ # Expenses: 75$ in Grocery
+ t = Transaction(name="Costco", amount=Decimal(-75))
+ t.rebalance("Grocery")
+ b.add_transaction(t)
+ # Cinema: -50
+ # Expenses: 50$ in Hobbies
+ t = Transaction(name="Cinema", amount=Decimal(-50), envelop="Hobbies")
+ assert(t.balance == 0)
+ b.add_transaction(t)
+ # Missing money: -20$
+ # Unassigned: -20$
+ t = Transaction(name="Missing", amount=Decimal(-20))
+ b.add_transaction(t)
+ # Total:
+ # Budget: 200$ Grocery, 100$ Taxes, 100$ Hobbies = 400$
+ # Expenses: 100$ Taxes, 75$ Grocery, 50$ Hobbies = 225$
+ # Unassigned: 200$ Salary, -20$ Missing = 180$
+
+ b.set_envelop_description("Grocery", "stuff for stomach")
+ b.rename_envelop("Grocery", "Food")
+ return b
+
+ def test_load_save(self, verbose = False):
+ b = self.create_budget()
+
+ from io import StringIO
+ fp = StringIO()
+ b.save(fp)
+ saved = fp.getvalue()
+ if verbose: print(saved)
+
+ b2 = Budget()
+ b2.load(StringIO(saved))
+ fp = StringIO()
+ b2.save(fp)
+ saved2 = fp.getvalue()
+ if verbose: print(saved2)
+ assert(saved == saved2)
+
+ def test_envelops(self, verbose = False):
+ b = self.create_budget()
+ envelops = list(b.envelops)
+ assert(len(envelops) == 5)
+ total = envelops[-2]
+ unassigned = envelops[-1]
+
+ assert(total.budget == 400)
+ assert(total.expenses == 225)
+ assert(total.balance == 175)
+ assert(unassigned.budget == 200)
+ assert(unassigned.expenses == 20)
+ assert(unassigned.balance == 180)
+
+ def test_report(self, verbose = False):
+ b = self.create_budget()
+ for l in b.report():
+ if verbose:
+ print(l)
+
+if __name__ == '__main__':
+ Test().run()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/budgettracker.py Sun Jul 14 22:47:29 2013 -0400
@@ -0,0 +1,245 @@
+#!/usr/bin/env python3
+
+import json
+from decimal import Decimal
+from datetime import datetime
+from collections import OrderedDict, defaultdict
+from operator import itemgetter
+
+class Transaction:
+ description = ""
+ amount = Decimal(0)
+
+ def __init__(self, **args):
+ self.budget = defaultdict(Decimal)
+ self.expenses = defaultdict(Decimal)
+ self.date = datetime.utcnow().replace(microsecond = 0)
+ balance = None
+ for arg, val in args.items():
+ if arg == "splits":
+ self.add_splits(val)
+ elif arg == "budget":
+ for env, amount in val:
+ self.add_budget(env, amount)
+ elif arg == "expenses":
+ for env, amount in val:
+ self.add_expense(env, amount)
+ elif arg in ("balance", "envelop"):
+ balance = val
+ else:
+ setattr(self, arg, val)
+ if balance is not None:
+ self.rebalance(balance)
+
+ def add_budget(self, envelop, amount):
+ assert(amount > 0)
+ self.budget[envelop] += amount
+
+ def add_expense(self, envelop, amount):
+ assert(amount > 0)
+ self.expenses[envelop] += amount
+
+ def add_splits(self, *splits):
+ for env, amount in splits:
+ if amount < 0:
+ self.add_expense(env, -amount)
+ else:
+ self.add_budget(env, amount)
+
+ def rebalance(self, envelop):
+ b = self.balance
+ if b < 0:
+ self.add_expense(envelop, -b)
+ else:
+ self.add_budget(envelop, b)
+ assert(self.balance == 0)
+
+ @property
+ def balance(self):
+ return self.amount \
+ - sum(v for v in self.budget.values()) \
+ + sum(v for v in self.expenses.values())
+
+ @property
+ def splits(self):
+ for env, amount in self.budget.items():
+ yield (env, amount)
+ for env, amount in self.expenses.items():
+ yield (env, -amount)
+
+ def rename_envelop(self, oldname, newname):
+ if oldname in self.budget:
+ self.budget[newname] += self.budget[oldname]
+ del self.budget[oldname]
+ if oldname in self.expenses:
+ self.expenses[newname] += self.expenses[oldname]
+ del self.expenses[oldname]
+
+ def as_dict(self):
+ def serialize(values):
+ for e, v in sorted(values.items(), key = itemgetter(0)):
+ d = OrderedDict.fromkeys(["envelop", "value"])
+ d.update(envelop = e, value = float(v))
+ yield d
+ d = OrderedDict.fromkeys(["description", "date", "amount", "budget", "expenses"])
+ d.update(
+ description = self.description,
+ date = self.date.isoformat(),
+ amount = float(self.amount),
+ budget = list(serialize(self.budget)),
+ expenses = list(serialize(self.expenses)))
+ return d
+
+ @classmethod
+ def from_dict(cls, d):
+ try:
+ date = datetime.strptime("%Y-%m-%dT%H:%M:%S", d["date"])
+ except:
+ date = datetime.utcnow().replace(microsecond = 0)
+ return Transaction(
+ description = d.get("description", ""),
+ date = date,
+ amount = Decimal(d.get("amount", Decimal(0))),
+ budget = ((v["envelop"], v["value"]) for v in d.get("budget", [])),
+ expenses = ((v["envelop"], v["value"]) for v in d.get("expenses", [])))
+
+class Envelop:
+ name = ""
+ description = ""
+ budget = Decimal(0)
+ expenses = Decimal(0)
+
+ def __init__(self, name = "", description = ""):
+ self.name = name
+ self.description = description
+
+ @property
+ def balance(self):
+ return self.budget - self.expenses
+
+ def update(self, amount):
+ if (amount < 0):
+ self.expenses -= amount
+ else:
+ self.budget += amount
+
+class Budget:
+ name = "Budget"
+ date = datetime.utcnow().replace(microsecond = 0)
+
+ def __init__(self):
+ self.metaenvelops = defaultdict(str)
+ self.transactions = []
+
+ def load(self, fp):
+ d = json.load(fp, parse_float = Decimal)
+ self.metaenvelops = dict((e["name"], e["description"]) for e in d.get("envelops",[]))
+ self.transactions = []
+ add = self.add_transaction
+ for t in d.get("transactions",[]): add(Transaction.from_dict(t))
+
+ def save(self, fp, **args):
+ d = OrderedDict.fromkeys(["name", "date", "envelops", "transactions"])
+ envelops = list()
+ for name, desc in sorted(self.metaenvelops.items(), key = itemgetter(0)):
+ if name != "":
+ env = OrderedDict.fromkeys(["name", "description"])
+ env.update(name = name, description = desc)
+ envelops.append(env)
+ d.update(name = self.name,
+ date = self.date.isoformat(),
+ envelops = envelops,
+ transactions = list(t.as_dict() for t in self.transactions))
+ json.dump(d, fp, allow_nan=False, **args)
+
+ def add_transaction(self, t):
+ for e in (s[0] for s in t.splits):
+ if e not in self.metaenvelops:
+ self.metaenvelops[e] = ""
+ self.transactions.append(t)
+ return t
+
+ def set_envelop_description(self, name, description):
+ self.metaenvelops[name] = description
+
+ def rename_envelop(self, oldname, newname):
+ for t in self.transactions:
+ t.rename_envelop(oldname, newname)
+ if oldname in self.metaenvelops:
+ self.metaenvelops.setdefault(newname, self.metaenvelops[oldname])
+ del self.metaenvelops[oldname]
+
+ @property
+ def envelops(self):
+ total = Envelop("Total")
+ unassigned = Envelop("Unassigned", "Unassigned balance")
+ envelops = defaultdict(Envelop)
+ for t in self.transactions:
+ for name, value in t.splits:
+ assert(name)
+ envelops[name].update(value)
+ unassigned.update(t.balance)
+ for name, env in envelops.items():
+ env.name = name
+ env.description = self.metaenvelops.get(name,"")
+ total.budget += env.budget
+ total.expenses += env.expenses
+ yield env
+ yield total
+ yield unassigned
+
+ def report(self):
+ for env in self.envelops:
+ yield "| {:<20} | {:<50} | {:> 10.2f} | {:> 10.2f} | {:> 10.2f} |".format(env.name, env.description, env.budget, env.expenses, env.balance)
+
+ def read_transaction(self, reader = input):
+ name = reader("Description: ")
+ amount = Decimal(reader("Amount: "))
+ t = Transaction(name = name, amount = amount)
+
+ while t.balance != 0:
+ envelop = reader("Balance: {:> 10.2f}\nEnvelop: ".format(t.balance))
+ if not envelop:
+ break
+ amount = reader("Amount:" )
+ if amount == "":
+ t.rebalance(envelop)
+ else:
+ amount = Decimal(amount)
+ t.add_expense(envelop, -amount) if amount < 0 else t.add_budget(envelop, amount)
+ keep = reader("Do you want to keep the transaction [y/N]? ")
+ if keep and keep[0].upper() == 'Y':
+ return t
+ return None
+
+
+def main(filename, action):
+ import os
+ import sys
+ b = Budget()
+ if action == "init":
+ with open(filename, "w") as fp:
+ b.save(fp)
+ elif action == "report":
+ with open(filename, "r") as fp:
+ b.load(fp)
+ for l in b.report():
+ print(l)
+ elif action == "add":
+ with open(filename, "r") as fp:
+ b.load(fp)
+ t = b.read_transaction()
+ if t:
+ b.add_transaction(t)
+ os.rename(os.path.realpath(filename), os.path.realpath(filename) + ".bak")
+ with open(filename, "w") as fp:
+ b.save(fp)
+ else:
+ print("bad command line option")
+
+if __name__ == '__main__':
+ import sys
+ import os
+ filename = sys.argv[1]
+ action = sys.argv[2]
+ main(filename, action)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/specs.org Sun Jul 14 22:47:29 2013 -0400
@@ -0,0 +1,32 @@
+* GNUCash Budgeting
+
+** Definition
+
+This tool should allow to:
+
+- Budget your future expenses and incomes.
+ - Report your budget from months to months.
+ - Create recurring budget transaction.
+ - Create morgage/investment prediction.
+- Import your current transactions journal and show how it get over/under your budget.
+- Allow you to adjust your budget accordingly.
+ - That should also be reflected on recurring transactions.
+- Allow to create alternate scenario based on present budget.
+- For every expenses, there must be a budget liabilities.
+- For every income, there must be a budget asset.
+- Since Equity = Asset - Liabilities, all budget transaction must also balance.
+
+** Workflow
+
+*** New incomes are split into budget envelops
+**** Manually created transaction
+**** Importing from QIF/QFX/CSV
+**** Recurring transaction
+*** New expenses are split into budget envelops
+**** Manual
+**** Importing
+**** Recurring
+***** Simple
+***** Recurring with a budget
+*** Reporting
+*** Exporting