Compare commits

...

10 Commits

4 changed files with 549 additions and 312 deletions

50
example.yaml Normal file
View File

@@ -0,0 +1,50 @@
end: 2025-05-01
accounts:
- name: Capital One
balance: 4500
minimum: 500
credit:
- name: Amazon Store Card
amount: -438.35
date: 2023-03-28
- name: Amex Platinum
amount: -3263.42
date: 2023-04-19
- name: Apple Card
amount: -80.07
date: 2023-04-03
- name: Chase
amount: -0
date: 2023-03-16
- name: Citi
amount: -2307.84
date: 2023-04-06
- name: Discover
amount: -197.90
date: 2023-04-03
transactions:
- name: Bills
amount: -1690.00
date: first
frequency: monthly
delayable: false
- name: Car Insurance
amount: -140.92
date: first
frequency: monthly
delayable: false
- name: Savings
amount: -1000
date: 14
frequency: monthly
delayable: false
- name: Brokerage
amount: -1000
date: 2023-04-30
frequency: monthly
delayable: true
- name: Paycheck
amount: 3000
date: 15
frequency: biweekly
shift: early

10
go.mod
View File

@@ -1,13 +1,11 @@
module github.com/samuelattwood/cashflow module github.com/samuelattwood/cashflow
go 1.19 go 1.24
require ( require (
github.com/sirupsen/logrus v1.9.0 github.com/google/uuid v1.6.0
github.com/sirupsen/logrus v1.9.3
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require golang.org/x/sys v0.32.0 // indirect
github.com/google/uuid v1.3.0 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
)

11
go.sum
View File

@@ -1,17 +1,18 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

716
main.go
View File

@@ -5,7 +5,10 @@ import (
"log" "log"
"math" "math"
"os" "os"
"os/exec"
"regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -15,18 +18,48 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const (
maxWidth = 100
DelayWeekend DelayReason = "Weekend"
DelayInsufficient DelayReason = "Insufficient Funds"
)
type DelayReason string
type timeSlice []time.Time type timeSlice []time.Time
func (s timeSlice) Len() int { return len(s) } 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) 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] } 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 { type Account struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Balance float64 `yaml:"balance"` Balance float64 `yaml:"balance"`
Minimum float64 `yaml:"minimum"` Minimum float64 `yaml:"minimum"`
} }
type AccountYaml struct {
Name string `yaml:"name"`
Balance string `yaml:"balance"`
Minimum string `yaml:"minimum"`
}
type Charge struct { type Charge struct {
Name string Name string
Late bool Late bool
@@ -36,14 +69,13 @@ type Charge struct {
type Config struct { type Config struct {
Accounts []Account Accounts []Account
Credit []Transaction Transactions transactionList
Transactions []Transaction
} }
type ConfigYaml struct { type ConfigYaml struct {
Start string Start string
End string End string
Accounts []Account Accounts []AccountYaml
Credit []TransactionYaml Credit []TransactionYaml
Transactions []TransactionYaml Transactions []TransactionYaml
} }
@@ -53,19 +85,19 @@ type Transaction struct {
Date time.Time Date time.Time
Delayable bool Delayable bool
Frequency string Frequency string
HardDate bool
ID string ID string
Late bool Delayed bool
DelayedReason DelayReason
Name string Name string
OrigDate time.Time OriginDate time.Time
Shift string Shift string
} }
type TransactionYaml struct { type TransactionYaml struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Amount float64 `yaml:"amount"` Amount string `yaml:"amount"`
Date string `yaml:"date"` Date string `yaml:"date"`
Delayable *bool `yaml:"delayable"` Delayable string `yaml:"delayable"`
Frequency string `yaml:"frequency,omitempty"` Frequency string `yaml:"frequency,omitempty"`
Shift string `yaml:"shift"` Shift string `yaml:"shift"`
} }
@@ -85,9 +117,9 @@ type LogDay struct {
Transactions map[string]*Transaction Transactions map[string]*Transaction
} }
func (t TransactionLog) dates() timeSlice { func (tl *TransactionLog) dates() timeSlice {
dateSlice := make(timeSlice, 0) dateSlice := make(timeSlice, 0)
for date, _ := range t.LogDays { for date := range tl.LogDays {
dateSlice = append(dateSlice, date) dateSlice = append(dateSlice, date)
} }
@@ -96,290 +128,399 @@ func (t TransactionLog) dates() timeSlice {
return dateSlice return dateSlice
} }
func (t TransactionLog) first() time.Time { func (t *TransactionLog) first() time.Time {
return t.dates()[0] return t.dates()[0]
} }
func (t TransactionLog) last() time.Time { func (t *TransactionLog) last() time.Time {
return t.dates()[t.dates().Len()-1] return t.dates()[t.dates().Len()-1]
} }
func (logDay LogDay) transactions() []*Transaction { func (logDay *LogDay) transactions() transactionList {
transactions := make([]*Transaction, 0) transactions := transactionList{}
for _, t := range logDay.Transactions { for _, t := range logDay.Transactions {
transactions = append(transactions, t) transactions = append(transactions, t)
} }
sortTransactions(transactions) sort.Sort(transactions)
return transactions return transactions
} }
func nextLogDay(transactionLog *TransactionLog, currentDate time.Time) *LogDay { func (tl *TransactionLog) nextLogDay(currentDate time.Time) *LogDay {
var next time.Time var next time.Time
for i, date := range transactionLog.dates() { for i, date := range tl.dates() {
j := i + 1
if j >= len(tl.dates()) {
next = tl.dates()[i]
break
}
if date == currentDate { if date == currentDate {
next = transactionLog.dates()[i+1] next = tl.dates()[j]
break break
} }
} }
return transactionLog.LogDays[next] return tl.LogDays[next]
} }
func previousLogDay(transactionLog *TransactionLog, currentDate time.Time) *LogDay { func (tl *TransactionLog) previousLogDay(currentDate time.Time) *LogDay {
var previous time.Time var previous time.Time
for _, date := range transactionLog.dates() { for _, date := range tl.dates() {
if date == currentDate { if date == currentDate {
break break
} }
previous = date previous = date
} }
return transactionLog.LogDays[previous] return tl.LogDays[previous]
} }
func parseConfig(configYaml *ConfigYaml) Config { func isWeekend(date time.Time) bool {
config := Config{ if date.Weekday() == 0 || date.Weekday() == 6 {
Accounts: configYaml.Accounts, return true
Credit: make([]Transaction, 0), }
Transactions: make([]Transaction, 0),
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
}
amountStr := strings.ReplaceAll(transactionYaml.Amount, "$", "")
amountStr = strings.ReplaceAll(amountStr, ",", "")
amount, err := strconv.ParseFloat(amountStr, 32)
if err != nil {
return nil, err
}
transaction := &Transaction{
Amount: 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 {
accounts := make([]Account, 0)
for _, account := range configYaml.Accounts {
balanceStr := strings.ReplaceAll(account.Balance, "$", "")
balanceStr = strings.ReplaceAll(balanceStr, ",", "")
balance, err := strconv.ParseFloat(balanceStr, 32)
if err != nil {
logrus.Fatal(err)
}
var minimum float64 = 0
if account.Minimum != "" {
minimumStr := strings.ReplaceAll(account.Minimum, "$", "")
minimumStr = strings.ReplaceAll(minimumStr, ",", "")
minimum, err = strconv.ParseFloat(minimumStr, 32)
if err != nil {
logrus.Fatal(err)
}
}
accounts = append(accounts, Account{
Name: account.Name,
Balance: balance,
Minimum: minimum,
})
}
config := &Config{
Accounts: accounts,
Transactions: make([]*Transaction, 0),
} }
for _, transaction := range configYaml.Credit { for _, transaction := range configYaml.Credit {
formalDate, hardDate := parseDate(transaction.Date) newTransaction, err := parseTransaction(transaction)
if transaction.Delayable == nil { if err != nil {
transaction.Delayable = &[]bool{true}[0] logrus.Error(err)
} }
if transaction.Shift == "" { config.Transactions = append(config.Transactions, newTransaction)
transaction.Shift = "late" logrus.Debugf("Parsed Transaction: %s", transaction.Name)
}
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 { for _, transaction := range configYaml.Transactions {
formalDate, hardDate := parseDate(transaction.Date) newTransaction, err := parseTransaction(transaction)
if transaction.Delayable == nil { if err != nil {
transaction.Delayable = &[]bool{true}[0] logrus.Error(err)
}
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) config.Transactions = append(config.Transactions, newTransaction)
logrus.Debugf("Parsed Transaction: %s", transaction.Name)
} }
return config return config
} }
func parseDate(date string) (time.Time, bool) { func parseDate(dateString string) (time.Time, error) {
hardDate := true currentDay := currentDay()
base := time.Now().AddDate(0, 0, -time.Now().Day()+1)
current := time.Date(base.Year(), regMatch, err := regexp.MatchString("^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$", dateString)
base.Month(), base.Day(), 0, 0, 0, 0, time.UTC)
dateString := string(date)
formalDate, err := time.Parse("2006-1-2", dateString)
if err != nil { if err != nil {
hardDate = false return time.Time{}, err
if date == "first" {
formalDate = current
if formalDate.Before(time.Now()) {
formalDate = formalDate.AddDate(0, 1, 0)
} }
} else if date == "last" {
formalDate = lastOfMonth(current) 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 { } else {
dateString = fmt.Sprintf("%d-%d-%s", return firstOfMonth(currentDay).AddDate(0, 1, 0), nil
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) { if dateString == "last" {
for _, transaction := range transactions { return lastOfMonth(currentDay), nil
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) { 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 { for _, transaction := range transactions {
if math.Abs(transaction.Amount) != 0 { if math.Abs(transaction.Amount) != 0 {
addTransaction(transactionLog, &transaction) transactionLog.addTransaction(transaction)
} }
} }
} }
func sortTransactions(transactions []*Transaction) { func (tl *TransactionLog) addTransaction(transaction *Transaction) {
sort.Slice(transactions, func(i, j int) bool { if transaction.Frequency == "" {
if transactions[i].Amount < 0 && transactions[j].Amount < 0 { tl.addDay(transaction.Date)
return math.Abs(transactions[i].Amount) > math.Abs(transactions[j].Amount) tl.LogDays[transaction.Date].Transactions[transaction.ID] = transaction
} else { tl.Transactions[transaction.ID] = transaction
return transactions[i].Amount > transactions[j].Amount
} logrus.Debugf("Adding transaction %s (%s | %f) to %s",
}) transaction.ID, transaction.Name, transaction.Amount, transaction.Date.String())
return
} }
func addTransaction(transactionLog *TransactionLog, transaction *Transaction) { dateList := calculateFrequency(transaction.Frequency,
var dateList []time.Time transaction.Date, tl.EndDate, transaction.Delayable)
if transaction.Frequency != "" {
dateList = calculateFrequency(transaction.Frequency,
transaction.Date, transactionLog.EndDate, transaction.HardDate)
} else {
dateList = []time.Time{transaction.Date}
}
for _, date := range dateList { for _, date := range dateList {
if _, ok := transactionLog.LogDays[date]; !ok { tl.addDay(date)
logDay := LogDay{ t := &Transaction{
Date: date,
Transactions: make(map[string]*Transaction),
}
transactionLog.LogDays[date] = &logDay
}
t := Transaction{
Amount: transaction.Amount, Amount: transaction.Amount,
Date: date, Date: date,
Delayable: transaction.Delayable, Delayable: transaction.Delayable,
Frequency: "", Frequency: "",
HardDate: false,
ID: uuid.NewString(), ID: uuid.NewString(),
Late: false, Delayed: false,
Name: transaction.Name, Name: transaction.Name,
OrigDate: date, OriginDate: date,
Shift: transaction.Shift, Shift: transaction.Shift,
} }
transactionLog.LogDays[date].Transactions[t.ID] = &t tl.LogDays[date].Transactions[t.ID] = t
transactionLog.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 { func calculateShift(transactionLog TransactionLog, transaction Transaction) time.Time {
if isWeekend(transaction.Date) {
add := 1
newDate := transaction.Date newDate := transaction.Date
if transaction.Late {
if transaction.Shift == "early" { if transaction.Shift == "early" {
newDate = newDate.AddDate(0, 0, -1) add = -1
} else {
newDate = newDate.AddDate(0, 0, 1)
}
} }
for newDate.Weekday() == 0 || newDate.Weekday() == 6 { for isWeekend(newDate) {
if transaction.Shift == "early" { newDate = newDate.AddDate(0, 0, add)
newDate = newDate.AddDate(0, 0, -1) }
} else {
newDate = newDate.AddDate(0, 0, 1) if newDate.Before(transactionLog.first()) || newDate.After(transactionLog.last()) {
for isWeekend(newDate) {
newDate = newDate.AddDate(0, 0, add)
} }
} }
return newDate return newDate
} }
func delayTransaction(transactionLog *TransactionLog, transaction *Transaction) { if transaction.Shift == "early" {
if transaction.Date.Weekday() != 0 && transaction.Date.Weekday() != 6 { return transactionLog.previousLogDay(transaction.Date).Date
transaction.Late = true
} }
newDate := calculateShift(*transactionLog, *transaction) return transactionLog.nextLogDay(transaction.Date).Date
if newDate.Before(transactionLog.first()) || newDate.After(transactionLog.last()) || newDate == transaction.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 transaction.Delayable = false
return
} }
if _, ok := transactionLog.LogDays[newDate]; !ok { if newDate.After(tl.last()) {
logDay := LogDay{ newDate = tl.last()
Date: newDate,
Transactions: map[string]*Transaction{},
} }
transactionLog.LogDays[newDate] = &logDay for isWeekend(newDate) {
newDate = newDate.AddDate(0, 0, 1)
} }
delete(transactionLog.LogDays[transaction.Date].Transactions, transaction.ID) tl.addDay(newDate)
delete(tl.LogDays[transaction.Date].Transactions, transaction.ID)
transaction.Date = newDate transaction.Date = newDate
transactionLog.LogDays[newDate].Transactions[transaction.ID] = transaction tl.LogDays[newDate].Transactions[transaction.ID] = transaction
transaction.Delayed = true
if transaction.DelayedReason == "" {
transaction.DelayedReason = DelayInsufficient
}
} }
func executeTransaction(transactionLog *TransactionLog, transaction *Transaction) bool { func (tl *TransactionLog) executeTransaction(transaction *Transaction) bool {
logDay := transactionLog.LogDays[transaction.Date] logDay := tl.LogDays[transaction.Date]
accounts := make([]Account, len(logDay.EndingBalance)) accounts := make([]Account, len(logDay.EndingBalance))
copy(accounts, logDay.EndingBalance) copy(accounts, logDay.EndingBalance)
executed := false
finalDate := transactionLog.EndDate if isWeekend(transaction.Date) && transaction.Delayable {
if transaction.Date.Weekday() == 0 || transaction.Date.Weekday() == 6 && transaction.Delayable {
logrus.Debugf("Delaying %s due to weekend scheduling\n", transaction.Name) logrus.Debugf("Delaying %s due to weekend scheduling\n", transaction.Name)
delayTransaction(transactionLog, transaction) tl.delayTransaction(transaction)
} else if transaction.Amount > 0 { transaction.DelayedReason = DelayWeekend
logrus.Debugf("Executing Credit: %s %s (%f)\n", logDay.Date, transaction.Name, transaction.Amount)
accounts[0].Balance = accounts[0].Balance + transaction.Amount return false
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
} }
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)) logDay.EndingBalance = make([]Account, len(accounts))
copy(logDay.EndingBalance, accounts) copy(logDay.EndingBalance, accounts)
return executed 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 { func firstOfMonth(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, currentTZ())
} }
func lastOfMonth(t time.Time) time.Time { func lastOfMonth(t time.Time) time.Time {
return time.Date(t.Year(), t.Month()+1, 0, 0, 0, 0, 0, time.UTC) 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 { func calculateFrequency(frequency string, startDate time.Time, endDate time.Time, hardDate bool) []time.Time {
base := time.Now() current := currentDay()
current := time.Date(base.Year(),
base.Month(), base.Day(), 0, 0, 0, 0, time.UTC)
initDate := startDate initDate := startDate
dateList := make([]time.Time, 0) dateList := make([]time.Time, 0)
for initDate.Before(endDate.AddDate(0, 0, 1)) { for initDate.Before(endDate.AddDate(0, 0, 1)) {
if !initDate.Before(current) || hardDate { if !initDate.Before(current) || hardDate {
dateList = append(dateList, initDate) dateList = append(dateList, initDate)
@@ -397,9 +538,10 @@ func calculateFrequency(frequency string, startDate time.Time, endDate time.Time
initDate = initDate.AddDate(0, 1, 0) initDate = initDate.AddDate(0, 1, 0)
} }
} else { } else {
break logrus.Fatal("Invalid Frequency")
} }
} }
return dateList return dateList
} }
@@ -413,7 +555,7 @@ func categorizeTransactions(transactions map[string]*Transaction) map[string][]s
for _, t := range transactions { for _, t := range transactions {
if t.Amount > 0 { if t.Amount > 0 {
categorized["credits"] = append(categorized["credits"], t.ID) categorized["credits"] = append(categorized["credits"], t.ID)
} else if t.Late { } else if t.Delayed {
categorized["late"] = append(categorized["late"], t.ID) categorized["late"] = append(categorized["late"], t.ID)
} else { } else {
categorized["standard"] = append(categorized["standard"], t.ID) categorized["standard"] = append(categorized["standard"], t.ID)
@@ -423,70 +565,54 @@ func categorizeTransactions(transactions map[string]*Transaction) map[string][]s
return categorized return categorized
} }
func processTransactionLog(transactionLog *TransactionLog, accounts []Account) bool { func (tl *TransactionLog) processTransactions(accounts []Account) bool {
shifted := false shifted := false
endBalance := accounts endBalance := accounts
logrus.Debugf("\n\nITERATION %d\n\n", transactionLog.Iteration)
if transactionLog.Iteration == 0 { logrus.Debugf("\n\nITERATION %d\n\n", tl.Iteration)
for _, date := range transactionLog.dates() { for _, date := range tl.dates() {
logDay := transactionLog.LogDays[date] logrus.Debugf("Processing Log Day %s", date.String())
logDay := tl.LogDays[date]
logDay.StartingBalance = make([]Account, len(endBalance)) logDay.StartingBalance = make([]Account, len(endBalance))
logDay.EndingBalance = make([]Account, len(endBalance)) logDay.EndingBalance = make([]Account, len(endBalance))
copy(logDay.StartingBalance, endBalance) copy(logDay.StartingBalance, endBalance)
copy(logDay.EndingBalance, endBalance) copy(logDay.EndingBalance, endBalance)
categorized := categorizeTransactions(logDay.Transactions) categorized := categorizeTransactions(logDay.Transactions)
logrus.Debugf("---\nStarting Balance %s: %f\n", date, logDay.StartingBalance[0].Balance) if processTransactionsByID(tl, categorized["credits"]) {
logrus.Debugln(categorized)
if processTransactions(transactionLog, categorized["credits"]) {
shifted = true shifted = true
} }
logrus.Debugf("Starting Balance %s: %f\n", date, logDay.StartingBalance[0].Balance) if processTransactionsByID(tl, categorized["late"]) {
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 shifted = true
} }
if processTransactions(transactionLog, categorized["late"]) { if processTransactionsByID(tl, categorized["standard"]) {
shifted = true shifted = true
} }
if processTransactions(transactionLog, categorized["standard"]) { logrus.Debugf("Starting Balance %s: %f", date, logDay.StartingBalance[0].Balance)
shifted = true logrus.Debugf("Ending Balance %s: %f", date, logDay.EndingBalance[0].Balance)
}
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 endBalance = logDay.EndingBalance
if shifted { if shifted {
break break
} }
if len(logDay.Transactions) == 0 { if len(logDay.Transactions) == 0 {
delete(transactionLog.LogDays, logDay.Date) tl.deleteDay(date)
}
} }
} }
transactionLog.Iteration++ tl.Iteration++
return shifted return shifted
} }
func processTransactions(transactionLog *TransactionLog, transactionIDs []string) bool { func processTransactionsByID(transactionLog *TransactionLog, transactionIDs []string) bool {
shifted := false shifted := false
for _, id := range transactionIDs { for _, id := range transactionIDs {
transaction, ok := transactionLog.Transactions[id] transaction, ok := transactionLog.Transactions[id]
if !ok { if !ok {
logrus.Fatalf("Transaction ID: %s not found in log\n", id) logrus.Fatalf("Transaction ID: %s not found in log\n", id)
} }
if !executeTransaction(transactionLog, transaction) { 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 shifted = true
} }
} }
@@ -494,76 +620,129 @@ func processTransactions(transactionLog *TransactionLog, transactionIDs []string
return shifted return shifted
} }
func printOutput(logDay LogDay) { func getTerminalWidth() int {
delim := strings.Repeat("=", 67) cmd := exec.Command("stty", "size")
divider := delim cmd.Stdin = os.Stdin
delim2 := strings.Repeat("-", 66) out, err := cmd.Output()
divider2 := delim2 + "|" 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"))
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 { for _, v := range logDay.StartingBalance {
spacer := strings.Repeat(" ", 19-len(fmt.Sprintf("%.2f", v.Balance))) printTransactionLine(v.Balance, v.Name, "", "|", fullWidth)
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() { for _, charge := range logDay.transactions() {
spacer := strings.Repeat(" ", 18-len(fmt.Sprintf("%.2f", math.Abs(charge.Amount)))) prefix := "-"
outString := "" suffix := "|"
if charge.Amount <= 0 { if charge.Amount > 0 {
outString = "-" prefix = "+"
} else {
outString = "+"
} }
outString = fmt.Sprintf("%s$%.2f%s| %s", outString, math.Abs(charge.Amount), spacer, charge.Name)
if charge.Late { if charge.DelayedReason != "" {
late_tag := fmt.Sprintf("(%s - %s)", "Late", charge.OrigDate.Format("Jan 02 2006")) suffix = fmt.Sprintf("(%s: %s - %s) |", "Delayed", charge.DelayedReason, charge.OriginDate.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)
} }
printTransactionLine(charge.Amount, charge.Name, prefix, suffix, fullWidth)
} }
fmt.Println(divider2)
fmt.Println(divider)
for _, v := range logDay.EndingBalance { for _, v := range logDay.EndingBalance {
spacer := strings.Repeat(" ", 19-len(fmt.Sprintf("%.2f", v.Balance))) prefix := ""
outString := fmt.Sprintf("$%.2f%s| %s", v.Balance, spacer, v.Name) if v.Balance < 0 {
rear_spacer := strings.Repeat(" ", 65-len(outString)) prefix = "-"
fmt.Printf("%s%s |\n", outString, rear_spacer)
} }
fmt.Println(divider + "\n") printTransactionLine(v.Balance, v.Name, prefix, "|", fullWidth)
}
fmt.Printf("%s\n\n", border)
} }
func main() { func main() {
yfile, err := os.ReadFile(os.Args[1]) if len(os.Getenv("DEBUG")) > 0 {
logrus.SetLevel(logrus.DebugLevel)
}
yfile, err := os.ReadFile(os.Args[1])
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
var configYaml ConfigYaml var configYaml ConfigYaml
transactionLog := TransactionLog{
LogDays: make(map[time.Time]*LogDay),
Transactions: make(map[string]*Transaction),
}
err = yaml.Unmarshal(yfile, &configYaml) err = yaml.Unmarshal(yfile, &configYaml)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
config := parseConfig(&configYaml) transactionLog := &TransactionLog{
preFetchDates(config.Credit, &transactionLog) LogDays: make(map[time.Time]*LogDay),
preFetchDates(config.Transactions, &transactionLog) Transactions: make(map[string]*Transaction),
}
if configYaml.Start != "" { if configYaml.Start != "" {
transactionLog.StartDate, err = time.Parse("2006-1-2", configYaml.Start) transactionLog.StartDate, err = time.Parse("2006-1-2", configYaml.Start)
@@ -579,15 +758,24 @@ func main() {
} }
} }
collectTransactions(config.Credit, &transactionLog) config := parseConfig(configYaml)
collectTransactions(config.Transactions, &transactionLog)
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 shifted := true
for shifted { for shifted {
//startingBalance := make([]Account, len(config.Accounts))
//copy(startingBalance, config.Accounts)
shifted = false shifted = false
shifted = processTransactionLog(&transactionLog, config.Accounts) shifted = transactionLog.processTransactions(config.Accounts)
} }
for _, date := range transactionLog.dates() { for _, date := range transactionLog.dates() {