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 { Transactions map[time.Time]*LogDay StartDate time.Time EndDate time.Time } type LogDay struct { Date time.Time StartingBalance []Account EndingBalance []Account Executed []*Transaction Transactions map[string]*Transaction } func (t TransactionLog) dates() timeSlice { dateSlice := make(timeSlice, 0) for date, _ := range t.Transactions { 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 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.Transactions[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.Transactions[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.Transactions[transaction.Date]; !ok && transaction.Amount != 0 { logDay := LogDay{ Date: transaction.Date, Transactions: make(map[string]*Transaction), } transactionLog.Transactions[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.Transactions[date]; !ok { logDay := LogDay{ Date: date, Transactions: make(map[string]*Transaction), } transactionLog.Transactions[date] = &logDay } transactionID := uuid.NewString() transactionLog.Transactions[date].Transactions[transactionID] = &Transaction{ Amount: transaction.Amount, Date: date, Delayable: transaction.Delayable, Frequency: "", HardDate: false, ID: transactionID, Late: false, Name: transaction.Name, OrigDate: date, Shift: transaction.Shift, } } } 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.Transactions[newDate]; !ok { logDay := LogDay{ Date: newDate, Transactions: map[string]*Transaction{}, } transactionLog.Transactions[newDate] = &logDay } delete(transactionLog.Transactions[transaction.Date].Transactions, transaction.ID) transaction.Date = newDate transactionLog.Transactions[newDate].Transactions[transaction.ID] = transaction } func executeTransaction(transactionLog *TransactionLog, accounts []Account, transaction *Transaction) bool { executed := false finalDate := transactionLog.EndDate if transaction.Date.Weekday() == 0 || transaction.Date.Weekday() == 6 && transaction.Delayable { delayTransaction(transactionLog, transaction) } else if transaction.Amount > 0 { 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 { delayTransaction(transactionLog, transaction) } else if transaction.Amount < 0 { accounts[0].Balance = accounts[0].Balance + transaction.Amount executed = true } 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 processTransactions(transactionLog *TransactionLog, accounts []Account) bool { shifted := false for _, date := range transactionLog.dates() { if _, ok := transactionLog.Transactions[date]; ok { logDay := transactionLog.Transactions[date] sortedCharges := make([]*Transaction, 0) for _, charge := range logDay.Transactions { sortedCharges = append(sortedCharges, charge) } sortTransactions(sortedCharges) startCount := len(sortedCharges) endCount := startCount logDay.Executed = make([]*Transaction, 0) credits := make([]*Transaction, 0) late := make([]*Transaction, 0) charges := make([]*Transaction, 0) logDay.StartingBalance = make([]Account, len(accounts)) copy(logDay.StartingBalance, accounts) for _, charge := range sortedCharges { if charge.Amount > 0 { credits = append(credits, charge) } else if charge.Late { late = append(late, charge) } else { charges = append(charges, charge) } } for _, charge := range credits { if executeTransaction(transactionLog, accounts, charge) { logDay.Executed = append(logDay.Executed, charge) } else { endCount-- } } for _, charge := range late { if executeTransaction(transactionLog, accounts, charge) { logDay.Executed = append(logDay.Executed, charge) } else { endCount-- } } for _, charge := range charges { if executeTransaction(transactionLog, accounts, charge) { logDay.Executed = append(logDay.Executed, charge) } else { endCount-- } } logDay.EndingBalance = make([]Account, len(accounts)) copy(logDay.EndingBalance, accounts) if len(logDay.Transactions) == 0 { delete(transactionLog.Transactions, date) } if endCount != startCount { 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.Executed { 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]) dates := make([]time.Time, 0) if err != nil { log.Fatal(err) } var configYaml ConfigYaml transactionLog := TransactionLog{ Transactions: make(map[time.Time]*LogDay), } 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) current := transactionLog.StartDate for current.Before(transactionLog.EndDate.AddDate(0, 0, 7)) { dates = append(dates, current) current = current.AddDate(0, 0, 1) } startingBalance := make([]Account, len(config.Accounts)) shifted := true i := 0 for shifted { copy(startingBalance, config.Accounts) shifted = false shifted = processTransactions(&transactionLog, startingBalance) i++ } for _, date := range transactionLog.dates() { logDay := transactionLog.Transactions[date] printOutput(*logDay) } }