From f35578a2e2225565d4402e407b00a2821ac3bdea Mon Sep 17 00:00:00 2001 From: Michael Morrissey Date: Fri, 6 Jan 2017 14:06:06 -0500 Subject: [PATCH] Initial cut at importing financial transactions using Plaid.com. No Plaid-specific UI in this first pass; just import/data model only. vendor: add github.com/plaid/plaid-go at rev 02b6af68061bf89a293eaf15dc6c955ce02dd22b Change-Id: I1003d1d21416b9f2c7eb40085e62ec8481a0c6ed --- config/dev-server-config.json | 3 + dev/devcam/server.go | 6 + pkg/importer/allimporters/importers.go | 1 + pkg/importer/plaid/README | 17 + pkg/importer/plaid/institutions.go | 65 +++ pkg/importer/plaid/plaid.go | 246 ++++++++++++ vendor/github.com/plaid/plaid-go/.gitignore | 1 + vendor/github.com/plaid/plaid-go/LICENSE | 21 + vendor/github.com/plaid/plaid-go/README.md | 116 ++++++ vendor/github.com/plaid/plaid-go/main.go | 152 +++++++ .../github.com/plaid/plaid-go/plaid/auth.go | 207 ++++++++++ .../plaid/plaid-go/plaid/balance.go | 28 ++ .../plaid/plaid-go/plaid/categories.go | 21 + .../plaid/plaid-go/plaid/categories_test.go | 52 +++ .../plaid/plaid-go/plaid/connect.go | 223 +++++++++++ .../github.com/plaid/plaid-go/plaid/errors.go | 20 + .../plaid/plaid-go/plaid/exchange-token.go | 51 +++ .../plaid-go/plaid/exchange-token_test.go | 24 ++ .../plaid/plaid-go/plaid/institutions.go | 29 ++ .../plaid/plaid-go/plaid/institutions_test.go | 55 +++ .../github.com/plaid/plaid-go/plaid/plaid.go | 373 ++++++++++++++++++ .../plaid/plaid-go/plaid/upgrade.go | 98 +++++ 22 files changed, 1809 insertions(+) create mode 100644 pkg/importer/plaid/README create mode 100644 pkg/importer/plaid/institutions.go create mode 100644 pkg/importer/plaid/plaid.go create mode 100644 vendor/github.com/plaid/plaid-go/.gitignore create mode 100644 vendor/github.com/plaid/plaid-go/LICENSE create mode 100644 vendor/github.com/plaid/plaid-go/README.md create mode 100644 vendor/github.com/plaid/plaid-go/main.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/auth.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/balance.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/categories.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/categories_test.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/connect.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/errors.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/exchange-token.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/exchange-token_test.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/institutions.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/institutions_test.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/plaid.go create mode 100644 vendor/github.com/plaid/plaid-go/plaid/upgrade.go diff --git a/config/dev-server-config.json b/config/dev-server-config.json index ff0713e0f..f5f7eb80a 100644 --- a/config/dev-server-config.json +++ b/config/dev-server-config.json @@ -349,6 +349,9 @@ "picasa": { "clientSecret": ["_env", "${CAMLI_PICASA_API_KEY}", ""] }, + "plaid": { + "clientSecret": ["_env", "${CAMLI_PLAID_API_KEY}", ""] + }, "twitter": { "clientSecret": ["_env", "${CAMLI_TWITTER_API_KEY}", ""] } diff --git a/dev/devcam/server.go b/dev/devcam/server.go index d1a62ba32..c3c6626e9 100644 --- a/dev/devcam/server.go +++ b/dev/devcam/server.go @@ -73,6 +73,7 @@ type serverCmd struct { flickrAPIKey string foursquareAPIKey string picasaAPIKey string + plaidAPIKey string twitterAPIKey string extraArgs string // passed to camlistored // end of flag vars @@ -117,6 +118,7 @@ func init() { flags.StringVar(&cmd.flickrAPIKey, "flickrapikey", "", "The key and secret to use with the Flickr importer. Formatted as ':'.") flags.StringVar(&cmd.foursquareAPIKey, "foursquareapikey", "", "The key and secret to use with the Foursquare importer. Formatted as ':'.") flags.StringVar(&cmd.picasaAPIKey, "picasakey", "", "The username and password to use with the Picasa importer. Formatted as ':'.") + flags.StringVar(&cmd.plaidAPIKey, "plaidkey", "", "The client_id and secret to use with the Plaid importer. Formatted as ':'.") flags.StringVar(&cmd.twitterAPIKey, "twitterapikey", "", "The key and secret to use with the Twitter importer. Formatted as ':'.") flags.StringVar(&cmd.root, "root", "", "A directory to store data in. Defaults to a location in the OS temp directory.") flags.StringVar(&cmd.extraArgs, "extraargs", "", @@ -315,6 +317,10 @@ func (c *serverCmd) setEnvVars() error { setenv("CAMLI_PICASA_ENABLED", "true") setenv("CAMLI_PICASA_API_KEY", c.picasaAPIKey) } + if c.plaidAPIKey != "" { + setenv("CAMLI_PLAID_ENABLED", "true") + setenv("CAMLI_PLAID_API_KEY", c.plaidAPIKey) + } if c.twitterAPIKey != "" { setenv("CAMLI_TWITTER_ENABLED", "true") setenv("CAMLI_TWITTER_API_KEY", c.twitterAPIKey) diff --git a/pkg/importer/allimporters/importers.go b/pkg/importer/allimporters/importers.go index 190d299a9..a49c81801 100644 --- a/pkg/importer/allimporters/importers.go +++ b/pkg/importer/allimporters/importers.go @@ -24,5 +24,6 @@ import ( _ "camlistore.org/pkg/importer/foursquare" _ "camlistore.org/pkg/importer/picasa" _ "camlistore.org/pkg/importer/pinboard" + _ "camlistore.org/pkg/importer/plaid" _ "camlistore.org/pkg/importer/twitter" ) diff --git a/pkg/importer/plaid/README b/pkg/importer/plaid/README new file mode 100644 index 000000000..b7914a978 --- /dev/null +++ b/pkg/importer/plaid/README @@ -0,0 +1,17 @@ +Plaid Importer +============== + +Plaid (plaid.com) is a service that allows users to connect to financial +institutions (namely credit card providers) and retreive financial +transactions in a uniform format. + +To use: + +1) Sign up for a Plaid developer account: https://dashboard.plaid.com/signup +2) Start the devcam server with plaidapikey flag: + $ devcam server -plaidapikey=: +3) Navigate to http:///importer/plaid and click "Add new account" +4) Enter username and password for your financial institution's account, and + select the institution type (eg, Amex). (Unfortunately, OAuth is not + available) +5) Watch your transactions roll on in diff --git a/pkg/importer/plaid/institutions.go b/pkg/importer/plaid/institutions.go new file mode 100644 index 000000000..07fa6fe33 --- /dev/null +++ b/pkg/importer/plaid/institutions.go @@ -0,0 +1,65 @@ +/* +Copyright 2017 The Camlistore Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plaid + +type Institution int + +const ( + AMEX = iota + BBT + BOFA + CAPONE + SCHWAB + CHASE + CITI + FIDELITY + NFCU + PNC + SUNTRUST + TD + US + USAA + WELLS +) + +type InstitutionNames struct { + DisplayName string + CodeName string +} + +type InstitutionNameMap map[Institution]InstitutionNames + +var supportedInstitutions InstitutionNameMap + +func init() { + supportedInstitutions = InstitutionNameMap{ + AMEX: {"Amex", "amex"}, + BBT: {"BB&T", "bbt"}, + BOFA: {"Bank of America", "bofa"}, + CAPONE: {"Capital One", "capone"}, + CITI: {"Citi", "citi"}, + SCHWAB: {"Chales Schwab", "schwab"}, + CHASE: {"Chase", "chase"}, + FIDELITY: {"Fidelity", "fidelity"}, + NFCU: {"Navy FCU", "nfcu"}, + PNC: {"PNC", "pnc"}, + SUNTRUST: {"Suntrust", "suntrust"}, + TD: {"TD Bank", "td"}, + US: {"US Bank", "us"}, + USAA: {"USAA", "usaa"}, + WELLS: {"Wells Fargo", "wells"}, + } +} diff --git a/pkg/importer/plaid/plaid.go b/pkg/importer/plaid/plaid.go new file mode 100644 index 000000000..67434a804 --- /dev/null +++ b/pkg/importer/plaid/plaid.go @@ -0,0 +1,246 @@ +/* +Copyright 2017 The Camlistore Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package plaid implements an importer for financial transactions from plaid.com +package plaid // import "camlistore.org/pkg/importer/plaid" + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "time" + + "camlistore.org/pkg/blob" + "camlistore.org/pkg/httputil" + "camlistore.org/pkg/importer" + "camlistore.org/pkg/schema" + "camlistore.org/pkg/schema/nodeattr" + + "github.com/plaid/plaid-go/plaid" +) + +func init() { + importer.Register("plaid", &imp{}) +} + +type imp struct{} + +func (*imp) SupportsIncremental() bool { + return true +} + +func (*imp) NeedsAPIKey() bool { + return true +} + +const ( + acctAttrToken = "plaidAccountToken" + acctAttrUsername = "username" + acctInstitution = "institutionType" + + plaidTransactionTimeFormat = "2006-01-02" + plaidTransactionNodeType = "plaid.io:transaction" + plaidLastTransaction = "lastTransactionSyncDate" +) + +func (*imp) IsAccountReady(acct *importer.Object) (ready bool, err error) { + return acct.Attr(acctAttrToken) != "" && acct.Attr(acctAttrUsername) != "", nil +} + +func (*imp) SummarizeAccount(acct *importer.Object) string { + return fmt.Sprintf("%s (%s)", acct.Attr(acctAttrUsername), acct.Attr(acctInstitution)) +} + +func (*imp) ServeSetup(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) error { + args := struct { + Ctx *importer.SetupContext + Inst InstitutionNameMap + }{ + ctx, + supportedInstitutions, + } + + return tmpl.ExecuteTemplate(w, "serveSetup", args) +} + +var tmpl = template.Must(template.New("root").Parse(` +{{define "serveSetup"}} +

Configuring Bank Account

+

Enter your username/password credentials for your bank/card account and select the institution type. +

+ + + + + + +
Username
Password
Institution
+
+{{end}} +`)) + +var _ importer.ImporterSetupHTMLer = (*imp)(nil) + +func (im *imp) AccountSetupHTML(host *importer.Host) string { + return fmt.Sprintf(` +

Configuring Plaid

+

Signup for a developer account on Plaid dashboard +

After following signup steps and verifying your email, get your developer credentials +(under "Send your first request"), and copy your client ID and secret above. +

+`) +} + +func (im *imp) ServeCallback(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) { + username := r.FormValue("username") + password := r.FormValue("password") + if username == "" || password == "" { + http.Error(w, "Username and password are both required", 400) + return + } + institution := r.FormValue("institution") + + clientID, secret, err := ctx.Credentials() + if err != nil { + httputil.ServeError(w, r, fmt.Errorf("Credentials error: %v", err)) + return + } + + client := plaid.NewClient(clientID, secret, plaid.Tartan) + res, _, err := client.ConnectAddUser(username, password, "", institution, nil) + if err != nil { + httputil.ServeError(w, r, fmt.Errorf("ConnectAddUser error: %v", err)) + return + } + + if err := ctx.AccountNode.SetAttrs( + "title", fmt.Sprintf("%s account: %s", institution, username), + acctAttrUsername, username, + acctAttrToken, res.AccessToken, + acctInstitution, institution, + ); err != nil { + httputil.ServeError(w, r, fmt.Errorf("Error setting attributes: %v", err)) + return + } + http.Redirect(w, r, ctx.AccountURL(), http.StatusFound) +} + +func (im *imp) Run(ctx *importer.RunContext) (err error) { + log.Printf("Running plaid importer.") + defer func() { + log.Printf("plaid importer returned: %v", err) + }() + + clientID, secret, err := ctx.Credentials() + if err != nil { + return err + } + + var opt plaid.ConnectGetOptions + if start := ctx.AccountNode().Attr(plaidLastTransaction); start != "" { + opt.GTE = start + } + + client := plaid.NewClient(clientID, secret, plaid.Tartan) + resp, _, err := client.ConnectGet(ctx.AccountNode().Attr(acctAttrToken), &opt) + if err != nil { + fmt.Errorf("ConnectGet: %s\n", err) + return + } + + var latestTrans string + for _, t := range resp.Transactions { + tdate, err := im.importTransaction(ctx, &t) + if err != nil { + return err + } else if tdate > latestTrans { + latestTrans = tdate + ctx.AccountNode().SetAttr(plaidLastTransaction, latestTrans) + } + } + + return nil +} + +func (im *imp) importTransaction(ctx *importer.RunContext, t *plaid.Transaction) (string, error) { + itemNode, err := ctx.RootNode().ChildPathObject(t.ID) + if err != nil { + return "", err + } + + transJSON, err := json.Marshal(t) + if err != nil { + return "", err + } + + fileRef, err := schema.WriteFileFromReader(ctx.Host.Target(), "", bytes.NewBuffer(transJSON)) + if err != nil { + return "", err + } + + transactionTime, err := time.Parse(plaidTransactionTimeFormat, t.Date) + if err != nil { + return "", err + } + + if err := itemNode.SetAttrs( + nodeattr.Type, plaidTransactionNodeType, + nodeattr.DateCreated, schema.RFC3339FromTime(transactionTime), + "transactionId", t.ID, + "vendor", t.Name, + "amount", fmt.Sprintf("%f", t.Amount), + "currency", "USD", + "categoryId", t.CategoryID, + nodeattr.Title, t.Name, + nodeattr.CamliContent, fileRef.String(), + ); err != nil { + return "", err + } + + // if the transaction includes location information (rare), use the supplied + // lat/long. Partial address data (eg, the US state) without corresponding lat/long + // is also sometimes returned; no attempt is made to geocode that info currently. + if t.Meta.Location.Coordinates.Lat != 0 && t.Meta.Location.Coordinates.Lon != 0 { + if err := itemNode.SetAttrs( + "latitude", fmt.Sprintf("%f", t.Meta.Location.Coordinates.Lat), + "longitude", fmt.Sprintf("%f", t.Meta.Location.Coordinates.Lon), + ); err != nil { + return "", err + } + } + + return t.Date, nil +} + +func (im *imp) ServeHTTP(w http.ResponseWriter, r *http.Request) { + httputil.BadRequestError(w, "Unexpected path: %s", r.URL.Path) +} + +func (im *imp) CallbackRequestAccount(r *http.Request) (blob.Ref, error) { + return importer.OAuth1{}.CallbackRequestAccount(r) +} + +func (im *imp) CallbackURLParameters(acctRef blob.Ref) url.Values { + return importer.OAuth1{}.CallbackURLParameters(acctRef) +} diff --git a/vendor/github.com/plaid/plaid-go/.gitignore b/vendor/github.com/plaid/plaid-go/.gitignore new file mode 100644 index 000000000..e43b0f988 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/vendor/github.com/plaid/plaid-go/LICENSE b/vendor/github.com/plaid/plaid-go/LICENSE new file mode 100644 index 000000000..26d8ea105 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2015-2016 Plaid Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/github.com/plaid/plaid-go/README.md b/vendor/github.com/plaid/plaid-go/README.md new file mode 100644 index 000000000..46fb2b0ae --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/README.md @@ -0,0 +1,116 @@ +# plaid-go + +plaid-go is a Go client implementation of the [Plaid API](https://plaid.com/docs). + +Install via `go get github.com/plaid/plaid-go`. + +**Documentation:** [![GoDoc](https://godoc.org/github.com/plaid/plaid-go?status.svg)](https://godoc.org/github.com/plaid/plaid-go/plaid) + +TODO: +- Complete README +- Complete testing +- Add CI + + +## Examples + +### Adding an Auth user + +```go +client := plaid.NewClient("test_id", "test_secret", plaid.Tartan) + +// POST /auth +postRes, mfaRes, err := client.AuthAddUser("plaid_test", "plaid_good", "", "bofa", nil) +if err != nil { + fmt.Println(err) +} else if mfaRes != nil { + // Need to switch on different MFA types. See https://plaid.com/docs/api/#auth-mfa. + switch mfaRes.Type { + case "device": + fmt.Println("--Device MFA--") + fmt.Println("Message:", mfaRes.Device.Message) + case "list": + fmt.Println("--List MFA--") + fmt.Println("Mask:", mfaRes.List[0].Mask, "\nType:", mfaRes.List[0].Type) + case "questions": + fmt.Println("--Questions MFA--") + fmt.Println("Question:", mfaRes.Questions[0].Question) + case "selections": + fmt.Println("--Selections MFA--") + fmt.Println("Question:", mfaRes.Selections[1].Question) + fmt.Println("Answers:", mfaRes.Selections[1].Answers) + } + + postRes2, mfaRes2, err := client.AuthStepSendMethod(mfaRes.AccessToken, "type", "email") + if err != nil { + fmt.Println("Error submitting send_method", err) + } + fmt.Println(mfaRes2, postRes2) + + postRes2, mfaRes2, err = client.AuthStep(mfaRes.AccessToken, "tomato") + if err != nil { + fmt.Println("Error submitting mfa", err) + } else { + fmt.Println(mfaRes2, postRes2) + } +} else { + fmt.Println(postRes.Accounts) + fmt.Println("Auth Get") + fmt.Println(client.AuthGet("test_bofa")) + + fmt.Println("Auth DELETE") + fmt.Println(client.AuthDelete("test_bofa")) +} +``` + +### Plaid Link Exchange Token Process + +Exchange a [Plaid Link][1] `public_token` for an API `access_token`: + +```go +client := plaid.NewClient("test_id", "test_secret", plaid.Tartan) + +// POST /exchange_token +postRes, err := client.ExchangeToken(public_token) +if err != nil { + fmt.Println(err) +} else { + // Use the returned Plaid API access_token to retrieve + // account information. + fmt.Println(postRes.AccessToken) + fmt.Println("Auth Get") + fmt.Println(client.AuthGet(postRes.AccessToken)) +} +``` + +With the [Plaid + Stripe ACH integration][2], exchange a Link `public_token` +and `account_id` for an API `access_token` and Stripe `bank_account_token`: + +```go +client := plaid.NewClient(CLIENT_ID, SECRET, plaid.Tartan) + +// POST /exchange_token +postRes, err := client.ExchangeTokenAccount(public_token, account_id) +if err != nil { + fmt.Println(err) +} else { + // Use the returned Plaid access_token to make Plaid API requests and the + // Stripe bank account token to make Stripe ACH API requests. + fmt.Println(postRes.AccessToken) + fmt.Println(postRes.BankAccountToken) +} +``` + +### Querying a category +```go +// GET /categories/13001001 +category, err := plaid.GetCategory(plaid.Tartan, "13001001") +if err != nil { + fmt.Println(err) +} else { + fmt.Println("category", category.ID, "is", strings.Join(category.Hierarchy, ", ")) +} +``` + +[1]: https://plaid.com/docs/link +[2]: https://plaid.com/docs/link/stripe diff --git a/vendor/github.com/plaid/plaid-go/main.go b/vendor/github.com/plaid/plaid-go/main.go new file mode 100644 index 000000000..6c1750a65 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/plaid/plaid-go/plaid" +) + +// main contains example usage of all functions +func main() { + // GET /institutions + res, err := plaid.GetInstitutions(plaid.Tartan) + if err != nil { + fmt.Println(err) + } + fmt.Println(res[0].Name, "has type:", res[0].Type) + + // GET /institutions/5301a93ac140de84910000e0 + inst, err := plaid.GetInstitution(plaid.Tartan, "5301a93ac140de84910000e0") + if err != nil { + fmt.Println("Institution Error:", err) + } + fmt.Println(inst.Name, "has mfa:", strings.Join(inst.MFA, ", ")) + + // GET /categories + categories, err := plaid.GetCategories(plaid.Tartan) + if err != nil { + fmt.Println(err) + } + fmt.Println("First category:", categories[0]) + + // GET /categories/13001001 + category, err := plaid.GetCategory(plaid.Tartan, "13001001") + if err != nil { + fmt.Println(err) + } + fmt.Println("category", category.ID, "is", strings.Join(category.Hierarchy, ", ")) + + client := plaid.NewClient("test_id", "test_secret", plaid.Tartan) + + // POST /auth + postRes, mfaRes, err := + client.AuthAddUser("plaid_test", "plaid_good", "", "citi", nil) + if err != nil { + fmt.Println(err) + } else if mfaRes != nil { + switch mfaRes.Type { + case "device": + fmt.Println("--Device MFA--") + fmt.Println("Message:", mfaRes.Device.Message) + case "list": + fmt.Println("--List MFA--") + fmt.Println("Mask:", mfaRes.List[0].Mask, "\nType:", mfaRes.List[0].Type) + case "questions": + fmt.Println("--Questions MFA--") + fmt.Println("Question:", mfaRes.Questions[0].Question) + case "selections": + fmt.Println("--Selections MFA--") + fmt.Println("Question:", mfaRes.Selections[1].Question) + fmt.Println("Answers:", mfaRes.Selections[1].Answers) + } + + postRes2, mfaRes2, err := client.AuthStepSendMethod(mfaRes.AccessToken, "type", "email") + if err != nil { + fmt.Println("Error submitting send_method", err) + } + fmt.Printf("%+v\n", mfaRes2) + fmt.Printf("%+v\n", postRes2) + + postRes2, mfaRes2, err = client.AuthStep(mfaRes.AccessToken, "tomato") + if err != nil { + fmt.Println("Error submitting mfa", err) + } + fmt.Printf("%+v\n", mfaRes2) + fmt.Printf("%+v\n", postRes2) + } else { + fmt.Println(postRes.Accounts) + fmt.Println("Auth Get") + fmt.Println(client.AuthGet("test_citi")) + + fmt.Println("Auth DELETE") + fmt.Println(client.AuthDelete("test_citi")) + } + + // POST /connect + postRes, mfaRes, err = client.ConnectAddUser("plaid_test", "plaid_good", "", "citi", nil) + if err != nil { + fmt.Println(err) + } else if mfaRes != nil { + switch mfaRes.Type { + case "device": + fmt.Println("--Device MFA--") + fmt.Println("Message:", mfaRes.Device.Message) + case "list": + fmt.Println("--List MFA--") + fmt.Println("Mask:", mfaRes.List[0].Mask, "\nType:", mfaRes.List[0].Type) + case "questions": + fmt.Println("--Questions MFA--") + fmt.Println("Question:", mfaRes.Questions[0].Question) + case "selections": + fmt.Println("--Selections MFA--") + fmt.Println("Question:", mfaRes.Selections[1].Question) + fmt.Println("Answers:", mfaRes.Selections[1].Answers) + } + + postRes2, mfaRes2, err := client.ConnectStepSendMethod(mfaRes.AccessToken, "type", "email") + if err != nil { + fmt.Println("Error submitting send_method", err) + } + fmt.Printf("%+v\n", mfaRes2) + fmt.Printf("%+v\n", postRes2) + + postRes2, mfaRes2, err = client.ConnectStep(mfaRes.AccessToken, "1234") + if err != nil { + fmt.Println("Error submitting mfa", err) + } + fmt.Printf("%+v\n", mfaRes2) + fmt.Printf("%+v\n", postRes2) + } else { + fmt.Println(postRes.Accounts) + fmt.Println("Connect GET") + connectRes, _, _ := client.ConnectGet("test_citi", &plaid.ConnectGetOptions{true, "", "", ""}) + fmt.Println(len(connectRes.Transactions)) + fmt.Println(connectRes.Transactions) + + fmt.Println("Connect DELETE") + fmt.Println(client.ConnectDelete("test_citi")) + } + + // POST /balance + fmt.Println("Balance") + postRes, err = client.Balance("test_citi") + if err != nil { + fmt.Println(err) + } + fmt.Printf("%+v\n", postRes) + + // POST /upgrade + fmt.Println("Upgrade") + postRes, mfaRes, err = client.Upgrade("test_bofa", "connect", nil) + if err != nil { + fmt.Println(err) + } + fmt.Printf("%+v\n", mfaRes) + fmt.Printf("%+v\n", postRes) + + // POST exchange_token + fmt.Println("ExchangeToken") + postRes, err = client.ExchangeToken("test,chase,connected") + fmt.Printf("%+v\n", postRes) +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/auth.go b/vendor/github.com/plaid/plaid-go/plaid/auth.go new file mode 100644 index 000000000..e9188dee4 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/auth.go @@ -0,0 +1,207 @@ +package plaid + +import ( + "bytes" + "encoding/json" +) + +// AuthAddUser (POST /auth) submits a set of user credentials to add an Auth user. +// +// See https://plaid.com/docs/api/#add-auth-user. +func (c *Client) AuthAddUser(username, password, pin, institutionType string, + options *AuthOptions) (postRes *postResponse, mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(authJson{ + ClientID: c.clientID, + Secret: c.secret, + Type: institutionType, + Username: username, + Password: password, + PIN: pin, + Options: options, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/auth", bytes.NewReader(jsonText)) +} + +// AuthStepSendMethod (POST /auth/step) specifies a particular send method for MFA, +// e.g. `{"mask":"xxx-xxx-5309"}`. +// +// See https://plaid.com/docs/api/#auth-mfa. +func (c *Client) AuthStepSendMethod(accessToken, key, value string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + sendMethod := map[string]string{key: value} + jsonText, err := json.Marshal(authStepSendMethodJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + Options: authStepOptions{sendMethod}, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/auth/step", bytes.NewReader(jsonText)) +} + +// AuthStep (POST /auth/step) submits an MFA answer for a given access token. +// +// See https://plaid.com/docs/api/#auth-mfa. +func (c *Client) AuthStep(accessToken, answer string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(authStepJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + MFA: answer, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/auth/step", bytes.NewReader(jsonText)) +} + +// AuthGet (POST /auth/get) retrieves account data for a given access token. +// +// See https://plaid.com/docs/api/#get-auth-data. +func (c *Client) AuthGet(accessToken string) (postRes *postResponse, err error) { + jsonText, err := json.Marshal(authGetJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + }) + if err != nil { + return nil, err + } + // /auth/get will never return an MFA response + postRes, _, err = c.postAndUnmarshal("/auth/get", bytes.NewReader(jsonText)) + return postRes, err +} + +// AuthUpdate (PATCH /auth) updates user credentials for a given access token. +// +// See https://plaid.com/docs/api/#update-auth-user. +func (c *Client) AuthUpdate(username, password, pin, accessToken string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(authUpdateJson{ + ClientID: c.clientID, + Secret: c.secret, + Username: username, + Password: password, + PIN: pin, + AccessToken: accessToken, + }) + if err != nil { + return nil, nil, err + } + return c.patchAndUnmarshal("/auth", bytes.NewReader(jsonText)) +} + +// AuthUpdateStep (PATCH /auth/step) updates user credentials and MFA for a given access token. +// +// See https://plaid.com/docs/api/#update-auth-user. +func (c *Client) AuthUpdateStep(username, password, pin, mfa, accessToken string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(authUpdateStepJson{ + ClientID: c.clientID, + Secret: c.secret, + Username: username, + Password: password, + PIN: pin, + MFA: mfa, + AccessToken: accessToken, + }) + if err != nil { + return nil, nil, err + } + return c.patchAndUnmarshal("/auth/step", bytes.NewReader(jsonText)) +} + +// AuthDelete (DELETE /auth) deletes data for a given access token. +// +// See https://plaid.com/docs/api/#delete-auth-user. +func (c *Client) AuthDelete(accessToken string) (deleteRes *deleteResponse, err error) { + jsonText, err := json.Marshal(authDeleteJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + }) + if err != nil { + return nil, err + } + return c.deleteAndUnmarshal("/auth", bytes.NewReader(jsonText)) +} + +// AuthOptions represents options associated with adding an Auth user. +// +// See https://plaid.com/docs/api/#add-auth-user. +type AuthOptions struct { + List bool `json:"list"` +} +type authJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + Type string `json:"type"` + + Username string `json:"username"` + Password string `json:"password"` + PIN string `json:"pin,omitempty"` + Options *AuthOptions `json:"options,omitempty"` +} + +type authStepOptions struct { + SendMethod map[string]string `json:"send_method"` +} + +type authStepSendMethodJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` + Options authStepOptions `json:"options"` +} + +type authStepJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` + + MFA string `json:"mfa"` +} + +type authGetJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` +} + +type authUpdateJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + + Username string `json:"username"` + Password string `json:"password"` + PIN string `json:"pin,omitempty"` + AccessToken string `json:"access_token"` +} + +type authUpdateStepJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + + Username string `json:"username"` + Password string `json:"password"` + PIN string `json:"pin,omitempty"` + MFA string `json:"mfa"` + AccessToken string `json:"access_token"` +} + +type authDeleteJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/balance.go b/vendor/github.com/plaid/plaid-go/plaid/balance.go new file mode 100644 index 000000000..42bbc2639 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/balance.go @@ -0,0 +1,28 @@ +package plaid + +import ( + "bytes" + "encoding/json" +) + +// Balance (POST /balance) retrieves real-time balance for a given access token. +// +// See https://plaid.com/docs/api/#balance. +func (c *Client) Balance(accessToken string) (postRes *postResponse, err error) { + jsonText, err := json.Marshal(balanceJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + }) + if err != nil { + return nil, err + } + postRes, _, err = c.postAndUnmarshal("/balance", bytes.NewReader(jsonText)) + return postRes, err +} + +type balanceJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/categories.go b/vendor/github.com/plaid/plaid-go/plaid/categories.go new file mode 100644 index 000000000..a2c6a3f95 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/categories.go @@ -0,0 +1,21 @@ +package plaid + +// GetCategories returns information for all categories. +// See https://plaid.com/docs/api/#category-overview. +func GetCategories(environment environmentURL) (categories []category, err error) { + err = getAndUnmarshal(environment, "/categories", &categories) + return +} + +// GetCategory returns information for a single category given an ID. +// See https://plaid.com/docs/api/#categories-by-id. +func GetCategory(environment environmentURL, id string) (cat category, err error) { + err = getAndUnmarshal(environment, "/categories/"+id, &cat) + return +} + +type category struct { + Hierarchy []string `json:"hierarchy"` // e.g.: ["Food and Drink", "Bar"] + ID string `json:"id"` // e.g.: "13001000" + Type string `json:"type"` // e.g.: "place" +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/categories_test.go b/vendor/github.com/plaid/plaid-go/plaid/categories_test.go new file mode 100644 index 000000000..8eb79c6e6 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/categories_test.go @@ -0,0 +1,52 @@ +package plaid + +import ( + "fmt" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestCategories(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "categories tests") +} + +var _ = Describe("categories", func() { + + Describe("GetCategories", func() { + + It("returns non-empty array", func() { + categories, err := GetCategories(Tartan) + Expect(err).To(BeNil(), "err should be nil") + Expect(categories).ToNot(BeEmpty()) + }) + + }) + + Describe("GetCategory", func() { + + It("returns proper fields", func() { + c, err := GetCategory(Tartan, "13001000") + Expect(err).To(BeNil(), "err should be nil") + Expect(c.Hierarchy).ToNot(BeEmpty()) + Expect(c.Hierarchy[0]).To(Equal("Food and Drink")) + Expect(c.Hierarchy[1]).To(Equal("Bar")) + Expect(c.ID).To(Equal("13001000")) + Expect(c.Type).To(Equal("place")) + }) + + }) + +}) + +func ExampleGetCategory() { + category, err := GetCategory(Tartan, "13005006") + fmt.Println(err) + fmt.Println(category.Hierarchy[2]) + fmt.Println(category.Type) + // Output: + // Sushi + // place +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/connect.go b/vendor/github.com/plaid/plaid-go/plaid/connect.go new file mode 100644 index 000000000..285677327 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/connect.go @@ -0,0 +1,223 @@ +package plaid + +import ( + "bytes" + "encoding/json" +) + +// ConnectAddUser (POST /connect) submits a set of user credentials to add a Connect user. +// +// See https://plaid.com/docs/api/#add-user. +func (c *Client) ConnectAddUser(username, password, pin, institutionType string, + options *ConnectOptions) (postRes *postResponse, mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(connectJson{ + ClientID: c.clientID, + Secret: c.secret, + Type: institutionType, + Username: username, + Password: password, + PIN: pin, + Options: options, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/connect", bytes.NewReader(jsonText)) +} + +// ConnectStepSendMethod (POST /connect/step) specifies a particular send method for MFA, +// e.g. `{"mask":"xxx-xxx-5309"}`. +// +// See https://plaid.com/docs/api/#mfa-authentication. +func (c *Client) ConnectStepSendMethod(accessToken, key, value string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + sendMethod := map[string]string{key: value} + jsonText, err := json.Marshal(connectStepSendMethodJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + Options: connectStepOptions{sendMethod}, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/connect/step", bytes.NewReader(jsonText)) +} + +// ConnectStep (POST /connect/step) submits an MFA answer for a given access token. +// +// See https://plaid.com/docs/api/#mfa-authentication. +func (c *Client) ConnectStep(accessToken, answer string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(connectStepJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + MFA: answer, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/connect/step", bytes.NewReader(jsonText)) +} + +// ConnectGet (POST /connect/get) retrieves account and transaction data for a given access token. +// +// See https://plaid.com/docs/api/#get-transactions. +func (c *Client) ConnectGet(accessToken string, options *ConnectGetOptions) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(connectGetJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + Options: options, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/connect/get", bytes.NewReader(jsonText)) +} + +// ConnectUpdate (PATCH /connect) updates user credentials for a given access token. +// +// See https://plaid.com/docs/api/#update-user. +func (c *Client) ConnectUpdate(username, password, pin, accessToken string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(connectUpdateJson{ + ClientID: c.clientID, + Secret: c.secret, + Username: username, + Password: password, + PIN: pin, + AccessToken: accessToken, + }) + if err != nil { + return nil, nil, err + } + return c.patchAndUnmarshal("/connect", bytes.NewReader(jsonText)) +} + +// ConnectUpdateStep (PATCH /connect/step) updates user credentials and MFA for a given access token. +// +// See https://plaid.com/docs/api/#update-user. +func (c *Client) ConnectUpdateStep(username, password, pin, mfa, accessToken string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(connectUpdateStepJson{ + ClientID: c.clientID, + Secret: c.secret, + Username: username, + Password: password, + PIN: pin, + MFA: mfa, + AccessToken: accessToken, + }) + if err != nil { + return nil, nil, err + } + return c.patchAndUnmarshal("/connect/step", bytes.NewReader(jsonText)) +} + +// ConnectDelete (DELETE /connect) deletes data for a given access token. +// +// See https://plaid.com/docs/api/#delete-user. +func (c *Client) ConnectDelete(accessToken string) (deleteRes *deleteResponse, err error) { + jsonText, err := json.Marshal(connectDeleteJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + }) + if err != nil { + return nil, err + } + return c.deleteAndUnmarshal("/connect", bytes.NewReader(jsonText)) +} + +// ConnectOptions represents options associated with adding an Connect user. +// +// See https://plaid.com/docs/api/#add-user. +type ConnectOptions struct { + Webhook string `json:"webhook,omitempty"` + Pending bool `json:"pending,omitempty"` + LoginOnly bool `json:"login_only,omitempty"` + List bool `json:"list,omitempty"` + StartDate string `json:"start_date,omitempty"` + EndDate string `json:"end_date,omitempty"` +} +type connectJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + Type string `json:"type"` + + Username string `json:"username"` + Password string `json:"password"` + PIN string `json:"pin,omitempty"` + Options *ConnectOptions `json:"options,omitempty"` +} + +type connectStepOptions struct { + SendMethod map[string]string `json:"send_method"` +} +type connectStepSendMethodJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` + Options connectStepOptions `json:"options"` +} + +type connectStepJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` + + MFA string `json:"mfa"` +} + +// ConnectGetOptions represents options associated with retrieving a Connect user. +// +// See https://plaid.com/docs/api/#retrieve-transactions. +type ConnectGetOptions struct { + Pending bool `json:"pending,omitempty"` + Account string `json:"account,omitempty"` + GTE string `json:"gte,omitempty"` + LTE string `json:"lte,omitempty"` +} +type connectGetJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` + + Options *ConnectGetOptions `json:"options,omitempty"` +} + +type connectUpdateJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + + Username string `json:"username"` + Password string `json:"password"` + PIN string `json:"pin,omitempty"` + AccessToken string `json:"access_token"` +} + +type connectUpdateStepJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + + Username string `json:"username"` + Password string `json:"password"` + PIN string `json:"pin,omitempty"` + MFA string `json:"mfa"` + AccessToken string `json:"access_token"` +} + +type connectDeleteJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/errors.go b/vendor/github.com/plaid/plaid-go/plaid/errors.go new file mode 100644 index 000000000..37fcb96f6 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/errors.go @@ -0,0 +1,20 @@ +package plaid + +import ( + "fmt" +) + +type plaidError struct { + // List of all errors: https://github.com/plaid/support/blob/master/errors.md + ErrorCode int `json:"code"` + Message string `json:"message"` + Resolve string `json:"resolve"` + + // StatusCode needs to manually set from the http response + StatusCode int +} + +func (e plaidError) Error() string { + return fmt.Sprintf("Plaid Error - http status: %d, code: %d, message: %s, resolve: %s", + e.StatusCode, e.ErrorCode, e.Message, e.Resolve) +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/exchange-token.go b/vendor/github.com/plaid/plaid-go/plaid/exchange-token.go new file mode 100644 index 000000000..7a95636f8 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/exchange-token.go @@ -0,0 +1,51 @@ +package plaid + +import ( + "bytes" + "encoding/json" +) + +// ExchangeToken (POST /exchange_token) exchanges a public token for an access token. +// +// See https://github.com/plaid/link +func (c *Client) ExchangeToken(publicToken string) (postRes *postResponse, err error) { + jsonText, err := json.Marshal(exchangeJson{ + ClientID: c.clientID, + Secret: c.secret, + PublicToken: publicToken, + }) + if err != nil { + return nil, err + } + postRes, _, err = c.postAndUnmarshal("/exchange_token", bytes.NewReader(jsonText)) + return postRes, err +} + +// ExchangeTokenAccount (POST /exchange_token) exchanges a public token and account id to receive a +// bank account token. +func (c *Client) ExchangeTokenAccount(publicToken string, accountId string) (postRes *postResponse, err error) { + jsonText, err := json.Marshal(exchangeAccountJson{ + ClientID: c.clientID, + Secret: c.secret, + PublicToken: publicToken, + AccountId: accountId, + }) + if err != nil { + return nil, err + } + postRes, _, err = c.postAndUnmarshal("/exchange_token", bytes.NewReader(jsonText)) + return postRes, err +} + +type exchangeJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + PublicToken string `json:"public_token"` +} + +type exchangeAccountJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + PublicToken string `json:"public_token"` + AccountId string `json:"account_id"` +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/exchange-token_test.go b/vendor/github.com/plaid/plaid-go/plaid/exchange-token_test.go new file mode 100644 index 000000000..8f8d19aea --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/exchange-token_test.go @@ -0,0 +1,24 @@ +package plaid + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestExchangeToken(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "exchange-token tests") +} + +var _ = Describe("exchange-token", func() { + Describe("ExchangeToken", func() { + It("returns public_token and access_token", func() { + c := NewClient("test_id", "test_secret", Tartan) + res, err := c.ExchangeToken("test,chase,connected") + Expect(err).To(BeNil(), "err should be nil") + Expect(res.AccessToken).To(Equal("test_chase")) + }) + }) +}) diff --git a/vendor/github.com/plaid/plaid-go/plaid/institutions.go b/vendor/github.com/plaid/plaid-go/plaid/institutions.go new file mode 100644 index 000000000..ea56285df --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/institutions.go @@ -0,0 +1,29 @@ +package plaid + +// GetInstitution returns information for a single institution given an ID. +// See https://plaid.com/docs/api/#institutions-by-id. +func GetInstitution(environment environmentURL, id string) (inst institution, err error) { + err = getAndUnmarshal(environment, "/institutions/"+id, &inst) + return +} + +// GetInstitution returns information for all institutions. +// See https://plaid.com/docs/api/#all-institutions. +func GetInstitutions(environment environmentURL) (institutions []institution, err error) { + err = getAndUnmarshal(environment, "/institutions", &institutions) + return +} + +type institution struct { + Credentials struct { + Password string `json:"password"` // e.g.: "Password" + PIN string `json:"pin"` // e.g.: "PIN" + Username string `json:"username"` // e.g.: "Online ID" + } + Name string `json:"name"` // e.g.: "Bank of America" + HasMFA bool `json:"has_mfa"` // e.g.: true + ID string `json:"id"` // e.g.: "5301a93ac140de84910000e0" + MFA []string `json:"mfa"` // e.g.: ["code", "list", "questions"] + Products []string `json:"products"` // e.g.: ["connect", "auth", "balance"] + Type string `json:"type"` // e.g.: "bofa" +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/institutions_test.go b/vendor/github.com/plaid/plaid-go/plaid/institutions_test.go new file mode 100644 index 000000000..c1fd83cb8 --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/institutions_test.go @@ -0,0 +1,55 @@ +package plaid + +import ( + "fmt" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestInstitutions(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "institutions tests") +} + +var _ = Describe("institutions", func() { + + Describe("GetInstitutions", func() { + + It("returns non-empty array", func() { + institutions, err := GetInstitutions(Tartan) + Expect(err).To(BeNil(), "err should be nil") + Expect(institutions).ToNot(BeEmpty()) + }) + + }) + + Describe("GetInstitution", func() { + + It("returns proper fields", func() { + i, err := GetInstitution(Tartan, "5301a9d704977c52b60000db") + Expect(err).To(BeNil(), "err should be nil") + Expect(i.HasMFA).To(BeFalse()) + Expect(i.ID).To(Equal("5301a9d704977c52b60000db")) + Expect(i.MFA).To(BeEmpty()) + Expect(i.Name).To(Equal("American Express")) + Expect(i.Type).To(Equal("amex")) + Expect(i.Products).ToNot(BeEmpty()) + Expect(i.Products).To(ContainElement("balance")) + Expect(i.Products).To(ContainElement("connect")) + }) + + }) + +}) + +func ExampleGetInstitution() { + institution, err := GetInstitution(Tartan, "5301a9d704977c52b60000db") + fmt.Println(err) + fmt.Println(institution.Name) + fmt.Println(institution.Type) + // Output: + // American Express + // amex +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/plaid.go b/vendor/github.com/plaid/plaid-go/plaid/plaid.go new file mode 100644 index 000000000..803283b9f --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/plaid.go @@ -0,0 +1,373 @@ +// Package plaid implements a Go client for the Plaid API (https://plaid.com/docs) +package plaid + +import ( + "encoding/json" + "errors" + "io" + "io/ioutil" + "net/http" +) + +// NewClient instantiates a Client associated with a client id, secret and environment. +// See https://plaid.com/docs/api/#gaining-access. +func NewClient(clientID, secret string, environment environmentURL) *Client { + return &Client{clientID, secret, environment, &http.Client{}} +} + +// Same as above but with additional parameter to pass http.Client. This is required +// if you want to run the code on Google AppEngine which prohibits use of http.DefaultClient +func NewCustomClient(clientID, secret string, environment environmentURL, httpClient *http.Client) *Client { + return &Client{clientID, secret, environment, httpClient} +} + +// Note: Client is only exported for method documentation purposes. +// Instances should only be created through the 'NewClient' function. +// +// See https://github.com/golang/go/issues/7823. +type Client struct { + clientID string + secret string + environment environmentURL + httpClient *http.Client +} + +type environmentURL string + +var Tartan environmentURL = "https://tartan.plaid.com" +var Production environmentURL = "https://api.plaid.com" + +type Account struct { + ID string `json:"_id"` + ItemID string `json:"_item"` + UserID string `json:"_user"` + Balance struct { + Available float64 `json:"available"` + Current float64 `json:"current"` + } `json:"balance"` + Meta struct { + Number string `json:"number"` + Name string `json:"name"` + } `json:"meta"` + Numbers struct { + Account string `json:"account"` + Routing string `json:"routing"` + WireRouting string `json:"wireRouting"` + } `json:"numbers"` + Type string `json:"type"` + InstitutionType string `json:"institution_type"` +} + +type Transaction struct { + ID string `json:"_id"` + AccountID string `json:"_account"` + + Amount float64 `json:"amount"` + Date string `json:"date"` + Name string `json:"name"` + Meta struct { + AccountOwner string `json:"account_owner"` + + Location struct { + Address string `json:"address"` + City string `json:"city"` + + Coordinates struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + } `json:"coordinates"` + + State string `json:"state"` + Zip string `json:"zip"` + } `json:"location"` + } `json:"meta"` + + Pending bool `json:"pending"` + + Type struct { + Primary string `json:"primary"` + } `json:"type"` + + Category []string `json:"category"` + CategoryID string `json:"category_id"` + + Score struct { + Location struct { + Address float64 `json:"address"` + City float64 `json:"city"` + State float64 `json:"state"` + Zip float64 `json:"zip"` + } + Name float64 `json:"name"` + } `json:"score"` +} + +type mfaIntermediate struct { + AccessToken string `json:"access_token"` + MFA interface{} `json:"mfa"` + Type string `json:"type"` +} +type mfaDevice struct { + Message string +} +type mfaList struct { + Mask string + Type string +} +type mfaQuestion struct { + Question string +} +type mfaSelection struct { + Answers []string + Question string +} + +// 'mfa' contains the union of all possible mfa types +// Users should switch on the 'Type' field +type mfaResponse struct { + AccessToken string + Type string + + Device mfaDevice + List []mfaList + Questions []mfaQuestion + Selections []mfaSelection +} + +type postResponse struct { + // Normal response fields + AccessToken string `json:"access_token"` + AccountId string `json:"account_id"` + Accounts []Account `json:"accounts"` + BankAccountToken string `json:"stripe_bank_account_token"` + MFA string `json:"mfa"` + Transactions []Transaction `json:"transactions"` +} + +type deleteResponse struct { + Message string `json:"message"` +} + +// getAndUnmarshal is not a method because no client authentication is required +func getAndUnmarshal(environment environmentURL, endpoint string, structure interface{}) error { + res, err := http.Get(string(environment) + endpoint) + if err != nil { + return err + } + raw, err := ioutil.ReadAll(res.Body) + if err != nil { + return err + } + res.Body.Close() + + // Successful response + if res.StatusCode == 200 { + if err = json.Unmarshal(raw, structure); err != nil { + return err + } + return nil + } + // Attempt to unmarshal into Plaid error format + var plaidErr plaidError + if err = json.Unmarshal(raw, &plaidErr); err != nil { + return err + } + plaidErr.StatusCode = res.StatusCode + return plaidErr +} + +func (c *Client) postAndUnmarshal(endpoint string, + body io.Reader) (*postResponse, *mfaResponse, error) { + // Read response body + req, err := http.NewRequest("POST", string(c.environment)+endpoint, body) + if err != nil { + return nil, nil, err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("User-Agent", "plaid-go") + res, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + raw, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, nil, err + } + res.Body.Close() + + return unmarshalPostMFA(res, raw) +} + +func (c *Client) patchAndUnmarshal(endpoint string, + body io.Reader) (*postResponse, *mfaResponse, error) { + + req, err := http.NewRequest("PATCH", string(c.environment)+endpoint, body) + if err != nil { + return nil, nil, err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("User-Agent", "plaid-go") + res, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + raw, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, nil, err + } + res.Body.Close() + + return unmarshalPostMFA(res, raw) +} + +func (c *Client) deleteAndUnmarshal(endpoint string, + body io.Reader) (*deleteResponse, error) { + + req, err := http.NewRequest("DELETE", string(c.environment)+endpoint, body) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("User-Agent", "plaid-go") + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + raw, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + res.Body.Close() + + // Successful response + var deleteRes deleteResponse + if res.StatusCode == 200 { + if err = json.Unmarshal(raw, &deleteRes); err != nil { + return nil, err + } + return &deleteRes, nil + } + // Attempt to unmarshal into Plaid error format + var plaidErr plaidError + if err = json.Unmarshal(raw, &plaidErr); err != nil { + return nil, err + } + plaidErr.StatusCode = res.StatusCode + return nil, plaidErr +} + +// Unmarshals response into postResponse, mfaResponse, or plaidError +func unmarshalPostMFA(res *http.Response, body []byte) (*postResponse, *mfaResponse, error) { + // Different marshaling cases + var mfaInter mfaIntermediate + var postRes postResponse + var err error + switch { + // Successful response + case res.StatusCode == 200: + if err = json.Unmarshal(body, &postRes); err != nil { + return nil, nil, err + } + return &postRes, nil, nil + + // MFA case + case res.StatusCode == 201: + if err = json.Unmarshal(body, &mfaInter); err != nil { + return nil, nil, err + } + mfaRes := mfaResponse{Type: mfaInter.Type, AccessToken: mfaInter.AccessToken} + switch mfaInter.Type { + case "device": + temp, ok := mfaInter.MFA.(interface{}) + if !ok { + return nil, nil, errors.New("Could not decode device mfa") + } + deviceStruct, ok := temp.(map[string]interface{}) + if !ok { + return nil, nil, errors.New("Could not decode device mfa") + } + deviceText, ok := deviceStruct["message"].(string) + if !ok { + return nil, nil, errors.New("Could not decode device mfa") + } + mfaRes.Device.Message = deviceText + + case "list": + temp, ok := mfaInter.MFA.([]interface{}) + if !ok { + return nil, nil, errors.New("Could not decode list mfa") + } + for _, v := range temp { + listArray, ok := v.(map[string]interface{}) + if !ok { + return nil, nil, errors.New("Could not decode list mfa") + } + maskText, ok := listArray["mask"].(string) + if !ok { + return nil, nil, errors.New("Could not decode list mfa") + } + typeText, ok := listArray["type"].(string) + if !ok { + return nil, nil, errors.New("Could not decode list mfa") + } + mfaRes.List = append(mfaRes.List, mfaList{Mask: maskText, Type: typeText}) + } + + case "questions": + questions, ok := mfaInter.MFA.([]interface{}) + if !ok { + return nil, nil, errors.New("Could not decode questions mfa") + } + for _, v := range questions { + q, ok := v.(map[string]interface{}) + if !ok { + return nil, nil, errors.New("Could not decode questions mfa") + } + questionText, ok := q["question"].(string) + if !ok { + return nil, nil, errors.New("Could not decode questions mfa question") + } + mfaRes.Questions = append(mfaRes.Questions, mfaQuestion{Question: questionText}) + } + + case "selections": + selections, ok := mfaInter.MFA.([]interface{}) + if !ok { + return nil, nil, errors.New("Could not decode selections mfa") + } + for _, v := range selections { + s, ok := v.(map[string]interface{}) + if !ok { + return nil, nil, errors.New("Could not decode selections mfa") + } + tempAnswers, ok := s["answers"].([]interface{}) + if !ok { + return nil, nil, errors.New("Could not decode selections answers") + } + answers := make([]string, len(tempAnswers)) + for i, a := range tempAnswers { + answers[i], ok = a.(string) + } + if !ok { + return nil, nil, errors.New("Could not decode selections answers") + } + question, ok := s["question"].(string) + if !ok { + return nil, nil, errors.New("Could not decode selections questions") + } + mfaRes.Selections = append(mfaRes.Selections, mfaSelection{Answers: answers, Question: question}) + } + } + return nil, &mfaRes, nil + + // Error case, attempt to unmarshal into Plaid error format + case res.StatusCode >= 400: + var plaidErr plaidError + if err = json.Unmarshal(body, &plaidErr); err != nil { + return nil, nil, err + } + plaidErr.StatusCode = res.StatusCode + return nil, nil, plaidErr + } + return nil, nil, errors.New("Unknown Plaid Error - Status:" + string(res.StatusCode)) +} diff --git a/vendor/github.com/plaid/plaid-go/plaid/upgrade.go b/vendor/github.com/plaid/plaid-go/plaid/upgrade.go new file mode 100644 index 000000000..05f6269cc --- /dev/null +++ b/vendor/github.com/plaid/plaid-go/plaid/upgrade.go @@ -0,0 +1,98 @@ +package plaid + +import ( + "bytes" + "encoding/json" +) + +// Upgrade (POST /upgrade) upgrades an access token to an additional product. +// +// See https://plaid.com/docs/api/#upgrade-user. +func (c *Client) Upgrade(accessToken, upgradeTo string, + options *UpgradeOptions) (postRes *postResponse, mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(upgradeJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + UpgradeTo: upgradeTo, + Options: options, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/upgrade", bytes.NewReader(jsonText)) +} + +// UpgradeStepSendMethod (POST /upgrade/step) specifies a particular send method for MFA, +// e.g. {"mask":"xxx-xxx-5309"}. +// +// See https://plaid.com/docs/api/#upgrade-user. +func (c *Client) UpgradeStepSendMethod(accessToken, key, value string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + sendMethod := map[string]string{key: value} + jsonText, err := json.Marshal(upgradeStepSendMethodJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + Options: upgradeStepOptions{sendMethod}, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/upgrade/step", bytes.NewReader(jsonText)) +} + +// UpgradeStep (POST /upgrade/step) submits an MFA answer for a given access token. +// +// See https://plaid.com/docs/api/#mfa-authentication for upgrades to Connect. +// See https://plaid.com/docs/api/#mfa-auth for upgrades to Auth. +func (c *Client) UpgradeStep(accessToken, answer string) (postRes *postResponse, + mfaRes *mfaResponse, err error) { + + jsonText, err := json.Marshal(upgradeStepJson{ + ClientID: c.clientID, + Secret: c.secret, + AccessToken: accessToken, + MFA: answer, + }) + if err != nil { + return nil, nil, err + } + return c.postAndUnmarshal("/upgrade/step", bytes.NewReader(jsonText)) +} + +// UpgradeOptions represents options associated with upgrading a user. +// +// See https://plaid.com/docs/api/#add-user for upgrades to Connect. +// See https://plaid.com/docs/api/#add-auth-user for upgrades to Auth. +type UpgradeOptions struct { + Webhook string `json:"webhook,omitempty"` +} + +type upgradeJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` + UpgradeTo string `json:"upgrade_to"` + Options *UpgradeOptions `json:"options,omitempty"` +} + +type upgradeStepOptions struct { + SendMethod map[string]string `json:"send_method"` +} + +type upgradeStepSendMethodJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` + Options upgradeStepOptions `json:"options"` +} + +type upgradeStepJson struct { + ClientID string `json:"client_id"` + Secret string `json:"secret"` + AccessToken string `json:"access_token"` + MFA string `json:"mfa"` +}