Compare commits
32 Commits
9bc4b463ef
...
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 |
@ -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:
|
||||
|
||||
@ -24,4 +24,4 @@ steps:
|
||||
- name: package in docker container
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.deadbeef.codes/ynab-portfolio-monitor
|
||||
repo: registry.stevenpolley.net/ynab-portfolio-monitor
|
||||
|
@ -1,7 +1,6 @@
|
||||
FROM scratch
|
||||
LABEL maintainer="himself@stevenpolley.net"
|
||||
COPY data data
|
||||
COPY public public
|
||||
COPY templates templates
|
||||
COPY ca-certificates.crt /etc/ssl/certs/
|
||||
COPY zoneinfo.zip zoneinfo.zip
|
||||
|
@ -1,6 +1,6 @@
|
||||
# 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. 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.
|
||||
|
||||
@ -8,7 +8,7 @@ It syncs your balance like magic!
|
||||
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: https://deadbeef.codes/steven/ynab-portfolio-monitor/raw/branch/main/example-image.png "It syncs your balance like magic!"
|
||||
[logo]: https://code.stevenpolley.net/steven/ynab-portfolio-monitor/raw/branch/main/example-image.png "It syncs your balance like magic!"
|
||||
|
||||
### Architecture
|
||||
|
||||
@ -56,7 +56,7 @@ version: '3.8'
|
||||
services:
|
||||
|
||||
ynab-portfolio-monitor:
|
||||
image: registry.deadbeef.codes/ynab-portfolio-monitor:latest
|
||||
image: registry.stevenpolley.net/ynab-portfolio-monitor:latest
|
||||
restart: always
|
||||
environment:
|
||||
- TZ=America/Edmonton
|
||||
|
@ -1,10 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/bitcoin"
|
||||
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/questrade"
|
||||
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/staticjsonFinnhub"
|
||||
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/staticjsonYahooFinance"
|
||||
"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
|
||||
|
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
|
13
main.go
13
main.go
@ -7,7 +7,7 @@ import (
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"deadbeef.codes/steven/ynab-portfolio-monitor/ynab"
|
||||
"code.stevenpolley.net/steven/ynab-portfolio-monitor/ynab"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -86,16 +86,21 @@ func refreshData() {
|
||||
}
|
||||
lastRefresh = time.Now()
|
||||
|
||||
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)
|
||||
continue
|
||||
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))
|
||||
continue
|
||||
return
|
||||
}
|
||||
for i := range balances {
|
||||
err = ynabClient.SetAccountBalance(accountIDs[i], balances[i])
|
||||
@ -103,5 +108,7 @@ func refreshData() {
|
||||
log.Printf("failed to update ynab account '%s' balance: %v", accountIDs[i], err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
@ -6,3 +6,4 @@ A provider for bitcoin, backed by blockstream.info to query address balance and
|
||||
|
||||
* *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
|
@ -5,7 +5,7 @@ import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Address struct {
|
||||
type addressData struct {
|
||||
Address string `json:"address"`
|
||||
ChainStats struct {
|
||||
FundedTxoCount int `json:"funded_txo_count"`
|
||||
@ -25,8 +25,8 @@ type Address struct {
|
||||
|
||||
// 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{}
|
||||
func (c *client) getAddress(address string) (*addressData, error) {
|
||||
addressResponse := &addressData{}
|
||||
|
||||
err := c.get(fmt.Sprintf("address/%s", address), addressResponse, url.Values{})
|
||||
if err != nil {
|
||||
|
@ -17,6 +17,7 @@ const apiBaseURL = "https://blockstream.info/api/"
|
||||
type client struct {
|
||||
httpClient *http.Client
|
||||
transport *http.Transport
|
||||
coinGeckoApiKey string
|
||||
}
|
||||
|
||||
// Send an HTTP GET request, and return the processed response
|
||||
@ -61,7 +62,7 @@ func (c *client) processResponse(res *http.Response, out interface{}) error {
|
||||
}
|
||||
|
||||
// newClient is the factory function for clients
|
||||
func newClient() (*client, error) {
|
||||
func newClient(coinGeckoApiKey string) *client {
|
||||
transport := &http.Transport{
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
@ -74,6 +75,7 @@ func newClient() (*client, error) {
|
||||
c := &client{
|
||||
httpClient: httpClient,
|
||||
transport: transport,
|
||||
coinGeckoApiKey: coinGeckoApiKey,
|
||||
}
|
||||
return c, nil
|
||||
return c
|
||||
}
|
||||
|
@ -5,46 +5,9 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
/* // CoinConvert BTC to CAD converter
|
||||
// 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
|
||||
}
|
||||
*/
|
||||
|
||||
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 FiatConversion struct {
|
||||
type coinGeckoResponse struct {
|
||||
MarketData struct {
|
||||
CurrentPrice struct {
|
||||
CAD int `json:"cad"`
|
||||
@ -52,10 +15,10 @@ type FiatConversion struct {
|
||||
} `json:"market_data"`
|
||||
}
|
||||
|
||||
func (c *client) ConvertBTCToCAD(amount int) (int, error) {
|
||||
fiatConversion := &FiatConversion{}
|
||||
func (c *client) convertBTCToCAD(amount int) (int, error) {
|
||||
coinGeckoData := &coinGeckoResponse{}
|
||||
|
||||
req, err := http.NewRequest("GET", fiatConvertURL, nil)
|
||||
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)
|
||||
}
|
||||
@ -65,10 +28,10 @@ func (c *client) ConvertBTCToCAD(amount int) (int, error) {
|
||||
return 0, fmt.Errorf("http GET request failed: %v", err)
|
||||
}
|
||||
|
||||
err = c.processResponse(res, fiatConversion)
|
||||
err = c.processResponse(res, coinGeckoData)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to process response: %v", err)
|
||||
}
|
||||
|
||||
return (amount * int(fiatConversion.MarketData.CurrentPrice.CAD*1000)) / 100000000, nil // one BTC = one hundred million satoshi's
|
||||
return (amount * int(coinGeckoData.MarketData.CurrentPrice.CAD*1000)) / 100000000, nil // one BTC = one hundred million satoshi's
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package bitcoin
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
@ -12,14 +13,12 @@ type Provider struct {
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string {
|
||||
return "Bitcoin - Blockstream.info"
|
||||
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 {
|
||||
var err 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
|
||||
@ -37,10 +36,7 @@ func (p *Provider) Configure() error {
|
||||
p.ynabAccountID = os.Getenv("bitcoin_ynab_account")
|
||||
|
||||
// Create new HTTP client
|
||||
p.client, err = newClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create new bitcoin client: %v", err)
|
||||
}
|
||||
p.client = newClient(os.Getenv("bitcoin_coingecko_api_key"))
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -50,16 +46,30 @@ 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 {
|
||||
return balances, ynabAccountIDs, fmt.Errorf("failed to get bitcoin address '%s': %v", bitcoinAddress, err)
|
||||
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)
|
||||
fiatBalance, err := p.client.convertBTCToCAD(satoshiBalance)
|
||||
if err != nil {
|
||||
return balances, ynabAccountIDs, fmt.Errorf("failed to convert satoshi balance to fiat balance: %v", err)
|
||||
}
|
||||
|
@ -71,7 +71,7 @@ func (p *Provider) Configure() error {
|
||||
// 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()) {
|
||||
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)
|
||||
|
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
|
||||
}
|
@ -3,9 +3,11 @@ package staticjsonYahooFinance
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type chart struct {
|
||||
// A chartResponse response is what we get back from Yahoo Finance
|
||||
type chartResponse struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Meta struct {
|
||||
@ -68,8 +70,16 @@ type chart struct {
|
||||
} `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) {
|
||||
chartResponse := &chart{}
|
||||
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)
|
||||
@ -79,5 +89,7 @@ func (c client) getChart(symbol string) (int, error) {
|
||||
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
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiBaseURL = "https://query1.finance.yahoo.com/v8/finance/"
|
||||
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,
|
||||
@ -26,6 +26,9 @@ func (c *client) get(endpoint string, out interface{}, query url.Values) error {
|
||||
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)
|
||||
@ -50,7 +53,7 @@ func (c *client) processResponse(res *http.Response, out interface{}) error {
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return fmt.Errorf("got http response status '%d' but expected 200", res.StatusCode)
|
||||
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)
|
||||
|
@ -10,7 +10,7 @@ import (
|
||||
|
||||
type security struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Quantity int `json:"quantity"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type account struct {
|
||||
@ -47,6 +47,8 @@ func (p *Provider) Configure() error {
|
||||
return fmt.Errorf("failed to create new client: %v", err)
|
||||
}
|
||||
|
||||
chartCache = make(map[string]chartCacheEntry)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -61,7 +63,7 @@ func (p *Provider) GetBalances() ([]int, []string, error) {
|
||||
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
|
||||
balance += int(float64(price) * p.data.Accounts[i].Securities[j].Quantity)
|
||||
}
|
||||
balances = append(balances, balance)
|
||||
ynabAccountIDs = append(ynabAccountIDs, p.data.Accounts[i].YnabAccountID)
|
||||
|
@ -1,3 +0,0 @@
|
||||
# Public
|
||||
|
||||
This directory is publicly accessible from the HTTP server and contains web assets such as images, css and javascript.
|
@ -32,7 +32,15 @@
|
||||
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;
|
||||
@ -149,8 +157,8 @@
|
||||
</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>
|
@ -7,9 +7,6 @@ import (
|
||||
|
||||
func webServer() {
|
||||
|
||||
//Public static files
|
||||
http.Handle("/public/", http.StripPrefix("/public/", http.FileServer(http.Dir("public"))))
|
||||
|
||||
// Page Handlers
|
||||
// Anything that is responsible for the base elements of a viewable web page
|
||||
http.HandleFunc("/", homePageHandler)
|
||||
@ -32,9 +29,10 @@ func homePageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("error executing home.html template: %v", err)
|
||||
}
|
||||
|
||||
//http.Redirect(w, r, fmt.Sprintf("https://app.ynab.com/%s", ynabClient.BudgetID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
@ -8,7 +8,7 @@ import (
|
||||
|
||||
// Reference: https://api.ynab.com/v1#/Accounts/
|
||||
|
||||
type Accounts struct {
|
||||
type accounts struct {
|
||||
Data struct {
|
||||
Account struct {
|
||||
ID string `json:"id"`
|
||||
@ -29,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 {
|
||||
@ -49,7 +49,7 @@ func (c *Client) SetAccountBalance(accountID string, balance int) error {
|
||||
return fmt.Errorf("failed to get ynab capital gains transaction ID: %v", err)
|
||||
}
|
||||
|
||||
ynabAccount, err := c.GetAccount(accountID)
|
||||
ynabAccount, err := c.getAccount(accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get ynab account with id '%s': %v", accountID, err)
|
||||
}
|
||||
@ -64,22 +64,11 @@ func (c *Client) SetAccountBalance(accountID string, balance int) error {
|
||||
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)
|
||||
}
|
||||
log.Printf("Capital gains transaction for YNAB account '%s' amount is: %d", accountID, balanceDelta)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ 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
|
||||
@ -32,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 {
|
||||
@ -58,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)
|
||||
@ -85,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)
|
||||
@ -140,7 +140,7 @@ func NewClient(budgetID, bearerToken string) (*Client, error) {
|
||||
// Create a new client
|
||||
c := &Client{
|
||||
BudgetID: budgetID,
|
||||
BearerToken: bearerToken,
|
||||
bearerToken: bearerToken,
|
||||
httpClient: client,
|
||||
transport: transport,
|
||||
loc: loc,
|
||||
|
@ -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,32 +31,32 @@ type BaseTransaction struct {
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// Used for single transaction requests
|
||||
type transactionRequest struct {
|
||||
Transaction transaction `json:"transaction,omitempty"`
|
||||
}
|
||||
|
||||
// Used for single transaction responses
|
||||
type Transaction struct {
|
||||
type transactionResponse struct {
|
||||
Data struct {
|
||||
TransactionIDs []string `json:"transaction_ids,omitempty"`
|
||||
Transaction BaseTransaction `json:"transaction"`
|
||||
Transaction transaction `json:"transaction"`
|
||||
ServerKnowledge int `json:"server_knowledge,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
// Used for single transaction requests
|
||||
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"`
|
||||
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"))
|
||||
|
||||
@ -84,43 +84,30 @@ func (c *Client) getTodayYnabCapitalGainsTransaction(accountID string) (string,
|
||||
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().In(c.loc).Format("2006-01-02")
|
||||
transaction.Transaction.Cleared = "cleared"
|
||||
transaction.Transaction.Approved = true
|
||||
transaction.Transaction.PayeeName = "Capital Gains or Losses"
|
||||
transaction.Transaction.Memo = fmt.Sprintf("Quoted at: %s", time.Now().In(c.loc).Format("2006-01-02 15:04:05"))
|
||||
|
||||
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, 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 {
|
||||
transaction := TransactionRequest{}
|
||||
transaction.Transaction.AccountID = accountID
|
||||
transaction.Transaction.ID = transactionID
|
||||
transaction.Transaction.Amount = amount
|
||||
transaction.Transaction.Date = time.Now().In(c.loc).Format("2006-01-02")
|
||||
transaction.Transaction.Cleared = "cleared"
|
||||
transaction.Transaction.Approved = true
|
||||
transaction.Transaction.PayeeName = "Capital Gains or Losses"
|
||||
transaction.Transaction.Memo = fmt.Sprintf("Quoted at: %s", time.Now().In(c.loc).Format("2006-01-02 15:04:05"))
|
||||
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.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