aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam House <a@context.town>2024-06-21 08:23:02 -0500
committerAdam House <a@context.town>2024-06-21 08:27:49 -0500
commit81e8021f6d873bcdcc1c602d02fc8a304884f813 (patch)
treef37a66d956f22dd89db6ce2796b97fbc092ae1d2
migrate to git.context.townHEADmain
-rw-r--r--.gitignore2
-rw-r--r--LICENSE18
-rw-r--r--Makefile25
-rw-r--r--README.md24
-rw-r--r--bud.1.scd70
-rw-r--r--expense/expense.go122
-rw-r--r--go.mod14
-rw-r--r--go.sum27
-rw-r--r--main.go258
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
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..524b8c5
--- /dev/null
+++ b/LICENSE
@@ -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
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..bc4dd59
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f839f60
--- /dev/null
+++ b/go.sum
@@ -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=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..ffdfd94
--- /dev/null
+++ b/main.go
@@ -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)
+}