package main import ( "fmt" "log" "math" "os" "os/exec" "regexp" "sort" "strconv" "strings" "time" "github.com/google/uuid" "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) type timeSlice []time.Time func (s timeSlice) Len() int { return len(s) } func (s timeSlice) Less(i, j int) bool { return s[i].Before(s[j]) } func (s timeSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } type transactionList []*Transaction func (t transactionList) Len() int { return len(t) } func (t transactionList) Less(i, j int) bool { if t[i].Date.Before(t[j].Date) { return true } if t[i].Amount < 0 && t[j].Amount < 0 { return math.Abs(t[i].Amount) > math.Abs(t[j].Amount) } return t[i].Amount > t[j].Amount } func (t transactionList) Swap(i, j int) { t[i], t[j] = t[j], t[i] } type Account struct { Name string `yaml:"name"` Balance float64 `yaml:"balance"` Minimum float64 `yaml:"minimum"` } type Charge struct { Name string Late bool Amount float64 Date time.Time } type Config struct { Accounts []Account Transactions transactionList } type ConfigYaml struct { Start string End string Accounts []Account Credit []TransactionYaml Transactions []TransactionYaml } type Transaction struct { Amount float64 Date time.Time Delayable bool Frequency string ID string Delayed bool Name string OriginDate time.Time Shift string } type TransactionYaml struct { Name string `yaml:"name"` Amount float64 `yaml:"amount"` Date string `yaml:"date"` Delayable string `yaml:"delayable"` Frequency string `yaml:"frequency,omitempty"` Shift string `yaml:"shift"` } type TransactionLog struct { StartDate time.Time EndDate time.Time Iteration int LogDays map[time.Time]*LogDay Transactions map[string]*Transaction } type LogDay struct { Date time.Time StartingBalance []Account EndingBalance []Account Transactions map[string]*Transaction } func (tl *TransactionLog) dates() timeSlice { dateSlice := make(timeSlice, 0) for date := range tl.LogDays { dateSlice = append(dateSlice, date) } sort.Sort(dateSlice) return dateSlice } func (t TransactionLog) first() time.Time { return t.dates()[0] } func (t TransactionLog) last() time.Time { return t.dates()[t.dates().Len()-1] } func (logDay LogDay) transactions() transactionList { transactions := transactionList{} for _, t := range logDay.Transactions { transactions = append(transactions, t) } sort.Sort(transactions) return transactions } func nextLogDay(transactionLog *TransactionLog, currentDate time.Time) *LogDay { var next time.Time for i, date := range transactionLog.dates() { if date == currentDate { next = transactionLog.dates()[i+1] break } } return transactionLog.LogDays[next] } func previousLogDay(transactionLog *TransactionLog, currentDate time.Time) *LogDay { var previous time.Time for _, date := range transactionLog.dates() { if date == currentDate { break } previous = date } return transactionLog.LogDays[previous] } func isWeekend(date time.Time) bool { if date.Weekday() == 0 || date.Weekday() == 6 { return true } return false } func parseDelayable(delayableString string) (bool, error) { if delayableString == "" { return true, nil } delayable, err := strconv.ParseBool(delayableString) if err != nil { return false, err } return delayable, nil } func parseTransaction(transactionYaml TransactionYaml) (*Transaction, error) { date, err := parseDate(transactionYaml.Date) if err != nil { return nil, err } if transactionYaml.Shift == "" { transactionYaml.Shift = "late" } delayable, err := parseDelayable(transactionYaml.Delayable) if err != nil { return nil, err } transaction := &Transaction{ Amount: transactionYaml.Amount, Date: date, Delayable: delayable, Frequency: transactionYaml.Frequency, ID: uuid.NewString(), Delayed: false, Name: transactionYaml.Name, OriginDate: date, Shift: transactionYaml.Shift, } return transaction, nil } func parseConfig(configYaml ConfigYaml) *Config { config := &Config{ Accounts: configYaml.Accounts, Transactions: make([]*Transaction, 0), } for _, transaction := range configYaml.Credit { newTransaction, err := parseTransaction(transaction) if err != nil { logrus.Error(err) } config.Transactions = append(config.Transactions, newTransaction) } for _, transaction := range configYaml.Transactions { newTransaction, err := parseTransaction(transaction) if err != nil { logrus.Error(err) } config.Transactions = append(config.Transactions, newTransaction) } return config } func dateIsFixed(dateString string) (bool, error) { return regexp.MatchString("^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$", dateString) } func parseDate(dateString string) (time.Time, error) { currentDay := currentDay() regMatch, err := regexp.MatchString("^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$", dateString) if err != nil { return time.Time{}, err } if regMatch { return time.Parse("2006-1-2", dateString) } if dateString == "first" { if currentDay.Day() == 1 { return currentDay, nil } else { return firstOfMonth(currentDay).AddDate(0, 1, 0), nil } } if dateString == "last" { return lastOfMonth(currentDay), nil } dateString = fmt.Sprintf("%d-%d-%s", currentDay.Year(), currentDay.Month(), dateString) parsedDate, err := time.Parse("2006-1-2", dateString) if err != nil { return time.Time{}, err } if parsedDate.Before(currentDay) { return parsedDate.AddDate(0, 1, 0), nil } return parsedDate, nil } func ingestTransactions(transactions []*Transaction, transactionLog *TransactionLog) { for _, transaction := range transactions { if math.Abs(transaction.Amount) != 0 { transactionLog.addTransaction(transaction) } } } func (tl *TransactionLog) addTransaction(transaction *Transaction) { if transaction.Frequency == "" { tl.addDay(transaction.Date) tl.LogDays[transaction.Date].Transactions[transaction.ID] = transaction tl.Transactions[transaction.ID] = transaction logrus.Debugf("Adding transaction %s (%s | %f) to %s", transaction.ID, transaction.Name, transaction.Amount, transaction.Date.String()) return } dateList := calculateFrequency(transaction.Frequency, transaction.Date, tl.EndDate, transaction.Delayable) for _, date := range dateList { tl.addDay(date) t := Transaction{ Amount: transaction.Amount, Date: date, Delayable: transaction.Delayable, Frequency: "", ID: uuid.NewString(), Delayed: false, Name: transaction.Name, OriginDate: date, Shift: transaction.Shift, } tl.LogDays[date].Transactions[t.ID] = &t tl.Transactions[t.ID] = &t } } func (tl *TransactionLog) addDay(date time.Time) { if _, ok := tl.LogDays[date]; !ok { logrus.Debugf("Adding Log Day %s", date.String()) tl.LogDays[date] = &LogDay{ Date: stripTime(date), Transactions: make(map[string]*Transaction), } } } func (tl *TransactionLog) deleteDay(date time.Time) { if _, ok := tl.LogDays[date]; ok { logrus.Debugf("Deleting Log Day %s", date.String()) delete(tl.LogDays, date) } } func calculateShift(transactionLog TransactionLog, transaction Transaction) time.Time { if transaction.Amount > 0 { add := 1 newDate := transaction.Date if transaction.Shift == "early" { add = -1 } for isWeekend(newDate) { newDate = newDate.AddDate(0, 0, add) } return newDate } if transaction.Shift == "early" { return previousLogDay(&transactionLog, transaction.Date).Date } return nextLogDay(&transactionLog, transaction.Date).Date } func (tl *TransactionLog) delayTransaction(transaction *Transaction) { newDate := calculateShift(*tl, *transaction) if newDate.Before(tl.first()) || newDate == transaction.Date { logrus.Debugf("Transaction not delayable %s (%s | %f)", transaction.ID, transaction.Name, transaction.Amount) transaction.Delayable = false return } if newDate.After(tl.last()) { newDate = tl.last() } tl.addDay(newDate) delete(tl.LogDays[transaction.Date].Transactions, transaction.ID) transaction.Date = newDate tl.LogDays[newDate].Transactions[transaction.ID] = transaction } func (tl *TransactionLog) executeTransaction(transaction *Transaction) bool { logDay := tl.LogDays[transaction.Date] accounts := make([]Account, len(logDay.EndingBalance)) copy(accounts, logDay.EndingBalance) if isWeekend(transaction.Date) && transaction.Delayable { logrus.Debugf("Delaying %s due to weekend scheduling\n", transaction.Name) tl.delayTransaction(transaction) return false } logrus.Debugf("Balance: %f | Minimum: %f\n", accounts[0].Balance, accounts[0].Minimum) logrus.Debugf("Delayable: %t", transaction.Delayable) if !isAffordable(transaction.Amount, accounts[0].Balance, accounts[0].Minimum) && transaction.Delayable { logrus.Debugf("Delaying %s: %f (%s) due to lack of funds\n", transaction.Name, transaction.Amount, transaction.Date) tl.delayTransaction(transaction) transaction.Delayed = true return false } logrus.Debugf("Executing: %s (%s | %s | %f)", transaction.ID, logDay.Date.String(), transaction.Name, transaction.Amount) accounts[0].Balance = accounts[0].Balance + transaction.Amount logDay.EndingBalance = make([]Account, len(accounts)) copy(logDay.EndingBalance, accounts) return true } func isAffordable(charge, balance, minimum float64) bool { if charge > 0 { return true } logrus.Debugf("Charge: %f | Balance - Miniumum: %f", charge, balance-minimum) logrus.Debugf("abs(charge) < (balance - minimum): %t", math.Abs(charge) < (balance-minimum)) return math.Abs(charge) < (balance - minimum) } func currentDay() time.Time { return stripTime(time.Now()) } func firstOfMonth(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.Now().Location()) } func lastOfMonth(t time.Time) time.Time { return time.Date(t.Year(), t.Month()+1, 0, 0, 0, 0, 0, time.Now().Location()) } func stripTime(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Now().Location()) } func calculateFrequency(frequency string, startDate time.Time, endDate time.Time, hardDate bool) []time.Time { current := currentDay() initDate := startDate dateList := make([]time.Time, 0) for initDate.Before(endDate.AddDate(0, 0, 1)) { if !initDate.Before(current) || hardDate { dateList = append(dateList, initDate) } if frequency == "daily" { initDate = initDate.AddDate(0, 0, 1) } else if frequency == "weekly" { initDate = initDate.AddDate(0, 0, 7) } else if frequency == "biweekly" { initDate = initDate.AddDate(0, 0, 14) } else if frequency == "monthly" { if initDate == lastOfMonth(initDate) { initDate = lastOfMonth(firstOfMonth(initDate).AddDate(0, 1, 0)) } else { initDate = initDate.AddDate(0, 1, 0) } } else { logrus.Error("Invalid Frequency") } } return dateList } func categorizeTransactions(transactions map[string]*Transaction) map[string][]string { var categorized = map[string][]string{ "credits": make([]string, 0), "late": make([]string, 0), "standard": make([]string, 0), } for _, t := range transactions { if t.Amount > 0 { categorized["credits"] = append(categorized["credits"], t.ID) } else if t.Delayed { categorized["late"] = append(categorized["late"], t.ID) } else { categorized["standard"] = append(categorized["standard"], t.ID) } } return categorized } func (tl *TransactionLog) processTransactions(accounts []Account) bool { shifted := false endBalance := accounts logrus.Debugf("\n\nITERATION %d\n\n", tl.Iteration) if tl.Iteration == 0 { for _, date := range tl.dates() { logrus.Debugf("Processing Log Day %s", date.String()) logDay := tl.LogDays[date] logDay.StartingBalance = make([]Account, len(endBalance)) logDay.EndingBalance = make([]Account, len(endBalance)) copy(logDay.StartingBalance, endBalance) copy(logDay.EndingBalance, endBalance) categorized := categorizeTransactions(logDay.Transactions) logrus.Debugln(categorized) processTransactionsByID(tl, categorized["credits"]) logrus.Debugf("Starting Balance %s: %f", date, logDay.StartingBalance[0].Balance) logrus.Debugf("Ending Balance %s: %f", date, logDay.EndingBalance[0].Balance) endBalance = logDay.EndingBalance shifted = true } } else { for _, date := range tl.dates() { logrus.Debugf("Processing Log Day %s", date.String()) logDay := tl.LogDays[date] logDay.StartingBalance = make([]Account, len(endBalance)) logDay.EndingBalance = make([]Account, len(endBalance)) copy(logDay.StartingBalance, endBalance) copy(logDay.EndingBalance, endBalance) categorized := categorizeTransactions(logDay.Transactions) logrus.Debugln(categorized) logrus.Debugln(logDay.Transactions) if processTransactionsByID(tl, categorized["credits"]) { shifted = true } if processTransactionsByID(tl, categorized["late"]) { shifted = true } if processTransactionsByID(tl, categorized["standard"]) { shifted = true } logrus.Debugf("Starting Balance %s: %f", date, logDay.StartingBalance[0].Balance) logrus.Debugf("Ending Balance %s: %f", date, logDay.EndingBalance[0].Balance) endBalance = logDay.EndingBalance if shifted { break } if len(logDay.Transactions) == 0 { tl.deleteDay(date) } } } tl.Iteration++ return shifted } func processTransactionsByID(transactionLog *TransactionLog, transactionIDs []string) bool { shifted := false for _, id := range transactionIDs { transaction, ok := transactionLog.Transactions[id] if !ok { logrus.Fatalf("Transaction ID: %s not found in log\n", id) } logrus.Debugf("Processing %s (%s | %f)", id, transaction.Name, transaction.Amount) if !transactionLog.executeTransaction(transaction) { shifted = true } } return shifted } func printOutput(logDay LogDay) { cmd := exec.Command("stty", "size") cmd.Stdin = os.Stdin out, err := cmd.Output() if err != nil { logrus.Fatal(err) } widthStr := string(out[:len(out)-1]) sttyOut := strings.Split(widthStr, " ") fullWidth, err := strconv.Atoi(sttyOut[1]) fullWidth = fullWidth / 2 width := fullWidth - 2 if err != nil { logrus.Fatal(err) } delim := strings.Repeat("=", fullWidth) divider := delim delim2 := strings.Repeat("-", fullWidth-1) divider2 := delim2 + "|" fmt.Println(divider) outString := fmt.Sprint(logDay.Date.Format("Jan 02 2006")) rear_spacer := strings.Repeat(" ", width-len(outString)) fmt.Printf("%s%s |\n", outString, rear_spacer) for _, v := range logDay.StartingBalance { spacer := strings.Repeat(" ", 19-len(fmt.Sprintf("%.2f", v.Balance))) outString := fmt.Sprintf("$%.2f%s| %s", v.Balance, spacer, v.Name) rear_spacer := strings.Repeat(" ", width-len(outString)) fmt.Printf("%s%s |\n", outString, rear_spacer) } for _, charge := range logDay.transactions() { spacer := strings.Repeat(" ", 18-len(fmt.Sprintf("%.2f", math.Abs(charge.Amount)))) outString := "" if charge.Amount <= 0 { outString = "-" } else { outString = "+" } outString = fmt.Sprintf("%s$%.2f%s| %s", outString, math.Abs(charge.Amount), spacer, charge.Name) if charge.Delayed { late_tag := fmt.Sprintf("(%s - %s)", "Late", charge.OriginDate.Format("Jan 02 2006")) rear_spacer := strings.Repeat(" ", width-len(late_tag)-len(outString)) fmt.Printf("%s%s%s |\n", outString, rear_spacer, late_tag) } else if charge.Date != charge.OriginDate { weekend_tag := fmt.Sprintf("(%s - %s)", "Weekend", charge.OriginDate.Format("Jan 02 2006")) rear_spacer := strings.Repeat(" ", width-len(weekend_tag)-len(outString)) fmt.Printf("%s%s%s |\n", outString, rear_spacer, weekend_tag) } else { rear_spacer := strings.Repeat(" ", width-len(outString)) fmt.Printf("%s%s |\n", outString, rear_spacer) } } fmt.Println(divider2) for _, v := range logDay.EndingBalance { spacer := strings.Repeat(" ", 19-len(fmt.Sprintf("%.2f", v.Balance))) outString := fmt.Sprintf("$%.2f%s| %s", v.Balance, spacer, v.Name) rear_spacer := strings.Repeat(" ", width-len(outString)) fmt.Printf("%s%s |\n", outString, rear_spacer) } fmt.Println(divider + "\n") } func main() { if len(os.Getenv("DEBUG")) > 0 { logrus.SetLevel(logrus.DebugLevel) } yfile, err := os.ReadFile(os.Args[1]) if err != nil { log.Fatal(err) } var configYaml ConfigYaml err = yaml.Unmarshal(yfile, &configYaml) if err != nil { log.Fatal(err) } transactionLog := &TransactionLog{ LogDays: make(map[time.Time]*LogDay), Transactions: make(map[string]*Transaction), } if configYaml.Start != "" { transactionLog.StartDate, err = time.Parse("2006-1-2", configYaml.Start) if err != nil { logrus.Error(err) } } if configYaml.End != "" { transactionLog.EndDate, err = time.Parse("2006-1-2", configYaml.End) if err != nil { logrus.Error(err) } } config := parseConfig(configYaml) sort.Sort(config.Transactions) if transactionLog.StartDate == (time.Time{}) { transactionLog.StartDate = config.Transactions[0].Date } if transactionLog.EndDate == (time.Time{}) { transactionLog.EndDate = config.Transactions[len(config.Transactions)-1].Date } ingestTransactions(config.Transactions, transactionLog) shifted := true for shifted { shifted = false shifted = transactionLog.processTransactions(config.Accounts) } for _, date := range transactionLog.dates() { logDay := transactionLog.LogDays[date] printOutput(*logDay) } }