#!/usr/bin/env /usr/local/bin/pythonw ''' PYTHON CHECKBOOK MANAGER OVERVIEW PyCheckbook.py A personal finance manager in Python that can read and write Quicken Interchange Format (qif) files. Usage: PyCheckbook.py [filename] Start the PyCheckbook program and optionally load the checkbook stored in filename, which is assumed to be in QIF format. MENU FUNCTIONS *Open* Open a new QIF file containing a checkbook. *Save* Save the current checkbook under the default name; asks for the name if it is not defined. *Save As* Save the current checkbook under a new name. *Import* Add the checks from another QIF archive to the current checkbook. *Export Text* Export a text version of the current checkbook *Archive* Archive checks older than a specified date to an archive QIF file. *Close* Close the current checkbook. *Quit* Quit PyCheckbook *New Entry* Create a new check record in the register. *Mark Cleared* Mark the current check record as cleared. *Void Entry* Void the current check. *Delete Entry* Delete the current check. *Sort* Sort the current check register by date. *Reconcile* Reconcile your checkbook register. *About* About PyCheckbook. *Help* Print this help file. INSTALLATION/REQUIREMENTS PyCheckbook requires Python (>=2.1) and wxPython. The code is contained in a single file (which you\'re reading), and only needs to be copied to someplace on your path. COPYRIGHT/LICENSING Copyright (c) 2000, Richard P. Muller. All rights reserved. This code is in development -- use at your own risk. Email comments, patches, complaints to rick_muller@yahoo.com 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, write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. ''' # Version information # 12/1/2000 Initial version v0.1 # 12/17/2000 v0.2 finished. Features: # * Nicer interface # * Better handling of bad dates # * Import vs. Open differentiated # * Save vs Save As differentiated # 1/13/2001 v0.3 released. New features: # * Ctrl keys bound # * General interface improvements (scrollbar acts more # intelligently, better size, etc.) # * SourceForge site announced. # * General bug fixes. # 1/13/2001 Fixed a bug in the CheckDialog, so that tabbing through # the form flows more intuitively. # 2/23/2001 Replaced all of the __repr__ overloads with __str__, # because it is better programming style. Fixed a bug in # Check.py that resulted. # Fixed another bug in CheckDialog so that tabbing through # is more intuitive. # 8/31/2001 Applied patches sent by Shaleh Perry. # Releaved v0.4. # 11/11/2001 Started work on a version where the widgets are # separated from the rest of the code. The plan is # to also release text and gtk versions of the program. # 10/06/2002 Configured the code to use Python distutils. # Added the abililty to output text files. # Released v0.51. # 3/25/2004 Preparing release v0.6. This and future releases # will use wxPython only. Packaged as a single file, which # I realize kills the whole point of distutils, but it # just didn't seem worth it for 700 lines of code. # To Do list: # Speed redraw_all: break into redraw_all, redraw_range, redraw_totals # Save a backup version of files # Read and save CBB files? # Use time module functions to parse dates # Undo? # Plot balance vs day # Search functions # goto date import os,sys,time import wx import wx.grid version = 0.6 class CheckbookFrame(wx.Frame): def __init__(self,parent,id,title="PyCheckbook",file=None,**kwds): self.cb = Checkbook() self.edited = 0 # begin wxGlade: CheckbookFrame.__init__ kwds["style"] = wx.DEFAULT_FRAME_STYLE wx.Frame.__init__(self, parent, id, title, **kwds) self.make_widgets() if file: self.cb.read_qif(file) self.redraw_all(-1) if self.cb.name: self.SetTitle("PyCheckbook: %s" % self.cb.name) return def make_widgets(self): self.menubar = wx.MenuBar() self.SetMenuBar(self.menubar) self.statusbar = self.CreateStatusBar(1, 0) self.make_filemenu() self.make_editmenu() self.make_helpmenu() self.make_grid() self.set_properties() self.do_layout() def make_filemenu(self): self.filemenu = wx.Menu() ID_IMPORT = wx.NewId() ID_EXPORT_TEXT = wx.NewId() ID_ARCHIVE = wx.NewId() self.filemenu.Append(wx.ID_OPEN, "Open\tCtrl-o", "Open a new checkbook file", wx.ITEM_NORMAL) self.filemenu.Append(wx.ID_SAVE, "Save\tCtrl-s", "Save the current checkbook", wx.ITEM_NORMAL) self.filemenu.Append(wx.ID_SAVEAS, "Save As", "Save the current checkbook " "under a different name", wx.ITEM_NORMAL) self.filemenu.Append(ID_IMPORT,"Import\tCtrl-i", "Import checks from another qif file", wx.ITEM_NORMAL) self.filemenu.Append(ID_EXPORT_TEXT, "Export Text", "Export the current register as a text file", wx.ITEM_NORMAL) self.filemenu.Append(ID_ARCHIVE, "Archive", "Archive checks older than a specified date", wx.ITEM_NORMAL) self.filemenu.Append(wx.ID_CLOSE, "Close\tCtrl-w", "Close the current file", wx.ITEM_NORMAL) self.filemenu.Append(wx.ID_EXIT, "Exit\tCtrl-q", "Exit PyCheckbook", wx.ITEM_NORMAL) self.menubar.Append(self.filemenu, "File") wx.EVT_MENU(self,wx.ID_OPEN,self.load_file) wx.EVT_MENU(self,wx.ID_SAVE,self.save_file) wx.EVT_MENU(self,wx.ID_SAVEAS,self.save_as_file) wx.EVT_MENU(self,ID_IMPORT,self.import_file) wx.EVT_MENU(self,ID_EXPORT_TEXT,self.export_text) wx.EVT_MENU(self,ID_ARCHIVE,self.archive) wx.EVT_MENU(self,wx.ID_CLOSE,self.close) wx.EVT_MENU(self,wx.ID_EXIT,self.quit) return def make_editmenu(self): ID_SORT = wx.NewId() ID_MARK_ENTRY = wx.NewId() ID_VOID_ENTRY = wx.NewId() ID_DELETE_ENTRY = wx.NewId() ID_RECONCILE = wx.NewId() self.editmenu = wx.Menu() self.editmenu.Append(wx.ID_NEW, "New Entry\tCtrl-n", "Create a new check in the register", wx.ITEM_NORMAL) self.editmenu.Append(ID_SORT,"Sort Entries", "Sort entries",wx.ITEM_NORMAL) self.editmenu.Append(ID_MARK_ENTRY, "Mark Cleared\tCtrl-m", "Mark the current check cleared", wx.ITEM_NORMAL) self.editmenu.Append(ID_VOID_ENTRY, "Void Entry\tCtrl-v", "", wx.ITEM_NORMAL) self.editmenu.Append(ID_DELETE_ENTRY, "Delete Entry", "Delete the current check", wx.ITEM_NORMAL) self.editmenu.Append(ID_RECONCILE, "Reconcile\tCtrl-r", "Reconcile your checkbook", wx.ITEM_NORMAL) self.menubar.Append(self.editmenu, "Edit") wx.EVT_MENU(self,wx.ID_NEW,self.newentry) wx.EVT_MENU(self,ID_SORT,self.sort) wx.EVT_MENU(self,ID_MARK_ENTRY,self.markcleared) wx.EVT_MENU(self,ID_VOID_ENTRY,self.voidentry) wx.EVT_MENU(self,ID_DELETE_ENTRY,self.deleteentry) wx.EVT_MENU(self,ID_RECONCILE,self.reconcile) return def make_helpmenu(self): ID_HELP = wx.NewId() self.helpmenu = wx.Menu() self.helpmenu.Append(wx.ID_ABOUT, "About", "About PyCheckbook", wx.ITEM_NORMAL) self.helpmenu.Append(ID_HELP, "Help\tCtrl-h", "PyCheckbook Help",wx.ITEM_NORMAL) self.menubar.Append(self.helpmenu, "Help") wx.EVT_MENU(self,wx.ID_ABOUT,self.about) wx.EVT_MENU(self,ID_HELP,self.gethelp) return def make_grid(self): self.cbgrid = wx.grid.Grid(self, -1) wx.grid.EVT_GRID_CELL_CHANGE(self, self.cellchange) return def set_properties(self): self.SetTitle("PyCheckbook") self.statusbar.SetStatusWidths([-1]) statusbar_fields = [""] for i in range(len(statusbar_fields)): self.statusbar.SetStatusText(statusbar_fields[i], i) self.cbgrid.CreateGrid(0, 7) self.cbgrid.SetRowLabelSize(40) self.cbgrid.SetColLabelSize(20) self.cbgrid.SetColLabelValue(0, "Date") self.cbgrid.SetColSize(0, 60) self.cbgrid.SetColLabelValue(1, "Number") self.cbgrid.SetColSize(1, 50) self.cbgrid.SetColLabelValue(2, "Payee") self.cbgrid.SetColSize(2, 150) self.cbgrid.SetColLabelValue(3, "X") self.cbgrid.SetColSize(3, 20) self.cbgrid.SetColLabelValue(4, "Memo") self.cbgrid.SetColSize(4, 150) self.cbgrid.SetColLabelValue(5, "Amount") self.cbgrid.SetColSize(5, 60) self.cbgrid.SetColLabelValue(6, "Balance") self.cbgrid.SetColSize(6, 60) self.cbgrid.SetSize((610, 300)) def do_layout(self): sizer_1 = wx.BoxSizer(wx.VERTICAL) sizer_1.Add(self.cbgrid, 1, wx.EXPAND, 0) self.SetAutoLayout(1) self.SetSizer(sizer_1) sizer_1.Fit(self) sizer_1.SetSizeHints(self) self.Layout() def redraw_all(self,index=None): nrows = self.cbgrid.GetNumberRows() if nrows: self.cbgrid.DeleteRows(0,nrows) nchecks = len(self.cb) total = 0 self.cbgrid.AppendRows(nchecks) for i in range(nchecks): check = self.cb[i] self.cbgrid.SetCellValue(i,0,check.date.formatUS()) if check.number: self.cbgrid.SetCellValue(i,1,'%d' % check.number) self.cbgrid.SetCellValue(i,2,check.payee) if check.cleared: self.cbgrid.SetCellValue(i,3,'x') if check.memo: self.cbgrid.SetCellValue(i,4,check.memo) self.cbgrid.SetCellValue(i,5,'%.2f' % check.amount) total += check.amount self.cbgrid.SetCellValue(i,6,'%.2f' % total) if index == -1: self.cbgrid.SetGridCursor(nchecks-1,0) self.cbgrid.MakeCellVisible(nchecks-1,1) elif index > 0: self.cbgrid.SetGridCursor(index,0) self.cbgrid.MakeCellVisible(index,1) return def cellchange(self, evt): doredraw = 0 row = evt.GetRow() col = evt.GetCol() if row < 0: return if row >= len(self.cb): print "Warning: modifying incorrect cell!" return self.edited = 1 check = self.cb[row] val = self.cbgrid.GetCellValue(row,col) if col == 0: check.setdate(val) elif col == 1: check.setnumber(val) elif col == 2: check.setpayee(val) elif col == 3: if val: check.setcleared('x') elif col == 4: check.setmemo(val) elif col == 5: doredraw = 1 check.setamount(val) else: print "Warning: modifying incorrect cell!" return if doredraw: self.redraw_all(row) # only redraw [row:] return def load_file(self, *args): self.close() self.cb = Checkbook() self.edited = 0 d = wx.FileDialog(self,"Open","","","*.qif",wx.OPEN) if d.ShowModal() == wx.ID_OK: fname = d.GetFilename() dir = d.GetDirectory() self.cb.read_qif(os.path.join(dir,fname)) self.redraw_all(-1) if self.cb.name: self.SetTitle("PyCheckbook: %s" % self.cb.name) return def save_file(self, *args): if not self.cb.filename: self.save_as_file() else: self.edited = 0 self.cb.write_qif() return def save_as_file(self, *args): d = wx.FileDialog(self,"Save","","","*.qif",wx.SAVE) if d.ShowModal() == wx.ID_OK: fname = d.GetFilename() dir = d.GetDirectory() self.cb.write_qif(os.path.join(dir,fname)) if self.cb.name: self.SetTitle("PyCheckbook: %s" % self.cb.name) return def close(self, *args): if self.edited: d = wx.MessageDialog(self,'Save file before closing','Question', wx.YES_NO) if d.ShowModal() == wx.ID_YES: self.save_file() nrows = self.cbgrid.GetNumberRows() if nrows: self.cbgrid.DeleteRows(0,nrows) self.edited = 0 self.cb = Checkbook() return def quit(self, *args): self.close() self.Close(wx.true) def import_file(self,*args): #Appends the records from a file to the current checkbook d = wx.FileDialog(self,"Import","","","*.qif",wx.OPEN) if d.ShowModal() == wx.ID_OK: self.edited = 1 fname = d.GetFilename() dir = d.GetDirectory() self.cb.read_qif(os.path.join(dir,fname),'import') self.redraw_all(-1) if self.cb.name: self.SetTitle("PyCheckbook: %s" % self.cb.name) return def export_text(self,*args): d = wx.FileDialog(self,"Save","","","*.txt",wx.SAVE) if d.ShowModal() == wx.ID_OK: fname = d.GetFilename() dir = d.GetDirectory() self.cb.write_txt(os.path.join(dir,fname)) return def archive(self,*args): d = wx.TextEntryDialog(self, "Archive checks before what date (mm/dd/yy)?", "Archive Date") if d.ShowModal() == wx.ID_OK: date = Date(d.GetValue()) else: date = None d.Destroy() if not date: return archive = Checkbook() newcb_startcheck = Check() newcb_startcheck.amount = 0 newcb_startcheck.payee = "Starting Balance" newcb_startcheck.memo = "Archived by PyCheckbook" newcb_startcheck.cleared = 1 newcb_startcheck.date = date newcb = Checkbook() newcb.filename = self.cb.filename newcb.name = self.cb.name newcb.append(newcb_startcheck) archtot = 0 for check in self.cb: if check.date < date and check.cleared: archive.append(check) archtot += check.amount else: newcb.append(check) newcb_startcheck.amount = archtot self.cb = newcb while 1: d = wx.FileDialog(self,"Save Archive As","","","*.qif",wx.SAVE) if d.ShowModal() == wx.ID_OK: fname = d.GetFilename() dir = d.GetDirectory() d.Destroy() if fname: break archive.write_qif(os.path.join(dir,fname)) self.redraw_all(-1) self.edited = 1 return def newentry(self,*args): self.edited = 1 self.cb.append(Check()) self.cbgrid.AppendRows() nchecks = self.cbgrid.GetNumberRows() self.cbgrid.SetGridCursor(nchecks-1,0) self.cbgrid.MakeCellVisible(nchecks-1,1) def sort(self,*args): self.edited = 1 self.cb.sort() self.redraw_all(-1) def voidentry(self,*args): index = self.cbgrid.GetGridCursorRow() if index < 0: return d = wx.MessageDialog(self, "Really void this check?", "Really void?", wx.YES_NO) if d.ShowModal() == wx.ID_YES: self.edited = 1 check = self.cb[index] today = Date() check.amount = 0 check.payee = "VOID: " + check.payee check.memo = "voided %s" % today.formatUS() self.redraw_all(index) # redraw only [index:] return def deleteentry(self, *args): index = self.cbgrid.GetGridCursorRow() if index < 0: return d = wx.MessageDialog(self, "Really delete this check?", "Really delete?", wx.YES_NO) if d.ShowModal() == wx.ID_YES: del self.cb[index] self.redraw_all(index-1) # only redraw cells [index-1:] return def reconcile(self, *args): d = wx.TextEntryDialog(self, "What is the balance of your last statement?", "Current Balance") if d.ShowModal() == wx.ID_OK: current_balance = float(d.GetValue()) else: current_balance = None d.Destroy() if not current_balance: return cleared_balance = self.get_cleared_balance() difference = current_balance - cleared_balance if abs(difference) < 0.01: d = wx.MessageDialog(self, "Your checkbook balances", "Balanced",wx.OK) d.ShowModal() d.Destroy() else: d = wx.MessageDialog(self, "Your checkbook balance differs by " "$%.2f. Adjust balance?" % difference, "Adjust balance?", wx.YES_NO) if d.ShowModal() == wx.ID_YES: self.adjust_balance(difference) d.Destroy() return def adjust_balance(self,diff): self.edited = 1 check = Check() check.payee = "Balance Adjustment" check.amount = diff check.cleared = 1 check.memo = "Adjustment" self.cb.append(check) self.redraw_all(-1) #only redraw [-1]? return def get_cleared_balance(self): total = 0. for check in self.cb: if check.cleared: total = total + check.amount return total def about(self, *args): d = wx.MessageDialog(self, "Python Checkbook Manager\n" "Copyright (c) 2000, Richard P. Muller\n" "Released under the Gnu GPL\n", "About PyCheckbook", wx.OK|wx.ICON_INFORMATION) d.ShowModal() d.Destroy() return def gethelp(self, *args): d = HelpDialog(self,-1,"Help",__doc__) val = d.ShowModal() d.Destroy() return def markcleared(self,*args): index = self.cbgrid.GetGridCursorRow() if index < 0: return if not self.cb[index].cleared: self.edited = 1 self.cb[index].cleared = 1 self.cbgrid.SetCellValue(index,3,'x') return # end of class CheckbookFrame class Checkbook: def __init__(self,filename = None): self.name = '' self.filename = None self.checks = [] self.total = 0. if filename: self.read_qif(filename) return def __len__(self): return len(self.checks) def __getitem__(self,i): return self.checks[i] def __setitem__(self,i,val): self.checks[i] = val def __str__(self): return " %-10s $%8.2f\n" % (self.name,self.total) def __delitem__(self,i): del self.checks[i] def append(self,item): self.checks.append(item) def read_qif(self,filename,readmode='normal'): if readmode=='normal': # things not to do on 'import': self.filename = filename name = filename.replace('.qif','') self.name = os.path.split(name)[1] file = open(filename,'r') lines = file.readlines() file.close() check = Check() type = lines.pop(0) for line in lines: type,rest = line[0],line[1:].strip() if type == "D": check.setdate(rest) elif type == "T": check.setamount(rest) elif type == "P": check.setpayee(rest) elif type == "C": check.setcleared(rest) elif type == "N": check.setnumber(rest) elif type == "L": check.setcomment(rest) elif type == "M": check.setmemo(rest) elif type == "^": self.checks.append(check) self.total = self.total + check.amount check = Check() else: print "Unparsable line: ",line[:-1] self.sort() return def sort(self): self.checks.sort() def write_qif(self,filename=None): if not filename: if not self.filename: raise "No checkbook filename defined" filename = self.filename self.filename = filename file = open(filename,'w') file.write("%s" % self.qif_repr()) file.close() return def write_txt(self,filename='pycb.txt'): file = open(filename,'w') file.write("%s" % self.text_repr()) file.close() return def text_repr(self): lines = [] for check in self.checks: lines.append(str(check)) return '\n'.join(lines) def qif_repr(self): lines = ['Type:Bank'] for check in self.checks: lines.append(check.qif_repr()) lines.append('') return '\n'.join(lines) class Check: def __init__(self): self.date = Date() self.number = None self.payee = None self.cleared = 0 self.comment = None self.memo = None self.amount = None def __str__(self): lines = [] lines.append("%10s " % self.date.formatUS()) if self.number: lines.append("%5d " % self.number) else: lines.append(" ") lines.append("%-20s " % string_limit(self.payee,20)) if self.cleared: lines.append("x ") else: lines.append(" ") if self.comment: lines.append("%-10s " % string_limit(self.comment,10)) else: lines.append(" ") if self.memo: lines.append("%-10s " % string_limit(self.memo,10)) else: lines.append(" ") lines.append("%8.2f " % self.amount) return ''.join(lines) def __cmp__(self,other): return cmp(self.date,other.date) def qif_repr(self): lines = [] lines.append("D%s" % self.date.formatUS()) lines.append("T%.2f" % self.amount) if self.cleared: lines.append("Cx") else: lines.append("C*") if self.number: lines.append("N%d" % self.number) lines.append("P%s" % self.payee) if self.comment: lines.append("L%s" % self.comment) if self.memo: lines.append("M%s" % self.memo) lines.append("^") return '\n'.join(lines) def setamount(self,rest): self.amount = float(rest.strip().replace(',','')) def setdate(self,rest): self.date = Date(rest) def setpayee(self,rest): self.payee = rest def setcomment(self,rest): self.comment = rest def setmemo(self,rest): self.memo = rest def setnumber(self,rest): val = rest.strip() if val: self.number = int(val) else: self.number = None return def setcleared(self,rest): if rest[0] == "x": self.cleared = 1 else: self.cleared = 0 return class Date: def __init__(self,datestring=None): self.year = 0 self.month = 0 self.day = 0 if datestring: self.parse_datestring(datestring) else: self.set_today() def __cmp__(self,other): val = cmp(self.year,other.year) if val: return val val = cmp(self.month,other.month) if val: return val val = cmp(self.day,other.day) return val def __str__(self): return self.formatUS() def formatUS(self): return "%02d/%02d/%02d" % (self.month,self.day,self.year2digit()) def year2digit(self): if self.year >= 2000: return self.year - 2000 return self.year - 1900 def parse_datestring(self,datestring): # set the date to today, then overwrite if more data today = Date() month,day,year = today.month, today.day, today.year words = datestring.split('/') if len(words) == 3: month,day,year = int(words[0]),int(words[1]),int(words[2]) elif len(words) == 2: month,day = int(words[0]),int(words[1]) # Don't support any other options if month > 12 or month < 1: print "Error: Bad month: %d/%d/%d " % (month,day,year) if year < 10: year = year + 2000 elif year < 100: year = year + 1900 elif year < 1970: print "Error: Bad year: %d/%d/%d " % (month,day,year) self.year = year self.month = month self.day = day return def set_today(self): today = time.localtime(time.time()) self.year = today[0] self.month = today[1] self.day = today[2] return class HelpDialog(wx.Dialog): def __init__(self, parent,id,title="Help",text=None, **kwds): kwds["style"] = wx.DEFAULT_DIALOG_STYLE | wx.HSCROLL wx.Dialog.__init__(self, parent,id,title, **kwds) self.text = wx.TextCtrl(self, -1, "", style=wx.TE_MULTILINE) self.ok_button = wx.Button(self, wx.ID_OK, "OK") self.set_properties() self.do_layout() if text: self.text.SetValue(text) def set_properties(self): self.SetTitle("Help") self.text.SetSize((480, 400)) self.ok_button.SetDefault() self.text.SetEditable(0) def do_layout(self): sizer = wx.BoxSizer(wx.VERTICAL) sizer.Add(self.text, 0, wx.EXPAND, 0) sizer.Add(self.ok_button, 0, wx.ALL|wx.ALIGN_CENTER_HORIZONTAL, 7) self.SetAutoLayout(1) self.SetSizer(sizer) sizer.Fit(self) sizer.SetSizeHints(self) self.Layout() def string_limit(str,limit): if str and str > limit: str = str[:limit] return str if __name__ == '__main__': if len(sys.argv) > 1: file = sys.argv[1] else: file = None app = wx.PySimpleApp() frame = CheckbookFrame(None,-1,'PyCheckbook',file) frame.Show() app.MainLoop()