aboutsummaryrefslogblamecommitdiffstats
path: root/main.go
blob: ffdfd9486df7589da35adeb228a23ef541d30638 (plain) (tree)

































































































































































































































































                                                                                                       
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)
}