diff --git a/.gitignore b/.gitignore index 0eb11f9..227d5e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ env.sh ynab-portfolio-monitor ynab-portfolio-monitor.exe -data/persistentData.json \ No newline at end of file +data/*.json \ No newline at end of file diff --git a/accountProviders.go b/accountProviders.go new file mode 100644 index 0000000..61e66c3 --- /dev/null +++ b/accountProviders.go @@ -0,0 +1,20 @@ +package main + +import ( + "deadbeef.codes/steven/ynab-portfolio-monitor/bitcoin" + "deadbeef.codes/steven/ynab-portfolio-monitor/questrade" +) + +// AccountProvider is the base set of requirements to be implemented for any integration +type AccountProvider interface { + Name() string // Returns the name of the provider + Configure() error // Configures the provider for first use - if an error is returned the provider is not used + GetBalances() ([]int, []string, error) // A slice of balances, and an index mapped slice of ynab account IDs this provider handles is returned +} + +// Instantiate all providers for configuration +// If configuration for a provider does not exist, it will be pruned during init() +var allProviders []AccountProvider = []AccountProvider{ + &questrade.Provider{}, + &bitcoin.Provider{}, +} diff --git a/bitcoin/address.go b/bitcoin/address.go index 06a361b..a13c61c 100644 --- a/bitcoin/address.go +++ b/bitcoin/address.go @@ -25,13 +25,12 @@ type Address struct { // GetAddress returns an Address struct populated with data from blockstream.info // for a given BTC address -func (c *Client) GetAddress(address string) (*Address, error) { +func (c *client) GetAddress(address string) (*Address, error) { addressResponse := &Address{} err := c.get(fmt.Sprintf("address/%s", address), addressResponse, url.Values{}) if err != nil { return nil, err } - return addressResponse, nil } diff --git a/bitcoin/client.go b/bitcoin/client.go index 463eb03..a2568e8 100644 --- a/bitcoin/client.go +++ b/bitcoin/client.go @@ -14,13 +14,13 @@ const apiBaseURL = "https://blockstream.info/api/" // A client is the structure that will be used to consume the API // endpoints. It holds the login credentials, http client/transport, // rate limit information, and the login session timer. -type Client struct { +type client struct { httpClient *http.Client transport *http.Transport } // Send an HTTP GET request, and return the processed response -func (c *Client) get(endpoint string, out interface{}, query url.Values) error { +func (c *client) get(endpoint string, out interface{}, query url.Values) error { req, err := http.NewRequest("GET", apiBaseURL+endpoint+"?"+query.Encode(), nil) if err != nil { return fmt.Errorf("failed to create new GET request: %v", err) @@ -42,7 +42,7 @@ func (c *Client) get(endpoint string, out interface{}, query url.Values) error { // the error code, or unmarshalls the JSON response, extracts // rate limit info, and places it into the object // output parameter. This function closes the response body after reading it. -func (c *Client) processResponse(res *http.Response, out interface{}) error { +func (c *client) processResponse(res *http.Response, out interface{}) error { body, err := io.ReadAll(res.Body) res.Body.Close() if err != nil { @@ -57,25 +57,23 @@ func (c *Client) processResponse(res *http.Response, out interface{}) error { if err != nil { return fmt.Errorf("failed to unmarshal response body: %v", err) } - return nil } -// NewClient is the factory function for clients -func NewClient() (*Client, error) { +// newClient is the factory function for clients +func newClient() (*client, error) { transport := &http.Transport{ ResponseHeaderTimeout: 5 * time.Second, } - client := &http.Client{ + httpClient := &http.Client{ Transport: transport, } // Create a new client - c := &Client{ - httpClient: client, + c := &client{ + httpClient: httpClient, transport: transport, } - return c, nil } diff --git a/bitcoin/fiat.go b/bitcoin/fiat.go index 5fc4a55..dbb5d02 100644 --- a/bitcoin/fiat.go +++ b/bitcoin/fiat.go @@ -16,8 +16,7 @@ type FiatConversion struct { // BTC to CAD FIAT conversion - accepts an // amount in satoshi's and returns a CAD amount * 1000 -func (c *Client) ConvertBTCToCAD(amount int) (int, error) { - +func (c *client) ConvertBTCToCAD(amount int) (int, error) { fiatConversion := &FiatConversion{} req, err := http.NewRequest("GET", fiatConvertURL+"?amount=1", nil) @@ -38,6 +37,5 @@ func (c *Client) ConvertBTCToCAD(amount int) (int, error) { if fiatConversion.Status != "success" { return 0, fmt.Errorf("fiat conversion status was '%s' but expected 'success'", fiatConversion.Status) } - return (amount * int(fiatConversion.CAD*1000)) / 100000000, nil // one BTC = one hundred million satoshi's } diff --git a/bitcoin/providerImpl.go b/bitcoin/providerImpl.go new file mode 100644 index 0000000..1354f40 --- /dev/null +++ b/bitcoin/providerImpl.go @@ -0,0 +1,70 @@ +package bitcoin + +import ( + "fmt" + "os" +) + +type Provider struct { + bitcoinAddresses []string // Slice of bitcoin addresses this provider monitors + ynabAccountID string // YNAB account ID this provider updates - all bitcoin addresses are summed up and mapped to this YNAB account + client *client // HTTP client for interacting with Questrade API +} + +func (p *Provider) Name() string { + return "Bitcoin - Blockstream.info" +} + +// Configures the provider for usage via environment variables and persistentData +// If an error is returned, the provider will not be used +func (p *Provider) Configure() error { + var err error + + // Load environment variables in continous series with suffix starting at 0 + // Multiple addresses can be configured, (eg _1, _2) + // As soon as the series is interrupted, we assume we're done + p.bitcoinAddresses = make([]string, 0) + for i := 0; true; i++ { + bitcoinAddress := os.Getenv(fmt.Sprintf("bitcoin_address_%d", i)) + if bitcoinAddress == "" { + if i == 0 { + return fmt.Errorf("this account provider is not configured") + } + break + } + p.bitcoinAddresses = append(p.bitcoinAddresses, bitcoinAddress) + } + p.ynabAccountID = os.Getenv("bitcoin_ynab_account") + + // Create new HTTP client + p.client, err = newClient() + if err != nil { + return fmt.Errorf("failed to create new bitcoin client: %v", err) + } + return nil +} + +// Returns slices of account balances and mapped YNAB account IDs, along with an error +func (p *Provider) GetBalances() ([]int, []string, error) { + + balances := make([]int, 0) + ynabAccountIDs := make([]string, 0) + var satoshiBalance int + for _, bitcoinAddress := range p.bitcoinAddresses { + addressResponse, err := p.client.GetAddress(bitcoinAddress) + if err != nil { + return balances, ynabAccountIDs, fmt.Errorf("failed to get bitcoin address '%s': %v", bitcoinAddress, err) + } + + satoshiBalance += addressResponse.ChainStats.FundedTxoSum - addressResponse.ChainStats.SpentTxoSum + } + + fiatBalance, err := p.client.ConvertBTCToCAD(satoshiBalance) + if err != nil { + return balances, ynabAccountIDs, fmt.Errorf("failed to convert satoshi balance to fiat balance: %v", err) + } + + balances = append(balances, fiatBalance) + ynabAccountIDs = append(ynabAccountIDs, p.ynabAccountID) + return balances, ynabAccountIDs, nil +} diff --git a/main.go b/main.go index a05d1da..52b53d7 100644 --- a/main.go +++ b/main.go @@ -1,37 +1,26 @@ package main import ( - "fmt" "log" "os" - "strconv" "time" - "deadbeef.codes/steven/ynab-portfolio-monitor/bitcoin" - "deadbeef.codes/steven/ynab-portfolio-monitor/questrade" "deadbeef.codes/steven/ynab-portfolio-monitor/ynab" ) var ( - persistentData *PersistentData - questradeClient *questrade.Client - ynabClient *ynab.Client - bitcoinClient *bitcoin.Client - questradeAccountIDs []int - questradeYnabAccountIDs []string - bitcoinAddresses []string - bitcoinYnabAccountID string + configuredProviders []AccountProvider // Any providers that are successfully configured get added to this slice + ynabClient *ynab.Client // YNAB HTTP client ) +// Called at program startup or if SIGHUP is received func init() { log.Printf("ynab-portfolio-monitor init") - // Load application configuration from environment variables + // Load mandatory application configuration from environment variables envVars := make(map[string]string) - envVars["questrade_refresh_token"] = os.Getenv("questrade_refresh_token") envVars["ynab_secret"] = os.Getenv("ynab_secret") envVars["ynab_budget_id"] = os.Getenv("ynab_budget_id") - envVars["bitcoin_ynab_account"] = os.Getenv("bitcoin_ynab_account") // Validate that all required environment variables are set for key, value := range envVars { @@ -40,133 +29,48 @@ func init() { } } - // questrade - questradeAccountIDs = make([]int, 0) - questradeYnabAccountIDs = make([]string, 0) - for i := 0; true; i++ { - questradeAccountIDString := os.Getenv(fmt.Sprintf("questrade_account_%d", i)) - ynabAccountID := os.Getenv(fmt.Sprintf("questrade_ynab_account_%d", i)) - if questradeAccountIDString == "" || ynabAccountID == "" { - break - } - - questradeAccountID, err := strconv.Atoi(questradeAccountIDString) + // Loop through all providers and attempt to configure them + configuredProviders = make([]AccountProvider, 0) + for _, p := range allProviders { + err := p.Configure() if err != nil { - log.Fatalf("failed to convert environment variable questrade_account_%d with value of '%s' to integer: %v", i, questradeAccountIDString, err) + log.Printf("skipping provider '%s': %v", p.Name(), err) + continue } - questradeAccountIDs = append(questradeAccountIDs, questradeAccountID) - questradeYnabAccountIDs = append(questradeYnabAccountIDs, ynabAccountID) - } - - // bitcoin - bitcoinAddresses = make([]string, 0) - for i := 0; true; i++ { - bitcoinAddress := os.Getenv(fmt.Sprintf("bitcoin_address_%d", i)) - if bitcoinAddress == "" { - break - } - bitcoinAddresses = append(bitcoinAddresses, bitcoinAddress) - } - bitcoinYnabAccountID = envVars["bitcoin_ynab_account"] - - // Load persistent data - var err error - persistentData, err = loadPersistentData() - if err != nil { - log.Fatalf("failed to load persistent data: %v", err) + configuredProviders = append(configuredProviders, p) + log.Printf("enabled provider '%s'", p.Name()) } // ynab client is static and has no persistent data so is initialized here and not in main program loop + var err error ynabClient, err = ynab.NewClient(envVars["ynab_budget_id"], envVars["ynab_secret"]) if err != nil { log.Fatalf("failed to create ynab client: %v", err) } - - bitcoinClient, err = bitcoin.NewClient() - if err != nil { - log.Fatalf("failed to create bitcoin client: %v", err) - } - } +// Main program loop func main() { for { - var err error - - // Questrade authentication needs to be refreshed and persistentData written to disk in case app restarts - questradeClient, err = questrade.NewClient(persistentData.QuestradeRefreshToken) - if err != nil { - log.Printf("failed to create questrade client: %v", err) - time.Sleep(time.Minute * 5) // prevent multiple fast login failures - continue + // Loop through each configured account provider and attempt to get the account balances, and update YNAB + for _, p := range configuredProviders { + balances, accountIDs, err := p.GetBalances() + if err != nil { + log.Printf("failed to get balances with provider '%s': %v", p.Name(), err) + continue + } + if len(balances) != len(accountIDs) { + log.Printf("mismatched balance and accountID slice lengths - expected the same: balances length = %d, accountIDs length = %d", len(balances), len(accountIDs)) + continue + } + for i := range balances { + err = ynabClient.SetAccountBalance(accountIDs[i], balances[i]) + if err != nil { + log.Printf("failed to update ynab account '%s' balance: %v", accountIDs[i], err) + } + } } - - persistentData.QuestradeRefreshToken = questradeClient.Credentials.RefreshToken - - err = savePersistentData(*persistentData) - if err != nil { - log.Fatalf("failed to save persistent data: %v", err) - } - - // Update Questrade accounts - err = syncQuestrade() - if err != nil { - log.Fatalf("failed to sync questrade to ynab: %v", err) - } - - // Update Bitcoin account - err = syncBitoin() - if err != nil { - log.Fatalf("failed to sync bitcoin to ynab: %v", err) - } - - // TBD: Update ComputerShare account - log.Print("Sleeping for 6 hours...") time.Sleep(time.Hour * 6) - } - -} - -func syncQuestrade() error { - - for i, questradeAccountID := range questradeAccountIDs { - questradeBalance, err := questradeClient.GetQuestradeAccountBalance(questradeAccountID) - if err != nil { - return fmt.Errorf("failed to get questrade account balance for account ID '%d': %v", questradeAccountID, err) - } - - err = ynabClient.SetAccountBalance(questradeYnabAccountIDs[i], questradeBalance) - if err != nil { - return fmt.Errorf("failed to set YNAB account balance: %v", err) - } - } - - return nil -} - -func syncBitoin() error { - - var satoshiBalance int - for _, bitcoinAddress := range bitcoinAddresses { - addressResponse, err := bitcoinClient.GetAddress(bitcoinAddress) - if err != nil { - return fmt.Errorf("failed to get bitcoin address '%s': %v", bitcoinAddress, err) - } - - satoshiBalance += addressResponse.ChainStats.FundedTxoSum - addressResponse.ChainStats.SpentTxoSum - } - - fiatBalance, err := bitcoinClient.ConvertBTCToCAD(satoshiBalance) - if err != nil { - return fmt.Errorf("failed to convert satoshi balance to fiat balance: %v", err) - } - - err = ynabClient.SetAccountBalance(bitcoinYnabAccountID, fiatBalance) - if err != nil { - return fmt.Errorf("failed to set YNAB account balance: %v", err) - } - - return nil } diff --git a/persistentData.go b/persistentData.go deleted file mode 100644 index a02e8a9..0000000 --- a/persistentData.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "os" -) - -type PersistentData struct { - QuestradeRefreshToken string `json:"questradeRefreshToken"` -} - -func loadPersistentData() (*PersistentData, error) { - persistentData := &PersistentData{} - - f, err := os.Open("data/persistentData.json") - if errors.Is(err, os.ErrNotExist) { - // handle the case where the file doesn't exist - persistentData.QuestradeRefreshToken = os.Getenv("questrade_refresh_token") - return persistentData, nil - } - - defer f.Close() - - b, err := io.ReadAll(f) - if err != nil { - return nil, fmt.Errorf("failed to read file data/persistentData.json: %v", err) - } - - err = json.Unmarshal(b, persistentData) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal data/persistentData.json to PersistentData struct: %v", err) - } - - return persistentData, nil - -} - -func savePersistentData(PersistentData) error { - b, err := json.Marshal(persistentData) - if err != nil { - return fmt.Errorf("failed to marshal persistentData to bytes: %v", err) - } - err = os.WriteFile("data/persistentData.json", b, 0644) - if err != nil { - return fmt.Errorf("failed to write file data/persistentData.json: %v", err) - } - return nil -} diff --git a/questrade/account.go b/questrade/account.go index fe1f090..431c35b 100644 --- a/questrade/account.go +++ b/questrade/account.go @@ -70,7 +70,7 @@ type AccountBalances struct { // GetAccounts returns the logged-in User ID, and a list of accounts // belonging to that user. -func (c *Client) GetAccounts() (int, []Account, error) { +func (c *client) GetAccounts() (int, []Account, error) { list := struct { UserID int `json:"userId"` Accounts []Account `json:"accounts"` @@ -80,23 +80,21 @@ func (c *Client) GetAccounts() (int, []Account, error) { if err != nil { return 0, []Account{}, err } - return list.UserID, list.Accounts, nil } // GetBalances returns the balances for the account with the specified account number -func (c *Client) GetBalances(number string) (AccountBalances, error) { +func (c *client) GetBalances(number string) (AccountBalances, error) { bal := AccountBalances{} err := c.get("v1/accounts/"+number+"/balances", &bal, url.Values{}) if err != nil { return AccountBalances{}, err } - return bal, nil } -func (c *Client) GetQuestradeAccountBalance(accountID int) (int, error) { +func (c *client) GetQuestradeAccountBalance(accountID int) (int, error) { balances, err := c.GetBalances(strconv.Itoa(accountID)) if err != nil { return 0, fmt.Errorf("failed to get balances for account ID '%d': %v", accountID, err) @@ -109,6 +107,5 @@ func (c *Client) GetQuestradeAccountBalance(accountID int) (int, error) { return int(balance.TotalEquity) * 1000, nil } - return 0, fmt.Errorf("could not find a CAD balance for this account in questade response") } diff --git a/questrade/client.go b/questrade/client.go index bcd62a3..c18ad86 100644 --- a/questrade/client.go +++ b/questrade/client.go @@ -23,7 +23,7 @@ type LoginCredentials struct { // A client is the structure that will be used to consume the API // endpoints. It holds the login credentials, http client/transport, // rate limit information, and the login session timer. -type Client struct { +type client struct { Credentials LoginCredentials SessionTimer *time.Timer RateLimitRemaining int @@ -39,7 +39,7 @@ func (l LoginCredentials) authHeader() string { } // Send an HTTP GET request, and return the processed response -func (c *Client) get(endpoint string, out interface{}, query url.Values) error { +func (c *client) get(endpoint string, out interface{}, query url.Values) error { req, err := http.NewRequest("GET", c.Credentials.ApiServer+endpoint+query.Encode(), nil) if err != nil { return err @@ -62,7 +62,7 @@ func (c *Client) get(endpoint string, out interface{}, query url.Values) error { // the error code, or unmarshalls the JSON response, extracts // rate limit info, and places it into the object // output parameter. This function closes the response body after reading it. -func (c *Client) processResponse(res *http.Response, out interface{}) error { +func (c *client) processResponse(res *http.Response, out interface{}) error { body, err := io.ReadAll(res.Body) res.Body.Close() if err != nil { @@ -81,7 +81,6 @@ func (c *Client) processResponse(res *http.Response, out interface{}) error { reset, _ := strconv.Atoi(res.Header.Get("X-RateLimit-Reset")) c.RateLimitReset = time.Unix(int64(reset), 0) c.RateLimitRemaining, _ = strconv.Atoi(res.Header.Get("X-RateLimit-Remaining")) - return nil } @@ -89,7 +88,7 @@ func (c *Client) processResponse(res *http.Response, out interface{}) error { // and exchanges it for an access token. Returns a timer that // expires when the login session is over. // TODO - Return a proper error when login fails with HTTP 400 - Bad Request -func (c *Client) Login() error { +func (c *client) Login() error { login := loginServerURL vars := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {c.Credentials.RefreshToken}} @@ -105,26 +104,25 @@ func (c *Client) Login() error { } c.SessionTimer = time.NewTimer(time.Duration(c.Credentials.ExpiresIn) * time.Second) - return nil } -// NewClient is the factory function for clients - takes a refresh token and logs in -func NewClient(refreshToken string) (*Client, error) { +// newClient is the factory function for clients - takes a refresh token and logs in +func newClient(refreshToken string) (*client, error) { transport := &http.Transport{ ResponseHeaderTimeout: 5 * time.Second, } - client := &http.Client{ + httpClient := &http.Client{ Transport: transport, } // Create a new client - c := &Client{ + c := &client{ Credentials: LoginCredentials{ RefreshToken: refreshToken, }, - httpClient: client, + httpClient: httpClient, transport: transport, } @@ -132,6 +130,5 @@ func NewClient(refreshToken string) (*Client, error) { if err != nil { return nil, err } - return c, nil } diff --git a/questrade/errors.go b/questrade/errors.go index 4b74caa..639f0cf 100644 --- a/questrade/errors.go +++ b/questrade/errors.go @@ -24,7 +24,6 @@ func newQuestradeError(res *http.Response, body []byte) QuestradeError { e.StatusCode = res.StatusCode e.Endpoint = res.Request.URL.String() - return e } diff --git a/questrade/providerImpl.go b/questrade/providerImpl.go new file mode 100644 index 0000000..43951da --- /dev/null +++ b/questrade/providerImpl.go @@ -0,0 +1,147 @@ +package questrade + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "time" +) + +type persistentData struct { + QuestradeRefreshToken string `json:"questradeRefreshToken"` // Questrade API OAuth2 refresh token +} + +type Provider struct { + questradeAccountIDs []int // Slice of Questrade account numbers this provider monitors + ynabAccountIDs []string // Slice of YNAB account ID's this provider updates - index position maps with questradeAccountIDs + data *persistentData // Data stored on disk and loaded when program starts + client *client // HTTP client for interacting with Questrade API + lastRefresh time.Time +} + +func (p *Provider) Name() string { + return "Questrade" +} + +// Configures the provider for usage via environment variables and persistentData +// If an error is returned, the provider will not be used +func (p *Provider) Configure() error { + var err error + + p.questradeAccountIDs = make([]int, 0) + p.ynabAccountIDs = make([]string, 0) + // Load environment variables in continous series with suffix starting at 0 + // Multiple accounts can be configured, (eg _1, _2) + // As soon as the series is interrupted, we assume we're done + for i := 0; true; i++ { + questradeAccountIDString := os.Getenv(fmt.Sprintf("questrade_account_%d", i)) + ynabAccountID := os.Getenv(fmt.Sprintf("questrade_ynab_account_%d", i)) + if questradeAccountIDString == "" || ynabAccountID == "" { + if i == 0 { + return fmt.Errorf("this account provider is not configured") + } + break + } + questradeAccountID, err := strconv.Atoi(questradeAccountIDString) + if err != nil { + return fmt.Errorf("failed to convert environment variable questrade_account_%d with value of '%s' to integer: %v", i, questradeAccountIDString, err) + } + p.questradeAccountIDs = append(p.questradeAccountIDs, questradeAccountID) + p.ynabAccountIDs = append(p.ynabAccountIDs, ynabAccountID) + } + + // Load persistent data from disk - the OAuth2.0 refresh tokens are one time use + p.data, err = loadPersistentData() + if err != nil { + return fmt.Errorf("failed to load questrade configuration: %v", err) + } + + // Create new HTTP client and login to API - will error if login fails + err = p.refresh() + if err != nil { + return fmt.Errorf("failed to refresh http client: %v", err) + } + + return nil +} + +// Returns slices of account balances and mapped YNAB account IDs, along with an error +func (p *Provider) GetBalances() ([]int, []string, error) { + // Refresh credentials if past half way until expiration + if p.lastRefresh.Add(time.Second * time.Duration(p.client.Credentials.ExpiresIn) / 2).Before(time.Now()) { + err := p.refresh() + if err != nil { + return make([]int, 0), make([]string, 0), fmt.Errorf("failed to refresh http client: %v", err) + } + } + + // Gather account balances from Questrade API + balances := make([]int, 0) + for _, questradeAccountID := range p.questradeAccountIDs { + balance, err := p.client.GetQuestradeAccountBalance(questradeAccountID) + if err != nil { + return balances, p.ynabAccountIDs, fmt.Errorf("failed to get questrade account balance for account ID '%d': %v", questradeAccountID, err) + } + balances = append(balances, balance) + } + + return balances, p.ynabAccountIDs, nil +} + +func (p *Provider) refresh() error { + var err error + // Create new HTTP client and login to API - will error if login fails + p.client, err = newClient(p.data.QuestradeRefreshToken) + if err != nil { + return fmt.Errorf("failed to create new questrade client: %v", err) + } + p.lastRefresh = time.Now() + + // After logging in, we get a new refresh token - save it for next login + p.data.QuestradeRefreshToken = p.client.Credentials.RefreshToken + err = savePersistentData(p.data) + if err != nil { + return fmt.Errorf("failed to save persistent data: %v", err) + } + return nil + +} + +// Load persistent data from disk, if it fails it initializes using environment variables +func loadPersistentData() (*persistentData, error) { + data := &persistentData{} + + f, err := os.Open("data/questrade-data.json") + if errors.Is(err, os.ErrNotExist) { + // handle the case where the file doesn't exist + data.QuestradeRefreshToken = os.Getenv("questrade_refresh_token") + return data, nil + } + defer f.Close() + b, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("failed to read file data/questrade-data.jsonn: %v", err) + } + + err = json.Unmarshal(b, data) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data/questrade-data.json to PersistentData struct: %v", err) + } + return data, nil +} + +// Save persistent data to disk, this should be done any time the data changes to ensure it can be loaded on next run +func savePersistentData(data *persistentData) error { + b, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal persistentData to bytes: %v", err) + } + err = os.WriteFile("data/questrade-data.json", b, 0644) + if err != nil { + return fmt.Errorf("failed to write file data/questrade-data.json: %v", err) + } + return nil +} diff --git a/ynab/accounts.go b/ynab/accounts.go index e8a89fe..38f56d7 100644 --- a/ynab/accounts.go +++ b/ynab/accounts.go @@ -56,7 +56,6 @@ func (c *Client) SetAccountBalance(accountID string, balance int) error { balanceDelta := balance - ynabAccount.Data.Account.Balance balanceDelta += ynabTransactionAmount // Take into account the existing transaction - if balanceDelta == 0 { return nil // If balanceDelta is 0 do not create a transaction i.e. market is closed today } @@ -71,6 +70,7 @@ func (c *Client) SetAccountBalance(accountID string, balance int) error { } else { // there is an existing transaction - so update the existing one + err = c.updateTodayYNABCapitalGainsTransaction(accountID, ynabTransactionID, balanceDelta) if err != nil { return fmt.Errorf("failed to update YNAB capital gains transaction for account ID '%s': %v", accountID, err) diff --git a/ynab/client.go b/ynab/client.go index 9a6e894..88d3478 100644 --- a/ynab/client.go +++ b/ynab/client.go @@ -117,7 +117,6 @@ func (c *Client) processResponse(res *http.Response, out interface{}) error { if err != nil { return fmt.Errorf("failed to unmarshal response body: %v", err) } - return nil } @@ -138,6 +137,5 @@ func NewClient(budgetID, bearerToken string) (*Client, error) { httpClient: client, transport: transport, } - return c, nil } diff --git a/ynab/transactions.go b/ynab/transactions.go index 63a74f8..c63a142 100644 --- a/ynab/transactions.go +++ b/ynab/transactions.go @@ -64,7 +64,6 @@ func (c *Client) GetAccountTransactions(accountID string, sinceDate time.Time) ( if err != nil { return nil, fmt.Errorf("failed to get account transactions: %v", err) } - return &response, nil } @@ -82,7 +81,6 @@ func (c *Client) getTodayYnabCapitalGainsTransaction(accountID string) (string, } return transaction.ID, transaction.Amount, nil } - return "", 0, nil }