Files
cashflow/main.go

735 lines
17 KiB
Go

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"
)
const (
maxWidth = 100
)
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 (tl *TransactionLog) nextLogDay(currentDate time.Time) *LogDay {
var next time.Time
for i, date := range tl.dates() {
j := i + 1
if j >= len(tl.dates()) {
next = tl.dates()[i]
break
}
if date == currentDate {
next = tl.dates()[j]
break
}
}
return tl.LogDays[next]
}
func (tl *TransactionLog) previousLogDay(currentDate time.Time) *LogDay {
var previous time.Time
for _, date := range tl.dates() {
if date == currentDate {
break
}
previous = date
}
return tl.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
}
date = stripTime(date)
if transactionYaml.Shift == "" {
transactionYaml.Shift = "late"
}
delayable, err := parseDelayable(transactionYaml.Delayable)
if err != nil {
return nil, err
}
transaction := &Transaction{
Amount: transactionYaml.Amount,
Date: stripTime(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)
logrus.Debugf("Parsed Transaction: %s", transaction.Name)
}
for _, transaction := range configYaml.Transactions {
newTransaction, err := parseTransaction(transaction)
if err != nil {
logrus.Error(err)
}
config.Transactions = append(config.Transactions, newTransaction)
logrus.Debugf("Parsed Transaction: %s", transaction.Name)
}
return config
}
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 {
parsedDate, err := time.Parse("2006-1-2", dateString)
if err != nil {
return time.Time{}, err
}
return parsedDate, err
}
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
logrus.Debugf("Adding transaction %s (%s | %f) to %s",
t.ID, t.Name, t.Amount, t.Date.String())
}
}
func (tl *TransactionLog) addDay(date time.Time) {
if _, ok := tl.LogDays[date]; !ok {
logrus.Debugf("Adding Log Day %s", stripTime(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 isWeekend(transaction.Date) {
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 transactionLog.previousLogDay(transaction.Date).Date
}
return transactionLog.nextLogDay(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()
}
if isWeekend(newDate) {
newDate = newDate.AddDate(0, 0, 1)
}
tl.addDay(newDate)
delete(tl.LogDays[transaction.Date].Transactions, transaction.ID)
transaction.Date = newDate
tl.LogDays[newDate].Transactions[transaction.ID] = transaction
transaction.Delayed = true
}
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)
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 currentTZ() *time.Location {
return time.Now().Local().Location()
}
func firstOfMonth(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, currentTZ())
}
func lastOfMonth(t time.Time) time.Time {
return t.AddDate(0, 1, -t.Day())
}
func stripTime(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, currentTZ())
}
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.Fatal("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)
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)
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) {
logrus.Debugf("Shifted due to: %s", transaction.Name)
shifted = true
}
}
return shifted
}
func getTerminalWidth() int {
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, " ")
width, err := strconv.Atoi(sttyOut[1])
if err != nil {
logrus.Fatal(err)
}
return width
}
func printWithMaxWidth(maxWidth int, suffix string, strs ...string) {
total := 0
whiteSpace := regexp.MustCompile(`^\s+$`)
for _, str := range strs {
line := ""
if whiteSpace.MatchString(str) {
fmt.Print(str)
total += len(str)
continue
}
words := strings.Fields(str)
for _, word := range words {
total += len(word)
if len(line)+len(word)+1 >= maxWidth {
fmt.Println(line)
line = ""
}
if line == "" {
line = word
} else {
line = line + " " + word
total += 1
}
}
fmt.Print(line)
}
repeatCount := maxWidth - total - len(suffix)
if repeatCount < 0 {
repeatCount = 0
}
terminate := strings.Repeat(" ", repeatCount) + suffix
fmt.Println(terminate)
}
func printTransactionLine(amount float64, name, prefix, suffix string, maxWidth int) {
segment := fmt.Sprintf("%s$%.2f", prefix, math.Abs(amount))
spacer := strings.Repeat(" ", (maxWidth/4)-len(segment))
printWithMaxWidth(maxWidth, suffix, segment, spacer, fmt.Sprintf("| %s", name))
}
func printOutput(logDay LogDay) {
fullWidth := getTerminalWidth()
if fullWidth > maxWidth {
fullWidth = maxWidth
}
border := strings.Repeat("=", fullWidth)
divider := strings.Repeat("-", fullWidth-1) + "|"
fmt.Println(border)
printWithMaxWidth(fullWidth, "|", logDay.Date.Format("Jan 02 2006"))
for _, v := range logDay.StartingBalance {
printTransactionLine(v.Balance, v.Name, "", "|", fullWidth)
}
for _, charge := range logDay.transactions() {
prefix := "-"
suffix := "|"
if charge.Amount > 0 {
prefix = "+"
}
if charge.Delayed {
suffix = fmt.Sprintf("(%s - %s) |", "Late", charge.OriginDate.Format("Jan 02 2006"))
} else if charge.Date != charge.OriginDate {
suffix = fmt.Sprintf("(%s - %s) |", "Weekend", charge.OriginDate.Format("Jan 02 2006"))
}
printTransactionLine(charge.Amount, charge.Name, prefix, suffix, fullWidth)
}
fmt.Println(divider)
for _, v := range logDay.EndingBalance {
prefix := ""
if v.Balance < 0 {
prefix = "-"
}
printTransactionLine(v.Balance, v.Name, prefix, "|", fullWidth)
}
fmt.Printf("%s\n\n", border)
}
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)
}
}