diff --git a/bitcoin/address.go b/bitcoin/address.go new file mode 100644 index 0000000..06a361b --- /dev/null +++ b/bitcoin/address.go @@ -0,0 +1,37 @@ +package bitcoin + +import ( + "fmt" + "net/url" +) + +type Address struct { + Address string `json:"address"` + ChainStats struct { + FundedTxoCount int `json:"funded_txo_count"` + FundedTxoSum int `json:"funded_txo_sum"` + SpentTxoCount int `json:"spent_txo_count"` + SpentTxoSum int `json:"spent_txo_sum"` + TxCount int `json:"tx_count"` + } `json:"chain_stats"` + MempoolStats struct { + FundedTxoCount int `json:"funded_txo_count"` + FundedTxoSum int `json:"funded_txo_sum"` + SpentTxoCount int `json:"spent_txo_count"` + SpentTxoSum int `json:"spent_txo_sum"` + TxCount int `json:"tx_count"` + } `json:"mempool_stats"` +} + +// GetAddress returns an Address struct populated with data from blockstream.info +// for a given BTC address +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 new file mode 100644 index 0000000..463eb03 --- /dev/null +++ b/bitcoin/client.go @@ -0,0 +1,81 @@ +package bitcoin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +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 { + 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 { + req, err := http.NewRequest("GET", apiBaseURL+endpoint+"?"+query.Encode(), nil) + if err != nil { + return fmt.Errorf("failed to create new GET request: %v", err) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("http GET request failed: %v", err) + } + + err = c.processResponse(res, out) + if err != nil { + return fmt.Errorf("failed to process response: %v", err) + } + return nil +} + +// processResponse takes the body of an HTTP response, and either returns +// 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 { + body, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return fmt.Errorf("failed to read response body: %v", err) + } + + if res.StatusCode != 200 && res.StatusCode != 201 { + return fmt.Errorf("unexpected http status code '%d': %v", res.StatusCode, err) + } + + err = json.Unmarshal(body, out) + 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) { + transport := &http.Transport{ + ResponseHeaderTimeout: 5 * time.Second, + } + + client := &http.Client{ + Transport: transport, + } + + // Create a new client + c := &Client{ + httpClient: client, + transport: transport, + } + + return c, nil +} diff --git a/bitcoin/fiat.go b/bitcoin/fiat.go new file mode 100644 index 0000000..5fc4a55 --- /dev/null +++ b/bitcoin/fiat.go @@ -0,0 +1,43 @@ +package bitcoin + +import ( + "fmt" + "net/http" +) + +// example: https://api.coinconvert.net/convert/btc/cad?amount=1 +const fiatConvertURL = "https://api.coinconvert.net/convert/btc/cad" + +type FiatConversion struct { + Status string `json:"status"` + BTC int `json:"BTC"` + CAD float64 `json:"CAD"` +} + +// 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) { + + fiatConversion := &FiatConversion{} + + req, err := http.NewRequest("GET", fiatConvertURL+"?amount=1", nil) + if err != nil { + return 0, fmt.Errorf("failed to create new GET request: %v", err) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("http GET request failed: %v", err) + } + + err = c.processResponse(res, fiatConversion) + if err != nil { + return 0, fmt.Errorf("failed to process response: %v", err) + } + + 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/main.go b/main.go index 350ba96..a05d1da 100644 --- a/main.go +++ b/main.go @@ -7,16 +7,20 @@ import ( "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 - questradeAccountIDs []int - ynabAccountIDs []string + persistentData *PersistentData + questradeClient *questrade.Client + ynabClient *ynab.Client + bitcoinClient *bitcoin.Client + questradeAccountIDs []int + questradeYnabAccountIDs []string + bitcoinAddresses []string + bitcoinYnabAccountID string ) func init() { @@ -27,6 +31,7 @@ func init() { 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 { @@ -35,6 +40,9 @@ 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)) @@ -47,9 +55,20 @@ func init() { log.Fatalf("failed to convert environment variable questrade_account_%d with value of '%s' to integer: %v", i, questradeAccountIDString, err) } questradeAccountIDs = append(questradeAccountIDs, questradeAccountID) - ynabAccountIDs = append(ynabAccountIDs, ynabAccountID) + 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() @@ -62,6 +81,12 @@ func init() { 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) + } + } func main() { @@ -90,8 +115,12 @@ func main() { } // Update Bitcoin account + err = syncBitoin() + if err != nil { + log.Fatalf("failed to sync bitcoin to ynab: %v", err) + } - // Update ComputerShare account + // TBD: Update ComputerShare account log.Print("Sleeping for 6 hours...") time.Sleep(time.Hour * 6) @@ -108,40 +137,36 @@ func syncQuestrade() error { return fmt.Errorf("failed to get questrade account balance for account ID '%d': %v", questradeAccountID, err) } - ynabTransactionID, ynabTransactionAmount, err := ynabClient.GetTodayYnabCapitalGainsTransaction(ynabAccountIDs[i]) + err = ynabClient.SetAccountBalance(questradeYnabAccountIDs[i], questradeBalance) if err != nil { - return fmt.Errorf("failed to get ynab capital gains transaction ID: %v", err) - } - - ynabAccount, err := ynabClient.GetAccount(ynabAccountIDs[i]) - if err != nil { - return fmt.Errorf("failed to get ynab account with id '%s': %v", ynabAccountIDs[i], err) - } - - balanceDelta := questradeBalance - ynabAccount.Data.Account.Balance - balanceDelta += ynabTransactionAmount // Take into account the existing transaction - - if balanceDelta == 0 { - continue // If balanceDelta is 0 do not create a transaction i.e. market is closed today - } - - if ynabTransactionID == "" { - // there is no transaction - so create a new one - err = ynabClient.CreateTodayYNABCapitalGainsTransaction(ynabAccountIDs[i], balanceDelta) - if err != nil { - return fmt.Errorf("failed to create YNAB capital gains transaction for account ID '%s': %v", ynabAccountIDs[i], err) - } - log.Printf("Creating new capital gains transaction for YNAB account '%s' for amount: %d", ynabAccountIDs[i], balanceDelta) - - } else { - // there is an existing transaction - so update the existing one - err = ynabClient.UpdateTodayYNABCapitalGainsTransaction(ynabAccountIDs[i], ynabTransactionID, balanceDelta) - if err != nil { - return fmt.Errorf("failed to update YNAB capital gains transaction for account ID '%s': %v", ynabAccountIDs[i], err) - } - log.Printf("Updating existing capital gains transaction for YNAB account '%s' for amount: %d", ynabAccountIDs[i], balanceDelta) + 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/ynab/accounts.go b/ynab/accounts.go index 4b0f5ee..e8a89fe 100644 --- a/ynab/accounts.go +++ b/ynab/accounts.go @@ -2,6 +2,7 @@ package ynab import ( "fmt" + "log" "net/url" ) @@ -38,3 +39,43 @@ func (c *Client) GetAccount(accountID string) (*Accounts, error) { return &response, nil } + +// Creates a "Capital Gains or Losses" adjustment transaction or updates an existing one if it exists to ensure the account balance +// for the accountID provided equals the balance provided +func (c *Client) SetAccountBalance(accountID string, balance int) error { + + ynabTransactionID, ynabTransactionAmount, err := c.getTodayYnabCapitalGainsTransaction(accountID) + if err != nil { + return fmt.Errorf("failed to get ynab capital gains transaction ID: %v", err) + } + + ynabAccount, err := c.GetAccount(accountID) + if err != nil { + return fmt.Errorf("failed to get ynab account with id '%s': %v", accountID, err) + } + + 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 + } + + if ynabTransactionID == "" { + // there is no transaction - so create a new one + err = c.createTodayYNABCapitalGainsTransaction(accountID, balanceDelta) + if err != nil { + return fmt.Errorf("failed to create YNAB capital gains transaction for account ID '%s': %v", accountID, err) + } + log.Printf("Creating new capital gains transaction for YNAB account '%s' for amount: %d", accountID, balanceDelta) + + } 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) + } + log.Printf("Updating existing capital gains transaction for YNAB account '%s' for amount: %d", accountID, balanceDelta) + } + return nil +} diff --git a/ynab/transactions.go b/ynab/transactions.go index 5ea3978..63a74f8 100644 --- a/ynab/transactions.go +++ b/ynab/transactions.go @@ -70,7 +70,7 @@ func (c *Client) GetAccountTransactions(accountID string, sinceDate time.Time) ( // Accepts a YNAB account ID and returns the transaction ID, amount and an error for the // the first transaction found with Payee Name "Capital Gains or Losses" -func (c *Client) GetTodayYnabCapitalGainsTransaction(accountID string) (string, int, error) { +func (c *Client) getTodayYnabCapitalGainsTransaction(accountID string) (string, int, error) { ynabTransactions, err := c.GetAccountTransactions(accountID, time.Now()) if err != nil { return "", 0, fmt.Errorf("failed to get ynab transactions: %v", err) @@ -87,7 +87,7 @@ func (c *Client) GetTodayYnabCapitalGainsTransaction(accountID string) (string, } // Accepts a YNAB account ID and transaction amount and creates a new YNAB transaction -func (c *Client) CreateTodayYNABCapitalGainsTransaction(accountID string, amount int) error { +func (c *Client) createTodayYNABCapitalGainsTransaction(accountID string, amount int) error { transaction := TransactionRequest{} transaction.Transaction.AccountID = accountID transaction.Transaction.Amount = amount @@ -106,7 +106,7 @@ func (c *Client) CreateTodayYNABCapitalGainsTransaction(accountID string, amount } // Accepts a YNAB account ID, transaction ID and transaction amount and updates the YNAB transaction with the matching ID -func (c *Client) UpdateTodayYNABCapitalGainsTransaction(accountID string, transactionID string, amount int) error { +func (c *Client) updateTodayYNABCapitalGainsTransaction(accountID string, transactionID string, amount int) error { transaction := TransactionRequest{} transaction.Transaction.AccountID = accountID transaction.Transaction.ID = transactionID