diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | LICENSE | 18 | ||||
-rw-r--r-- | Makefile | 25 | ||||
-rw-r--r-- | README.md | 24 | ||||
-rw-r--r-- | bud.1.scd | 70 | ||||
-rw-r--r-- | expense/expense.go | 122 | ||||
-rw-r--r-- | go.mod | 14 | ||||
-rw-r--r-- | go.sum | 27 | ||||
-rw-r--r-- | main.go | 258 |
9 files changed, 560 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1e1ae6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bud +bud.1 @@ -0,0 +1,18 @@ +Copyright 2023-2024 Adam House + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ae83367 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +VERSION=0.1 +PREFIX?=/usr/local +BINDIR?=$(PREFIX)/bin +MANDIR?=$(PREFIX)/share/man +.DEFAULT_GOAL=all + +bud: + go build -o $@ main.go + +bud.1: bud.1.scd + scdoc < $< > $@ + +all: bud bud.1 + +clean: + rm bud bud.1 + +install: all + mkdir -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1 + install -m755 bud $(DESTDIR)$(BINDIR)/bud + install -m644 bud.1 $(DESTDIR)$(MANDIR)/man1/bud.1 + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/bud + rm -f $(DESTDIR)$(MANDIR)/man1/bud.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4215f2 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# bud + +Simple monthly budget tracker + +`bud` is a simple monthly budget tracker that is designed solely to keep track +of known, routine expenses. It is a simple afternoon project that has become a +reliable way for me to avoid using spreadsheets to organize regular expenses. + +## Installation + +`bud` requires a working Go toolchain. + +[scdoc](https://git.sr.ht/~sircmpwn/scdoc) is required if you want to build the man page. + +``` +make +sudo make install +``` + +See the Makefile for more. + +## Configuration + +See bud(1) for details. diff --git a/bud.1.scd b/bud.1.scd new file mode 100644 index 0000000..fb10ddd --- /dev/null +++ b/bud.1.scd @@ -0,0 +1,70 @@ +bud(1) + +# NAME + +bud - simple monthly budget tracker + +# DESCRIPTION + +*bud* is intended to replace the customary spreadsheet as a means of recording +routine expenses. It parses enumerated expenses, summing up which ones are +expected on which days of which months, and displays this information in +either an annual summary overview or a month-specific view. + +# USAGE + +## bud daily [month] + +Shows total expenses for each day of the current month. Days with zero totals +are skipped. Lastly, shows total expenses for the first and second half of each +month. In the future this may be configurable, but right now it uses the 1st +though 14th and the 15th through end of month. This is intended to align with +semi-monthly pay cycles. + +This will fail if any expenses in the given month do not have a day associated +with them. + +Calling *bud* with no argument calls this. + +## bud show [month] + +Shows all expenses applicable to the given month, sorted by date then amount, +with a total at the bottom. Shows all available metadata for each expense. If +you don't specify a month, the current month is used. + +## bud overview + +Shows an overview of all expenses across all months. The "base" amount is the +minimum amount for any month (that is, the sum of all expenses incurred in +_all_ months). Amounts for other months are only shown if they differ from the +base amount. + +# EXPENSE SYNTAX + +The file containing expenses must be located at *$HOME/.config/bud/expenses*. +Expenses are organized in a newline-delimited *Key = Value* format, e.g. + + Name = Rent++ +Category = Essentials++ +Amount = 27500++ +Method = Checking Account++ +Months = \*++ +Day = 1 + +*Name*, *Amount*, and *Months* are required. Other items are optional. + +Amounts are indicated in the smallest possible currency denomination (i.e. +cents for USD, yen for JPY). This is for future-proofing when/if custom +currency formating becomes possible. + +Months are comma-delimited three-letter abbreviations, e.g. "Jan,Feb". The +asterisk (\*) is a special case meaning all months. In this way you can +easily represent an e.g. quarterly expense like "Jan,Jul". + +Each expense must be separated by exactly one empty line, and the file must +end with a newline character. Lines beginning with the hash (#) are treated as +comments and ignored. + +# CONFIGURATION + +No configuration options exist. These may or may not exist in the future. diff --git a/expense/expense.go b/expense/expense.go new file mode 100644 index 0000000..fdab698 --- /dev/null +++ b/expense/expense.go @@ -0,0 +1,122 @@ +package expense + +import ( + "errors" + "strconv" + "strings" +) + +// Assumes USD for now. Simple to rewrite otherwise later. + +type Expense struct { + Name string // name of expense + Amount int // amount in smallest unit (e.g. cents, yen) + Category *string // type of expense (e.g. Housing, Food) + Months [12]bool // months (0 => Jan, etc.) when expense occurs + Day *int // day of month on which expense is incurred + Method *string // method of payment (arbitrary string, ref only) +} + +func (e *Expense) A(i string) error { + d, err := strconv.Atoi(i) + if err != nil { + return err + } + if d < 0 { + return errors.New("negative amount") + } + + e.Amount = d + return nil +} + +func (e *Expense) M(i string) error { + if i == "*" || i == "All" { + for j := range e.Months { + e.Months[j] = true + } + return nil + } + + p := strings.Split(i, ",") + for _, m := range p { + switch m { + case "Jan": + e.Months[0] = true + case "Feb": + e.Months[1] = true + case "Mar": + e.Months[2] = true + case "Apr": + e.Months[3] = true + case "May": + e.Months[4] = true + case "Jun": + e.Months[5] = true + case "Jul": + e.Months[6] = true + case "Aug": + e.Months[7] = true + case "Sep": + e.Months[8] = true + case "Oct": + e.Months[9] = true + case "Nov": + e.Months[10] = true + case "Dec": + e.Months[11] = true + default: + return errors.New("invalid input") + } + } + + return nil +} + +func (e *Expense) D(i string) error { + d, err := strconv.Atoi(i) + if err != nil { + return err + } + + if d < 0 || d > 31 { + return errors.New("day out of range") + } + + e.Day = &d + + return nil +} + +func (e *Expense) IsValid() bool { + if len(e.Name) == 0 { + return false + } + + if e.Amount == 0 { + return false + } + + anyMonth := false + for _, m := range e.Months { + if m { + anyMonth = true + break + } + } + if !anyMonth { + return false + } + + return true +} + +func (e *Expense) IsAll() bool { + for _, m := range e.Months { + if !m { + return false + } + } + + return true +} @@ -0,0 +1,14 @@ +module git.sr.ht/~ajwh/bud + +go 1.21.4 + +require ( + github.com/fatih/color v1.16.0 + github.com/rodaine/table v1.1.0 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.14.0 // indirect +) @@ -0,0 +1,27 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rodaine/table v1.1.0 h1:/fUlCSdjamMY8VifdQRIu3VWZXYLY7QHFkVorS8NTr4= +github.com/rodaine/table v1.1.0/go.mod h1:Qu3q5wi1jTQD6B6HsP6szie/S4w1QUQ8pq22pz9iL8g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -0,0 +1,258 @@ +package main + +import ( + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + "time" + + "git.sr.ht/~ajwh/bud/expense" + + "github.com/fatih/color" + "github.com/rodaine/table" +) + +func fail(n int) { + log.Fatalf("invalid expense file (line %d)", n+1) +} + +func main() { + h, err := os.UserHomeDir() + if err != nil { + log.Fatal("could not get user home dir") + } + + f, err := os.ReadFile(h + "/.config/bud/expenses") + if err != nil { + log.Fatal("no expense file") + } + + // load expenses + var x []expense.Expense + c := expense.Expense{} + for n, l := range strings.Split(string(f), "\n") { + if len(l) > 0 && l[0] == '#' { + continue + } + + p := strings.Split(l, " = ") + if len(p) == 1 { + if c.IsValid() { + x = append(x, c) + c = expense.Expense{} + continue + } else { + fail(n) + } + } + + // all lines should be like + // Property=Value + // with exactly one blank line separating each + // (comments ignored) + + if len(p) != 2 { + fail(n) + } + + switch p[0] { + case "Name": + c.Name = p[1] + case "Amount": + if e := c.A(p[1]); e != nil { + fail(n) + } + case "Months": + if e := c.M(p[1]); e != nil { + fail(n) + } + case "Day": + if e := c.D(p[1]); e != nil { + fail(n) + } + case "Category": + c.Category = &p[1] + case "Method": + c.Method = &p[1] + default: + fail(n) + } + } + + args := os.Args + + if len(args) == 1 { + showDaily(x, time.Now().Month()) + return + } + + switch args[1] { + case "show": + if len(args) >= 3 { + m, err := strconv.Atoi(args[2]) + if err != nil || m < 1 || m > 12 { + log.Fatal("invalid month") + } + showMonth(x, time.Month(m)) + } else { + showMonth(x, time.Now().Month()) + } + case "daily": + if len(args) >= 3 { + m, err := strconv.Atoi(args[2]) + if err != nil || m < 1 || m > 12 { + log.Fatal("invalid month") + } + showDaily(x, time.Month(m)) + } else { + showDaily(x, time.Now().Month()) + } + case "overview": + showOverview(x) + default: + showDaily(x, time.Now().Month()) + } +} + +func showOverview(x []expense.Expense) { + base := 0 + var byMonth [12]int + for _, e := range x { + if e.IsAll() { + base += e.Amount + } + + for i, m := range e.Months { + if m { + byMonth[i] += e.Amount + } + } + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("Month", "Amount") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + tbl.AddRow("Base", fmt.Sprintf("$%.2f", float64(base)/100.0)) + tbl.AddRow("---------", "") + for i, a := range byMonth { + if a == base { + tbl.AddRow(time.Month(i+1).String(), "-") + } else { + tbl.AddRow(time.Month(i+1).String(), fmt.Sprintf("$%.2f", float64(a)/100.0)) + } + } + + tbl.Print() +} + +func showDaily(x []expense.Expense, m time.Month) { + days := make([]int, 31) + + // right now this is just 1st-14th and 15th-EOM + segments := []int{0, 0} + + for _, e := range x { + if !e.Months[m-1] { + continue + } + + if e.Day == nil { + log.Fatal("daily requires all relevant expenses to have days") + } + d := *e.Day + + days[d-1] += e.Amount + + if d < 15 { + segments[0] += e.Amount + } else { + segments[1] += e.Amount + } + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("Day", "Total") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for i, t := range days { + if t > 0 { + tbl.AddRow(i+1, fmt.Sprintf("$%.2f", float64(t)/100.0)) + } + } + + fmt.Print("\n") + tbl.Print() + fmt.Print("\n") + + fmt.Printf("1st Half: $%.2f\n", float64(segments[0])/100.0) + fmt.Printf("2nd Half: $%.2f\n\n", float64(segments[1])/100.0) +} + +func showMonth(x []expense.Expense, m time.Month) { + total := 0 + + var a []expense.Expense + + for _, e := range x { + if e.Months[m-1] { + total += e.Amount + a = append(a, e) + } + } + + sort.Slice(a, func(i, j int) bool { + if a[i].Day == nil && a[j].Day != nil { + return true + } + if a[i].Day != nil && a[j].Day == nil { + return false + } + + if a[i].Day == nil && a[j].Day == nil { + return a[i].Amount > a[j].Amount + } + + if *a[i].Day == *a[j].Day { + return a[i].Amount > a[j].Amount + } + + return *a[i].Day < *a[j].Day + }) + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("Name", "Amount", "Category", "Day", "Method") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, e := range a { + c, m := "", "" + if e.Category != nil { + c = *e.Category + } + if e.Method != nil { + m = *e.Method + } + + if e.Day != nil { + tbl.AddRow(e.Name, fmt.Sprintf("$%.2f", float64(e.Amount)/100.0), c, *e.Day, m) + } else { + tbl.AddRow(e.Name, fmt.Sprintf("$%.2f", float64(e.Amount)/100.0), c, "", m) + } + + } + + fmt.Print("\n") + tbl.Print() + fmt.Print("\n") + + fmt.Printf("Total: $%.2f\n\n", float64(total)/100.0) +} |