diff --git a/config.go b/config.go index 9d79a1c..1744dfa 100644 --- a/config.go +++ b/config.go @@ -4,19 +4,21 @@ import ( "fmt" "os" + "github.com/google/uuid" "gopkg.in/yaml.v3" ) type config struct { - YnabToken string `yaml:"ynabToken"` - BudgetId string `yaml:"budgetId"` - SplitAccountIds []string `yaml:"splitAccountIds"` + YnabToken string `yaml:"ynabToken"` + BudgetId uuid.UUID `yaml:"budgetId"` + SplitCategoryId uuid.UUID `yaml:"splitCategoryId"` + SplitAccountIds []uuid.UUID `yaml:"splitAccountIds"` } func LoadConfig() (*config, error) { f, err := os.Open("config.yml") if err != nil { - return nil, fmt.Errorf("Error opening config file. Did you create a config.yml?\n\t%w\n", err) + return nil, fmt.Errorf("error opening config file. Did you create a config.yml?\n\t%w\n", err) } defer f.Close() @@ -25,17 +27,31 @@ func LoadConfig() (*config, error) { err = decoder.Decode(&cfg) if err != nil { return nil, fmt.Errorf( - "Error decoding config file. Make sure it has the correct format. See config.yml.example for an example\n\t%w\n", + "error decoding config file. Make sure it has the correct format. See config.yml.example for an example\n\t%w\n", err, ) } + missingFields := make([]string, 0) + fmt.Println(cfg.SplitCategoryId) if len(cfg.YnabToken) == 0 { - return nil, fmt.Errorf("Error: config file is missing ynabToken") + missingFields = append(missingFields, "ynabToken") + } + if cfg.BudgetId == uuid.Nil { + missingFields = append(missingFields, "budgetId") + } + if cfg.SplitCategoryId == uuid.Nil { + missingFields = append(missingFields, "splitCategoryId") + } + + if len(missingFields) > 0 { + return nil, fmt.Errorf("missing required fields in config file: %v", missingFields) } - if len(cfg.BudgetId) == 0 { - return nil, fmt.Errorf("Error: config file is missing budgetId") + for _, id := range cfg.SplitAccountIds { + if id == uuid.Nil { + return nil, fmt.Errorf("invalid or mal-formatted UUID in splitAccountIds config") + } } return &cfg, nil diff --git a/go.mod b/go.mod index 57a32ab..6b21e2a 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,13 @@ module github.com/samshadwell/split-ynab go 1.21 require ( + github.com/google/uuid v1.4.0 github.com/oapi-codegen/runtime v1.1.0 + go.uber.org/zap v1.26.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/google/uuid v1.4.0 // indirect + go.uber.org/multierr v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index c1b836a..f3f4eaa 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,12 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index d006677..a7f9906 100644 --- a/main.go +++ b/main.go @@ -2,59 +2,208 @@ package main import ( "context" + "errors" "fmt" + "math/rand" "net/http" "os" + "slices" + "time" + "github.com/google/uuid" + "github.com/oapi-codegen/runtime/types" "github.com/samshadwell/split-ynab/storage" "github.com/samshadwell/split-ynab/ynab" + "go.uber.org/zap" ) const ynabServer = "https://api.ynab.com/v1" func main() { + logger, err := zap.NewDevelopment() + if err != nil { + fmt.Fprintf(os.Stderr, "error while creating logger: %v\n", err) + os.Exit(1) + } + defer func() { + err = errors.Join(err, logger.Sync()) + }() + config, err := LoadConfig() if err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) + logger.Error("failed to load config", zap.Error(err)) os.Exit(1) } - client, err := constructClient(config.YnabToken) + client, err := constructYnabClient(config.YnabToken) if err != nil { - fmt.Fprintf(os.Stderr, "Error while constructing client: %v\n", err) + logger.Error("failed to construct client", zap.Error(err)) os.Exit(1) } storage := storage.NewLocalStorageAdapter() - // Ignore error return, we can use the default value of 0 in case of error. + // In case of error we'll process more transactions than we need to, but don't need to exit. serverKnowledge, _ := storage.GetLastServerKnowledge(config.BudgetId) - transactionsResponse, err := client.GetTransactionsByAccountWithResponse( - context.TODO(), - config.BudgetId, - config.SplitAccountIds[0], - &ynab.GetTransactionsByAccountParams{ - LastKnowledgeOfServer: &serverKnowledge, - }, - ) + transactionsResponse, err := fetchTransactions(logger, config.BudgetId, serverKnowledge, client) + if err != nil { + logger.Error("failed to fetch transactions from YNAB", zap.Error(err)) + os.Exit(1) + } + + updatedServerKnowledge := transactionsResponse.JSON200.Data.ServerKnowledge + filteredTransactions := filterTransactions(transactionsResponse.JSON200.Data.Transactions, config) + logger.Info("finished filtering transactions", zap.Int("count", len(filteredTransactions))) + + if len(filteredTransactions) == 0 { + logger.Info("no transactions to update, exiting") + // Ignore errors since we're exiting anyway + _ = storage.SetLastServerKnowledge(config.BudgetId, updatedServerKnowledge) + os.Exit(0) + } + + updatedTransactions := splitTransactions(filteredTransactions, config) + + err = updateTransactions(logger, config.BudgetId, updatedTransactions, client) if err != nil { - fmt.Fprintf(os.Stderr, "Error while getting transactions: %v\n", err) + logger.Error("failed to update transactions in YNAB", zap.Error(err)) os.Exit(1) } - newKnowledge := transactionsResponse.JSON200.Data.ServerKnowledge - fmt.Printf("Transaction count: %v, New server knowledge: %v\n", len(transactionsResponse.JSON200.Data.Transactions), newKnowledge) - err = storage.SetLastServerKnowledge(config.BudgetId, newKnowledge) + logger.Info("setting server knowledge", zap.Int64("serverKnowledge", updatedServerKnowledge)) + err = storage.SetLastServerKnowledge(config.BudgetId, updatedServerKnowledge) if err != nil { - fmt.Fprintf(os.Stderr, "Error while setting new server knowledge: %v\n", err) + logger.Error("failed to set new server knowledge", zap.Error(err)) os.Exit(1) } + + logger.Info("run complete, program finished successfully") } -func constructClient(authToken string) (*ynab.ClientWithResponses, error) { +func constructYnabClient(authToken string) (*ynab.ClientWithResponses, error) { authRequestEditor := func(ctx context.Context, req *http.Request) error { req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", authToken)) return nil } return ynab.NewClientWithResponses(ynabServer, ynab.WithRequestEditorFn(authRequestEditor)) } + +func fetchTransactions( + logger *zap.Logger, + budgetId uuid.UUID, + serverKnowledge int64, + client *ynab.ClientWithResponses, +) (*ynab.GetTransactionsResponse, error) { + logger.Info("fetching transactions from YNAB", + zap.String("budgetId", budgetId.String()), + zap.Int64("lastKnowledgeOfServer", serverKnowledge), + ) + + transactionParams := ynab.GetTransactionsParams{} + if serverKnowledge == 0 { + // If we don't have any server knowledge, only update transactions from the last 30 days + today := time.Now() + thirtyDaysAgo := today.AddDate(0, 0, -30) + transactionParams.SinceDate = &types.Date{Time: thirtyDaysAgo} + } else { + transactionParams.LastKnowledgeOfServer = &serverKnowledge + } + + transactionsResponse, err := client.GetTransactionsWithResponse( + context.TODO(), + budgetId.String(), + &transactionParams, + ) + if err != nil { + return nil, err + } + statusCode := transactionsResponse.StatusCode() + if statusCode != http.StatusOK { + return nil, fmt.Errorf("non-200 response from YNAB when fetching transactions: %v", statusCode) + } + + logger.Info("successfully fetched transactions from YNAB", + zap.Int("count", len(transactionsResponse.JSON200.Data.Transactions)), + ) + + return transactionsResponse, err +} + +func filterTransactions(transactions []ynab.TransactionDetail, cfg *config) []ynab.TransactionDetail { + var filtered []ynab.TransactionDetail + for _, t := range transactions { + if t.Amount == 0 || t.Cleared == ynab.Reconciled || len(t.Subtransactions) != 0 { + // Skip if zero amount, reconciled, or already split + continue + } + + if slices.Contains(cfg.SplitAccountIds, t.AccountId) { + filtered = append(filtered, t) + continue + } + } + return filtered +} + +func splitTransactions(transactions []ynab.TransactionDetail, cfg *config) []ynab.SaveTransactionWithId { + split := make([]ynab.SaveTransactionWithId, len(transactions)) + for i, t := range transactions { + // Copy the fields we need pointers to + id := t.Id + categoryId := t.CategoryId + + totalCentiUnits := t.Amount / 10 + paidAmount := (totalCentiUnits / 2) * 10 + owedAmount := paidAmount + if paidAmount+owedAmount != t.Amount { + extra := t.Amount - (paidAmount + owedAmount) + // Randomly assign the extra cent to one of the two people + if rand.Intn(2) == 0 { + paidAmount += extra + } else { + owedAmount += extra + } + } + + split[i] = ynab.SaveTransactionWithId{ + Id: &id, + CategoryId: nil, + Subtransactions: &[]ynab.SaveSubTransaction{ + { + Amount: paidAmount, + CategoryId: categoryId, + }, + { + Amount: owedAmount, + CategoryId: &cfg.SplitCategoryId, + }, + }, + } + } + + return split +} + +func updateTransactions( + logger *zap.Logger, + budgetId uuid.UUID, + updatedTransactions []ynab.SaveTransactionWithId, + client *ynab.ClientWithResponses, +) error { + logger.Info("updating transactions in YNAB") + resp, err := client.UpdateTransactionsWithResponse( + context.TODO(), + budgetId.String(), + ynab.UpdateTransactionsJSONRequestBody{ + Transactions: updatedTransactions, + }, + ) + if err != nil { + return err + } + if resp.StatusCode() != http.StatusOK { + return fmt.Errorf("non-200 response from YNAB when updating transactions: %v", resp.StatusCode()) + } + logger.Info("successfully updated transactions in YNAB") + return nil +} diff --git a/storage/file_storage_adapter.go b/storage/file_storage_adapter.go index fba9b55..efcfe72 100644 --- a/storage/file_storage_adapter.go +++ b/storage/file_storage_adapter.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/google/uuid" "gopkg.in/yaml.v3" ) @@ -12,8 +13,8 @@ type localStorageAdapter struct{} const storageFile = "storage.yml" type budgetData struct { - BudgetId string `yaml:"budgetId"` - LastServerKnowledge int64 `yaml:"lastServerKnowledge"` + BudgetId uuid.UUID `yaml:"budgetId"` + LastServerKnowledge int64 `yaml:"lastServerKnowledge"` } // Creates a StorageAdapter which stores data in a yaml file. Intended mostly for prototyping or running in environments @@ -22,8 +23,8 @@ func NewLocalStorageAdapter() StorageAdapter { return &localStorageAdapter{} } -func (l *localStorageAdapter) GetLastServerKnowledge(budgetId string) (int64, error) { - data, err := l.readData(budgetId) +func (l *localStorageAdapter) GetLastServerKnowledge(budgetId uuid.UUID) (int64, error) { + data, err := l.readData() if err != nil { return 0, err } @@ -34,14 +35,14 @@ func (l *localStorageAdapter) GetLastServerKnowledge(budgetId string) (int64, er } } - return 0, fmt.Errorf("No budget found with id %v", budgetId) + return 0, fmt.Errorf("no budget found with id %v", budgetId) } -func (l *localStorageAdapter) SetLastServerKnowledge(budgetId string, serverKnowledge int64) error { +func (l *localStorageAdapter) SetLastServerKnowledge(budgetId uuid.UUID, serverKnowledge int64) error { var data []budgetData if _, err := os.Stat(storageFile); err == nil { - data, err = l.readData(budgetId) + data, err = l.readData() if err != nil { return err } @@ -74,7 +75,7 @@ func (l *localStorageAdapter) SetLastServerKnowledge(budgetId string, serverKnow return encoder.Encode(data) } -func (l *localStorageAdapter) readData(budgetId string) ([]budgetData, error) { +func (l *localStorageAdapter) readData() ([]budgetData, error) { f, err := os.Open(storageFile) if err != nil { return nil, err diff --git a/storage/storage_adapter.go b/storage/storage_adapter.go index 6dde5f9..e8b1644 100644 --- a/storage/storage_adapter.go +++ b/storage/storage_adapter.go @@ -1,6 +1,8 @@ package storage +import "github.com/google/uuid" + type StorageAdapter interface { - GetLastServerKnowledge(budgetId string) (int64, error) - SetLastServerKnowledge(budgetId string, serverKnowledge int64) error + GetLastServerKnowledge(budgetId uuid.UUID) (int64, error) + SetLastServerKnowledge(budgetId uuid.UUID, serverKnowledge int64) error }