8 Commits
v0.2 ... v0.6

Author SHA1 Message Date
bel
a6c5121d54 check ledger output a little bit 2021-07-29 23:42:16 -06:00
bel
1ebaf9eac8 test new and old scrape for chase 2021-07-29 23:38:27 -06:00
bel
cea7a30884 chagne account to vendor 2021-07-29 22:58:01 -06:00
bel
c5c77a2b9b write banks diff for ledger, filter by bank 2021-07-29 22:50:17 -06:00
bel
bbd51ea9c5 support ledger file append 2021-07-29 22:24:30 -06:00
Bel LaPointe
f32fb5aad1 Impl uccu 2020-07-13 14:55:20 -06:00
Bel LaPointe
4006975a21 Update chase scrape 2020-07-01 05:26:20 -06:00
bel
f5a40f7890 Implement citi scrape 2020-04-04 20:44:30 +00:00
11 changed files with 860 additions and 64 deletions

View File

@@ -5,6 +5,7 @@ type Bank int
const (
Chase Bank = iota + 1
Citi Bank = iota + 1
UCCU Bank = iota + 1
)
func (b Bank) String() string {
@@ -13,6 +14,8 @@ func (b Bank) String() string {
return "Chase"
case Citi:
return "Citi"
case UCCU:
return "UCCU"
}
return "?"
}

154
config.go
View File

@@ -2,6 +2,8 @@ package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"local/args"
"local/oauth2"
"local/storage"
@@ -9,6 +11,18 @@ import (
"strings"
)
type Uploader int
const (
UploaderTodo = Uploader(iota)
UploaderLedger
)
var uploaders = map[string]Uploader{
"todo": UploaderTodo,
"ledger": UploaderLedger,
}
type Config struct {
EmailUser string
EmailPass string
@@ -17,7 +31,9 @@ type Config struct {
TodoToken string
TodoList string
TodoTag string
Uploader Uploader
Storage storage.DB
Banks map[Bank]bool
}
var config Config
@@ -28,11 +44,15 @@ func NewConfig() Config {
as.Append(args.STRING, "emailuser", "email username", "breellocaldev@gmail.com")
as.Append(args.STRING, "emailpass", "email password", "ML3WQRFSqe9rQ8qNkm")
as.Append(args.STRING, "emailimap", "email imap", "imap.gmail.com:993")
as.Append(args.STRING, "uploader", "todo, ledger", "todo")
as.Append(args.STRING, "todoaddr", "todo addr", "https://todo-server.remote.blapointe.com")
as.Append(args.STRING, "todopass", "todo pass", "gJtEXbbLHLf54yS9EdujtVN2n6Y")
as.Append(args.STRING, "todotoken", "todo token", "")
as.Append(args.STRING, "todolist", "todo list", "")
as.Append(args.STRING, "todotag", "todo tag", "expense")
as.Append(args.STRING, "banks", "uccu,citi,chase", "uccu,citi,chase")
as.Append(args.STRING, "authaddr", "auth addr", "https://auth.remote.blapointe.com")
as.Append(args.STRING, "store", "store type", "map")
@@ -44,50 +64,10 @@ func NewConfig() Config {
panic(err)
}
token := as.GetString("todotoken")
if len(token) == 0 {
c := &http.Client{CheckRedirect: func(r *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
body := "username=" + as.GetString("todopass")
req, err := http.NewRequest("POST", as.GetString("authaddr")+"/authorize/todo-server?"+oauth2.REDIRECT+"=127.0.0.1", strings.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
cookie := resp.Header.Get("Set-Cookie")
token = cookie[strings.Index(cookie, "=")+1:]
token = strings.Split(token, "; ")[0]
}
list := as.GetString("todolist")
if len(list) == 0 {
req, err := http.NewRequest("GET", as.GetString("todoaddr")+"/ajax.php?loadLists", nil)
if err != nil {
panic(err)
}
req.Header.Set("Cookie", oauth2.COOKIE+"="+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var r struct {
List []struct {
ID string `json:"id"`
} `json:"list"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
panic(err)
}
if len(r.List) == 0 {
panic("no lists found")
}
list = r.List[0].ID
uploader := as.GetString("uploader")
ul, ok := uploaders[uploader]
if !ok {
panic("invalid uploader: " + uploader)
}
storage, err := storage.New(storage.TypeFromString(as.GetString("store")), as.GetString("storeaddr"), as.GetString("storeuser"), as.GetString("storepass"))
@@ -100,10 +80,92 @@ func NewConfig() Config {
EmailPass: as.GetString("emailpass"),
EmailIMAP: as.GetString("emailimap"),
TodoAddr: as.GetString("todoaddr"),
TodoToken: token,
TodoList: list,
TodoTag: as.GetString("todotag"),
Storage: storage,
Uploader: ul,
Banks: map[Bank]bool{
Chase: strings.Contains(strings.ToLower(as.GetString("banks")), strings.ToLower(Chase.String())),
Citi: strings.Contains(strings.ToLower(as.GetString("banks")), strings.ToLower(Citi.String())),
UCCU: strings.Contains(strings.ToLower(as.GetString("banks")), strings.ToLower(UCCU.String())),
},
}
if config.Uploader == UploaderTodo {
token := as.GetString("todotoken")
if len(token) == 0 {
token = getToken(as)
}
list := as.GetString("todolist")
if len(list) == 0 {
list = getList(as, token)
}
config.TodoToken = token
config.TodoList = list
}
return config
}
func getToken(as *args.ArgSet) string {
c := &http.Client{CheckRedirect: func(r *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
body := "username=" + as.GetString("todopass")
name := strings.Split(as.GetString("todoaddr"), ".")[0]
name = strings.TrimPrefix(name, "http://")
name = strings.TrimPrefix(name, "https://")
req, err := http.NewRequest("POST", as.GetString("authaddr")+"/authorize/"+name+"?"+oauth2.REDIRECT+"=127.0.0.1", strings.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode > 399 {
panic("bad status getting token: " + resp.Status)
}
cookie := resp.Header.Get("Set-Cookie")
token := cookie[strings.Index(cookie, "=")+1:]
token = strings.Split(token, "; ")[0]
if len(token) == 0 {
panic(fmt.Sprintf("no token found: (%v) %v", resp.StatusCode, resp.Header))
}
return token
}
func getList(as *args.ArgSet, token string) string {
req, err := http.NewRequest("GET", as.GetString("todoaddr")+"/ajax.php?loadLists", nil)
if err != nil {
panic(err)
}
req.Header.Set("Cookie", oauth2.COOKIE+"="+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var r struct {
List []struct {
ID string `json:"id"`
} `json:"list"`
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
if err := json.Unmarshal(b, &r); err != nil {
panic(fmt.Errorf("%v: %s", err, b))
}
if len(r.List) == 0 {
panic("no lists found")
}
list := r.List[0].ID
if len(list) == 0 {
panic("empty list found")
}
return list
}

View File

@@ -17,7 +17,7 @@ func main() {
panic(err)
}
for email := range emails {
transactions, err := Scrape(email)
transactions, err := Scrape(email, config.Banks)
if err != nil {
log.Println("failed to scrape email:", err)
}

145
scrape.go
View File

@@ -1,11 +1,13 @@
package main
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"net/mail"
"regexp"
"strconv"
"strings"
)
@@ -15,27 +17,31 @@ type scraper interface {
type chaseScraper struct{}
type citiScraper struct{}
type uccuScraper struct{}
func Scrape(m *mail.Message) ([]*Transaction, error) {
scraper, err := buildScraper(m)
func Scrape(m *mail.Message, banks map[Bank]bool) ([]*Transaction, error) {
scraper, err := buildScraper(m, banks)
if err != nil {
return nil, err
}
return scraper.scrape(m)
}
func buildScraper(m *mail.Message) (scraper, error) {
func buildScraper(m *mail.Message, banks map[Bank]bool) (scraper, error) {
subject := fmt.Sprint(m.Header["Subject"])
if !containsAny(subject, "transaction", "report", "Transaction") {
return nil, errors.New("cannot build scraper for subject " + subject)
}
from := fmt.Sprint(m.Header["From"])
if strings.Contains(from, "Chase") {
if strings.Contains(from, "Chase") && banks[Chase] {
return newChaseScraper(), nil
}
if strings.Contains(from, "Citi") {
if strings.Contains(from, "Citi") && banks[Citi] {
return newCitiScraper(), nil
}
if strings.Contains(from, "Notifications@uccu.com") && banks[UCCU] {
return newUCCUScraper(), nil
}
return nil, errors.New("unknown sender: " + from)
}
@@ -43,6 +49,10 @@ func newChaseScraper() scraper {
return &chaseScraper{}
}
func newUCCUScraper() scraper {
return &uccuScraper{}
}
func newCitiScraper() scraper {
return &citiScraper{}
}
@@ -57,31 +67,142 @@ func containsAny(a string, b ...string) bool {
}
func (c *chaseScraper) scrape(m *mail.Message) ([]*Transaction, error) {
transactions, err := c.scrape2020(m)
if err == nil && len(transactions) > 0 {
return transactions, err
}
return c.scrape2021(m)
}
func (c *chaseScraper) scrape2021(m *mail.Message) ([]*Transaction, error) {
re := regexp.MustCompile(`^Your \$(?P<amount>[0-9\.]*) transaction with (?P<vendor>.*)$`)
matches := re.FindSubmatch([]byte(m.Header["Subject"][0]))
if len(matches) < 1 {
return nil, errors.New("no match subject search")
}
amount := string(matches[1])
vendor := string(matches[2])
b, _ := ioutil.ReadAll(m.Body)
re = regexp.MustCompile(`\(\.\.\.[0-9]{4}\)`)
match := re.Find(b)
re = regexp.MustCompile(`[0-9]{4}`)
account := string(re.Find(match))
return []*Transaction{NewTransaction(account, amount, vendor, fmt.Sprint(m.Header["Date"]), Chase)}, nil
}
func (c *chaseScraper) scrape2020(m *mail.Message) ([]*Transaction, error) {
b, err := ioutil.ReadAll(m.Body)
if err != nil {
return nil, err
}
regexp := regexp.MustCompile(`A charge of \([^)]*\) (?P<amount>[\d\.]+) at (?P<account>.*) has been authorized`)
matches := regexp.FindSubmatch(b)
re := regexp.MustCompile(`A charge of \([^)]*\) (?P<amount>[\d\.]+) at (?P<vendor>.*) has been authorized`)
matches := re.FindSubmatch(b)
if len(matches) < 2 {
return nil, fmt.Errorf("no matches found: %+v: %s", matches, b)
return nil, fmt.Errorf("no full matches found")
}
results := make(map[string][]string)
for i, name := range regexp.SubexpNames() {
for i, name := range re.SubexpNames() {
if i != 0 && name != "" {
results[name] = append(results[name], string(matches[i]))
}
}
if len(results) != 2 || len(results["amount"]) != len(results["account"]) {
if len(results) != 2 || len(results["amount"]) != len(results["vendor"]) {
return nil, fmt.Errorf("unexpected matches found looking for transactions: %+v", results)
}
re = regexp.MustCompile(`account ending in (?P<account>[0-9]{4})\.`)
match := re.Find(b)
re = regexp.MustCompile(`[0-9]{4}`)
account := string(re.Find(match))
transactions := make([]*Transaction, len(results["amount"]))
for i := range results["amount"] {
transactions[i] = NewTransaction(results["amount"][i], results["account"][i], fmt.Sprint(m.Header["Date"]), Chase)
transactions[i] = NewTransaction(account, results["amount"][i], results["vendor"][i], fmt.Sprint(m.Header["Date"]), Chase)
}
return transactions, nil
}
func (c *citiScraper) scrape(m *mail.Message) ([]*Transaction, error) {
panic("not impl")
date := fmt.Sprint(m.Header["Date"])
b, err := ioutil.ReadAll(m.Body)
if err != nil {
return nil, err
}
re := regexp.MustCompile(`Citi Alert: A \$[0-9][0-9]*\.[0-9][0-9] transaction was made at .* on card ending in`)
match := re.Find(b)
if len(match) == 0 {
return nil, nil
}
rePrice := regexp.MustCompile(`[0-9][0-9]*\.[0-9][0-9]`)
price := rePrice.Find(match)
vendor := bytes.Split(bytes.Split(match, []byte(" on card ending in"))[0], []byte("transaction was made at "))[1]
transaction := NewTransaction(Citi.String(), string(price), string(vendor), date, Citi)
return []*Transaction{transaction}, nil
//Citi Alert: A $598.14 transaction was made at REMIX MUSIC SPRINGDA on card ending in 3837
/*
b, err := ioutil.ReadAll(m.Body)
if err != nil {
return nil, err
}
targetLineRegexp := regexp.MustCompile(`Account #: XXXX[0-9]{4} .*`)
targetMatches := targetLineRegexp.FindAll(b, -1)
if len(targetMatches) == 0 {
return nil, errors.New("no lines with transactions found")
}
results := make(map[string][]string)
for _, b := range targetMatches {
// Account #: XXXX3837 $137.87 at AMZN Mktp US Amzn.com/bill WA on 04/03/2020, 09:05 PM ET
regexp := regexp.MustCompile(`Account #: XXXX[0-9]{4} \$(?P<amount>[0-9]+\.[0-9]*) at (?P<account>[^,]*)`)
matches := regexp.FindSubmatch(b)
if len(matches) < 2 {
return nil, fmt.Errorf("no full matches found: %s", b)
}
for i, name := range regexp.SubexpNames() {
if i != 0 && name != "" {
if name == "account" {
matches[i] = bytes.Split(matches[i], []byte(" on "))[0]
}
results[name] = append(results[name], string(matches[i]))
}
}
if len(results) != 2 || len(results["amount"]) != len(results["account"]) {
return nil, fmt.Errorf("unexpected matches found looking for transactions: %+v", results)
}
}
transactions := make([]*Transaction, len(results["amount"]))
for i := range results["amount"] {
transactions[i] = NewTransaction(Citi.String(), results["amount"][i], results["account"][i], fmt.Sprint(m.Header["Date"]), Citi)
}
return transactions, nil
*/
}
func (c *uccuScraper) scrape(m *mail.Message) ([]*Transaction, error) {
b, err := ioutil.ReadAll(m.Body)
if err != nil {
return nil, err
}
regexp := regexp.MustCompile(`\$([0-9]+,?)+\.[0-9][0-9]`)
match := regexp.Find(b)
if len(match) == 0 {
return nil, fmt.Errorf("no matches found")
}
match = match[1:]
match = bytes.ReplaceAll(match, []byte(","), []byte{})
f, err := strconv.ParseFloat(string(match), 10)
if err != nil {
return nil, err
}
if !bytes.Contains(b, []byte("credit")) {
f *= -1.0
}
transaction := NewTransaction(UCCU.String(), fmt.Sprintf("%.2f", f), "?", fmt.Sprint(m.Header["Date"]), UCCU)
return []*Transaction{transaction}, nil
}

75
scrape_test.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"bytes"
"io/ioutil"
"net/mail"
"testing"
)
func TestScrapeChase2021(t *testing.T) {
b, err := ioutil.ReadFile("./testdata/chase.2021.txt")
if err != nil {
t.Fatal(err)
}
message := &mail.Message{
Header: map[string][]string{
"Subject": []string{"Your $38.84 transaction with TARGET T-1754"},
},
Body: bytes.NewReader(b),
}
chase := &chaseScraper{}
gots, err := chase.scrape2021(message)
if err != nil {
t.Fatal(err)
}
if len(gots) != 1 {
t.Fatal(gots)
}
got := gots[0]
if got.Account != "8824" {
t.Fatalf("bad account: %v: %+v", got.Account, got)
}
if got.Amount != "38.84" {
t.Fatalf("bad amount: %v: %+v", got.Amount, got)
}
if got.Vendor != "TARGET T-1754" {
t.Fatalf("bad vendor: %v: %+v", got.Vendor, got)
}
t.Logf("%+v", got)
}
func TestScrapeChase2020(t *testing.T) {
b, err := ioutil.ReadFile("./testdata/chase.2020.txt")
if err != nil {
t.Fatal(err)
}
message := &mail.Message{
Body: bytes.NewReader(b),
}
chase := &chaseScraper{}
gots, err := chase.scrape2020(message)
if err != nil {
t.Fatal(err)
}
if len(gots) != 1 {
t.Fatal(gots)
}
got := gots[0]
if got.Account != "8824" {
t.Fatalf("bad account: %v: %+v", got.Account, got)
}
if got.Amount != "16.08" {
t.Fatalf("bad amount: %v: %+v", got.Amount, got)
}
if got.Vendor != "PAYPAL *BLIZZARDENT" {
t.Fatalf("bad vendor: %q: %+v", got.Vendor, got)
}
t.Logf("%+v", got)
}

10
testdata/chase.2020.txt vendored Normal file
View File

@@ -0,0 +1,10 @@
This is an Alert to help you manage your credit card account ending in 8824.
As you requested, we are notifying you of any charges over the amount of ($USD) 0.00, as specified in your Alert settings.
A charge of ($USD) 16.08 at PAYPAL *BLIZZARDENT has been authorized on Jul 6, 2021 at 6:21 PM ET.
Do not reply to this Alert.
If you have questions, please call the number on the back of your credit card, or send a secure message from your Inbox on www.chase.com.
To see all of the Alerts available to you, or to manage your Alert settings, please log on to www.chase.com.

396
testdata/chase.2021.txt vendored Normal file
View File

@@ -0,0 +1,396 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.=
w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns=3D"http://www.w3.org/1999/xhtml" lang=3D"en">
<head>
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3DUTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=3D1.=
0"/>
<title>This transaction is above the level you set, see more here.</title>
<style type=3D"text/css">
* {
=09line-height: normal !important;
}
strong {
=09font-weight: bold !important;
}
em {
=09font-style: italic !important;
}
body {
=09background-color: #d7dbe0 !important;
=09-webkit-text-size-adjust: none !important;
}
.ExternalClass * {
=09line-height: 112%
}
.ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass =
td {
=09line-height: 112%
}
td {
=09-webkit-text-size-adjust: none;
}
a[href^=3Dtel] {
=09color: inherit;
=09text-decoration: none;
}
.applelinksgray41 a {
=09color: #414042 !important;
=09text-decoration: none;
}
.applelinksgray a {
=09color: #717171 !important;
=09text-decoration: none;
}
.wordBreak {
=09overflow-wrap: break-word;
=09word-wrap: break-word;
=09word-break: break-all;
=09word-break: break-word;
}
@media screen and (max-width: 800px) {
.fullWidth {
=09width: 100% !important;
=09min-width: 100% !important;
=09margin-left: auto !important;
=09margin-right: auto !important;
=09padding: 0px !important;
=09text-align: center !important;
}
.moPad {
=09padding-right: 20px !important;
=09padding-left: 20px !important;
}
.zeroPad {
=09padding-right: 0px !important;
=09padding-left: 0px !important;
}
.font14 {
=09font-size: 14px !important;
}
}
@media print and (max-width: 800px) {
.fullWidth {
=09width: 100% !important;
=09min-width: 100% !important;
=09margin-left: auto !important;
=09margin-right: auto !important;
=09padding: 0px !important;
=09text-align: center !important;
}
.moPad {
=09padding-right: 20px !important;
=09padding-left: 20px !important;
}
.zeroPad {
=09padding-right: 0px !important;
=09padding-left: 0px !important;
}
.font14 {
=09font-size: 14px !important;
}
}
</style>
</head>
<body style=3D"padding: 0px;margin: 0px; background-color:#d7dbe0;">
<table role=3D"presentation" align=3D"center" width=3D"100%" border=3D"0" c=
ellspacing=3D"0" cellpadding=3D"0" style=3D"min-width:800px; background-col=
or:#d7dbe0;" class=3D"fullWidth">
<tr>
<td align=3D"center" style=3D"vertical-align:top; padding:0px 0px 20px =
0px; min-width:800px; background-color:#d7dbe0;" class=3D"fullWidth"><table=
role=3D"presentation" align=3D"center" width=3D"800" cellpadding=3D"0" cel=
lspacing=3D"0" border=3D"0" class=3D"fullWidth" style=3D"background-color:#=
FFFFFF;">
<!-- Start of Content -->
<tr>
<td align=3D"center" style=3D"vertical-align:top; padding: 23px 0=
px 0px;background-color: #005EB8;"><table role=3D"presentation" cellpadding=
=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"right" style=3D"vertical-align:bottom; padding=
:0px 0px; width:12px;"><img src=3D"https://www.chase.com/content/dam/email/=
images/blue-left.jpg" width=3D"12" height=3D"226" border=3D"0" style=3D"dis=
play:block;" alt=3D""/></td>
<td align=3D"center" style=3D"vertical-align:bottom; paddin=
g: 0px 0px 0px;width:616px; background-color: #FFFFFF;"><table role=3D"pres=
entation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-align:top; paddi=
ng: 0px 0px;"><table role=3D"presentation" width=3D"100%" cellpadding=3D"0"=
cellspacing=3D"0" border=3D"0">
<!-- Start hidden preview text -->
<div style=3D"display: none; max-height: 0px; ove=
rflow: hidden;">This transaction is above the level you set, see more here.=
</div>
<!-- Insert &zwnj;&nbsp; after hidden preview tex=
t -->
<div style=3D"display: none; max-height: 0px; ove=
rflow: hidden;"> &nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&=
nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&=
nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbs=
p;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&=
zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&=
nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbs=
p;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&=
zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&nbsp;&zwnj;&nbs=
p;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&=
zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&=
nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbs=
p;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&=
zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwn=
j;&nbsp;</div>
<!-- End hidden preview text -->
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding-left: 30px;" class=3D"moPad"><table role=3D"presentation" width=3D"=
100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:bottom; padding:36px 0px 20px;"><img src=3D"https://www.chase.com/content=
/dam/email/images/chase-logo-h-rgb.png" width=3D"104" height=3D"20" border=
=3D"0" style=3D"display:block;" alt=3D"Chase Logo"/></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 20px 28px 0px;" class=3D"moPad"><table role=3D"presentation" alig=
n=3D"left" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top;"><table role=3D"presentation" width=3D"100%" cellpadding=3D"0" cells=
pacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertica=
l-align:top; padding:5px 10px; font-family:Arial, Helvetica, sans-serif; fo=
nt-size:12px; font-weight:bold; color:#414042; background-color:#D7DBE0; bo=
rder-radius:20px; -moz-border-radius: 20px; -webkit-border-radius:20px; whi=
te-space: nowrap;" class=3D"font14">Transaction alert</td>
</tr>
</table></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
"><table role=3D"presentation" width=3D"100%" cellpadding=3D"0" cellspacing=
=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding: 20px 30px 28px;" class=3D"moPad"><table role=3D"presentatio=
n" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertica=
l-align:top; padding: 0px 20px 0px 0px;"><img src=3D"https://static.chasecd=
n.com/content/services/rendition/image.small.png/unified-assets/digital-car=
ds/aarp/41473417018.png" width=3D"57" height=3D"auto" alt=3D"" border=3D"0"=
style=3D"display:block;"/></td>
<td align=3D"left" style=3D"vertica=
l-align:top; padding:0px 50px 0px 0px; font-family:Arial, Helvetica, sans-s=
erif; font-size:30px; font-weight: bold; color:#414042;" class=3D"zeroPad">=
You made a $38.84 transaction</td>
</tr>
</table></td>
</tr>
</table></td>
</tr>
</table></td>
</tr>
</table></td>
<td align=3D"left" style=3D"vertical-align:bottom; padding:=
0px 0px;width:12px; "><img src=3D"https://www.chase.com/content/dam/email/i=
mages/blue-right.jpg" width=3D"12" height=3D"226" border=3D"0" style=3D"dis=
play:block;" alt=3D""/></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"center" style=3D"vertical-align:top; padding: 0px 0p=
x 0px; background-color: #FFFFFF;"><table role=3D"presentation" cellpadding=
=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"right" style=3D"vertical-align:top; padding:0p=
x 0px; width:12px;"><img src=3D"https://www.chase.com/content/dam/email/ima=
ges/white-left.jpg" width=3D"12" height=3D"77" border=3D"0" style=3D"displa=
y:block;" alt=3D""/></td>
<td align=3D"center" style=3D"vertical-align:top; padding: =
0px 0px 0px;width:616px;"><table role=3D"presentation" width=3D"100%" cellp=
adding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-align:top; paddi=
ng: 0px 150px 0px 30px;" class=3D"moPad"><table role=3D"presentation" width=
=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 10px 0px;border-bottom: solid 1px #414042;"><table role=3D"presen=
tation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding:0px 0px 0px 0px; font-family:Arial, Helvetica, sans-serif; f=
ont-size:16px; color:#414042;" class=3D"font14">Account</td>
<td align=3D"right" style=3D"vertical-ali=
gn:top; padding:0px 0px 0px 5px; font-family:Arial, Helvetica, sans-serif; =
font-size:16px; font-weight:bold; color:#414042;" class=3D"font14">AARP fro=
m Chase (...8824)</td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 10px 0px;border-bottom: solid 1px #414042;"><table role=3D"presen=
tation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding:0px 0px 0px 0px; font-family:Arial, Helvetica, sans-serif; f=
ont-size:16px; color:#414042;" class=3D"font14">Date</td>
<td align=3D"right" style=3D"vertical-ali=
gn:top; padding:0px 0px 0px 5px; font-family:Arial, Helvetica, sans-serif; =
font-size:16px; font-weight:bold; color:#414042;" class=3D"font14"><span cl=
ass=3D"applelinksgray41"><a style=3D"color:#414042;text-decoration: none;">=
Jul 23, 2021 at 3:06 PM ET</td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 10px 0px;border-bottom: solid 1px #414042;"><table role=3D"presen=
tation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding:0px 0px 0px 0px; font-family:Arial, Helvetica, sans-serif; f=
ont-size:16px; color:#414042;" class=3D"font14">Merchant</td>
<td align=3D"right" style=3D"vertical-ali=
gn:top; padding:0px 0px 0px 5px; font-family:Arial, Helvetica, sans-serif; =
font-size:16px; font-weight:bold; color:#414042;" class=3D"font14">TARGET T=
-1754</td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding: 10px 0px;border-bottom: solid 1px #414042;"><table role=3D"presen=
tation" width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-alig=
n:top; padding:0px 0px 0px 0px; font-family:Arial, Helvetica, sans-serif; f=
ont-size:16px; color:#414042;" class=3D"font14">Amount</td>
<td align=3D"right" style=3D"vertical-ali=
gn:top; padding:0px 0px 0px 5px; font-family:Arial, Helvetica, sans-serif; =
font-size:16px; font-weight:bold; color:#414042;" class=3D"font14">$38.84</=
td>
</tr>
</table></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top; pad=
ding:40px 150px 40px 30px; font-family:Arial, Helvetica, sans-serif; font-s=
ize:16px; color:#414042;" class=3D"moPad">You are receiving this alert beca=
use your transaction was more than the $0.00 level you set. You can visit o=
ur <a style=3D"text-decoration: underline; color:#0060F0;" href=3D"https://=
www.chase.com/personal/credit-cards/card-resource-center" rel=3D"noopener n=
oreferrer" target=3D"_blank">Resource Center</a> anytime to help answer yo=
ur questions or manage your account.</td>
</tr>
<tr>
<td align=3D"left" style=3D"padding:0px; vertical-ali=
gn:top; padding: 0px 0px 30px 30px;" class=3D"moPad"><table role=3D"present=
ation" align=3D"left" cellpadding=3D"0" cellspacing=3D"0" border=3D"0" styl=
e=3D"vertical-align:top;">
<tr>
<td role=3D"button" align=3D"center" style=3D"b=
ackground-color:#0060f0; color: #fffffe; font-size: 16px; font-family: Aria=
l, Helvetica, sans-serif; padding: 10px 0px; border: 1px solid #0060f0; ver=
tical-align:top; border-radius:4px; -moz-border-radius: 4px; -webkit-border=
-radius:4px;width: 168px;"><a href=3D"https://secure.chase.com/web/auth/nav=
?navKey=3DrequestDashboard" target=3D"_blank" style=3D"color: #fffffe; text=
-decoration:none;">Review account</a></td>
</tr>
</table></td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top; paddi=
ng:0px 30px 20px; font-family:Arial, Helvetica, sans-serif; font-size:12px;=
color:#717171;" class=3D"moPad font14">Securely access your accounts with =
the <a style=3D"text-decoration: underline; color:#0060F0;" href=3D"https:/=
/www.chase.com/digital/mobile-banking" rel=3D"noopener noreferrer" target=
=3D"_blank">Chase&nbsp;Mobile<span style=3D"font-size:70%; line-height:0; v=
ertical-align:3px; text-decoration: none;">&reg;</span> app</a> or <a style=
=3D"text-decoration: underline; color:#0060F0;" href=3D"https://secure.chas=
e.com/web/auth/nav?navKey=3DrequestDashboard" rel=3D"noopener noreferrer" =
target=3D"_blank">chase.com</a>. </td>
</tr>
<tr>
<td align=3D"left" style=3D"vertical-align:top; paddi=
ng: 0px 0px; background-color: #F6F6F6;"><table role=3D"presentation" width=
=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
<tr>
<td align=3D"left" style=3D"vertical-align:top;=
padding:20px 30px 60px; font-family:Arial, Helvetica, sans-serif; font-siz=
e:12px; color:#717171;" class=3D"moPad font14"><span role=3D"heading" style=
=3D"text-transform: uppercase; font-weight: bold;">About this message</span=
><br />
<br />
Chase&nbsp;Mobile<span style=3D"font-size:70%=
; line-height:0; vertical-align:3px;">&reg;</span>=C2=A0app is available f=
or select mobile devices. Message and data rates may apply.<br />
<br />
This service email was sent based on your ale=
rt settings. Use the Chase&nbsp;Mobile app or visit <a href=3D"https://www.=
chase.com/personal/mobile-online-banking/login-alerts" target=3D"_blank" st=
yle=3D"text-decoration: underline; color:#0060F0;" rel=3D"noopener noreferr=
er">chase.com/alerts</a> to view or manage your settings.<br />
<br />
Chase cannot guarantee the delivery of alerts=
and notifications.=C2=A0Wireless or internet service provider outages or o=
ther circumstances could delay them. You can always check <span class=3D"ap=
plelinksgray"><a style=3D"color:#717171;text-decoration: none;">chase.com</=
a></span> or the Chase&nbsp;Mobile=C2=A0app for the status of your accounts=
including your latest account balances and transaction details.=C2=A0<br /=
>
<br />
To protect your personal information, please =
don't reply to this message. Chase won't ask for confidential information i=
n an email. <br />
<br />
If you have concerns about the authenticity o=
f this message or have questions about your account visit <a style=3D"text-=
decoration: underline; color:#0060F0;" href=3D"https://www.chase.com/digita=
l/customer-service" target=3D"_blank" rel=3D"noopener noreferrer">chase.com=
/CustomerService</a> for ways to contact us.<br />
<br />
Your privacy is important to us. See our onli=
ne <a style=3D"text-decoration: underline; color:#0060F0;" href=3D"https://=
www.chase.com/digital/resources/privacy-security" target=3D"_blank" rel=3D"=
noopener noreferrer">Security Center</a> to learn how to protect your infor=
mation.<br />
<br />
&copy; 2021 JPMorgan Chase &amp; Co. </td>
</tr>
</table></td>
</tr>
</table></td>
<td align=3D"left" style=3D"vertical-align:top; padding:0px=
0px; width:12px;"><img src=3D"https://www.chase.com/content/dam/email/imag=
es/white-right.jpg" width=3D"12" height=3D"77" border=3D"0" style=3D"displa=
y:block;" alt=3D""/></td>
</tr>
</table></td>
</tr>
<!--End of Content -->
=20
</table></td>
</tr>
</table>
</body>
</html>

View File

@@ -12,22 +12,25 @@ type Transaction struct {
ID string
Bank Bank
Amount string
Account string
Vendor string
Date string
Account string
}
func (t *Transaction) Format() string {
return fmt.Sprintf("(%s) %v: %s @ %s", cleanDate(t.Date), t.Bank, t.Amount, t.Account)
return fmt.Sprintf("(%s) %v/%v: %s @ %s", cleanDate(t.Date), t.Account, t.Bank, t.Amount, t.Vendor)
}
func (t *Transaction) String() string {
return fmt.Sprint(*t)
}
func NewTransaction(amount, account, date string, bank Bank) *Transaction {
func NewTransaction(account, amount, vendor, date string, bank Bank) *Transaction {
regexp := regexp.MustCompile(`\s\s+`)
t := &Transaction{
Amount: amount,
Account: account,
Amount: regexp.ReplaceAllString(amount, " "),
Vendor: regexp.ReplaceAllString(vendor, " "),
Bank: bank,
Date: date,
}

2
transaction_test.go Normal file → Executable file
View File

@@ -3,7 +3,7 @@ package main
import "testing"
func TestTransactionFormat(t *testing.T) {
x := NewTransaction("12.34", "Amazon", "[Wed, 1 Apr 2020 10:14:11 -0400 (EDT)]", Chase)
x := NewTransaction("me", "12.34", "Amazon", "[Wed, 1 Apr 2020 10:14:11 -0400 (EDT)]", Chase)
t.Logf("%s", x.String())
t.Logf("%s", x.Format())
}

View File

@@ -1,15 +1,31 @@
package main
import (
"errors"
"fmt"
"io/ioutil"
"local/oauth2"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
)
func Upload(config Config, transaction *Transaction) error {
switch config.Uploader {
case UploaderTodo:
return uploadTodo(config, transaction)
case UploaderLedger:
return uploadLedger(config, transaction)
default:
return errors.New("not impl: uploader")
}
}
func uploadTodo(config Config, transaction *Transaction) error {
params := url.Values{
"list": {config.TodoList},
"title": {transaction.Format()},
@@ -32,3 +48,38 @@ func Upload(config Config, transaction *Transaction) error {
}
return nil
}
func uploadLedger(config Config, transaction *Transaction) error {
f, err := os.OpenFile(config.TodoAddr, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
amount, _ := strconv.ParseFloat(transaction.Amount, 32)
amount *= -1
remote := "Withdrawal:"
if amount > 0 {
remote = "Deposit:"
}
regexp := regexp.MustCompile(`[0-9a-zA-Z]`)
for _, substr := range regexp.FindAllString(transaction.Vendor, -1) {
remote += substr
}
fmt.Fprintf(f, "%-50s%-s\n", formatGMailDate(transaction.Date), transaction.Vendor)
fmt.Fprintf(f, "%-50s%-50s$%.2f\n", "", "AssetAccount:"+transaction.Bank.String(), amount)
fmt.Fprintf(f, "%-50s%-s\n", "", remote)
return nil
}
func formatGMailDate(s string) string {
for _, format := range []string{
"[Mon, 2 Jan 2006 15:04:05 -0700 (MST)]",
"[Mon, 2 Jan 2006 15:04:05 -0700]",
} {
time, err := time.Parse(format, s)
if err == nil {
return time.Format("2006-01-02")
}
}
return s
}

75
upload_test.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"bytes"
"io/ioutil"
"local/storage"
"path"
"testing"
"github.com/google/uuid"
)
func TestUploadLedger(t *testing.T) {
cases := map[string]struct {
transaction Transaction
}{
"simple": {
transaction: Transaction{
ID: uuid.New().String(),
Bank: Chase,
Amount: "1.10",
Vendor: "my's totally realistic!!! VENDOR 11",
Date: "today",
},
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
config := Config{
TodoAddr: path.Join(t.TempDir(), name),
Uploader: UploaderLedger,
Storage: storage.NewMap(),
}
if err := uploadLedger(config, &c.transaction); err != nil {
t.Fatal(err)
}
b, err := ioutil.ReadFile(config.TodoAddr)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(b, []byte("mystotallyrealisticVENDOR11")) {
t.Fatal(string(b))
}
t.Logf("\n%s", b)
})
}
}
func TestFormatGMailDate(t *testing.T) {
cases := map[string]struct {
input string
want string
}{
"ok": {
input: "[Tue, 20 Jul 2021 23:46:00 -0400 (EDT)]",
want: "2021-07-20",
},
"bad": {
input: "2021-07-20",
want: "2021-07-20",
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
got := formatGMailDate(c.input)
if got != c.want {
t.Fatal(c.input, c.want, got)
}
})
}
}