diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..80520eb --- /dev/null +++ b/.drone.yml @@ -0,0 +1,26 @@ +kind: pipeline +name: default + +workspace: + base: /go + path: src/deadbeef.codes/steven/ynab-portfolio-monitor + +steps: + +- name: build server + image: golang + pull: always + environment: + GOOS: linux + GOARCH: amd64 + CGO_ENABLED: 0 + commands: + - go version + - go get + - go build -a -ldflags '-w' + - cp /etc/ssl/certs/ca-certificates.crt . + +- name: package in docker container + image: plugins/docker + settings: + repo: registry.deadbeef.codes/ynab-portfolio-monitor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0eb11f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +env.sh +ynab-portfolio-monitor +ynab-portfolio-monitor.exe +data/persistentData.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0da0aec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM scratch +LABEL maintainer="himself@stevenpolley.net" +COPY data data +COPY ca-certificates.crt /etc/ssl/certs/ +COPY ynab-portfolio-monitor . + +EXPOSE 8080 + +CMD [ "./ynab-portfolio-monitor" ] diff --git a/README.md b/README.md index 4798725..1e06282 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # 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) + Track your securities in YNAB for account types and update your balance automatically. \ No newline at end of file diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..0fb81e7 --- /dev/null +++ b/data/README.md @@ -0,0 +1 @@ +data that's required to persist or cache will be written to this directory \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d840ad6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module deadbeef.codes/steven/ynab-portfolio-monitor + +go 1.21.1 diff --git a/go.work b/go.work new file mode 100644 index 0000000..27aae81 --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.21.1 + +use ( + . + ./questrade +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..8a66a5e --- /dev/null +++ b/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "fmt" + "log" + "os" + "strconv" + "time" + + "deadbeef.codes/steven/ynab-portfolio-monitor/questrade" + "deadbeef.codes/steven/ynab-portfolio-monitor/ynab" +) + +var ( + persistentData *PersistentData + questradeClient *questrade.Client + ynabClient *ynab.Client + questradeAccountIDs []int + ynabAccountIDs []string +) + +func init() { + log.Printf("ynab-portfolio-monitor init") + + // Load 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") + + // Validate that all required environment variables are set + for key, value := range envVars { + if value == "" { + log.Fatalf("shell environment variable %s is not set", key) + } + } + + 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) + if err != nil { + log.Fatalf("failed to convert environment variable questrade_account_%d with value of '%s' to integer: %v", i, questradeAccountIDString, err) + } + questradeAccountIDs = append(questradeAccountIDs, questradeAccountID) + ynabAccountIDs = append(ynabAccountIDs, ynabAccountID) + } + + // Load persistent data + var err error + persistentData, err = loadPersistentData() + if err != nil { + log.Fatalf("failed to load persistent data: %v", err) + } + + // ynab client is static and has no persistent data so is initialized here and not in main program loop + ynabClient, err = ynab.NewClient(envVars["ynab_budget_id"], envVars["ynab_secret"]) + if err != nil { + log.Fatalf("failed to create ynab client: %v", err) + } +} + +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 + + log.Print("Sleeping for 6 hours...") + time.Sleep(time.Hour * 6) + + } + +} + +func syncQuestrade() error { + + 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) + } + + 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 nil +} diff --git a/persistentData.go b/persistentData.go new file mode 100644 index 0000000..a02e8a9 --- /dev/null +++ b/persistentData.go @@ -0,0 +1,51 @@ +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 +} diff --git a/questrade/account.go b/questrade/account.go new file mode 100644 index 0000000..fe1f090 --- /dev/null +++ b/questrade/account.go @@ -0,0 +1,114 @@ +package questrade + +import ( + "fmt" + "net/url" + "strconv" +) + +// Account represents an account associated with the user on behalf +// of which the API client is authorized. +// +// Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts +type Account struct { + // Type of the account (e.g., "Cash", "Margin"). + Type string `json:"type"` + + // Eight-digit account number (e.g., "26598145") + // Stored as a string, it's used for making account-related API calls + Number string `json:"number"` + + // Status of the account (e.g., Active). + Status string `json:"status"` + + // Whether this is a primary account for the holder. + IsPrimary bool `json:"isPrimary"` + + // Whether this account is one that gets billed for various expenses such as inactivity fees, market data, etc. + IsBilling bool `json:"isBilling"` + + // Type of client holding the account (e.g., "Individual"). + ClientAccountType string `json:"clientAccountType"` +} + +// Balance belonging to an Account +// +// Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts-id-balances +type Balance struct { + + // Currency of the balance figure(e.g., "USD" or "CAD"). + Currency string `json:"currency"` + + // Balance amount. + Cash float32 `json:"cash"` + + // Market value of all securities in the account in a given currency. + MarketValue float32 `json:"marketValue"` + + // Equity as a difference between cash and marketValue properties. + TotalEquity float32 `json:"totalEquity"` + + // Buying power for that particular currency side of the account. + BuyingPower float32 `json:"buyingPower"` + + // Maintenance excess for that particular side of the account. + MaintenanceExcess float32 `json:"maintenanceExcess"` + + // Whether real-time data was used to calculate the above values. + IsRealTime bool `json:"isRealTime"` +} + +// 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"` +} + +// GetAccounts returns the logged-in User ID, and a list of accounts +// belonging to that user. +func (c *Client) GetAccounts() (int, []Account, error) { + list := struct { + UserID int `json:"userId"` + Accounts []Account `json:"accounts"` + }{} + + err := c.get("v1/accounts", &list, url.Values{}) + if err != nil { + 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{} + + err := c.get("v1/accounts/"+number+"/balances", &bal, url.Values{}) + if err != nil { + return AccountBalances{}, err + } + + return bal, nil +} + +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) + } + + for _, balance := range balances.CombinedBalances { + if balance.Currency != "CAD" { + continue + } + + return int(balance.TotalEquity) * 1000, nil + } + + return 0, fmt.Errorf("could not find a CAD balance for this account in questade response") +} diff --git a/questrade/client.go b/questrade/client.go new file mode 100644 index 0000000..bcd62a3 --- /dev/null +++ b/questrade/client.go @@ -0,0 +1,137 @@ +package questrade + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +const loginServerURL = "https://login.questrade.com/oauth2/" + +type LoginCredentials struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + ApiServer string `json:"api_server"` +} + +// 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 + SessionTimer *time.Timer + RateLimitRemaining int + RateLimitReset time.Time + httpClient *http.Client + transport *http.Transport +} + +// authHeader is a shortcut that returns a string to be placed +// in the authorization header of API calls +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) + if err != nil { + return err + } + req.Header.Add("Authorization", c.Credentials.authHeader()) + + 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 newQuestradeError(res, body) + } + + err = json.Unmarshal(body, out) + if err != nil { + return err + } + + 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 +} + +// Login takes the refresh token from the client login credentials +// 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 + + vars := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {c.Credentials.RefreshToken}} + res, err := c.httpClient.PostForm(login+"token", vars) + + if err != nil { + return err + } + + err = c.processResponse(res, &c.Credentials) + if err != nil { + return err + } + + 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) { + transport := &http.Transport{ + ResponseHeaderTimeout: 5 * time.Second, + } + + client := &http.Client{ + Transport: transport, + } + + // Create a new client + c := &Client{ + Credentials: LoginCredentials{ + RefreshToken: refreshToken, + }, + httpClient: client, + transport: transport, + } + + err := c.Login() + if err != nil { + return nil, err + } + + return c, nil +} diff --git a/questrade/errors.go b/questrade/errors.go new file mode 100644 index 0000000..4b74caa --- /dev/null +++ b/questrade/errors.go @@ -0,0 +1,41 @@ +package questrade + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type QuestradeError struct { + Code int `json:"code"` + StatusCode int + Message string `json:"message"` + Endpoint string +} + +func newQuestradeError(res *http.Response, body []byte) QuestradeError { + // Unmarshall the error text + var e QuestradeError + err := json.Unmarshal(body, &e) + if err != nil { + e.Code = -999 + e.Message = string(body) + } + + e.StatusCode = res.StatusCode + e.Endpoint = res.Request.URL.String() + + return e +} + +func (q QuestradeError) Error() string { + return fmt.Sprintf("\nQuestradeError:\n"+ + "\tStatus code: HTTP %d\n"+ + "\tEndpoint: %s\n"+ + "\tError code: %d\n"+ + "\tMessage: %s\n", + q.StatusCode, + q.Endpoint, + q.Code, + q.Message) +} diff --git a/questrade/go.mod b/questrade/go.mod new file mode 100644 index 0000000..d81b514 --- /dev/null +++ b/questrade/go.mod @@ -0,0 +1,3 @@ +module deadbeef.codes/steven/ynab-portfolio-monitor/questrade + +go 1.21.1 diff --git a/ynab/accounts.go b/ynab/accounts.go new file mode 100644 index 0000000..4b0f5ee --- /dev/null +++ b/ynab/accounts.go @@ -0,0 +1,40 @@ +package ynab + +import ( + "fmt" + "net/url" +) + +// Reference: https://api.ynab.com/v1#/Accounts/ + +type Accounts struct { + Data struct { + Account struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + OnBudget bool `json:"on_budget"` + Closed bool `json:"closed"` + Note interface{} `json:"note"` + Balance int `json:"balance"` + ClearedBalance int `json:"cleared_balance"` + UnclearedBalance int `json:"uncleared_balance"` + TransferPayeeID string `json:"transfer_payee_id"` + DirectImportLinked bool `json:"direct_import_linked"` + DirectImportInError bool `json:"direct_import_in_error"` + Deleted bool `json:"deleted"` + } `json:"account"` + ServerKnowledge int `json:"server_knowledge"` + } `json:"data"` +} + +func (c *Client) GetAccount(accountID string) (*Accounts, error) { + response := Accounts{} + + err := c.get(fmt.Sprintf("/accounts/%s", accountID), &response, url.Values{}) + if err != nil { + return nil, fmt.Errorf("failed to get accounts: %v", err) + } + + return &response, nil +} diff --git a/ynab/client.go b/ynab/client.go new file mode 100644 index 0000000..9a6e894 --- /dev/null +++ b/ynab/client.go @@ -0,0 +1,143 @@ +package ynab + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Reference: https://api.ynab.com/ + +const apiBaseURL = "https://api.ynab.com/v1/budgets/" + +// 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 { + BearerToken string + BudgetID 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+c.BudgetID+endpoint+"?"+query.Encode(), nil) + if err != nil { + return fmt.Errorf("failed to create new GET request: %v", err) + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.BearerToken)) + + 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 +} + +// Format the message body, send an HTTP POST request, and return the processed response +func (c *Client) post(endpoint string, out interface{}, body interface{}) error { + // Attempt to marshall the body as JSON + json, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal body to json: %v", err) + } + + req, err := http.NewRequest("POST", apiBaseURL+c.BudgetID+endpoint, bytes.NewBuffer(json)) + 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("Content-Type", "application/json") + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("http POST request failed: %v", err) + } + + err = c.processResponse(res, out) + if err != nil { + return fmt.Errorf("failed to process response: %v", err) + } + return nil +} + +// Format the message body, send an HTTP POST request, and return the processed response +func (c *Client) put(endpoint string, out interface{}, body interface{}) error { + // Attempt to marshall the body as JSON + json, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal body to json: %v", err) + } + + req, err := http.NewRequest("PUT", apiBaseURL+c.BudgetID+endpoint, bytes.NewBuffer(json)) + 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("Content-Type", "application/json") + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("http POST 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 - takes a bearer token +func NewClient(budgetID, bearerToken string) (*Client, error) { + transport := &http.Transport{ + ResponseHeaderTimeout: 5 * time.Second, + } + + client := &http.Client{ + Transport: transport, + } + + // Create a new client + c := &Client{ + BudgetID: budgetID, + BearerToken: bearerToken, + httpClient: client, + transport: transport, + } + + return c, nil +} diff --git a/ynab/transactions.go b/ynab/transactions.go new file mode 100644 index 0000000..f1d6047 --- /dev/null +++ b/ynab/transactions.go @@ -0,0 +1,125 @@ +package ynab + +import ( + "fmt" + "net/url" + "time" +) + +// Reference: https://api.ynab.com/v1#/Transactions/ + +type BaseTransaction struct { + Type string `json:"type,omitempty"` + ParentTransactionID interface{} `json:"parent_transaction_id,omitempty"` + ID string `json:"id,omitempty"` + Date string `json:"date,omitempty"` + Amount int `json:"amount,omitempty"` + Memo string `json:"memo,omitempty"` + Cleared string `json:"cleared,omitempty"` + Approved bool `json:"approved,omitempty"` + FlagColor interface{} `json:"flag_color,omitempty"` + AccountID string `json:"account_id,omitempty"` + AccountName string `json:"account_name,omitempty"` + PayeeID string `json:"payee_id,omitempty"` + PayeeName string `json:"payee_name,omitempty"` + CategoryID string `json:"category_id,omitempty"` + CategoryName string `json:"category_name,omitempty"` + TransferAccountID interface{} `json:"transfer_account_id,omitempty"` + TransferTransactionID interface{} `json:"transfer_transaction_id,omitempty"` + MatchedTransactionID interface{} `json:"matched_transaction_id,omitempty"` + ImportID string `json:"import_id,omitempty"` + Deleted bool `json:"deleted,omitempty"` +} + +// Used for single transaction requests / responses +type Transaction struct { + Data struct { + TransactionIDs []string `json:"transaction_ids,omitempty"` + Transaction BaseTransaction `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 { + Data struct { + Transactions []BaseTransaction `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{} + urlQuery := url.Values{} + urlQuery.Add("since_date", sinceDate.Format("2006-01-02")) + + err := c.get(fmt.Sprintf("/accounts/%s/transactions", accountID), &response, urlQuery) + 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()) + if err != nil { + return "", 0, fmt.Errorf("failed to get ynab transactions: %v", err) + } + + for _, transaction := range ynabTransactions.Data.Transactions { + if transaction.PayeeName != "Capital Gains or Losses" { + continue + } + 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" + + 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 nil +}