1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
16
17
18 package 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
222
223
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 }