Skip to content

Commit

Permalink
Implement core logic.
Browse files Browse the repository at this point in the history
  • Loading branch information
samshadwell committed Nov 20, 2023
1 parent 5c25ad6 commit 70695a9
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 37 deletions.
32 changes: 24 additions & 8 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
185 changes: 167 additions & 18 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
17 changes: 9 additions & 8 deletions storage/file_storage_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"

"github.com/google/uuid"
"gopkg.in/yaml.v3"
)

Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions storage/storage_adapter.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 70695a9

Please sign in to comment.