diff --git a/monzo-ynab/main.go b/monzo-ynab/main.go deleted file mode 100644 index aee66e7c2..000000000 --- a/monzo-ynab/main.go +++ /dev/null @@ -1,81 +0,0 @@ -// Creating a job to import Monzo transactions into YNAB. -// -// This is going to run N times per 24 hours. - -package main - -import ( - "fmt" - "log" - "net/http" - "os" - "os/exec" -) - -var ( - clientId = os.Getenv("client_id") - clientSecret = os.Getenv("client_secret") - accessToken = nil - refreshToken = nil -) - -const ( - state = "xyz123" - redirectUri = "http://localhost:8080/authorize" -) - -func getAccessCode(string authCode) { - form := map[string]string{ - "grant_type": "authorization_code", - "client_id": client_id, - "client_secret": client_secret, - "redirect_uri": redirectUri, - "code": authCode, - } - json := map[string]string{ - "access_token": "access_token", - "client_id": "client_id", - "expires_in": 21600, - "refresh_token": "refresh_token", - "token_type": "Bearer", - "user_id": "user_id", - } - - // TODO: Handle retry with backoff logic here. - resp, error := http.Post("https://api.monzo.com/oauth2/token", form.Form(), json.Json()) - if err != nil { - log.Fatal("Could not exchange authorization code for an access token.") - } - - resp.Body() -} - -func handleRedirect(w http.ResponseWriter, r *http.Request) { - // assert that `r.state` is the same as `state`. - params := r.URL.Query() - - reqState := params["state"][0] - reqCode := params["code"][0] - - if reqState != state { - log.Fatal(fmt.Sprintf("Value for state returned by Monzo does not equal our state. %s != %s", reqState, state)) - } - - go getAccessCode(reqCode) - - fmt.Printf("Received the authorization code from Monzo: %s", reqCode) - fmt.Fprintf(w, fmt.Sprintf("Authorization code: %s", reqCode)) -} - -func authorizeClient() { - url := - fmt.Sprintf("https://auth.monzo.com/?client_id=%s&redirect_uri=%s&response_type=code&state=%s", - clientId, redirectUri, state) - exec.Command("google-chrome", url).Start() -} - -func main() { - authorizeClient() - http.HandleFunc("/authorize", handleRedirect) - go log.Fatal(http.ListenAndServe(":8080", nil)) -} diff --git a/monzo_ynab/README.md b/monzo_ynab/README.md new file mode 100644 index 000000000..4ccbb35d8 --- /dev/null +++ b/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 -f ~/briefcase/monzo_ynab -i +``` + +## 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/monzo_ynab/default.nix b/monzo_ynab/default.nix new file mode 100644 index 000000000..735029bb1 --- /dev/null +++ b/monzo_ynab/default.nix @@ -0,0 +1,10 @@ +{ depot ? import {}, ... }: + +depot.buildGo.program { + name = "monzo_ynab"; + srcs = [ + ./utils.go + ./main.go + ./monzo.go + ]; +} diff --git a/monzo_ynab/main.go b/monzo_ynab/main.go new file mode 100644 index 000000000..5b6c654e2 --- /dev/null +++ b/monzo_ynab/main.go @@ -0,0 +1,125 @@ +// 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 ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/exec" +) + +//////////////////////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////////////////////// + +var ( + clientId = os.Getenv("client_id") + clientSecret = os.Getenv("client_secret") +) + +const ( + redirectURI = "http://localhost:8080/authorization-code" + // TODO(wpcarro): Consider generating a random string for the state when the + // application starts instead of hardcoding it here. + state = "xyz123" +) + +//////////////////////////////////////////////////////////////////////////////// +// Business Logic +//////////////////////////////////////////////////////////////////////////////// + +// 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"` +} + +// TODO(wpcarro): Replace http.PostForm and other similar calls with +// client.postForm. The default http.Get and other methods doesn't timeout, so +// it's better to create a configured client with a value for the timeout. + +func getAccessToken(code string) { + res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {clientId}, + "client_secret": {clientSecret}, + "redirect_uri": {redirectURI}, + "code": {code}, + }) + failOn(err) + defer res.Body.Close() + + payload := accessTokenResponse{} + json.NewDecoder(res.Body).Decode(&payload) + + log.Printf("Access token: %s\n", payload.AccessToken) + log.Printf("Refresh token: %s\n", payload.AccessToken) +} + +func listenHttp(sigint chan os.Signal) { + // Use a go-routine to listen for interrupt signals to shutdown our HTTP + // server. + go func() { + <-sigint + // TODO(wpcarro): Do we need context here? I took this example from the + // example on golang.org. + log.Println("Warning: I should be shutting down and closing the connection here, but I'm not.") + close(sigint) + }() + + 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) + } + + // TODO(wpcarro): Add a more interesting authorization confirmation + // screen -- or even nothing at all. + fmt.Fprintf(w, "Authorized!") + + // Exchange the authorization code for an access token. + getAccessToken(code) + return + } + + log.Printf("Unhandled request: %v\n", *req) + }))) +} + +// Open a web browser to allow the user to authorize this application. +// TODO(wpcarro): Prefer using an environment variable for the web browser +// instead of assuming it will be google-chrome. +func authorizeClient() { + url := fmt.Sprintf("https://auth.monzo.com/?client_id=%s&redirect_uri=%s&response_type=code&state=%s", clientId, redirectURI, state) + exec.Command("google-chrome", url).Start() +} + +func main() { + sigint := make(chan os.Signal, 1) + // TODO(wpcarro): Remove state here. I'm using as a hack to prevent my + // program from halting before I'd like it to. Once I'm more comfortable + // using channels, this should be a trivial change. + state := make(chan bool) + + authorizeClient() + listenHttp(sigint) + <-state +} diff --git a/monzo_ynab/monzo.go b/monzo_ynab/monzo.go new file mode 100644 index 000000000..d27f65165 --- /dev/null +++ b/monzo_ynab/monzo.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + "net/http" +) + +// TODO: Support a version of this function that doesn't need the token +// parameter. +func monzoGet(token, string, endpoint string) { + client := &http.Client{} + req, err := http.NewRequest("GET", fmt.Sprintf("https://api.monzo.com/%s", endpoint), nil) + failOn(err) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + res, err := client.Do(req) + failOn(err) + fmt.Println(res) +} diff --git a/monzo_ynab/utils.go b/monzo_ynab/utils.go new file mode 100644 index 000000000..8b7decb90 --- /dev/null +++ b/monzo_ynab/utils.go @@ -0,0 +1,9 @@ +package main + +import "log" + +func failOn(err error) { + if err != nil { + log.Fatal(err) + } +}