557 lines
15 KiB
Go
557 lines
15 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 {
|
|
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)
|
|
}
|
|
|
|
}
|