diff options
Diffstat (limited to 'monzo_ynab')
-rw-r--r-- | monzo_ynab/README.md | 41 | ||||
-rw-r--r-- | monzo_ynab/default.nix | 10 | ||||
-rw-r--r-- | monzo_ynab/main.go | 125 | ||||
-rw-r--r-- | monzo_ynab/monzo.go | 18 | ||||
-rw-r--r-- | monzo_ynab/utils.go | 9 |
5 files changed, 203 insertions, 0 deletions
diff --git a/monzo_ynab/README.md b/monzo_ynab/README.md new file mode 100644 index 000000000000..4ccbb35d8c5d --- /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 000000000000..735029bb1808 --- /dev/null +++ b/monzo_ynab/default.nix @@ -0,0 +1,10 @@ +{ depot ? import <depot> {}, ... }: + +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 000000000000..5b6c654e2832 --- /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 000000000000..d27f65165620 --- /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 000000000000..8b7decb90a3f --- /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) + } +} |