# HG changeset patch # User Fabien Ninoles # Date 1373856449 14400 # Node ID 811cd790a493a4f3540d33d19128c0669e64acb1 First version of BudgetTracker diff -r 000000000000 -r 811cd790a493 bttests.py --- /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() diff -r 000000000000 -r 811cd790a493 budgettracker.py --- /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) diff -r 000000000000 -r 811cd790a493 specs.org --- /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