Compare commits

...

22 Commits

Author SHA1 Message Date
3c274c614b add partial share ownership support (float)... and penny rounding errors.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2025-05-07 21:14:02 -06:00
61074bfd80 implement rudimentary caching for yahoo finance to avoid http 429 rate limiting
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 19:59:30 -06:00
13291da691 fix double slash in URL
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 12:16:55 -06:00
3fbfbab7d6 output URL in the https response error message
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 12:14:48 -06:00
a34dca1076 spoof user agent string for yahoo finance API
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 12:06:39 -06:00
0110941ac7 attempt to avoid yahoo finance rate limiting
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 11:57:33 -06:00
43cd399c18 Fix concurrent error handling for BTC provider
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2025-01-23 23:31:25 -07:00
88552ba042 fix nil pointer dereference
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-11-14 09:25:39 -07:00
068004ba14 bitcoin address failure - actually check the error
All checks were successful
continuous-integration/drone/push Build is passing
2024-11-11 06:25:26 -07:00
287acc03eb change from deadbeef.codes to code.stevenpolley.net 2024-11-11 06:24:37 -07:00
c4a79b0f4c do not update bitcoin balance if getting address balance fails
All checks were successful
continuous-integration/drone/push Build is passing
2024-11-08 13:17:01 -07:00
6ed332d8b6 fix nil pointer dereference if http error when getting btc address
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-09-23 08:59:42 -06:00
5e401c06ae switch to free api
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-09 17:47:17 -06:00
7689e3e1f2 fix fiat conversion - requires cg api key
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-09 17:43:14 -06:00
c119f1f57c get bitcoin addresses concurrently
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-09-01 09:05:21 -06:00
647f9a8f7b fix waitgroup instantiation
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-01 07:30:42 -06:00
a7d0005423 sync providers concurrently instead of in series
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-01 07:27:49 -06:00
39f3b27a8b update readme links
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-06-28 12:30:09 -06:00
3ae78f3b32 move from deadbeef.codes to stevenpolley.net
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-28 11:59:31 -06:00
7ce58c03d7 comments and formatting
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
2024-03-30 19:42:58 -06:00
54417bf436 don't export BearerToken
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 19:45:28 -06:00
e35f0ef659 do not export what's not required 2024-03-23 19:00:30 -06:00
18 changed files with 126 additions and 75 deletions

View File

@ -3,7 +3,7 @@ name: default
workspace: workspace:
base: /go base: /go
path: src/deadbeef.codes/steven/ynab-portfolio-monitor path: src/code.stevenpolley.net/steven/ynab-portfolio-monitor
steps: steps:
@ -24,4 +24,4 @@ steps:
- name: package in docker container - name: package in docker container
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.deadbeef.codes/ynab-portfolio-monitor repo: registry.stevenpolley.net/ynab-portfolio-monitor

View File

@ -1,6 +1,6 @@
# ynab-portfolio-monitor # ynab-portfolio-monitor
[![Build Status](https://drone.deadbeef.codes/api/badges/steven/ynab-portfolio-monitor/status.svg)](https://drone.deadbeef.codes/steven/ynab-portfolio-monitor) [![Build Status](https://drone.stevenpolley.net/api/badges/steven/ynab-portfolio-monitor/status.svg)](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. 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] ![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 ### Architecture
@ -56,7 +56,7 @@ version: '3.8'
services: services:
ynab-portfolio-monitor: ynab-portfolio-monitor:
image: registry.deadbeef.codes/ynab-portfolio-monitor:latest image: registry.stevenpolley.net/ynab-portfolio-monitor:latest
restart: always restart: always
environment: environment:
- TZ=America/Edmonton - TZ=America/Edmonton

View File

@ -1,10 +1,10 @@
package main package main
import ( import (
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/bitcoin" "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/bitcoin"
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/questrade" "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/questrade"
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/staticjsonFinnhub" "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/staticjsonFinnhub"
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/staticjsonYahooFinance" "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/staticjsonYahooFinance"
) )
// AccountProvider is the base set of requirements to be implemented for any integration // AccountProvider is the base set of requirements to be implemented for any integration

2
go.mod
View File

@ -1,4 +1,4 @@
module deadbeef.codes/steven/ynab-portfolio-monitor module code.stevenpolley.net/steven/ynab-portfolio-monitor
go 1.22 go 1.22

35
main.go
View File

@ -7,7 +7,7 @@ import (
"text/template" "text/template"
"time" "time"
"deadbeef.codes/steven/ynab-portfolio-monitor/ynab" "code.stevenpolley.net/steven/ynab-portfolio-monitor/ynab"
) )
var ( var (
@ -86,22 +86,29 @@ func refreshData() {
} }
lastRefresh = time.Now() lastRefresh = time.Now()
wg := sync.WaitGroup{}
// Loop through each configured account provider and attempt to get the account balances, and update YNAB // Loop through each configured account provider and attempt to get the account balances, and update YNAB
for _, p := range configuredProviders { for _, p := range configuredProviders {
balances, accountIDs, err := p.GetBalances() wg.Add(1)
if err != nil { go func() {
log.Printf("failed to get balances with provider '%s': %v", p.Name(), err) defer wg.Done()
continue balances, accountIDs, err := p.GetBalances()
}
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
}
for i := range balances {
err = ynabClient.SetAccountBalance(accountIDs[i], balances[i])
if err != nil { if err != nil {
log.Printf("failed to update ynab account '%s' balance: %v", accountIDs[i], err) 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()
} }

View File

@ -5,4 +5,5 @@ A provider for bitcoin, backed by blockstream.info to query address balance and
### Environment Variables ### Environment Variables
* *bitcoin_address_[0...]* - A series of bitcoin addresses with a suffix of _0 and each additional address counting up. * *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_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

View File

@ -15,8 +15,9 @@ const apiBaseURL = "https://blockstream.info/api/"
// endpoints. It holds the login credentials, http client/transport, // endpoints. It holds the login credentials, http client/transport,
// rate limit information, and the login session timer. // rate limit information, and the login session timer.
type client struct { type client struct {
httpClient *http.Client httpClient *http.Client
transport *http.Transport transport *http.Transport
coinGeckoApiKey string
} }
// Send an HTTP GET request, and return the processed response // 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 // newClient is the factory function for clients
func newClient() (*client, error) { func newClient(coinGeckoApiKey string) *client {
transport := &http.Transport{ transport := &http.Transport{
ResponseHeaderTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second,
} }
@ -72,8 +73,9 @@ func newClient() (*client, error) {
// Create a new client // Create a new client
c := &client{ c := &client{
httpClient: httpClient, httpClient: httpClient,
transport: transport, transport: transport,
coinGeckoApiKey: coinGeckoApiKey,
} }
return c, nil return c
} }

View File

@ -18,7 +18,7 @@ type coinGeckoResponse struct {
func (c *client) convertBTCToCAD(amount int) (int, error) { func (c *client) convertBTCToCAD(amount int) (int, error) {
coinGeckoData := &coinGeckoResponse{} 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 { if err != nil {
return 0, fmt.Errorf("failed to create new GET request: %v", err) return 0, fmt.Errorf("failed to create new GET request: %v", err)
} }

View File

@ -3,6 +3,7 @@ package bitcoin
import ( import (
"fmt" "fmt"
"os" "os"
"sync"
) )
type Provider struct { type Provider struct {
@ -12,14 +13,12 @@ type Provider struct {
} }
func (p *Provider) Name() string { func (p *Provider) Name() string {
return "Bitcoin - Blockstream.info" return "Bitcoin - Blockstream.info / CoinGecko"
} }
// Configures the provider for usage via environment variables and persistentData // Configures the provider for usage via environment variables and persistentData
// If an error is returned, the provider will not be used // If an error is returned, the provider will not be used
func (p *Provider) Configure() error { func (p *Provider) Configure() error {
var err error
// Load environment variables in continous series with suffix starting at 0 // Load environment variables in continous series with suffix starting at 0
// Multiple addresses can be configured, (eg _1, _2) // Multiple addresses can be configured, (eg _1, _2)
// As soon as the series is interrupted, we assume we're done // 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") p.ynabAccountID = os.Getenv("bitcoin_ynab_account")
// Create new HTTP client // Create new HTTP client
p.client, err = newClient() p.client = newClient(os.Getenv("bitcoin_coingecko_api_key"))
if err != nil {
return fmt.Errorf("failed to create new bitcoin client: %v", err)
}
return nil return nil
} }
@ -50,13 +46,27 @@ func (p *Provider) GetBalances() ([]int, []string, error) {
balances := make([]int, 0) balances := make([]int, 0)
ynabAccountIDs := make([]string, 0) ynabAccountIDs := make([]string, 0)
var satoshiBalance int var satoshiBalance int
for _, bitcoinAddress := range p.bitcoinAddresses { wg := sync.WaitGroup{}
addressResponse, err := p.client.getAddress(bitcoinAddress) var goErr *error
if err != nil {
return balances, ynabAccountIDs, fmt.Errorf("failed to get bitcoin address '%s': %v", bitcoinAddress, err)
}
satoshiBalance += addressResponse.ChainStats.FundedTxoSum - addressResponse.ChainStats.SpentTxoSum 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) fiatBalance, err := p.client.convertBTCToCAD(satoshiBalance)

View 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
}

View File

@ -3,9 +3,11 @@ package staticjsonYahooFinance
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"time"
) )
type chart struct { // A chartResponse response is what we get back from Yahoo Finance
type chartResponse struct {
Chart struct { Chart struct {
Result []struct { Result []struct {
Meta struct { Meta struct {
@ -68,8 +70,16 @@ type chart struct {
} `json:"chart"` } `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) { 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{}) err := c.get(fmt.Sprintf("/chart/%s", symbol), chartResponse, url.Values{})
if err != nil { if err != nil {
return 0, fmt.Errorf("http get request error: %v", err) 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)) 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 return int(chartResponse.Chart.Result[0].Meta.RegularMarketPrice * 1000), nil
} }

View File

@ -9,7 +9,7 @@ import (
"time" "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 // A client is the structure that will be used to consume the API
// endpoints. It holds the login credentials, http client/transport, // 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 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) res, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("http get request failed: %v", err) 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 { 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) err = json.Unmarshal(body, out)

View File

@ -9,8 +9,8 @@ import (
) )
type security struct { type security struct {
Symbol string `json:"symbol"` Symbol string `json:"symbol"`
Quantity int `json:"quantity"` Quantity float64 `json:"quantity"`
} }
type account struct { type account struct {
@ -47,6 +47,8 @@ func (p *Provider) Configure() error {
return fmt.Errorf("failed to create new client: %v", err) return fmt.Errorf("failed to create new client: %v", err)
} }
chartCache = make(map[string]chartCacheEntry)
return nil return nil
} }
@ -61,7 +63,7 @@ func (p *Provider) GetBalances() ([]int, []string, error) {
if err != nil { 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) 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) balances = append(balances, balance)
ynabAccountIDs = append(ynabAccountIDs, p.data.Accounts[i].YnabAccountID) ynabAccountIDs = append(ynabAccountIDs, p.data.Accounts[i].YnabAccountID)

View File

@ -160,6 +160,5 @@
<h3 style="color:#fff";>Created by Steven Polley</h3> <h3 style="color:#fff";>Created by Steven Polley</h3>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -29,7 +29,6 @@ func homePageHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("error executing home.html template: %v", err) 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 // Returns status 200 if a refresh is not running, otherwise waits for refresh to finish

View File

@ -8,7 +8,7 @@ import (
// Reference: https://api.ynab.com/v1#/Accounts/ // Reference: https://api.ynab.com/v1#/Accounts/
type Accounts struct { type accounts struct {
Data struct { Data struct {
Account struct { Account struct {
ID string `json:"id"` ID string `json:"id"`
@ -29,8 +29,8 @@ type Accounts struct {
} `json:"data"` } `json:"data"`
} }
func (c *Client) GetAccount(accountID string) (*Accounts, error) { func (c *Client) getAccount(accountID string) (*accounts, error) {
response := Accounts{} response := accounts{}
err := c.get(fmt.Sprintf("/accounts/%s", accountID), &response, url.Values{}) err := c.get(fmt.Sprintf("/accounts/%s", accountID), &response, url.Values{})
if err != nil { 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) 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 { if err != nil {
return fmt.Errorf("failed to get ynab account with id '%s': %v", accountID, err) return fmt.Errorf("failed to get ynab account with id '%s': %v", accountID, err)
} }

View File

@ -19,7 +19,7 @@ const apiBaseURL = "https://api.ynab.com/v1/budgets/"
// endpoints. It holds the login credentials, http client/transport, // endpoints. It holds the login credentials, http client/transport,
// rate limit information, and the login session timer. // rate limit information, and the login session timer.
type Client struct { type Client struct {
BearerToken string bearerToken string
BudgetID string BudgetID string
httpClient *http.Client httpClient *http.Client
transport *http.Transport transport *http.Transport
@ -32,7 +32,7 @@ func (c *Client) get(endpoint string, out interface{}, query url.Values) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create new GET request: %v", err) 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) res, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@ -58,7 +58,7 @@ func (c *Client) post(endpoint string, out interface{}, body interface{}) error
if err != nil { if err != nil {
return fmt.Errorf("failed to create new POST request: %v", err) 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") req.Header.Add("Content-Type", "application/json")
res, err := c.httpClient.Do(req) res, err := c.httpClient.Do(req)
@ -85,7 +85,7 @@ func (c *Client) put(endpoint string, out interface{}, body interface{}) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create new POST request: %v", err) 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") req.Header.Add("Content-Type", "application/json")
res, err := c.httpClient.Do(req) res, err := c.httpClient.Do(req)
@ -140,7 +140,7 @@ func NewClient(budgetID, bearerToken string) (*Client, error) {
// Create a new client // Create a new client
c := &Client{ c := &Client{
BudgetID: budgetID, BudgetID: budgetID,
BearerToken: bearerToken, bearerToken: bearerToken,
httpClient: client, httpClient: client,
transport: transport, transport: transport,
loc: loc, loc: loc,

View File

@ -1,3 +1,4 @@
// Package ynab provides a very simple API client for getting account data and setting account balances.
package ynab package ynab
import ( import (
@ -7,7 +8,7 @@ import (
) )
// Reference: https://api.ynab.com/v1#/Transactions/ // Reference: https://api.ynab.com/v1#/Transactions/
type Transaction struct { type transaction struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
ParentTransactionID interface{} `json:"parent_transaction_id,omitempty"` ParentTransactionID interface{} `json:"parent_transaction_id,omitempty"`
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
@ -31,31 +32,31 @@ type Transaction struct {
} }
// Used for single transaction requests // Used for single transaction requests
type TransactionRequest struct { type transactionRequest struct {
Transaction Transaction `json:"transaction,omitempty"` Transaction transaction `json:"transaction,omitempty"`
} }
// Used for single transaction responses // Used for single transaction responses
type TransactionResponse struct { type transactionResponse struct {
Data struct { Data struct {
TransactionIDs []string `json:"transaction_ids,omitempty"` TransactionIDs []string `json:"transaction_ids,omitempty"`
Transaction Transaction `json:"transaction"` Transaction transaction `json:"transaction"`
ServerKnowledge int `json:"server_knowledge,omitempty"` ServerKnowledge int `json:"server_knowledge,omitempty"`
} }
} }
// Used for multiple transaction requests / responses // Used for multiple transaction requests / responses
type TransactionsResponse struct { type transactionListResponse struct {
Data struct { Data struct {
Transactions []Transaction `json:"transactions"` Transactions []transaction `json:"transactions"`
ServerKnowledge int `json:"server_knowledge"` ServerKnowledge int `json:"server_knowledge"`
} `json:"data"` } `json:"data"`
} }
// Accepts a YNAB account ID and timestamp and returns all transactions in that account // Accepts a YNAB account ID and timestamp and returns all transactions in that account
// since the date provided // since the date provided
func (c *Client) GetAccountTransactions(accountID string, sinceDate time.Time) (*TransactionsResponse, error) { func (c *Client) GetAccountTransactions(accountID string, sinceDate time.Time) (*transactionListResponse, error) {
response := TransactionsResponse{} response := transactionListResponse{}
urlQuery := url.Values{} urlQuery := url.Values{}
urlQuery.Add("since_date", sinceDate.Format("2006-01-02")) urlQuery.Add("since_date", sinceDate.Format("2006-01-02"))
@ -86,8 +87,8 @@ func (c *Client) getTodayYnabCapitalGainsTransaction(accountID string) (string,
// Accepts a YNAB account ID, transaction ID and transaction amount and updates the YNAB transaction with the matching ID // Accepts a YNAB account ID, transaction ID and transaction amount and updates the YNAB transaction with the matching ID
// If transaction ID is blank, a new transaction is created for the amount specified // 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 { func (c *Client) updateTodayYNABCapitalGainsTransaction(accountID string, transactionID string, amount int) error {
request := TransactionRequest{ request := transactionRequest{
Transaction: Transaction{ Transaction: transaction{
AccountID: accountID, AccountID: accountID,
Amount: amount, Amount: amount,
Date: time.Now().In(c.loc).Format("2006-01-02"), Date: time.Now().In(c.loc).Format("2006-01-02"),
@ -97,7 +98,7 @@ func (c *Client) updateTodayYNABCapitalGainsTransaction(accountID string, transa
Memo: fmt.Sprintf("Quoted at: %s", time.Now().In(c.loc).Format("2006-01-02 15:04:05")), Memo: fmt.Sprintf("Quoted at: %s", time.Now().In(c.loc).Format("2006-01-02 15:04:05")),
}, },
} }
response := &TransactionResponse{} response := &transactionResponse{}
var err error var err error
if transactionID == "" { // create transaction if transactionID == "" { // create transaction
err = c.post("/transactions", response, request) err = c.post("/transactions", response, request)