2023-11-12 19:04:22 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"log"
|
|
|
|
"os"
|
|
|
|
"strconv"
|
|
|
|
"time"
|
|
|
|
|
2023-11-12 23:50:46 +00:00
|
|
|
"deadbeef.codes/steven/ynab-portfolio-monitor/bitcoin"
|
2023-11-12 19:04:22 +00:00
|
|
|
"deadbeef.codes/steven/ynab-portfolio-monitor/questrade"
|
|
|
|
"deadbeef.codes/steven/ynab-portfolio-monitor/ynab"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
2023-11-12 23:50:46 +00:00
|
|
|
persistentData *PersistentData
|
|
|
|
questradeClient *questrade.Client
|
|
|
|
ynabClient *ynab.Client
|
|
|
|
bitcoinClient *bitcoin.Client
|
|
|
|
questradeAccountIDs []int
|
|
|
|
questradeYnabAccountIDs []string
|
|
|
|
bitcoinAddresses []string
|
|
|
|
bitcoinYnabAccountID string
|
2023-11-12 19:04:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
log.Printf("ynab-portfolio-monitor init")
|
|
|
|
|
|
|
|
// Load 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")
|
2023-11-12 23:50:46 +00:00
|
|
|
envVars["bitcoin_ynab_account"] = os.Getenv("bitcoin_ynab_account")
|
2023-11-12 19:04:22 +00:00
|
|
|
|
|
|
|
// Validate that all required environment variables are set
|
|
|
|
for key, value := range envVars {
|
|
|
|
if value == "" {
|
|
|
|
log.Fatalf("shell environment variable %s is not set", key)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-11-12 23:50:46 +00:00
|
|
|
// questrade
|
|
|
|
questradeAccountIDs = make([]int, 0)
|
|
|
|
questradeYnabAccountIDs = make([]string, 0)
|
2023-11-12 19:04:22 +00:00
|
|
|
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)
|
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("failed to convert environment variable questrade_account_%d with value of '%s' to integer: %v", i, questradeAccountIDString, err)
|
|
|
|
}
|
|
|
|
questradeAccountIDs = append(questradeAccountIDs, questradeAccountID)
|
2023-11-12 23:50:46 +00:00
|
|
|
questradeYnabAccountIDs = append(questradeYnabAccountIDs, ynabAccountID)
|
2023-11-12 19:04:22 +00:00
|
|
|
}
|
|
|
|
|
2023-11-12 23:50:46 +00:00
|
|
|
// 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"]
|
|
|
|
|
2023-11-12 19:04:22 +00:00
|
|
|
// Load persistent data
|
|
|
|
var err error
|
|
|
|
persistentData, err = loadPersistentData()
|
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("failed to load persistent data: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ynab client is static and has no persistent data so is initialized here and not in main program loop
|
|
|
|
ynabClient, err = ynab.NewClient(envVars["ynab_budget_id"], envVars["ynab_secret"])
|
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("failed to create ynab client: %v", err)
|
|
|
|
}
|
2023-11-12 23:50:46 +00:00
|
|
|
|
|
|
|
bitcoinClient, err = bitcoin.NewClient()
|
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("failed to create bitcoin client: %v", err)
|
|
|
|
}
|
|
|
|
|
2023-11-12 19:04:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
2023-11-12 20:06:30 +00:00
|
|
|
log.Printf("failed to create questrade client: %v", err)
|
|
|
|
time.Sleep(time.Minute * 5) // prevent multiple fast login failures
|
|
|
|
continue
|
2023-11-12 19:04:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
2023-11-12 23:50:46 +00:00
|
|
|
err = syncBitoin()
|
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("failed to sync bitcoin to ynab: %v", err)
|
|
|
|
}
|
2023-11-12 19:04:22 +00:00
|
|
|
|
2023-11-12 23:50:46 +00:00
|
|
|
// TBD: Update ComputerShare account
|
2023-11-12 19:04:22 +00:00
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2023-11-12 23:50:46 +00:00
|
|
|
err = ynabClient.SetAccountBalance(questradeYnabAccountIDs[i], questradeBalance)
|
2023-11-12 19:04:22 +00:00
|
|
|
if err != nil {
|
2023-11-12 23:50:46 +00:00
|
|
|
return fmt.Errorf("failed to set YNAB account balance: %v", err)
|
2023-11-12 19:04:22 +00:00
|
|
|
}
|
2023-11-12 23:50:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func syncBitoin() error {
|
2023-11-12 19:04:22 +00:00
|
|
|
|
2023-11-12 23:50:46 +00:00
|
|
|
var satoshiBalance int
|
|
|
|
for _, bitcoinAddress := range bitcoinAddresses {
|
|
|
|
addressResponse, err := bitcoinClient.GetAddress(bitcoinAddress)
|
2023-11-12 19:04:22 +00:00
|
|
|
if err != nil {
|
2023-11-12 23:50:46 +00:00
|
|
|
return fmt.Errorf("failed to get bitcoin address '%s': %v", bitcoinAddress, err)
|
2023-11-12 19:04:22 +00:00
|
|
|
}
|
|
|
|
|
2023-11-12 23:50:46 +00:00
|
|
|
satoshiBalance += addressResponse.ChainStats.FundedTxoSum - addressResponse.ChainStats.SpentTxoSum
|
|
|
|
}
|
2023-11-12 19:04:22 +00:00
|
|
|
|
2023-11-12 23:50:46 +00:00
|
|
|
fiatBalance, err := bitcoinClient.ConvertBTCToCAD(satoshiBalance)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("failed to convert satoshi balance to fiat balance: %v", err)
|
|
|
|
}
|
2023-11-12 19:04:22 +00:00
|
|
|
|
2023-11-12 23:50:46 +00:00
|
|
|
err = ynabClient.SetAccountBalance(bitcoinYnabAccountID, fiatBalance)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("failed to set YNAB account balance: %v", err)
|
2023-11-12 19:04:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|