Compare commits
70 Commits
f592bf77a6
...
main
Author | SHA1 | Date | |
---|---|---|---|
3c274c614b | |||
61074bfd80 | |||
13291da691 | |||
3fbfbab7d6 | |||
a34dca1076 | |||
0110941ac7 | |||
43cd399c18 | |||
88552ba042 | |||
068004ba14 | |||
287acc03eb | |||
c4a79b0f4c | |||
6ed332d8b6 | |||
5e401c06ae | |||
7689e3e1f2 | |||
c119f1f57c | |||
647f9a8f7b | |||
a7d0005423 | |||
39f3b27a8b | |||
3ae78f3b32 | |||
7ce58c03d7 | |||
54417bf436 | |||
e35f0ef659 | |||
92a6246052 | |||
7d52632af6 | |||
079ab596f8 | |||
e6d62a3e7b | |||
9d4ca8ca9f | |||
acd6728c19 | |||
13d3c2e77c | |||
65d4780a3b | |||
6bf5a48d3f | |||
b146ef3170 | |||
9bc4b463ef | |||
ce4615d833 | |||
4f320952b0 | |||
b82aaee05d | |||
0d4010f1dd | |||
47e460a9dc | |||
5eea9ede3a | |||
81672fbd19 | |||
a3bcb61bae | |||
fb4b22cd08 | |||
4c904e7196 | |||
0a518fd31a | |||
4c01dc4ea2 | |||
64f7eed9fd | |||
8a61bba688 | |||
4ff4c62daf | |||
77e467071b | |||
f3a7df7de9 | |||
3691ab496e | |||
80f7641bef | |||
1328310dbd | |||
feaa07e251 | |||
a95df7c42b | |||
7a2e79682c | |||
2a13fc9182 | |||
5f830294b0 | |||
6436707a5d | |||
58d4d0ba41 | |||
be7e068fe6 | |||
67fcfeb177 | |||
7cdf013b1e | |||
7284545571 | |||
82f9c94d10 | |||
bb7d0a29ea | |||
b690c20a90 | |||
1ca36eaba9 | |||
c139d7b713 | |||
362e895b2e |
@ -3,7 +3,7 @@ name: default
|
||||
|
||||
workspace:
|
||||
base: /go
|
||||
path: src/deadbeef.codes/steven/ynab-portfolio-monitor
|
||||
path: src/code.stevenpolley.net/steven/ynab-portfolio-monitor
|
||||
|
||||
steps:
|
||||
|
||||
@ -18,9 +18,10 @@ steps:
|
||||
- go version
|
||||
- go get
|
||||
- go build -a -ldflags '-w'
|
||||
- cp /usr/local/go/lib/time/zoneinfo.zip .
|
||||
- cp /etc/ssl/certs/ca-certificates.crt .
|
||||
|
||||
- name: package in docker container
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.deadbeef.codes/ynab-portfolio-monitor
|
||||
repo: registry.stevenpolley.net/ynab-portfolio-monitor
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,4 @@
|
||||
env.sh
|
||||
ynab-portfolio-monitor
|
||||
ynab-portfolio-monitor.exe
|
||||
data/persistentData.json
|
||||
data/*.json
|
@ -1,9 +1,12 @@
|
||||
FROM scratch
|
||||
LABEL maintainer="himself@stevenpolley.net"
|
||||
COPY data data
|
||||
COPY templates templates
|
||||
COPY ca-certificates.crt /etc/ssl/certs/
|
||||
COPY zoneinfo.zip zoneinfo.zip
|
||||
COPY ynab-portfolio-monitor .
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD [ "./ynab-portfolio-monitor" ]
|
||||
ENV ZONEINFO zoneinfo.zip
|
||||
CMD [ "./ynab-portfolio-monitor" ]
|
82
README.md
82
README.md
@ -1,5 +1,83 @@
|
||||
# ynab-portfolio-monitor
|
||||
|
||||
[](https://drone.deadbeef.codes/steven/ynab-portfolio-monitor)
|
||||
[](https://drone.stevenpolley.net/steven/ynab-portfolio-monitor)
|
||||
|
||||
Track your securities in YNAB for account types and update your balance automatically.
|
||||
Track your securities in YNAB for account types and update your balance automatically. For each configured account, it will update the balance from your broker in YNAB every 6 hours by creating / editing a transaction named "Capital Gains or Losses". On days that exchanges are closed, it will not do anything. The end result is that there will be transaction each day with payee "Capital Gains or Losses" in YNAB for each account you configure, which allows tracking your account balance over time.
|
||||
|
||||
It syncs your balance like magic!
|
||||
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: https://code.stevenpolley.net/steven/ynab-portfolio-monitor/raw/branch/main/example-image.png "It syncs your balance like magic!"
|
||||
|
||||
### Architecture
|
||||
|
||||
No bloat, uses only the standard library, separate all integrations into their own package.
|
||||
|
||||
#### Main Package
|
||||
|
||||
Each account provider / integration is defined in its own package and adheres to the interface specified in accountProviders.go. When the program starts, init() inside main.go attempts to configure all providers, and uses providers where configuration succeeds and ignores providers if configuration fails (eg: missing environment variable or config file). init() will run again if SIGHUP is received to reload configuration.
|
||||
|
||||
The main() program loop refreshes data every 6 hours by looping through each configured provider and calling AccountProvider.GetBalances(). This returns a slice of balances, and a slice of YNAB account IDs. The index position of the balance slice maps with the index of the YNAB account ID slice. This allows a single provider to provide multiple balances for different YNAB accounts depending on the provider's configuration (eg: if you have multiple accounts at your broker). We loop through the balance slice and update each YNAB account balance with the value. Finally, sleep for 6 hours before doing it again.
|
||||
|
||||
#### Provider Packages
|
||||
|
||||
Provider packages are used for any integration and are found in their own sub directories. Providers must adhere to the interface speficied in accountProviders.go.
|
||||
|
||||
```golang
|
||||
// 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
|
||||
}
|
||||
```
|
||||
|
||||
By convention, these methods are implemented in a file called providerImpl.go in each of the provider packages.
|
||||
|
||||
The following providers are currently available:
|
||||
|
||||
* bitcoin
|
||||
* questrade
|
||||
* staticjsonFinnhub
|
||||
* staticjsonYahooFinance
|
||||
|
||||
#### YNAB Package
|
||||
|
||||
This is a special package used for messaging to and from YNAB, instead of adhering to the AccountProvider interface, it exposes multiple public methods for use in the Main package's business logic.
|
||||
|
||||
### Example docker-compose.yml
|
||||
|
||||
The values below are examples only, and show how to configure the Questrade and Bitcoin providers. With these providers, you can configure as many account pairings as you want using environment variables in a continuous series starting from 0 as shown below. Two example Questrade accounts are configured ending in _0 and _1 but a third can be added by adding account numbers/ID with _2. See the respective README.md files inside the provider directories for more details.
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
|
||||
ynab-portfolio-monitor:
|
||||
image: registry.stevenpolley.net/ynab-portfolio-monitor:latest
|
||||
restart: always
|
||||
environment:
|
||||
- TZ=America/Edmonton
|
||||
- questrade_refresh_token=4dsO6652dS3cxtcctscd3ds4Df2E0
|
||||
- questrade_account_0=51000001 # TFSA
|
||||
- questrade_account_1=51000002 # RRSP
|
||||
- questrade_ynab_account_0=731af51e-cb40-4d4a-8094-8654e59e11fc # TFSA
|
||||
- questrade_ynab_account_1=78e76e45-2fbe-4ab1-84e9-64ba0996d015 # RRSP
|
||||
- ynab_budget_id=76566452-67ff-4642-99d1-47d752216fb3
|
||||
- ynab_secret=98Q_J655F_TAyGnhCCDS4uqRe4R5654DT2d-ZXdssZ
|
||||
- bitcoin_address_0=bc1qg0edu4tr7pza8qsxf576r0eulr2ygt3mhldswg
|
||||
- bitcoin_address_1=bc1qeh0dkdqvjyt646657lge0nxqj67z5xa8zxl8q3
|
||||
- bitcoin_address_2=bc1qsfsdqvj4443t64dfssfgexsaqj67z44dsjkfkj
|
||||
- bitcoin_ynab_account=1f5bec0d-f852-2fbe-bbee-02fa98ded566 # Bitcoin addresses map to single YNAB account
|
||||
volumes:
|
||||
- /data/ynab-portfolio-monitor-data:/data
|
||||
|
||||
```
|
||||
|
||||
### Static JSON Providers
|
||||
|
||||
If your broker doesn't have a provider available, or if you don't want to connect this application to your brokerage account, there is an offline solution available where you can configure your holdings in local static JSON files - and these only need to be updated if your holdings change such as after a trade has been made, or a stock split.
|
||||
|
||||
For more details, see the README.md files located in the provider directories prefixed with "staticjson".
|
24
accountProviders.go
Normal file
24
accountProviders.go
Normal file
@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/bitcoin"
|
||||
"code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/questrade"
|
||||
"code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/staticjsonFinnhub"
|
||||
"code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/staticjsonYahooFinance"
|
||||
)
|
||||
|
||||
// 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{},
|
||||
&staticjsonFinnhub.Provider{},
|
||||
&staticjsonYahooFinance.Provider{},
|
||||
}
|
BIN
example-image.png
Normal file
BIN
example-image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 61 KiB |
6
go.mod
6
go.mod
@ -1,3 +1,5 @@
|
||||
module deadbeef.codes/steven/ynab-portfolio-monitor
|
||||
module code.stevenpolley.net/steven/ynab-portfolio-monitor
|
||||
|
||||
go 1.21.4
|
||||
go 1.22
|
||||
|
||||
// Goal is no third party dependencies
|
161
main.go
161
main.go
@ -1,30 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"deadbeef.codes/steven/ynab-portfolio-monitor/questrade"
|
||||
"deadbeef.codes/steven/ynab-portfolio-monitor/ynab"
|
||||
"code.stevenpolley.net/steven/ynab-portfolio-monitor/ynab"
|
||||
)
|
||||
|
||||
var (
|
||||
persistentData *PersistentData
|
||||
questradeClient *questrade.Client
|
||||
ynabClient *ynab.Client
|
||||
questradeAccountIDs []int
|
||||
ynabAccountIDs []string
|
||||
configuredProviders []AccountProvider // Any account providers that are successfully configured get added to this slice
|
||||
ynabClient *ynab.Client // YNAB HTTP client
|
||||
lastRefresh time.Time // Timestamp that last data refresh ran - used for rate limiting
|
||||
refreshRunning *sync.Mutex // True if a refresh is currently running
|
||||
t *template.Template // HTML templates in the templates directory
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
@ -35,111 +34,81 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
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 account providers and attempt to configure them
|
||||
// if configuration fails, the provider will not be used
|
||||
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)
|
||||
ynabAccountIDs = append(ynabAccountIDs, ynabAccountID)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Web Templates
|
||||
// Parse all template files at startup
|
||||
t, err = template.ParseGlob("./templates/*")
|
||||
if err != nil {
|
||||
log.Fatalf("couldn't parse HTML templates: %v", err)
|
||||
}
|
||||
|
||||
refreshRunning = &sync.Mutex{}
|
||||
}
|
||||
|
||||
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.Fatalf("failed to create questrade client: %v", 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
|
||||
|
||||
// Update ComputerShare account
|
||||
go webServer()
|
||||
|
||||
for { // Main program loop
|
||||
refreshData()
|
||||
log.Print("Sleeping for 6 hours...")
|
||||
time.Sleep(time.Hour * 6)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func syncQuestrade() error {
|
||||
func refreshData() {
|
||||
refreshRunning.Lock()
|
||||
defer refreshRunning.Unlock()
|
||||
|
||||
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)
|
||||
}
|
||||
// Only allow a refresh at most once every 5 minutes
|
||||
if time.Now().Before(lastRefresh.Add(time.Minute * 5)) {
|
||||
log.Printf("refresh rate limited")
|
||||
|
||||
ynabTransactionID, ynabTransactionAmount, err := ynabClient.GetTodayYnabCapitalGainsTransaction(ynabAccountIDs[i])
|
||||
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
|
||||
}
|
||||
lastRefresh = time.Now()
|
||||
|
||||
return nil
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
// Loop through each configured account provider and attempt to get the account balances, and update YNAB
|
||||
for _, p := range configuredProviders {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
balances, accountIDs, err := p.GetBalances()
|
||||
if err != nil {
|
||||
log.Printf("failed to get balances with provider '%s': %v", p.Name(), err)
|
||||
return
|
||||
}
|
||||
if len(balances) != len(accountIDs) {
|
||||
log.Printf("'%s' provider data validation error: mismatched balance and accountID slice lengths - expected the same: balances length = %d, accountIDs length = %d", p.Name(), len(balances), len(accountIDs))
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
@ -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
|
||||
}
|
21
providers/README.md
Normal file
21
providers/README.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Provider Packages
|
||||
|
||||
Provider packages are used for any integration and are found in their own sub directories. Providers must adhere to the interface speficied in accountProviders.go.
|
||||
|
||||
```golang
|
||||
// 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
|
||||
}
|
||||
```
|
||||
|
||||
By convention, these methods are implemented in a file called providerImpl.go in each of the provider packages.
|
||||
|
||||
The following providers are currently available:
|
||||
|
||||
* bitcoin
|
||||
* questrade
|
||||
* staticjsonFinnhub
|
||||
* staticjsonYahooFinance
|
9
providers/bitcoin/README.md
Normal file
9
providers/bitcoin/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Bitcoin Provider
|
||||
|
||||
A provider for bitcoin, backed by blockstream.info to query address balance and coinconvert.net for fiat conversion.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
* *bitcoin_address_[0...]* - A series of bitcoin addresses with a suffix of _0 and each additional address counting up.
|
||||
* *bitcoin_ynab_account* - The YNAB account ID used to keep track of Bitcoin balance
|
||||
* *bitcoin_coingecko_api_key* - Required for fiat conversion, your addresses nor balances are not transmitted. Docs: https://docs.coingecko.com/reference/setting-up-your-api-key
|
36
providers/bitcoin/address.go
Normal file
36
providers/bitcoin/address.go
Normal file
@ -0,0 +1,36 @@
|
||||
package bitcoin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type addressData 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) (*addressData, error) {
|
||||
addressResponse := &addressData{}
|
||||
|
||||
err := c.get(fmt.Sprintf("address/%s", address), addressResponse, url.Values{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return addressResponse, nil
|
||||
}
|
81
providers/bitcoin/client.go
Normal file
81
providers/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
|
||||
coinGeckoApiKey string
|
||||
}
|
||||
|
||||
// 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(coinGeckoApiKey string) *client {
|
||||
transport := &http.Transport{
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
// Create a new client
|
||||
c := &client{
|
||||
httpClient: httpClient,
|
||||
transport: transport,
|
||||
coinGeckoApiKey: coinGeckoApiKey,
|
||||
}
|
||||
return c
|
||||
}
|
37
providers/bitcoin/fiat.go
Normal file
37
providers/bitcoin/fiat.go
Normal file
@ -0,0 +1,37 @@
|
||||
package bitcoin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const fiatConvertURL = "https://api.coingecko.com/api/v3/coins/bitcoin?localization=false&tickers=false&market_data=true&community_data=false&developer_data=false&sparkline=false"
|
||||
|
||||
type coinGeckoResponse struct {
|
||||
MarketData struct {
|
||||
CurrentPrice struct {
|
||||
CAD int `json:"cad"`
|
||||
} `json:"current_price"`
|
||||
} `json:"market_data"`
|
||||
}
|
||||
|
||||
func (c *client) convertBTCToCAD(amount int) (int, error) {
|
||||
coinGeckoData := &coinGeckoResponse{}
|
||||
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s&x-cg-demo-api-key=%s", fiatConvertURL, c.coinGeckoApiKey), 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, coinGeckoData)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to process response: %v", err)
|
||||
}
|
||||
|
||||
return (amount * int(coinGeckoData.MarketData.CurrentPrice.CAD*1000)) / 100000000, nil // one BTC = one hundred million satoshi's
|
||||
}
|
80
providers/bitcoin/providerImpl.go
Normal file
80
providers/bitcoin/providerImpl.go
Normal file
@ -0,0 +1,80 @@
|
||||
package bitcoin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
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 / CoinGecko"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 = newClient(os.Getenv("bitcoin_coingecko_api_key"))
|
||||
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
|
||||
wg := sync.WaitGroup{}
|
||||
var goErr *error
|
||||
|
||||
for _, bitcoinAddress := range p.bitcoinAddresses {
|
||||
wg.Add(1)
|
||||
go func(goErr *error) {
|
||||
defer wg.Done()
|
||||
addressResponse, err := p.client.getAddress(bitcoinAddress)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("failed to get BTC balance for bitcoin address '%s': %v", bitcoinAddress, err)
|
||||
if err != nil {
|
||||
goErr = &err
|
||||
}
|
||||
return
|
||||
}
|
||||
satoshiBalance += addressResponse.ChainStats.FundedTxoSum - addressResponse.ChainStats.SpentTxoSum
|
||||
}(goErr)
|
||||
}
|
||||
wg.Wait()
|
||||
if goErr != nil {
|
||||
return nil, nil, *goErr
|
||||
}
|
||||
|
||||
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
|
||||
}
|
8
providers/questrade/README.md
Normal file
8
providers/questrade/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# Questrade Provider
|
||||
|
||||
A provider for Questrade using the Questrade API.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
* *questrade_account_[0...]* - A series of Questrade account numbers with a suffix of _0 and each additional account counting up.
|
||||
* *questrade_ynab_account_[0...]* - The YNAB account ID for the matching questrade account. Each configured questrade account must have a matching YNAB account.
|
@ -10,7 +10,7 @@ import (
|
||||
// of which the API client is authorized.
|
||||
//
|
||||
// Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts
|
||||
type Account struct {
|
||||
type account struct {
|
||||
// Type of the account (e.g., "Cash", "Margin").
|
||||
Type string `json:"type"`
|
||||
|
||||
@ -34,7 +34,7 @@ type Account struct {
|
||||
// Balance belonging to an Account
|
||||
//
|
||||
// Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts-id-balances
|
||||
type Balance struct {
|
||||
type balance struct {
|
||||
|
||||
// Currency of the balance figure(e.g., "USD" or "CAD").
|
||||
Currency string `json:"currency"`
|
||||
@ -61,42 +61,40 @@ type Balance struct {
|
||||
// AccountBalances represents per-currency and combined balances for a specified account.
|
||||
//
|
||||
// Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts-id-balances
|
||||
type AccountBalances struct {
|
||||
PerCurrencyBalances []Balance `json:"perCurrencyBalances"`
|
||||
CombinedBalances []Balance `json:"combinedBalances"`
|
||||
SODPerCurrencyBalances []Balance `json:"sodPerCurrencyBalances"`
|
||||
SODCombinedBalances []Balance `json:"sodCombinedBalances"`
|
||||
type accountBalances struct {
|
||||
PerCurrencyBalances []balance `json:"perCurrencyBalances"`
|
||||
CombinedBalances []balance `json:"combinedBalances"`
|
||||
SODPerCurrencyBalances []balance `json:"sodPerCurrencyBalances"`
|
||||
SODCombinedBalances []balance `json:"sodCombinedBalances"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Accounts []account `json:"accounts"`
|
||||
}{}
|
||||
|
||||
err := c.get("v1/accounts", &list, url.Values{})
|
||||
if err != nil {
|
||||
return 0, []Account{}, err
|
||||
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) {
|
||||
bal := AccountBalances{}
|
||||
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 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")
|
||||
}
|
@ -12,7 +12,7 @@ import (
|
||||
|
||||
const loginServerURL = "https://login.questrade.com/oauth2/"
|
||||
|
||||
type LoginCredentials struct {
|
||||
type loginCredentials struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
@ -23,8 +23,8 @@ 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 {
|
||||
Credentials LoginCredentials
|
||||
type client struct {
|
||||
Credentials loginCredentials
|
||||
SessionTimer *time.Timer
|
||||
RateLimitRemaining int
|
||||
RateLimitReset time.Time
|
||||
@ -34,13 +34,13 @@ type Client struct {
|
||||
|
||||
// authHeader is a shortcut that returns a string to be placed
|
||||
// in the authorization header of API calls
|
||||
func (l LoginCredentials) authHeader() string {
|
||||
func (l loginCredentials) authHeader() string {
|
||||
return l.TokenType + " " + l.AccessToken
|
||||
}
|
||||
|
||||
// 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", c.Credentials.ApiServer+endpoint+query.Encode(), nil)
|
||||
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,11 +88,9 @@ 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 {
|
||||
login := loginServerURL
|
||||
|
||||
func (c *client) login() error {
|
||||
vars := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {c.Credentials.RefreshToken}}
|
||||
res, err := c.httpClient.PostForm(login+"token", vars)
|
||||
res, err := c.httpClient.PostForm(loginServerURL+"token", vars)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@ -105,33 +102,31 @@ 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{
|
||||
Credentials: LoginCredentials{
|
||||
c := &client{
|
||||
Credentials: loginCredentials{
|
||||
RefreshToken: refreshToken,
|
||||
},
|
||||
httpClient: client,
|
||||
httpClient: httpClient,
|
||||
transport: transport,
|
||||
}
|
||||
|
||||
err := c.Login()
|
||||
err := c.login()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
@ -24,7 +24,6 @@ func newQuestradeError(res *http.Response, body []byte) QuestradeError {
|
||||
|
||||
e.StatusCode = res.StatusCode
|
||||
e.Endpoint = res.Request.URL.String()
|
||||
|
||||
return e
|
||||
}
|
||||
|
147
providers/questrade/providerImpl.go
Normal file
147
providers/questrade/providerImpl.go
Normal file
@ -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()) || p.client == nil {
|
||||
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.json: %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
|
||||
}
|
48
providers/staticjsonFinnhub/README.md
Normal file
48
providers/staticjsonFinnhub/README.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Static JSON Finnhub Provider
|
||||
|
||||
If you just want to provide a static JSON file as input containing symbols and quantities owned, this provider will use finnhub for pricing quotes for the symbols provided.
|
||||
|
||||
### Example data/staticjsonFinnhub-data.json
|
||||
|
||||
You can define many to many relationships, multiple YNAB accounts containing multiple types of securities using json arrays.
|
||||
|
||||
```json
|
||||
{
|
||||
"accounts":[
|
||||
{
|
||||
"ynabAccountId":"d54da05a-cs20-4654-bcff-9ce36f43225d",
|
||||
"securities":[
|
||||
{
|
||||
"symbol":"SPY",
|
||||
"quantity":420
|
||||
},
|
||||
{
|
||||
"symbol":"BRK.A",
|
||||
"quantity":5
|
||||
},
|
||||
{
|
||||
"symbol":"CAPE",
|
||||
"quantity":69
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ynabAccountId":"fdfedg45-c2g1-445-abdd-9dsa445sd54",
|
||||
"securities":[
|
||||
{
|
||||
"symbol":"VCN.TO",
|
||||
"quantity":100
|
||||
},
|
||||
{
|
||||
"symbol":"XSB.TO",
|
||||
"quantity":50
|
||||
},
|
||||
{
|
||||
"symbol":"DLR.TO",
|
||||
"quantity":20
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
84
providers/staticjsonFinnhub/client.go
Normal file
84
providers/staticjsonFinnhub/client.go
Normal file
@ -0,0 +1,84 @@
|
||||
package staticjsonFinnhub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiBaseURL = "https://finnhub.io/api/v1/"
|
||||
|
||||
// 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 {
|
||||
apiToken string
|
||||
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 err
|
||||
}
|
||||
req.Header.Add("X-Finnhub-Token", c.apiToken)
|
||||
|
||||
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 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 err
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return fmt.Errorf("got http response status '%d' but expected 200", res.StatusCode)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newClient is the factory function for clients - takes an API token
|
||||
func newClient(apiToken string) (*client, error) {
|
||||
transport := &http.Transport{
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
// Create a new client
|
||||
c := &client{
|
||||
apiToken: apiToken,
|
||||
httpClient: httpClient,
|
||||
transport: transport,
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
100
providers/staticjsonFinnhub/providerImpl.go
Normal file
100
providers/staticjsonFinnhub/providerImpl.go
Normal file
@ -0,0 +1,100 @@
|
||||
package staticjsonFinnhub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type security struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
type account struct {
|
||||
YnabAccountID string `json:"ynabAccountId"`
|
||||
Securities []security `json:"securities"`
|
||||
}
|
||||
|
||||
type inputData struct {
|
||||
Accounts []account `json:"accounts"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
data *inputData // Data stored on disk and loaded when program starts
|
||||
client *client // HTTP client for interacting with Finnhub API
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string {
|
||||
return "Static JSON - Finnhub"
|
||||
}
|
||||
|
||||
// Configures the provider for usage via environment variables and inputData
|
||||
// If an error is returned, the provider will not be used
|
||||
func (p *Provider) Configure() error {
|
||||
var err error
|
||||
|
||||
// Load input data from disk
|
||||
p.data, err = loadInputData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(p.data)
|
||||
fmt.Println(string(b))
|
||||
|
||||
apiKey := os.Getenv("staticjson_finnhub_key")
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("this account provider is not configured: missing staticjson_finnhub_key environment variable")
|
||||
}
|
||||
|
||||
p.client, err = newClient(apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create new 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)
|
||||
for i := range p.data.Accounts {
|
||||
balance := 0
|
||||
for j := range p.data.Accounts[i].Securities {
|
||||
price, err := p.client.getQuote(p.data.Accounts[i].Securities[j].Symbol)
|
||||
if err != nil {
|
||||
return balances, ynabAccountIDs, fmt.Errorf("failed to get quote for security with symbol '%s': %v", p.data.Accounts[i].Securities[j].Symbol, err)
|
||||
}
|
||||
balance += price * p.data.Accounts[i].Securities[j].Quantity
|
||||
}
|
||||
balances = append(balances, balance)
|
||||
ynabAccountIDs = append(ynabAccountIDs, p.data.Accounts[i].YnabAccountID)
|
||||
}
|
||||
return balances, ynabAccountIDs, nil
|
||||
}
|
||||
|
||||
// Load input data from disk
|
||||
func loadInputData() (*inputData, error) {
|
||||
data := &inputData{}
|
||||
|
||||
f, err := os.Open("data/staticjsonFinnhub-data.json")
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("this account provider is not configured: missing data/staticjsonFinnhub-data.json")
|
||||
}
|
||||
defer f.Close()
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file data/staticjsonFinnhub-data.json: %v", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal data/staticjsonFinnhub-data.json to inputData struct: %v", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
26
providers/staticjsonFinnhub/quote.go
Normal file
26
providers/staticjsonFinnhub/quote.go
Normal file
@ -0,0 +1,26 @@
|
||||
package staticjsonFinnhub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type quote struct {
|
||||
C float64 `json:"c"` // Current price
|
||||
H float64 `json:"h"` // High price of the day
|
||||
L float64 `json:"l"` // Low price of the day
|
||||
O float64 `json:"O"` // Open price of the day
|
||||
Pc float64 `json:"pc"` // Previous close price
|
||||
T int `json:"t"` // ?
|
||||
}
|
||||
|
||||
func (c client) getQuote(symbol string) (int, error) {
|
||||
quoteResponse := "e{}
|
||||
query := url.Values{}
|
||||
query.Add("symbol", symbol)
|
||||
err := c.get("/quote", quoteResponse, query)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("http get request error: %v", err)
|
||||
}
|
||||
return int(quoteResponse.C * 1000), nil
|
||||
}
|
48
providers/staticjsonYahooFinance/README.md
Normal file
48
providers/staticjsonYahooFinance/README.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Static JSON Yahoo Finance Provider
|
||||
|
||||
If you just want to provide a static JSON file as input containing symbols and quantities owned, this provider will use Yahoo Finance for pricing quotes for the symbols provided.
|
||||
|
||||
### Example data/staticjsonYahooFinance-data.json
|
||||
|
||||
You can define many to many relationships, multiple YNAB accounts containing multiple types of securities using json arrays.
|
||||
|
||||
```json
|
||||
{
|
||||
"accounts":[
|
||||
{
|
||||
"ynabAccountId":"d54da05a-cs20-4654-bcff-9ce36f43225d",
|
||||
"securities":[
|
||||
{
|
||||
"symbol":"SPY",
|
||||
"quantity":420
|
||||
},
|
||||
{
|
||||
"symbol":"BRK.A",
|
||||
"quantity":5
|
||||
},
|
||||
{
|
||||
"symbol":"CAPE",
|
||||
"quantity":69
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ynabAccountId":"fdfedg45-c2g1-445-abdd-9dsa445sd54",
|
||||
"securities":[
|
||||
{
|
||||
"symbol":"VCN.TO",
|
||||
"quantity":100
|
||||
},
|
||||
{
|
||||
"symbol":"XSB.TO",
|
||||
"quantity":50
|
||||
},
|
||||
{
|
||||
"symbol":"DLR.TO",
|
||||
"quantity":20
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
15
providers/staticjsonYahooFinance/cache.go
Normal file
15
providers/staticjsonYahooFinance/cache.go
Normal file
@ -0,0 +1,15 @@
|
||||
package staticjsonYahooFinance
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const cacheAgeSeconds = 900
|
||||
|
||||
// intialized in providerImpl.go's Configure
|
||||
var chartCache map[string]chartCacheEntry
|
||||
|
||||
type chartCacheEntry struct {
|
||||
Chart chartResponse
|
||||
LastUpdated time.Time
|
||||
}
|
95
providers/staticjsonYahooFinance/chart.go
Normal file
95
providers/staticjsonYahooFinance/chart.go
Normal file
@ -0,0 +1,95 @@
|
||||
package staticjsonYahooFinance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A chartResponse response is what we get back from Yahoo Finance
|
||||
type chartResponse struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Meta struct {
|
||||
Currency string `json:"currency"`
|
||||
Symbol string `json:"symbol"`
|
||||
ExchangeName string `json:"exchangeName"`
|
||||
InstrumentType string `json:"instrumentType"`
|
||||
FirstTradeDate int `json:"firstTradeDate"`
|
||||
RegularMarketTime int `json:"regularMarketTime"`
|
||||
Gmtoffset int `json:"gmtoffset"`
|
||||
Timezone string `json:"timezone"`
|
||||
ExchangeTimezoneName string `json:"exchangeTimezoneName"`
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||
PreviousClose float64 `json:"previousClose"`
|
||||
Scale int `json:"scale"`
|
||||
PriceHint int `json:"priceHint"`
|
||||
CurrentTradingPeriod struct {
|
||||
Pre struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
Gmtoffset int `json:"gmtoffset"`
|
||||
} `json:"pre"`
|
||||
Regular struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
Gmtoffset int `json:"gmtoffset"`
|
||||
} `json:"regular"`
|
||||
Post struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
Gmtoffset int `json:"gmtoffset"`
|
||||
} `json:"post"`
|
||||
} `json:"currentTradingPeriod"`
|
||||
TradingPeriods [][]struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
Gmtoffset int `json:"gmtoffset"`
|
||||
} `json:"tradingPeriods"`
|
||||
DataGranularity string `json:"dataGranularity"`
|
||||
Range string `json:"range"`
|
||||
ValidRanges []string `json:"validRanges"`
|
||||
} `json:"meta"`
|
||||
Timestamp []int `json:"timestamp"`
|
||||
Indicators struct {
|
||||
Quote []struct {
|
||||
Volume []any `json:"volume"`
|
||||
High []any `json:"high"`
|
||||
Close []any `json:"close"`
|
||||
Open []any `json:"open"`
|
||||
Low []any `json:"low"`
|
||||
} `json:"quote"`
|
||||
} `json:"indicators"`
|
||||
} `json:"result"`
|
||||
Error any `json:"error"`
|
||||
} `json:"chart"`
|
||||
}
|
||||
|
||||
// getChart first checks if the symbol chartCacheEntry is valid,
|
||||
// and if not then it downloads fresh chart data
|
||||
func (c client) getChart(symbol string) (int, error) {
|
||||
if cacheItem, ok := chartCache[symbol]; ok {
|
||||
// if the cacheEntry is still valid, use it
|
||||
if time.Now().Before(cacheItem.LastUpdated.Add(cacheAgeSeconds)) {
|
||||
return int(cacheItem.Chart.Chart.Result[0].Meta.RegularMarketPrice * 1000), nil
|
||||
}
|
||||
}
|
||||
chartResponse := &chartResponse{}
|
||||
err := c.get(fmt.Sprintf("/chart/%s", symbol), chartResponse, url.Values{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("http get request error: %v", err)
|
||||
}
|
||||
|
||||
if len(chartResponse.Chart.Result) != 1 {
|
||||
return 0, fmt.Errorf("unexpected length of results - expected 1 but got %d", len(chartResponse.Chart.Result))
|
||||
}
|
||||
|
||||
chartCache[symbol] = chartCacheEntry{Chart: *chartResponse, LastUpdated: time.Now()}
|
||||
|
||||
return int(chartResponse.Chart.Result[0].Meta.RegularMarketPrice * 1000), nil
|
||||
}
|
84
providers/staticjsonYahooFinance/client.go
Normal file
84
providers/staticjsonYahooFinance/client.go
Normal file
@ -0,0 +1,84 @@
|
||||
package staticjsonYahooFinance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiBaseURL = "https://query1.finance.yahoo.com/v8/finance"
|
||||
|
||||
// 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 err
|
||||
}
|
||||
|
||||
// Yahoo finance requires user agent string now?
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0")
|
||||
|
||||
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 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 err
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return fmt.Errorf("got http response status '%d' but expected 200 for request at URL '%s'", res.StatusCode, res.Request.URL)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newClient is the factory function for clients - takes an API token
|
||||
func newClient() (*client, error) {
|
||||
transport := &http.Transport{
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
// Create a new client
|
||||
c := &client{
|
||||
httpClient: httpClient,
|
||||
transport: transport,
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
94
providers/staticjsonYahooFinance/providerImpl.go
Normal file
94
providers/staticjsonYahooFinance/providerImpl.go
Normal file
@ -0,0 +1,94 @@
|
||||
package staticjsonYahooFinance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type security struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type account struct {
|
||||
YnabAccountID string `json:"ynabAccountId"`
|
||||
Securities []security `json:"securities"`
|
||||
}
|
||||
|
||||
type inputData struct {
|
||||
Accounts []account `json:"accounts"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
data *inputData // Data stored on disk and loaded when program starts
|
||||
client *client // HTTP client for interacting with Finnhub API
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string {
|
||||
return "Static JSON - Yahoo Finance"
|
||||
}
|
||||
|
||||
// Configures the provider for usage via environment variables and inputData
|
||||
// If an error is returned, the provider will not be used
|
||||
func (p *Provider) Configure() error {
|
||||
var err error
|
||||
|
||||
// Load input data from disk
|
||||
p.data, err = loadInputData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.client, err = newClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create new client: %v", err)
|
||||
}
|
||||
|
||||
chartCache = make(map[string]chartCacheEntry)
|
||||
|
||||
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)
|
||||
for i := range p.data.Accounts {
|
||||
balance := 0
|
||||
for j := range p.data.Accounts[i].Securities {
|
||||
price, err := p.client.getChart(p.data.Accounts[i].Securities[j].Symbol)
|
||||
if err != nil {
|
||||
return balances, ynabAccountIDs, fmt.Errorf("failed to get quote for security with symbol '%s': %v", p.data.Accounts[i].Securities[j].Symbol, err)
|
||||
}
|
||||
balance += int(float64(price) * p.data.Accounts[i].Securities[j].Quantity)
|
||||
}
|
||||
balances = append(balances, balance)
|
||||
ynabAccountIDs = append(ynabAccountIDs, p.data.Accounts[i].YnabAccountID)
|
||||
}
|
||||
return balances, ynabAccountIDs, nil
|
||||
}
|
||||
|
||||
// Load input data from disk
|
||||
func loadInputData() (*inputData, error) {
|
||||
data := &inputData{}
|
||||
|
||||
f, err := os.Open("data/staticjsonYahooFinance-data.json")
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("this account provider is not configured: missing data/staticjsonYahooFinance-data.json")
|
||||
}
|
||||
defer f.Close()
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file data/staticjsonYahooFinance-data.json: %v", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal data/staticjsonYahooFinance-data.json to inputData struct: %v", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
3
templates/README.md
Normal file
3
templates/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Templates
|
||||
|
||||
This directory contains HTML templates used by the Go templating engine.
|
164
templates/home.html
Normal file
164
templates/home.html
Normal file
@ -0,0 +1,164 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>YNAB Portfolio Monitor</title>
|
||||
<script>
|
||||
req = new XMLHttpRequest();
|
||||
req.open("GET", "/status");
|
||||
req.send();
|
||||
req.onload = () => {
|
||||
if (req.readyState == 4 && req.status == 200) {
|
||||
// redirect to budget when refresh complete signal is received
|
||||
window.location.href = "https://app.ynab.com/{{ .BudgetID }}"
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.loader {
|
||||
background: #2c396a;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 99999;
|
||||
}
|
||||
.loader h1 {
|
||||
color: #fff;
|
||||
color: var(--label_dark_background);
|
||||
font-family: 'Figtree', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center
|
||||
}
|
||||
.loader h3 {
|
||||
color: #fff;
|
||||
color: var(--label_dark_background);
|
||||
font-family: 'Figtree', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center
|
||||
}
|
||||
.loader-inner {
|
||||
bottom: 0;
|
||||
height: 60px;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.loader-inner-header {
|
||||
bottom: 0;
|
||||
height: 80px;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 250;
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.loader-line-wrap {
|
||||
animation:
|
||||
spin 2000ms cubic-bezier(.175, .885, .32, 1.275) infinite
|
||||
;
|
||||
box-sizing: border-box;
|
||||
height: 50px;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform-origin: 50% 100%;
|
||||
width: 100px;
|
||||
}
|
||||
.loader-line {
|
||||
border: 4px solid transparent;
|
||||
border-radius: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 100px;
|
||||
left: 0;
|
||||
margin: 0 auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100px;
|
||||
}
|
||||
.loader-line-wrap:nth-child(1) { animation-delay: -50ms; }
|
||||
.loader-line-wrap:nth-child(2) { animation-delay: -100ms; }
|
||||
.loader-line-wrap:nth-child(3) { animation-delay: -150ms; }
|
||||
.loader-line-wrap:nth-child(4) { animation-delay: -200ms; }
|
||||
.loader-line-wrap:nth-child(5) { animation-delay: -250ms; }
|
||||
|
||||
.loader-line-wrap:nth-child(1) .loader-line {
|
||||
border-color: hsl(0, 80%, 60%);
|
||||
height: 90px;
|
||||
width: 90px;
|
||||
top: 7px;
|
||||
}
|
||||
.loader-line-wrap:nth-child(2) .loader-line {
|
||||
border-color: hsl(60, 80%, 60%);
|
||||
height: 76px;
|
||||
width: 76px;
|
||||
top: 14px;
|
||||
}
|
||||
.loader-line-wrap:nth-child(3) .loader-line {
|
||||
border-color: hsl(120, 80%, 60%);
|
||||
height: 62px;
|
||||
width: 62px;
|
||||
top: 21px;
|
||||
}
|
||||
.loader-line-wrap:nth-child(4) .loader-line {
|
||||
border-color: hsl(180, 80%, 60%);
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
top: 28px;
|
||||
}
|
||||
.loader-line-wrap:nth-child(5) .loader-line {
|
||||
border-color: hsl(240, 80%, 60%);
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
top: 35px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0%, 15% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background-color: rgb(44, 57, 106);font-family: 'Figtree','Helvetica Neue',Helvetica,Arial,sans-serif;">
|
||||
|
||||
<div class="loader">
|
||||
<div class="loader-inner">
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loader-inner-header">
|
||||
<h1 style="color:white";>YNAB Portfolio Sync</h1>
|
||||
<h3 style="color:#fff";>Created by Steven Polley</h3>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
40
webServer.go
Normal file
40
webServer.go
Normal file
@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func webServer() {
|
||||
|
||||
// Page Handlers
|
||||
// Anything that is responsible for the base elements of a viewable web page
|
||||
http.HandleFunc("/", homePageHandler)
|
||||
http.HandleFunc("/status", statusHandler)
|
||||
|
||||
// Start web server
|
||||
log.Print("Service listening on :8080")
|
||||
log.Printf("Web server unexpectedly exiting!: %v", http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
type homePageData struct {
|
||||
BudgetID string
|
||||
}
|
||||
|
||||
func homePageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
go refreshData()
|
||||
pageData := &homePageData{BudgetID: ynabClient.BudgetID}
|
||||
err := t.ExecuteTemplate(w, "home.html", pageData)
|
||||
if err != nil {
|
||||
log.Printf("error executing home.html template: %v", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Returns status 200 if a refresh is not running, otherwise waits for refresh to finish
|
||||
// Can be used by clients to tell when a refresh finishes
|
||||
func statusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
refreshRunning.Lock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
refreshRunning.Unlock()
|
||||
}
|
4
ynab/README.md
Normal file
4
ynab/README.md
Normal file
@ -0,0 +1,4 @@
|
||||
# YNAB API
|
||||
|
||||
This is not considered an account provider as it does not use the AccountProvider interface, instead this package is used for interacting with YNAB, creating transactions, updating transactions, getting account balances, etc.
|
||||
|
@ -2,12 +2,13 @@ package ynab
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Reference: https://api.ynab.com/v1#/Accounts/
|
||||
|
||||
type Accounts struct {
|
||||
type accounts struct {
|
||||
Data struct {
|
||||
Account struct {
|
||||
ID string `json:"id"`
|
||||
@ -28,8 +29,8 @@ type Accounts struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) GetAccount(accountID string) (*Accounts, error) {
|
||||
response := Accounts{}
|
||||
func (c *Client) getAccount(accountID string) (*accounts, error) {
|
||||
response := accounts{}
|
||||
|
||||
err := c.get(fmt.Sprintf("/accounts/%s", accountID), &response, url.Values{})
|
||||
if err != nil {
|
||||
@ -38,3 +39,36 @@ 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)
|
||||
}
|
||||
|
||||
if balance == ynabAccount.Data.Account.ClearedBalance {
|
||||
return nil // The YNAB account already has the balance we're requesting, so there is no need to do anything
|
||||
}
|
||||
|
||||
balanceDelta := balance - ynabAccount.Data.Account.ClearedBalance
|
||||
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
|
||||
}
|
||||
|
||||
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("Capital gains transaction for YNAB account '%s' amount is: %d", accountID, balanceDelta)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -18,10 +19,11 @@ const apiBaseURL = "https://api.ynab.com/v1/budgets/"
|
||||
// endpoints. It holds the login credentials, http client/transport,
|
||||
// rate limit information, and the login session timer.
|
||||
type Client struct {
|
||||
BearerToken string
|
||||
bearerToken string
|
||||
BudgetID string
|
||||
httpClient *http.Client
|
||||
transport *http.Transport
|
||||
loc *time.Location
|
||||
}
|
||||
|
||||
// Send an HTTP GET request, and return the processed response
|
||||
@ -30,7 +32,7 @@ func (c *Client) get(endpoint string, out interface{}, query url.Values) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create new GET request: %v", err)
|
||||
}
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.BearerToken))
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@ -56,7 +58,7 @@ func (c *Client) post(endpoint string, out interface{}, body interface{}) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create new POST request: %v", err)
|
||||
}
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.BearerToken))
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
@ -83,7 +85,7 @@ func (c *Client) put(endpoint string, out interface{}, body interface{}) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create new POST request: %v", err)
|
||||
}
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.BearerToken))
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
@ -117,7 +119,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
|
||||
}
|
||||
|
||||
@ -131,13 +132,18 @@ func NewClient(budgetID, bearerToken string) (*Client, error) {
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(os.Getenv("TZ"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load timezone location '%s': %v", os.Getenv("TZ"), err)
|
||||
}
|
||||
|
||||
// Create a new client
|
||||
c := &Client{
|
||||
BudgetID: budgetID,
|
||||
BearerToken: bearerToken,
|
||||
bearerToken: bearerToken,
|
||||
httpClient: client,
|
||||
transport: transport,
|
||||
loc: loc,
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
// Package ynab provides a very simple API client for getting account data and setting account balances.
|
||||
package ynab
|
||||
|
||||
import (
|
||||
@ -7,8 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// Reference: https://api.ynab.com/v1#/Transactions/
|
||||
|
||||
type BaseTransaction struct {
|
||||
type transaction struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
ParentTransactionID interface{} `json:"parent_transaction_id,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
@ -31,31 +31,32 @@ type BaseTransaction struct {
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// Used for single transaction requests / responses
|
||||
type Transaction struct {
|
||||
// Used for single transaction requests
|
||||
type transactionRequest struct {
|
||||
Transaction transaction `json:"transaction,omitempty"`
|
||||
}
|
||||
|
||||
// Used for single transaction responses
|
||||
type transactionResponse struct {
|
||||
Data struct {
|
||||
TransactionIDs []string `json:"transaction_ids,omitempty"`
|
||||
Transaction BaseTransaction `json:"transaction"`
|
||||
ServerKnowledge int `json:"server_knowledge,omitempty"`
|
||||
TransactionIDs []string `json:"transaction_ids,omitempty"`
|
||||
Transaction transaction `json:"transaction"`
|
||||
ServerKnowledge int `json:"server_knowledge,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
type TransactionRequest struct {
|
||||
Transaction BaseTransaction `json:"transaction,omitempty"`
|
||||
}
|
||||
|
||||
// Used for multiple transaction requests / responses
|
||||
type Transactions struct {
|
||||
type transactionListResponse struct {
|
||||
Data struct {
|
||||
Transactions []BaseTransaction `json:"transactions"`
|
||||
ServerKnowledge int `json:"server_knowledge"`
|
||||
Transactions []transaction `json:"transactions"`
|
||||
ServerKnowledge int `json:"server_knowledge"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Accepts a YNAB account ID and timestamp and returns all transactions in that account
|
||||
// since the date provided
|
||||
func (c *Client) GetAccountTransactions(accountID string, sinceDate time.Time) (*Transactions, error) {
|
||||
response := Transactions{}
|
||||
func (c *Client) GetAccountTransactions(accountID string, sinceDate time.Time) (*transactionListResponse, error) {
|
||||
response := transactionListResponse{}
|
||||
urlQuery := url.Values{}
|
||||
urlQuery.Add("since_date", sinceDate.Format("2006-01-02"))
|
||||
|
||||
@ -63,14 +64,13 @@ 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ynabTransactions, err := c.GetAccountTransactions(accountID, time.Now())
|
||||
func (c *Client) getTodayYnabCapitalGainsTransaction(accountID string) (string, int, error) {
|
||||
ynabTransactions, err := c.GetAccountTransactions(accountID, time.Now().In(c.loc))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to get ynab transactions: %v", err)
|
||||
}
|
||||
@ -81,45 +81,33 @@ func (c *Client) GetTodayYnabCapitalGainsTransaction(accountID string) (string,
|
||||
}
|
||||
return transaction.ID, transaction.Amount, nil
|
||||
}
|
||||
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
// Accepts a YNAB account ID and transaction amount and creates a new YNAB transaction
|
||||
func (c *Client) CreateTodayYNABCapitalGainsTransaction(accountID string, amount int) error {
|
||||
transaction := TransactionRequest{}
|
||||
transaction.Transaction.AccountID = accountID
|
||||
transaction.Transaction.Amount = amount
|
||||
transaction.Transaction.Date = time.Now().Format("2006-01-02")
|
||||
transaction.Transaction.Cleared = "cleared"
|
||||
transaction.Transaction.Approved = true
|
||||
transaction.Transaction.PayeeName = "Capital Gains or Losses"
|
||||
// Accepts a YNAB account ID, transaction ID and transaction amount and updates the YNAB transaction with the matching ID
|
||||
// If transaction ID is blank, a new transaction is created for the amount specified
|
||||
func (c *Client) updateTodayYNABCapitalGainsTransaction(accountID string, transactionID string, amount int) error {
|
||||
request := transactionRequest{
|
||||
Transaction: transaction{
|
||||
AccountID: accountID,
|
||||
Amount: amount,
|
||||
Date: time.Now().In(c.loc).Format("2006-01-02"),
|
||||
Cleared: "cleared",
|
||||
Approved: true,
|
||||
PayeeName: "Capital Gains or Losses",
|
||||
Memo: fmt.Sprintf("Quoted at: %s", time.Now().In(c.loc).Format("2006-01-02 15:04:05")),
|
||||
},
|
||||
}
|
||||
response := &transactionResponse{}
|
||||
var err error
|
||||
if transactionID == "" { // create transaction
|
||||
err = c.post("/transactions", response, request)
|
||||
} else { // update existing transaction
|
||||
err = c.put(fmt.Sprintf("/transactions/%s", transactionID), response, request)
|
||||
}
|
||||
|
||||
response := &Transaction{}
|
||||
|
||||
err := c.post("/transactions", response, transaction)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to post transaction: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accepts a YNAB account ID and transaction amount and creates a new YNAB transaction
|
||||
func (c *Client) UpdateTodayYNABCapitalGainsTransaction(accountID string, transactionID string, amount int) error {
|
||||
transaction := TransactionRequest{}
|
||||
transaction.Transaction.AccountID = accountID
|
||||
transaction.Transaction.ID = transactionID
|
||||
transaction.Transaction.Amount = amount
|
||||
transaction.Transaction.Date = time.Now().Format("2006-01-02")
|
||||
transaction.Transaction.Cleared = "cleared"
|
||||
transaction.Transaction.Approved = true
|
||||
transaction.Transaction.PayeeName = "Capital Gains or Losses"
|
||||
|
||||
response := &Transaction{}
|
||||
|
||||
err := c.put(fmt.Sprintf("/transactions/%s", transactionID), response, transaction)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to put transaction: %v", err)
|
||||
return fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user