Home Download Docs Code Community
     1	/*
     2	Copyright 2017 The Perkeep Authors
     3	
     4	Licensed under the Apache License, Version 2.0 (the "License");
     5	you may not use this file except in compliance with the License.
     6	You may obtain a copy of the License at
     7	
     8	     http://www.apache.org/licenses/LICENSE-2.0
     9	
    10	Unless required by applicable law or agreed to in writing, software
    11	distributed under the License is distributed on an "AS IS" BASIS,
    12	WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13	See the License for the specific language governing permissions and
    14	limitations under the License.
    15	*/
    16	
    17	// Package plaid implements an importer for financial transactions from plaid.com
    18	package plaid // import "perkeep.org/pkg/importer/plaid"
    19	
    20	import (
    21		"bytes"
    22		"encoding/json"
    23		"fmt"
    24		"html/template"
    25		"log"
    26		"net/http"
    27		"net/url"
    28		"time"
    29	
    30		"perkeep.org/internal/httputil"
    31		"perkeep.org/pkg/blob"
    32		"perkeep.org/pkg/importer"
    33		"perkeep.org/pkg/schema"
    34		"perkeep.org/pkg/schema/nodeattr"
    35	
    36		"github.com/plaid/plaid-go/plaid"
    37	)
    38	
    39	func init() {
    40		importer.Register("plaid", &imp{})
    41	}
    42	
    43	type imp struct{}
    44	
    45	func (*imp) Properties() importer.Properties {
    46		return importer.Properties{
    47			Title:               "Plaid",
    48			Description:         "import your financial transactions from plaid.com",
    49			SupportsIncremental: true,
    50			NeedsAPIKey:         true,
    51		}
    52	}
    53	
    54	const (
    55		acctAttrToken    = "plaidAccountToken"
    56		acctAttrUsername = "username"
    57		acctInstitution  = "institutionType"
    58	
    59		plaidTransactionTimeFormat = "2006-01-02"
    60		plaidTransactionNodeType   = "plaid.io:transaction"
    61		plaidLastTransaction       = "lastTransactionSyncDate"
    62	)
    63	
    64	func (*imp) IsAccountReady(acct *importer.Object) (ready bool, err error) {
    65		return acct.Attr(acctAttrToken) != "" && acct.Attr(acctAttrUsername) != "", nil
    66	}
    67	
    68	func (*imp) SummarizeAccount(acct *importer.Object) string {
    69		return fmt.Sprintf("%s (%s)", acct.Attr(acctAttrUsername), acct.Attr(acctInstitution))
    70	}
    71	
    72	func (*imp) ServeSetup(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) error {
    73		args := struct {
    74			Ctx  *importer.SetupContext
    75			Inst InstitutionNameMap
    76		}{
    77			ctx,
    78			supportedInstitutions,
    79		}
    80	
    81		return tmpl.ExecuteTemplate(w, "serveSetup", args)
    82	}
    83	
    84	var tmpl = template.Must(template.New("root").Parse(`
    85	{{define "serveSetup"}}
    86	<h1>Configuring Bank Account</h1>
    87	<p>Enter your username/password credentials for your bank/card account and select the institution type.
    88	<form method="get" action="{{.Ctx.CallbackURL}}">
    89	  <input type="hidden" name="acct" value="{{.Ctx.AccountNode.PermanodeRef}}">
    90	  <table border=0 cellpadding=3>
    91	  <tr><td align=right>Username</td><td><input name="username" size=50></td></tr>
    92	  <tr><td align=right>Password</td><td><input name="password" size=50 type="password"></td></tr>
    93	  <tr><td>Institution</td><td><select name="institution">
    94	    {{range .Inst}}
    95	        <option value="{{.CodeName}}">{{.DisplayName}}</option>
    96	    {{end}}
    97	  </select></td></tr>
    98	  <tr><td align=right></td><td align=right><input type="submit" value="Add"></td></tr>
    99	  </table>
   100	</form>
   101	{{end}}
   102	`))
   103	
   104	var _ importer.ImporterSetupHTMLer = (*imp)(nil)
   105	
   106	func (im *imp) AccountSetupHTML(host *importer.Host) string {
   107		return fmt.Sprintf(`
   108	<h1>Configuring Plaid</h1>
   109	<p>Signup for a developer account on <a href='https://dashboard.plaid.com/signup'>Plaid dashboard</a>
   110	<p>After following signup steps and verifying your email, get your developer credentials
   111	(under "Send your first request"), and copy your client ID and secret above.
   112	<p>
   113	`)
   114	}
   115	
   116	func (im *imp) ServeCallback(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) {
   117		username := r.FormValue("username")
   118		password := r.FormValue("password")
   119		if username == "" || password == "" {
   120			http.Error(w, "Username and password are both required", http.StatusBadRequest)
   121			return
   122		}
   123		institution := r.FormValue("institution")
   124	
   125		clientID, secret, err := ctx.Credentials()
   126		if err != nil {
   127			httputil.ServeError(w, r, fmt.Errorf("Credentials error: %v", err))
   128			return
   129		}
   130	
   131		client := plaid.NewClient(clientID, secret, plaid.Tartan)
   132		res, _, err := client.ConnectAddUser(username, password, "", institution, nil)
   133		if err != nil {
   134			httputil.ServeError(w, r, fmt.Errorf("ConnectAddUser error: %v", err))
   135			return
   136		}
   137	
   138		if err := ctx.AccountNode.SetAttrs(
   139			"title", fmt.Sprintf("%s account: %s", institution, username),
   140			acctAttrUsername, username,
   141			acctAttrToken, res.AccessToken,
   142			acctInstitution, institution,
   143		); err != nil {
   144			httputil.ServeError(w, r, fmt.Errorf("Error setting attributes: %v", err))
   145			return
   146		}
   147		http.Redirect(w, r, ctx.AccountURL(), http.StatusFound)
   148	}
   149	
   150	func (im *imp) Run(ctx *importer.RunContext) (err error) {
   151		log.Printf("Running plaid importer.")
   152		defer func() {
   153			log.Printf("plaid importer returned: %v", err)
   154		}()
   155	
   156		clientID, secret, err := ctx.Credentials()
   157		if err != nil {
   158			return err
   159		}
   160	
   161		var opt plaid.ConnectGetOptions
   162		if start := ctx.AccountNode().Attr(plaidLastTransaction); start != "" {
   163			opt.GTE = start
   164		}
   165	
   166		client := plaid.NewClient(clientID, secret, plaid.Tartan)
   167		resp, _, err := client.ConnectGet(ctx.AccountNode().Attr(acctAttrToken), &opt)
   168		if err != nil {
   169			return fmt.Errorf("connectGet: %s", err)
   170		}
   171	
   172		var latestTrans string
   173		for _, t := range resp.Transactions {
   174			tdate, err := im.importTransaction(ctx, &t)
   175			if err != nil {
   176				return err
   177			} else if tdate > latestTrans {
   178				latestTrans = tdate
   179				ctx.AccountNode().SetAttr(plaidLastTransaction, latestTrans)
   180			}
   181		}
   182	
   183		return nil
   184	}
   185	
   186	func (im *imp) importTransaction(ctx *importer.RunContext, t *plaid.Transaction) (string, error) {
   187		itemNode, err := ctx.RootNode().ChildPathObject(t.ID)
   188		if err != nil {
   189			return "", err
   190		}
   191	
   192		transJSON, err := json.Marshal(t)
   193		if err != nil {
   194			return "", err
   195		}
   196	
   197		fileRef, err := schema.WriteFileFromReader(ctx.Context(), ctx.Host.Target(), "", bytes.NewBuffer(transJSON))
   198		if err != nil {
   199			return "", err
   200		}
   201	
   202		transactionTime, err := time.Parse(plaidTransactionTimeFormat, t.Date)
   203		if err != nil {
   204			return "", err
   205		}
   206	
   207		if err := itemNode.SetAttrs(
   208			nodeattr.Type, plaidTransactionNodeType,
   209			nodeattr.DateCreated, schema.RFC3339FromTime(transactionTime),
   210			"transactionId", t.ID,
   211			"vendor", t.Name,
   212			"amount", fmt.Sprintf("%f", t.Amount),
   213			"currency", "USD",
   214			"categoryId", t.CategoryID,
   215			nodeattr.Title, t.Name,
   216			nodeattr.CamliContent, fileRef.String(),
   217		); err != nil {
   218			return "", err
   219		}
   220	
   221		// if the transaction includes location information (rare), use the supplied
   222		// lat/long. Partial address data (eg, the US state) without corresponding lat/long
   223		// is also sometimes returned; no attempt is made to geocode that info currently.
   224		if t.Meta.Location.Coordinates.Lat != 0 && t.Meta.Location.Coordinates.Lon != 0 {
   225			if err := itemNode.SetAttrs(
   226				"latitude", fmt.Sprintf("%f", t.Meta.Location.Coordinates.Lat),
   227				"longitude", fmt.Sprintf("%f", t.Meta.Location.Coordinates.Lon),
   228			); err != nil {
   229				return "", err
   230			}
   231		}
   232	
   233		return t.Date, nil
   234	}
   235	
   236	func (im *imp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   237		httputil.BadRequestError(w, "Unexpected path: %s", r.URL.Path)
   238	}
   239	
   240	func (im *imp) CallbackRequestAccount(r *http.Request) (blob.Ref, error) {
   241		return importer.OAuth1{}.CallbackRequestAccount(r)
   242	}
   243	
   244	func (im *imp) CallbackURLParameters(acctRef blob.Ref) url.Values {
   245		return importer.OAuth1{}.CallbackURLParameters(acctRef)
   246	}
Website layout inspired by memcached.
Content by the authors.