Files
cashflow/main.go

614 lines
17 KiB
Go

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, transaction.Frequency)
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, transaction.Frequency)
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 getFirstDate(date time.Time, frequency string) time.Time {
var firstDate time.Time
current := time.Date(time.Now().Year(),
time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC)
if date.Before(current) && frequency != "" {
firstDate = calculateFrequency(frequency, date, date.AddDate(0, 1, 1), false)[0]
} else {
firstDate = date.AddDate(0, 1, 0)
}
return firstDate
}
func parseDate(date, frequency 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 = getFirstDate(formalDate, frequency)
}
} 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 = getFirstDate(formalDate, frequency)
}
}
}
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.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.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]
logrus.Debugf("Processing %s (%f)", transaction.Name, transaction.Amount)
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() {
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
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 {
shifted = false
shifted = processTransactionLog(&transactionLog, config.Accounts)
}
for _, date := range transactionLog.dates() {
logDay := transactionLog.LogDays[date]
printOutput(*logDay)
}
}