about summary refs log tree commit diff
path: root/monzo_ynab
diff options
context:
space:
mode:
Diffstat (limited to 'monzo_ynab')
-rw-r--r--monzo_ynab/README.md41
-rw-r--r--monzo_ynab/default.nix10
-rw-r--r--monzo_ynab/main.go125
-rw-r--r--monzo_ynab/monzo.go18
-rw-r--r--monzo_ynab/utils.go9
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)
+	}
+}