diff options
Diffstat (limited to 'users/wpcarro/tools/monzo_ynab')
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/.envrc | 9 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/.gitignore | 3 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/.skip-subtree | 2 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/README.md | 41 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/auth.go | 101 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/job.nix | 15 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/main.go | 44 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/monzo/client.go | 52 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/monzo/serde.go | 82 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/requests.txt | 80 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/shell.nix | 9 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/tokens.go | 283 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/tokens.nix | 26 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/ynab/client.go | 24 | ||||
-rw-r--r-- | users/wpcarro/tools/monzo_ynab/ynab/serde.go | 52 |
15 files changed, 823 insertions, 0 deletions
diff --git a/users/wpcarro/tools/monzo_ynab/.envrc b/users/wpcarro/tools/monzo_ynab/.envrc new file mode 100644 index 000000000000..2e3b53cd616f --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/.envrc @@ -0,0 +1,9 @@ +source_up +use_nix +# TODO(wpcarro): Prefer age-nix solution if possible. +export monzo_client_id="$(jq -j '.monzo | .clientId' < $WPCARRO/secrets.json)" +export monzo_client_secret="$(jq -j '.monzo | .clientSecret' < $WPCARRO/secrets.json)" +export ynab_personal_access_token="$(jq -j '.ynab | .personalAccessToken' < $WPCARRO/secrets.json)" +export ynab_account_id="$(jq -j '.ynab | .accountId' < $WPCARRO/secrets.json)" +export ynab_budget_id="$(jq -j '.ynab | .budgetId' < $WPCARRO/secrets.json)" +export store_path="$(pwd)" diff --git a/users/wpcarro/tools/monzo_ynab/.gitignore b/users/wpcarro/tools/monzo_ynab/.gitignore new file mode 100644 index 000000000000..e92078303bec --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/.gitignore @@ -0,0 +1,3 @@ +/ynab/fixture.json +/monzo/fixture.json +/kv.json diff --git a/users/wpcarro/tools/monzo_ynab/.skip-subtree b/users/wpcarro/tools/monzo_ynab/.skip-subtree new file mode 100644 index 000000000000..8db1f814f653 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/.skip-subtree @@ -0,0 +1,2 @@ +Subdirectories of this folder should not be imported since they are +internal to buildGo.nix and incompatible with readTree. diff --git a/users/wpcarro/tools/monzo_ynab/README.md b/users/wpcarro/tools/monzo_ynab/README.md new file mode 100644 index 000000000000..c0c0c772f646 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/README.md @@ -0,0 +1,41 @@ +# monzo_ynab + +Exporting Monzo transactions to my YouNeedABudget.com (i.e. YNAB) account. YNAB +unfortunately doesn't currently offer an Monzo integration. As a workaround and +a practical excuse to learn Go, I decided to write one myself. + +This job is going to run N times per 24 hours. Monzo offers webhooks for +reacting to certain types of events. I don't expect I'll need realtime data for +my YNAB integration. That may change, however, so it's worth noting. + +## Installation + +Like many other packages in this repository, `monzo_ynab` is packaged using +Nix. To install and use, you have two options: + +You can install using `nix-build` and then run the resulting +`./result/bin/monzo_ynab`. + +```shell +> nix-build . && ./result/bin/monzo_ynab +``` + +Or you can install using `nix-env` if you'd like to create the `monzo_ynab` +symlink. + +```shell +> nix-env -iA users.wpcarro.monzo_ynab +``` + +## Deployment + +While this project is currently not deployed, my plan is to host it on Google +Cloud and run it as a Cloud Run application. What I don't yet know is whether or +not this is feasible or a good idea. One complication that I foresee is that the +OAuth 2.0 login flow requires a web browser until the access token and refresh +tokens are acquired. I'm unsure how to workaround this at the moment. + +For more information about the general packaging and deployment strategies I'm +currently using, refer to the [deployments][deploy] writeup. + +[deploy]: ../deploy/README.md diff --git a/users/wpcarro/tools/monzo_ynab/auth.go b/users/wpcarro/tools/monzo_ynab/auth.go new file mode 100644 index 000000000000..b66bacb10687 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/auth.go @@ -0,0 +1,101 @@ +package auth + +//////////////////////////////////////////////////////////////////////////////// +// Dependencies +//////////////////////////////////////////////////////////////////////////////// + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "utils" +) + +//////////////////////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////////////////////// + +var ( + BROWSER = os.Getenv("BROWSER") + REDIRECT_URI = "http://localhost:8080/authorization-code" +) + +//////////////////////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////////////////////// + +// This is the response returned from Monzo when we exchange our authorization +// code for an access token. While Monzo returns additional fields, I'm only +// interested in AccessToken and RefreshToken. +type accessTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +type Tokens struct { + AccessToken string + RefreshToken string + ExpiresIn int +} + +//////////////////////////////////////////////////////////////////////////////// +// Functions +//////////////////////////////////////////////////////////////////////////////// + +// Returns the access token and refresh tokens for the Monzo API. +func GetTokensFromAuthCode(authCode string, clientID string, clientSecret string) *Tokens { + res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + "redirect_uri": {REDIRECT_URI}, + "code": {authCode}, + }) + utils.FailOn(err) + defer res.Body.Close() + payload := &accessTokenResponse{} + json.NewDecoder(res.Body).Decode(payload) + + return &Tokens{payload.AccessToken, payload.RefreshToken, payload.ExpiresIn} +} + +// Open a web browser to allow the user to authorize this application. Return +// the authorization code sent from Monzo. +func GetAuthCode(clientID string) string { + // TODO(wpcarro): Consider generating a random string for the state when the + // application starts instead of hardcoding it here. + state := "xyz123" + url := fmt.Sprintf( + "https://auth.monzo.com/?client_id=%s&redirect_uri=%s&response_type=code&state=%s", + clientID, REDIRECT_URI, state) + exec.Command(BROWSER, url).Start() + + authCode := make(chan string) + go func() { + log.Fatal(http.ListenAndServe(":8080", + http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // 1. Get authorization code from Monzo. + if req.URL.Path == "/authorization-code" { + params := req.URL.Query() + reqState := params["state"][0] + code := params["code"][0] + + if reqState != state { + log.Fatalf("Value for state returned by Monzo does not equal our state. %s != %s", reqState, state) + } + authCode <- code + + fmt.Fprintf(w, "Authorized!") + } else { + log.Printf("Unhandled request: %v\n", *req) + } + }))) + }() + result := <-authCode + return result +} diff --git a/users/wpcarro/tools/monzo_ynab/job.nix b/users/wpcarro/tools/monzo_ynab/job.nix new file mode 100644 index 000000000000..f710b73cefdb --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/job.nix @@ -0,0 +1,15 @@ +{ depot, ... }: + +let + inherit (depot.users.wpcarro) gopkgs; +in +depot.nix.buildGo.program { + name = "job"; + srcs = [ + ./main.go + ]; + deps = with gopkgs; [ + kv + utils + ]; +} diff --git a/users/wpcarro/tools/monzo_ynab/main.go b/users/wpcarro/tools/monzo_ynab/main.go new file mode 100644 index 000000000000..bf3707138148 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/main.go @@ -0,0 +1,44 @@ +// Exporting Monzo transactions to my YouNeedABudget.com (i.e. YNAB) +// account. YNAB unfortunately doesn't currently offer an Monzo integration. As +// a workaround and a practical excuse to learn Go, I decided to write one +// myself. +// +// This job is going to run N times per 24 hours. Monzo offers webhooks for +// reacting to certain types of events. I don't expect I'll need realtime data +// for my YNAB integration. That may change, however, so it's worth noting. + +package main + +import ( + "monzoSerde" + "os" +) + +var ( + ynabAccountID = os.Getenv("ynab_account_id") +) + +//////////////////////////////////////////////////////////////////////////////// +// Business Logic +//////////////////////////////////////////////////////////////////////////////// + +// Convert a Monzo transaction struct, `tx`, into a YNAB transaction struct. +func toYnab(tx monzoSerde.Transaction) ynabSerde.Transaction { + return ynabSerde.Transaction{ + Id: tx.Id, + Date: tx.Created, + Amount: tx.Amount, + Memo: tx.Notes, + AccountId: ynabAccountID, + } +} + +func main() { + txs := monzo.TransactionsLast24Hours() + var ynabTxs []ynabSerde.Transaction + for tx := range txs { + append(ynabTxs, toYnab(tx)) + } + ynab.PostTransactions(ynabTxs) + os.Exit(0) +} diff --git a/users/wpcarro/tools/monzo_ynab/monzo/client.go b/users/wpcarro/tools/monzo_ynab/monzo/client.go new file mode 100644 index 000000000000..8c6c41e29f40 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/monzo/client.go @@ -0,0 +1,52 @@ +package monzoClient + +import ( + "fmt" + "log" + "monzoSerde" + "net/http" + "net/url" + "strings" + "time" + "tokens" + "utils" +) + +const ( + accountID = "pizza" +) + +type Client struct{} + +// Ensure that the token server is running and return a new instance of a Client +// struct. +func Create() *Client { + tokens.StartServer() + time.Sleep(time.Second * 1) + return &Client{} +} + +// Returns a slice of transactions from the last 24 hours. +func (c *Client) Transactions24Hours() []monzoSerde.Transaction { + token := tokens.AccessToken() + form := url.Values{"account_id": {accountID}} + client := http.Client{} + req, _ := http.NewRequest("POST", "https://api.monzo.com/transactions", + strings.NewReader(form.Encode())) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("User-Agent", "monzo-ynab") + res, err := client.Do(req) + + utils.DebugRequest(req) + utils.DebugResponse(res) + + if err != nil { + utils.DebugRequest(req) + utils.DebugResponse(res) + log.Fatal(err) + } + defer res.Body.Close() + + return []monzoSerde.Transaction{} +} diff --git a/users/wpcarro/tools/monzo_ynab/monzo/serde.go b/users/wpcarro/tools/monzo_ynab/monzo/serde.go new file mode 100644 index 000000000000..a38585eca632 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/monzo/serde.go @@ -0,0 +1,82 @@ +// This package hosts the serialization and deserialization logic for all of the +// data types with which our application interacts from the Monzo API. +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "time" +) + +type TxMetadata struct { + FasterPayment string `json:"faster_payment"` + FpsPaymentId string `json:"fps_payment_id"` + Insertion string `json:"insertion"` + Notes string `json:"notes"` + Trn string `json:"trn"` +} + +type TxCounterparty struct { + AccountNumber string `json:"account_number"` + Name string `json:"name"` + SortCode string `json:"sort_code"` + UserId string `json:"user_id"` +} + +type Transaction struct { + Id string `json:"id"` + Created time.Time `json:"created"` + Description string `json:"description"` + Amount int `json:"amount"` + Currency string `json:"currency"` + Notes string `json:"notes"` + Metadata TxMetadata + AccountBalance int `json:"account_balance"` + International interface{} `json:"international"` + Category string `json:"category"` + IsLoad bool `json:"is_load"` + Settled time.Time `json:"settled"` + LocalAmount int `json:"local_amount"` + LocalCurrency string `json:"local_currency"` + Updated time.Time `json:"updated"` + AccountId string `json:"account_id"` + UserId string `json:"user_id"` + Counterparty TxCounterparty `json:"counterparty"` + Scheme string `json:"scheme"` + DedupeId string `json:"dedupe_id"` + Originator bool `json:"originator"` + IncludeInSpending bool `json:"include_in_spending"` + CanBeExcludedFromBreakdown bool `json:"can_be_excluded_from_breakdown"` + CanBeMadeSubscription bool `json:"can_be_made_subscription"` + CanSplitTheBill bool `json:"can_split_the_bill"` + CanAddToTab bool `json:"can_add_to_tab"` + AmountIsPending bool `json:"amount_is_pending"` + // Fees interface{} `json:"fees"` + // Merchant interface `json:"merchant"` + // Labels interface{} `json:"labels"` + // Attachments interface{} `json:"attachments"` + // Categories interface{} `json:"categories"` +} + +// Attempts to encode a Monzo transaction struct into a string. +func serializeTx(tx *Transaction) (string, error) { + x, err := json.Marshal(tx) + return string(x), err +} + +// Attempts to parse a string encoding a transaction presumably sent from a +// Monzo server. +func deserializeTx(x string) (*Transaction, error) { + target := &Transaction{} + err := json.Unmarshal([]byte(x), target) + return target, err +} + +func main() { + b, _ := ioutil.ReadFile("./fixture.json") + tx := string(b) + target, _ := deserializeTx(tx) + out, _ := serializeTx(target) + fmt.Println(out) +} diff --git a/users/wpcarro/tools/monzo_ynab/requests.txt b/users/wpcarro/tools/monzo_ynab/requests.txt new file mode 100644 index 000000000000..2da17c0b326a --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/requests.txt @@ -0,0 +1,80 @@ +################################################################################ +# YNAB +################################################################################ +:ynab = https://api.youneedabudget.com/v1 +:ynab-access-token := (getenv "ynab_personal_access_token") +:ynab-budget-id := (getenv "ynab_budget_id") +:ynab-account-id := (getenv "ynab_account_id") + +# Test +GET :ynab/budgets +Authorization: Bearer :ynab-access-token + +# List transactions +GET :ynab/budgets/:ynab-budget-id/transactions +Authorization: Bearer :ynab-access-token + +# Post transactions +POST :ynab/budgets/:ynab-budget-id/transactions +Authorization: Bearer :ynab-access-token +Content-Type: application/json +{ + "transactions": [ + { + "account_id": ":ynab-account-id", + "date": "2019-12-30", + "amount": 10000, + "payee_name": "Richard Stallman", + "memo": "Not so free software after all...", + "cleared": "cleared", + "approved": true, + "flag_color": "red", + "import_id": "xyz-123" + } + ] +} + +################################################################################ +# Monzo +################################################################################ +:monzo = https://api.monzo.com +:monzo-access-token := (getenv "monzo_cached_access_token") +:monzo-refresh-token := (getenv "monzo_cached_refresh_token") +:monzo-client-id := (getenv "monzo_client_id") +:monzo-client-secret := (getenv "monzo_client_secret") +:monzo-account-id := (getenv "monzo_account_id") + +# List transactions +GET :monzo/transactions +Authorization: Bearer :monzo-access-token +account_id==:monzo-account-id + +# Refresh access token +# According from the docs, the access token expires in 6 hours. +POST :monzo/oauth2/token +Content-Type: application/x-www-form-urlencoded +Authorization: Bearer :monzo-access-token +grant_type=refresh_token&client_id=:monzo-client-id&client_secret=:monzo-client-secret&refresh_token=:monzo-refresh-token + +################################################################################ +# Tokens server +################################################################################ +:tokens = http://localhost:4242 + +# Get tokens +GET :tokens/tokens + +# Get application state for debugging purposes +GET :tokens/state + +# Force refresh tokens +POST :tokens/refresh-tokens + +# Set tokens +POST :tokens/set-tokens +Content-Type: application/json +{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_in": 120 +} diff --git a/users/wpcarro/tools/monzo_ynab/shell.nix b/users/wpcarro/tools/monzo_ynab/shell.nix new file mode 100644 index 000000000000..f777c13fefae --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/shell.nix @@ -0,0 +1,9 @@ +{ pkgs, ... }: + +pkgs.mkShell { + buildInputs = with pkgs; [ + go + goimports + godef + ]; +} diff --git a/users/wpcarro/tools/monzo_ynab/tokens.go b/users/wpcarro/tools/monzo_ynab/tokens.go new file mode 100644 index 000000000000..4be967ccb803 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/tokens.go @@ -0,0 +1,283 @@ +// Creating a Tokens server to manage my access and refresh tokens. Keeping this +// as a separate server allows me to develop and use the access tokens without +// going through client authorization. +package main + +//////////////////////////////////////////////////////////////////////////////// +// Dependencies +//////////////////////////////////////////////////////////////////////////////// + +import ( + "auth" + "encoding/json" + "fmt" + "io" + "kv" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "syscall" + "time" + "utils" +) + +//////////////////////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////////////////////// + +// This is the response from Monzo's API after we request an access token +// refresh. +type refreshTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ClientId string `json:"client_id"` + ExpiresIn int `json:"expires_in"` +} + +// This is the shape of the request from clients wishing to set state of the +// server. +type setTokensRequest struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +// This is our application state. +type state struct { + accessToken string `json:"access_token"` + refreshToken string `json:"refresh_token"` +} + +type readMsg struct { + sender chan state +} + +type writeMsg struct { + state state + sender chan bool +} + +type channels struct { + reads chan readMsg + writes chan writeMsg +} + +//////////////////////////////////////////////////////////////////////////////// +// Top-level Definitions +//////////////////////////////////////////////////////////////////////////////// + +var chans = &channels{ + reads: make(chan readMsg), + writes: make(chan writeMsg), +} + +var ( + monzoClientId = os.Getenv("monzo_client_id") + monzoClientSecret = os.Getenv("monzo_client_secret") + storePath = os.Getenv("store_path") +) + +//////////////////////////////////////////////////////////////////////////////// +// Utils +//////////////////////////////////////////////////////////////////////////////// + +// Print the access and refresh tokens for debugging. +func logTokens(access string, refresh string) { + log.Printf("Access: %s\n", access) + log.Printf("Refresh: %s\n", refresh) +} + +func (state *state) String() string { + return fmt.Sprintf("state{\n\taccessToken: \"%s\",\n\trefreshToken: \"%s\"\n}\n", state.accessToken, state.refreshToken) +} + +// Schedule a token refresh for `expiresIn` seconds using the provided +// `refreshToken`. This will update the application state with the access token +// and schedule an additional token refresh for the newly acquired tokens. +func scheduleTokenRefresh(expiresIn int, refreshToken string) { + duration := time.Second * time.Duration(expiresIn) + timestamp := time.Now().Local().Add(duration) + // TODO(wpcarro): Consider adding a more human readable version that will + // log the number of hours, minutes, etc. until the next refresh. + log.Printf("Scheduling token refresh for %v\n", timestamp) + time.Sleep(duration) + log.Println("Refreshing tokens now...") + accessToken, refreshToken := refreshTokens(refreshToken) + log.Println("Successfully refreshed tokens.") + logTokens(accessToken, refreshToken) + setState(accessToken, refreshToken) +} + +// Exchange existing credentials for a new access token and `refreshToken`. Also +// schedule the next refresh. This function returns the newly acquired access +// token and refresh token. +func refreshTokens(refreshToken string) (string, string) { + // TODO(wpcarro): Support retries with exponential backoff. + res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {monzoClientId}, + "client_secret": {monzoClientSecret}, + "refresh_token": {refreshToken}, + }) + if res.StatusCode != http.StatusOK { + // TODO(wpcarro): Considering panicking here. + utils.DebugResponse(res) + } + if err != nil { + utils.DebugResponse(res) + log.Fatal("The request to Monzo to refresh our access token failed.", err) + } + defer res.Body.Close() + payload := &refreshTokenResponse{} + err = json.NewDecoder(res.Body).Decode(payload) + if err != nil { + log.Fatal("Could not decode the JSON response from Monzo.", err) + } + + go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken) + + // Interestingly, JSON decoding into the refreshTokenResponse can success + // even if the decoder doesn't populate any of the fields in the + // refreshTokenResponse struct. From what I read, it isn't possible to make + // these fields as required using an annotation, so this guard must suffice + // for now. + if payload.AccessToken == "" || payload.RefreshToken == "" { + log.Fatal("JSON parsed correctly but failed to populate token fields.") + } + + return payload.AccessToken, payload.RefreshToken +} + +func persistTokens(access string, refresh string) { + log.Println("Persisting tokens...") + kv.Set(storePath, "monzoAccessToken", access) + kv.Set(storePath, "monzoRefreshToken", refresh) + log.Println("Successfully persisted tokens.") +} + +// Listen for SIGINT and SIGTERM signals. When received, persist the access and +// refresh tokens and shutdown the server. +func handleInterrupts() { + // Gracefully handle interruptions. + sigs := make(chan os.Signal, 1) + done := make(chan bool) + + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigs + log.Printf("Received signal to shutdown. %v\n", sig) + state := getState() + persistTokens(state.accessToken, state.refreshToken) + done <- true + }() + + <-done + log.Println("Exiting...") + os.Exit(0) +} + +// Set `accessToken` and `refreshToken` on application state. +func setState(accessToken string, refreshToken string) { + msg := writeMsg{state{accessToken, refreshToken}, make(chan bool)} + chans.writes <- msg + <-msg.sender +} + +// Return our application state. +func getState() state { + msg := readMsg{make(chan state)} + chans.reads <- msg + return <-msg.sender +} + +//////////////////////////////////////////////////////////////////////////////// +// Main +//////////////////////////////////////////////////////////////////////////////// + +func main() { + // Manage application state. + go func() { + state := &state{} + for { + select { + case msg := <-chans.reads: + log.Println("Reading from state...") + log.Println(state) + msg.sender <- *state + case msg := <-chans.writes: + log.Println("Writing to state.") + log.Printf("Old: %s\n", state) + *state = msg.state + log.Printf("New: %s\n", state) + // As an attempt to maintain consistency between application + // state and persisted state, everytime we write to the + // application state, we will write to the store. + persistTokens(state.accessToken, state.refreshToken) + msg.sender <- true + } + } + }() + + // Retrieve cached tokens from store. + accessToken := fmt.Sprintf("%v", kv.Get(storePath, "monzoAccessToken")) + refreshToken := fmt.Sprintf("%v", kv.Get(storePath, "monzoRefreshToken")) + + log.Println("Attempting to retrieve cached credentials...") + logTokens(accessToken, refreshToken) + + if accessToken == "" || refreshToken == "" { + log.Println("Cached credentials are absent. Authorizing client...") + authCode := auth.GetAuthCode(monzoClientId) + tokens := auth.GetTokensFromAuthCode(authCode, monzoClientId, monzoClientSecret) + setState(tokens.AccessToken, tokens.RefreshToken) + go scheduleTokenRefresh(tokens.ExpiresIn, tokens.RefreshToken) + } else { + setState(accessToken, refreshToken) + // If we have tokens, they may be expiring soon. We don't know because + // we aren't storing the expiration timestamp in the state or in the + // store. Until we have that information, and to be safe, let's refresh + // the tokens. + go scheduleTokenRefresh(0, refreshToken) + } + + // Gracefully handle shutdowns. + go handleInterrupts() + + // Listen to inbound requests. + fmt.Println("Listening on http://localhost:4242 ...") + log.Fatal(http.ListenAndServe(":4242", + http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/refresh-tokens" && req.Method == "POST" { + state := getState() + go scheduleTokenRefresh(0, state.refreshToken) + fmt.Fprintf(w, "Done.") + } else if req.URL.Path == "/set-tokens" && req.Method == "POST" { + // Parse + payload := &setTokensRequest{} + err := json.NewDecoder(req.Body).Decode(payload) + if err != nil { + log.Fatal("Could not decode the user's JSON request.", err) + } + + // Update application state + setState(payload.AccessToken, payload.RefreshToken) + + // Refresh tokens + go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken) + + // Ack + fmt.Fprintf(w, "Done.") + } else if req.URL.Path == "/state" && req.Method == "GET" { + // TODO(wpcarro): Ensure that this returns serialized state. + w.Header().Set("Content-type", "application/json") + state := getState() + payload, _ := json.Marshal(state) + io.WriteString(w, string(payload)) + } else { + log.Printf("Unhandled request: %v\n", *req) + } + }))) +} diff --git a/users/wpcarro/tools/monzo_ynab/tokens.nix b/users/wpcarro/tools/monzo_ynab/tokens.nix new file mode 100644 index 000000000000..4e2761bc7882 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/tokens.nix @@ -0,0 +1,26 @@ +{ depot, ... }: + +let + inherit (depot.users.wpcarro) gopkgs; + + auth = depot.nix.buildGo.package { + name = "auth"; + srcs = [ + ./auth.go + ]; + deps = with gopkgs; [ + utils + ]; + }; +in +depot.nix.buildGo.program { + name = "token-server"; + srcs = [ + ./tokens.go + ]; + deps = with gopkgs; [ + kv + utils + auth + ]; +} diff --git a/users/wpcarro/tools/monzo_ynab/ynab/client.go b/users/wpcarro/tools/monzo_ynab/ynab/client.go new file mode 100644 index 000000000000..b3e9930f621d --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/ynab/client.go @@ -0,0 +1,24 @@ +package client + +import ( + "serde" +) + +// // See requests.txt for more details. +// func PostTransactions(accountID string, txs []serde.Transaction{}) error { +// return map[string]string{ +// "transactions": [ +// { +// "account_id": accountID, +// "date": "2019-12-30", +// "amount": 10000, +// "payee_name": "Richard Stallman", +// "memo": "Not so free software after all...", +// "cleared": "cleared", +// "approved": true, +// "flag_color": "red", +// "import_id": "xyz-123" +// } +// ] +// } +// } diff --git a/users/wpcarro/tools/monzo_ynab/ynab/serde.go b/users/wpcarro/tools/monzo_ynab/ynab/serde.go new file mode 100644 index 000000000000..53dd33e83637 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/ynab/serde.go @@ -0,0 +1,52 @@ +// This package hosts the serialization and deserialization logic for all of the +// data types with which our application interacts from the YNAB API. +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +type Transaction struct { + Id string `json:"id"` + Date time.Time `json:"date"` + Amount int `json:"amount"` + Memo string `json:"memo"` + Cleared string `json:"cleared"` + Approved bool `json:"approved"` + FlagColor string `json:"flag_color"` + AccountId string `json:"account_id"` + AccountName string `json:"account_name"` + PayeeId string `json:"payeed_id"` + PayeeName string `json:"payee_name"` + CategoryId string `json:"category_id"` + CategoryName string `json:"category_name"` + Deleted bool `json:"deleted"` + // TransferAccountId interface{} `json:"transfer_account_id"` + // TransferTransactionId interface{} `json:"transfer_transaction_id"` + // MatchedTransactionId interface{} `json:"matched_transaction_id"` + // ImportId interface{} `json:"import_id"` + // Subtransactions interface{} `json:"subtransactions"` +} + +// Attempts to encode a YNAB transaction into a string. +func serializeTx(tx *Transaction) (string, error) { + x, err := json.Marshal(tx) + return string(x), err +} + +// Attempts to parse a string encoding a transaction presumably sent from a +// YNAB server. +func deserializeTx(x string) (*Transaction, error) { + target := &Transaction{} + err := json.Unmarshal([]byte(x), target) + return target, err +} + +func main() { + target, _ := deserializeTx(tx) + out, _ := serializeTx(target) + fmt.Println(out) + fmt.Println(ynabOut) +} |