add support for bitcoin
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
b690c20a90
commit
bb7d0a29ea
37
bitcoin/address.go
Normal file
37
bitcoin/address.go
Normal file
@ -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
|
||||||
|
}
|
81
bitcoin/client.go
Normal file
81
bitcoin/client.go
Normal file
@ -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
|
||||||
|
}
|
43
bitcoin/fiat.go
Normal file
43
bitcoin/fiat.go
Normal file
@ -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
|
||||||
|
}
|
93
main.go
93
main.go
@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"deadbeef.codes/steven/ynab-portfolio-monitor/bitcoin"
|
||||||
"deadbeef.codes/steven/ynab-portfolio-monitor/questrade"
|
"deadbeef.codes/steven/ynab-portfolio-monitor/questrade"
|
||||||
"deadbeef.codes/steven/ynab-portfolio-monitor/ynab"
|
"deadbeef.codes/steven/ynab-portfolio-monitor/ynab"
|
||||||
)
|
)
|
||||||
@ -15,8 +16,11 @@ var (
|
|||||||
persistentData *PersistentData
|
persistentData *PersistentData
|
||||||
questradeClient *questrade.Client
|
questradeClient *questrade.Client
|
||||||
ynabClient *ynab.Client
|
ynabClient *ynab.Client
|
||||||
|
bitcoinClient *bitcoin.Client
|
||||||
questradeAccountIDs []int
|
questradeAccountIDs []int
|
||||||
ynabAccountIDs []string
|
questradeYnabAccountIDs []string
|
||||||
|
bitcoinAddresses []string
|
||||||
|
bitcoinYnabAccountID string
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -27,6 +31,7 @@ func init() {
|
|||||||
envVars["questrade_refresh_token"] = os.Getenv("questrade_refresh_token")
|
envVars["questrade_refresh_token"] = os.Getenv("questrade_refresh_token")
|
||||||
envVars["ynab_secret"] = os.Getenv("ynab_secret")
|
envVars["ynab_secret"] = os.Getenv("ynab_secret")
|
||||||
envVars["ynab_budget_id"] = os.Getenv("ynab_budget_id")
|
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
|
// Validate that all required environment variables are set
|
||||||
for key, value := range envVars {
|
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++ {
|
for i := 0; true; i++ {
|
||||||
questradeAccountIDString := os.Getenv(fmt.Sprintf("questrade_account_%d", i))
|
questradeAccountIDString := os.Getenv(fmt.Sprintf("questrade_account_%d", i))
|
||||||
ynabAccountID := os.Getenv(fmt.Sprintf("questrade_ynab_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)
|
log.Fatalf("failed to convert environment variable questrade_account_%d with value of '%s' to integer: %v", i, questradeAccountIDString, err)
|
||||||
}
|
}
|
||||||
questradeAccountIDs = append(questradeAccountIDs, questradeAccountID)
|
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
|
// Load persistent data
|
||||||
var err error
|
var err error
|
||||||
persistentData, err = loadPersistentData()
|
persistentData, err = loadPersistentData()
|
||||||
@ -62,6 +81,12 @@ func init() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to create ynab client: %v", err)
|
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() {
|
func main() {
|
||||||
@ -90,8 +115,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update Bitcoin account
|
// 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...")
|
log.Print("Sleeping for 6 hours...")
|
||||||
time.Sleep(time.Hour * 6)
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get ynab capital gains transaction ID: %v", err)
|
return fmt.Errorf("failed to set YNAB account balance: %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 nil
|
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
|
||||||
|
}
|
||||||
|
@ -2,6 +2,7 @@ package ynab
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -38,3 +39,43 @@ func (c *Client) GetAccount(accountID string) (*Accounts, error) {
|
|||||||
|
|
||||||
return &response, nil
|
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
|
||||||
|
}
|
||||||
|
@ -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
|
// 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"
|
// 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())
|
ynabTransactions, err := c.GetAccountTransactions(accountID, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("failed to get ynab transactions: %v", err)
|
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
|
// 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 := TransactionRequest{}
|
||||||
transaction.Transaction.AccountID = accountID
|
transaction.Transaction.AccountID = accountID
|
||||||
transaction.Transaction.Amount = amount
|
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
|
// 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 := TransactionRequest{}
|
||||||
transaction.Transaction.AccountID = accountID
|
transaction.Transaction.AccountID = accountID
|
||||||
transaction.Transaction.ID = transactionID
|
transaction.Transaction.ID = transactionID
|
||||||
|
Loading…
Reference in New Issue
Block a user