Skip to content

Commit

Permalink
initial
Browse files Browse the repository at this point in the history
  • Loading branch information
tmus committed Jun 4, 2019
0 parents commit 179afaa
Show file tree
Hide file tree
Showing 8 changed files with 509 additions and 0 deletions.
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Monzo Go Client

A Go client for interacting with the Monzo API.

## Installation

```
go get -u github.com/tmus/monzo
```

## Usage

> The client API may change at some point, don't rely on this
> for anything serious, and make sure you use version numbers
> in your `go.mod` file.
1. Create a `monzo.Client` and pass it your Monzo access token:

```go
token, _ := os.LookupEnv("MONZO_TOKEN")
c = monzo.NewClient(token)
```

Creating a new client doesn't verify the connection to Monzo,
so you should call the `Ping` method on the new client to ensure
that the client can access Monzo:

```go
if err := c.Ping(); err != nil {
panic(err)
}
```

2. Call the `Accounts` function on the client to return a slice
of accounts associated with the Monzo token.

```go
accs, _ := c.Accounts()

for _, acc := range accs {
fmt.Println(acc.ID)
}
```

3. You can call the `Account` function on the client to return
a single account.

```go
acc, _ := c.Account("acc_00000XXXXXXXXXXXXXXXXX")
fmt.Println(acc.ID)
```

**More details coming soon.**
74 changes: 74 additions & 0 deletions account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package monzo

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)

type Account struct {
ID string
Closed bool
Created string
Description string
Type AccountType
Currency AccountCurrency
Country string `json:"country_code"`
AccountNumber string `json:"account_number"`
SortCode string `json:"sort_code"`

// The monzo.Client is embedded here to enable a fluent API.
client *Client
}

// AccountCurrency is a label for the currency that the account
// represents. Currently only GBP?
type AccountCurrency string

// AccountType is the way that Monzo identifies accounts internally.
type AccountType string

const (
// PrepaidAccount is for accounts that were created before
// the UKRetailAccount existed and can no longer be opened.
PrepaidAccount AccountType = "uk_prepaid"
// UKRetailAccount is a Current Account.
UKRetailAccount AccountType = "uk_retail"
// UKRetailJointAccount is a Current Account shared by two
// Monzo users.
UKRetailJointAccount AccountType = "uk_retail_joint"

// CurrencyGBP is Pound Sterling.
CurrencyGBP AccountCurrency = "GBP"
)

// Balance returns the current balance for the Account that
// it is called on.
func (a Account) Balance() (Balance, error) {
req, err := a.client.NewRequest(http.MethodGet, "balance", nil)
if err != nil {
return Balance{}, err
}

q := req.URL.Query()
q.Add("account_id", a.ID)
req.URL.RawQuery = q.Encode()

resp, _ := a.client.Do(req)

b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
str := b.String()

if resp.StatusCode != http.StatusOK {
return Balance{}, fmt.Errorf("failed to fetch balance: %s", str)
}

var bal Balance
if err := json.Unmarshal(b.Bytes(), &bal); err != nil {
return Balance{}, err
}

return bal, nil
}
22 changes: 22 additions & 0 deletions balance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package monzo

import "fmt"

// Balance returns the data from the Monzo API's /balance endpoint.
// All values are returned in pence.
type Balance struct {
Balance int
Total int `json:"total_balance"`
WithSavings int `json:"balance_including_flexible_savings"`
Currency string
}

func (b Balance) String() string {
return fmt.Sprintf(
"%s %.2f (Total: %s %.2f)",
b.Currency,
float64(b.Balance/100),
b.Currency,
float64(b.Total/100),
)
}
33 changes: 33 additions & 0 deletions deposit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package monzo

import (
"bytes"
"fmt"
"net/http"
)

// Deposit represents a deposit that is ready to be made against
// Monzo. It is read-only. There are no side effects if the
// deposit is not ran.
type Deposit struct {
Request *http.Request
Client *http.Client
}

// Run executes the deposit against the Monzo API. An error is
// only returned if the deposit fails to run. If the deposit
// has already ran against the account, it is not ran again
// and an error is not returned.
func (d Deposit) Run() error {
resp, _ := d.Client.Do(d.Request)

b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
str := b.String()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to fetch accounts: %s", str)
}

return nil
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/tmus/monzo

go 1.12
194 changes: 194 additions & 0 deletions monzo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package monzo

import (
"bytes"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)

const (
// APIBase is the root of the Monzo API.
APIBase string = "https://api.monzo.com"
)

// Client is the way to interact with the Monzo API.
type Client struct {
Token string

http.Client
}

// NewClient uses the passed token to create a new Monzo Client.
// No validation is done on this method so users should call
// the Ping method after creating a client to ensure that
// the new connection has been created successsfully.
func NewClient(token string) *Client {
return &Client{
Token: token,
}
}

// Ping attempts to connect to the Monzo API using the given
// client.
//
// No "ping" endpoint currently exists at Monzo, so for now
// this simply queries the /accounts endpoint.
func (c *Client) Ping() error {
req, err := c.NewRequest(http.MethodGet, "accounts", nil)
if err != nil {
return err
}

resp, _ := c.Do(req)

if resp.StatusCode != http.StatusOK {
b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
str := b.String()
return fmt.Errorf("error pinging Monzo API. JSON response: %s", str)
}

return nil
}

func (c *Client) NewRequest(method string, endpoint string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, APIBase+"/"+endpoint, body)
if err != nil {
return nil, err
}

req.Header.Add("Authorization", "Bearer "+c.Token)
return req, nil
}

func (c *Client) resourceRequest(resource string) (*http.Request, error) {
req, err := c.NewRequest(http.MethodGet, resource, nil)
if err != nil {
return nil, err
}

return req, nil
}

// Account returns a single Account from the Monzo API. If the
// account is not found (does not exist), an error is returned.
func (c *Client) Account(id string) (Account, error) {
accs, err := c.accounts()
if err != nil {
return Account{}, err
}

// Monzo doesn't have the capability to retrieve a single
// account, so we need to get all the users accounts
// and filter them down using the provided id.
for _, acc := range accs {
if acc.ID == id {
return acc, nil
}
}

return Account{}, fmt.Errorf("no account found with ID %s", id)
}

// Accounts returns a slice of Account structs, one for each of
// the Monzo accounts associated with the authentication.
func (c *Client) Accounts() ([]Account, error) {
return c.accounts()
}

func (c *Client) accounts() ([]Account, error) {
req, err := c.resourceRequest("accounts")
if err != nil {
return nil, err
}

resp, _ := c.Do(req)

b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
str := b.String()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch accounts: %s", str)
}

bytes := b.Bytes()
var accounts []Account
if err := unwrapJSON(bytes, "accounts", &accounts); err != nil {
return nil, err
}

var accs []Account
for _, acc := range accounts {
// The API still returns the Monzo beta prepaid accounts.
// These can't be actioned meaningfully, so they are
// removed from the slice if they exist.
if acc.Type != PrepaidAccount {
// To provide a fluent API for the account, it needs
// to know how to talk to Monzo. The monzo.Client
// is embedded in the Account struct so that
// calls can be passed to it.
acc.client = c
accs = append(accs, acc)
}
}

return accs, nil
}

// Deposit creates a new Deposit struct. Monzo uses a 'dedupe_id'
// to ensure that the request is idempotent, so the deposit is
// not ran when it is created. To action the deposit, call
// the `Run` method on it.
func (a Account) Deposit(p Pot, amt int) (*Deposit, error) {
endpoint := "/pots/" + p.ID + "/deposit"

data := url.Values{}
data.Add("source_account_id", a.ID)
data.Add("amount", strconv.Itoa(amt))

src := rand.NewSource(time.Now().UnixNano())
r := rand.New(src)

data.Add("dedupe_id", strconv.FormatFloat(r.Float64(), 'f', 6, 64))

req, err := a.client.NewRequest(http.MethodPut, endpoint, strings.NewReader(data.Encode()))
if err != nil {
return &Deposit{}, err
}

req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

return &Deposit{req, &a.client.Client}, nil
}

// Withdraw creates a new Withdrawal struct. Monzo uses a 'dedupe_id'
// to ensure that the request is idempotent, so the withdrawal is
// not ran when it is created. To action the withdrawal, call
// the `Run` method on it.
func (a Account) Withdraw(p Pot, amt int) (*Withdrawal, error) {
endpoint := "/pots/" + p.ID + "/withdraw"
data := url.Values{}
data.Add("destination_account_id", a.ID)
data.Add("amount", strconv.Itoa(amt))

src := rand.NewSource(time.Now().UnixNano())
r := rand.New(src)

data.Add("dedupe_id", strconv.FormatFloat(r.Float64(), 'f', 6, 64))

req, err := a.client.NewRequest(http.MethodPut, endpoint, strings.NewReader(data.Encode()))
if err != nil {
return &Withdrawal{}, err
}

req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

return &Withdrawal{req, &a.client.Client}, nil
}
Loading

0 comments on commit 179afaa

Please sign in to comment.