ynab-portfolio-monitor/questrade/providerImpl.go

148 lines
4.9 KiB
Go

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
}