package main import ( "fmt" "log" "math" "os" "sort" "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 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 Credit []Transaction Transactions []Transaction } 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 HardDate bool ID string Late bool Name string OrigDate time.Time Shift string } type TransactionYaml struct { Name string `yaml:"name"` Amount float64 `yaml:"amount"` Date string `yaml:"date"` Delayable *bool `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 (t TransactionLog) dates() timeSlice { dateSlice := make(timeSlice, 0) for date, _ := range t.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() []*Transaction { transactions := make([]*Transaction, 0) for _, t := range logDay.Transactions { transactions = append(transactions, t) } sortTransactions(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 parseConfig(configYaml *ConfigYaml) Config { config := Config{ Accounts: configYaml.Accounts, Credit: make([]Transaction, 0), Transactions: make([]Transaction, 0), } for _, transaction := range configYaml.Credit { formalDate, hardDate := parseDate(transaction.Date) if transaction.Delayable == nil { transaction.Delayable = &[]bool{true}[0] } if transaction.Shift == "" { transaction.Shift = "late" } newTransaction := Transaction{ Amount: transaction.Amount, Date: formalDate, Delayable: *transaction.Delayable, Frequency: transaction.Frequency, HardDate: hardDate, ID: uuid.NewString(), Late: false, Name: transaction.Name, OrigDate: formalDate, Shift: transaction.Shift, } config.Credit = append(config.Credit, newTransaction) } for _, transaction := range configYaml.Transactions { formalDate, hardDate := parseDate(transaction.Date) if transaction.Delayable == nil { transaction.Delayable = &[]bool{true}[0] } if transaction.Shift == "" { transaction.Shift = "late" } newTransaction := Transaction{ Amount: transaction.Amount, Date: formalDate, Delayable: *transaction.Delayable, Frequency: transaction.Frequency, HardDate: hardDate, ID: uuid.NewString(), Late: false, Name: transaction.Name, OrigDate: formalDate, Shift: transaction.Shift, } config.Transactions = append(config.Transactions, newTransaction) } return config } func parseDate(date string) (time.Time, bool) { hardDate := true base := time.Now().AddDate(0, 0, -time.Now().Day()+1) current := time.Date(base.Year(), base.Month(), base.Day(), 0, 0, 0, 0, time.UTC) dateString := string(date) formalDate, err := time.Parse("2006-1-2", dateString) if err != nil { hardDate = false if date == "first" { formalDate = current if formalDate.Before(time.Now()) { formalDate = formalDate.AddDate(0, 1, 0) } } else if date == "last" { formalDate = lastOfMonth(current) } else { dateString = fmt.Sprintf("%d-%d-%s", time.Now().Year(), int(time.Now().Month()), date) formalDate, _ = time.Parse("2006-1-2", dateString) if formalDate.Before(time.Now()) { formalDate = formalDate.AddDate(0, 1, 0) } } } return formalDate, hardDate } func preFetchDates(transactions []Transaction, transactionLog *TransactionLog) { for _, transaction := range transactions { if _, ok := transactionLog.LogDays[transaction.Date]; !ok && transaction.Amount != 0 { logDay := LogDay{ Date: transaction.Date, Transactions: make(map[string]*Transaction), } transactionLog.LogDays[transaction.Date] = &logDay } } logDates := transactionLog.dates() transactionLog.StartDate = logDates[0] transactionLog.EndDate = logDates[len(logDates)-1] } func collectTransactions(transactions []Transaction, transactionLog *TransactionLog) { for _, transaction := range transactions { if math.Abs(transaction.Amount) != 0 { addTransaction(transactionLog, &transaction) } } } func sortTransactions(transactions []*Transaction) { sort.Slice(transactions, func(i, j int) bool { if transactions[i].Amount < 0 && transactions[j].Amount < 0 { return math.Abs(transactions[i].Amount) > math.Abs(transactions[j].Amount) } else { return transactions[i].Amount > transactions[j].Amount } }) } func addTransaction(transactionLog *TransactionLog, transaction *Transaction) { var dateList []time.Time if transaction.Frequency != "" { dateList = calculateFrequency(transaction.Frequency, transaction.Date, transactionLog.EndDate, transaction.HardDate) } else { dateList = []time.Time{transaction.Date} } for _, date := range dateList { if _, ok := transactionLog.LogDays[date]; !ok { logDay := LogDay{ Date: date, Transactions: make(map[string]*Transaction), } transactionLog.LogDays[date] = &logDay } t := Transaction{ Amount: transaction.Amount, Date: date, Delayable: transaction.Delayable, Frequency: "", HardDate: false, ID: uuid.NewString(), Late: false, Name: transaction.Name, OrigDate: date, Shift: transaction.Shift, } transactionLog.LogDays[date].Transactions[t.ID] = &t transactionLog.Transactions[t.ID] = &t } } func calculateShift(transactionLog TransactionLog, transaction Transaction) time.Time { newDate := transaction.Date if transaction.Late { if transaction.Shift == "early" { newDate = newDate.AddDate(0, 0, -1) } else { newDate = newDate.AddDate(0, 0, 1) } } for newDate.Weekday() == 0 || newDate.Weekday() == 6 { if transaction.Shift == "early" { newDate = newDate.AddDate(0, 0, -1) } else { newDate = newDate.AddDate(0, 0, 1) } } return newDate } func delayTransaction(transactionLog *TransactionLog, transaction *Transaction) { if transaction.Date.Weekday() != 0 && transaction.Date.Weekday() != 6 { transaction.Late = true } newDate := calculateShift(*transactionLog, *transaction) if newDate.Before(transactionLog.first()) || newDate.After(transactionLog.last()) || newDate == transaction.Date { transaction.Delayable = false } if _, ok := transactionLog.LogDays[newDate]; !ok { logDay := LogDay{ Date: newDate, Transactions: map[string]*Transaction{}, } transactionLog.LogDays[newDate] = &logDay } delete(transactionLog.LogDays[transaction.Date].Transactions, transaction.ID) transaction.Date = newDate transactionLog.LogDays[newDate].Transactions[transaction.ID] = transaction } func executeTransaction(transactionLog *TransactionLog, transaction *Transaction) bool { logDay := transactionLog.LogDays[transaction.Date] accounts := make([]Account, len(logDay.EndingBalance)) copy(accounts, logDay.EndingBalance) executed := false finalDate := transactionLog.EndDate if transaction.Date.Weekday() == 0 || transaction.Date.Weekday() == 6 && transaction.Delayable { logrus.Debugf("Delaying %s due to weekend scheduling\n", transaction.Name) delayTransaction(transactionLog, transaction) } else if transaction.Amount > 0 { logrus.Debugf("Executing Credit: %s %s (%f)\n", logDay.Date, transaction.Name, transaction.Amount) accounts[0].Balance = accounts[0].Balance + transaction.Amount executed = true } else if transaction.Amount < 0 && math.Abs(transaction.Amount) > (accounts[0].Balance-accounts[0].Minimum) && transaction.Date.Before(finalDate) && transaction.Delayable { logrus.Debugf("Balance: %f | Minimum: %f\n", accounts[0].Balance, accounts[0].Minimum) logrus.Debugf("Delaying %s: %f (%s) due to lack of funds\n", transaction.Name, transaction.Amount, transaction.Date) delayTransaction(transactionLog, transaction) } else if transaction.Amount < 0 { logrus.Debugf("Executing Charge: %s %s (%f)\n", logDay.Date, transaction.Name, transaction.Amount) accounts[0].Balance = accounts[0].Balance + transaction.Amount executed = true } logDay.EndingBalance = make([]Account, len(accounts)) copy(logDay.EndingBalance, accounts) return executed } func firstOfMonth(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) } func lastOfMonth(t time.Time) time.Time { return time.Date(t.Year(), t.Month()+1, 0, 0, 0, 0, 0, time.UTC) } func calculateFrequency(frequency string, startDate time.Time, endDate time.Time, hardDate bool) []time.Time { base := time.Now() current := time.Date(base.Year(), base.Month(), base.Day(), 0, 0, 0, 0, time.UTC) 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 { break } } 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.Late { categorized["late"] = append(categorized["late"], t.ID) } else { categorized["standard"] = append(categorized["standard"], t.ID) } } return categorized } func processTransactionLog(transactionLog *TransactionLog, accounts []Account) bool { shifted := false endBalance := accounts logrus.Debugf("\n\nITERATION %d\n\n", transactionLog.Iteration) if transactionLog.Iteration == 0 { for _, date := range transactionLog.dates() { logDay := transactionLog.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.Debugf("---\nStarting Balance %s: %f\n", date, logDay.StartingBalance[0].Balance) logrus.Debugln(categorized) if processTransactions(transactionLog, categorized["credits"]) { shifted = true } logrus.Debugf("Starting Balance %s: %f\n", date, logDay.StartingBalance[0].Balance) logrus.Debugf("Ending Balance %s: %f\n", date, logDay.EndingBalance[0].Balance) endBalance = logDay.EndingBalance } } else { for _, date := range transactionLog.dates() { logDay := transactionLog.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.Debugf("---\nStarting Balance %s: %f\n", date, logDay.StartingBalance[0].Balance) logrus.Debugln(categorized) if processTransactions(transactionLog, categorized["credits"]) { shifted = true } if processTransactions(transactionLog, categorized["late"]) { shifted = true } if processTransactions(transactionLog, categorized["standard"]) { shifted = true } logrus.Debugf("Starting Balance %s: %f\n", date, logDay.StartingBalance[0].Balance) logrus.Debugf("Ending Balance %s: %f\n", date, logDay.EndingBalance[0].Balance) endBalance = logDay.EndingBalance if shifted { break } if len(logDay.Transactions) == 0 { delete(transactionLog.LogDays, logDay.Date) } } } transactionLog.Iteration++ return shifted } func processTransactions(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) } if !executeTransaction(transactionLog, transaction) { shifted = true } } return shifted } func printOutput(logDay LogDay) { delim := strings.Repeat("=", 67) divider := delim delim2 := strings.Repeat("-", 66) divider2 := delim2 + "|" fmt.Println(divider) outString := fmt.Sprint(logDay.Date.Format("Jan 02 2006")) rear_spacer := strings.Repeat(" ", 65-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(" ", 65-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.Late { late_tag := fmt.Sprintf("(%s - %s)", "Late", charge.OrigDate.Format("Jan 02 2006")) rear_spacer := strings.Repeat(" ", 65-len(late_tag)-len(outString)) fmt.Printf("%s%s%s |\n", outString, rear_spacer, late_tag) } else if charge.Date != charge.OrigDate { weekend_tag := fmt.Sprintf("(%s - %s)", "Weekend", charge.OrigDate.Format("Jan 02 2006")) rear_spacer := strings.Repeat(" ", 65-len(weekend_tag)-len(outString)) fmt.Printf("%s%s%s |\n", outString, rear_spacer, weekend_tag) } else { rear_spacer := strings.Repeat(" ", 65-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(" ", 65-len(outString)) fmt.Printf("%s%s |\n", outString, rear_spacer) } fmt.Println(divider + "\n") } func main() { yfile, err := os.ReadFile(os.Args[1]) if err != nil { log.Fatal(err) } var configYaml ConfigYaml transactionLog := TransactionLog{ LogDays: make(map[time.Time]*LogDay), Transactions: make(map[string]*Transaction), } err = yaml.Unmarshal(yfile, &configYaml) if err != nil { log.Fatal(err) } config := parseConfig(&configYaml) preFetchDates(config.Credit, &transactionLog) preFetchDates(config.Transactions, &transactionLog) 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) } } collectTransactions(config.Credit, &transactionLog) collectTransactions(config.Transactions, &transactionLog) shifted := true for shifted { //startingBalance := make([]Account, len(config.Accounts)) //copy(startingBalance, config.Accounts) shifted = false shifted = processTransactionLog(&transactionLog, config.Accounts) } for _, date := range transactionLog.dates() { logDay := transactionLog.LogDays[date] printOutput(*logDay) } }