comparison brinksmoney.py @ 0:72fab2710469 default tip

Import
author Daniele Nicolodi <daniele@grinta.net>
date Fri, 05 Aug 2016 23:16:31 -0600
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:72fab2710469
1 import email
2 import imp
3 import itertools
4 import json
5 import os.path
6 import smtplib
7 import smtplib
8 import sqlite3
9 import sqlite3
10 import subprocess
11
12 import re
13 import csv
14
15 from contextlib import contextmanager
16 from datetime import datetime, date, timedelta
17 from email.mime.text import MIMEText
18 from email.utils import format_datetime, localtime, parseaddr
19 from base64 import b32encode
20 from hashlib import sha1
21 from urllib.parse import urljoin
22
23 import click
24 import requests
25
26
27 URL = 'https://www.brinksmoney.com/'
28
29
30 # transactions table row template
31 TRANSACTION = """{0.shortid:} {0.date:%d-%m-%Y} {0.description:57s} {0.amount:+8.2f}"""
32
33 # transactions table header
34 HEADER = """{:14s} {:10s} {:57s} {:>8s}""".format('Id', 'Date', 'Description', 'Amount')
35
36 # transactions table footer
37 FOOTER = """{:14s} {:10s} {:57s} {{balance:8.2f}}""".format('', '', 'BALANCE')
38
39 # transactions table horizontal separator
40 SEP = """-""" * len(HEADER)
41
42
43 # GPG encrypted text is ascii and as such does not require encoding
44 # but its decrypted form is utf-8 and therefore the charset header
45 # must be set accordingly. define an appropriate charset object
46 email.charset.add_charset('utf8 7bit', header_enc=email.charset.SHORTEST,
47 body_enc=None, output_charset='utf-8')
48
49
50 def prevmonth(d):
51 year = d.year
52 month = d.month - 1
53 if month < 1:
54 year = yeat - 1
55 return date(year, month, 1)
56
57
58 class Mailer:
59 def __init__(self, host='localhost', port=25, starttls=True, username=None, password=None):
60 self.host = host
61 self.port = port
62 self.starttls = starttls
63 self.username = username
64 self.password = password
65
66 @contextmanager
67 def connect(self):
68 smtp = smtplib.SMTP(self.host, self.port)
69 if self.starttls:
70 smtp.starttls()
71 if self.username:
72 smtp.login(self.username, self.password)
73 yield smtp
74 smtp.quit()
75
76 def send(self, message, fromaddr=None, toaddr=None):
77 if not fromaddr:
78 fromaddr = message['From']
79 if not toaddr:
80 toaddr = message['To']
81 with self.connect() as conn:
82 conn.sendmail(fromaddr, toaddr, str(message))
83
84
85 class GPG:
86 def __init__(self, homedir):
87 self.homedir = homedir
88
89 def encrypt(self, message, sender, recipient):
90 sender = parseaddr(sender)[1]
91 recipient = parseaddr(recipient)[1]
92 cmd = [ "gpg2", "--homedir", self.homedir, "--sign", "--encrypt",
93 "--batch", "--no-options", "--yes", "--armor",
94 "--local-user", sender, "--recipient", recipient, ]
95 p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
96 encdata, err = p.communicate(input=message.encode('utf-8'))
97 if p.returncode:
98 raise RuntimeError(p.returncode, err)
99 return encdata.decode('ascii')
100
101
102 def normalize(s):
103 s = s.strip()
104 m = re.match(r'Debit: (Signature|PIN) purchase from \d*\s+(.*)', s)
105 if m:
106 return m.group(2)
107 m = re.match(r'Debit: ATM Cash Withdrawal at \d*\s+(.*)', s)
108 if m:
109 return 'ATM ' + m.group(1)
110 m = re.match(r'Debit: (ATM Cash Withdrawal Fee .*)', s)
111 if m:
112 return m.group(1).upper()
113 m = re.match(r'Credit: Direct Deposit from (.+) for (.+)', s)
114 if m:
115 return m.group(2) + ' ' + m.group(1)
116 return s
117
118
119 class Transaction(object):
120 __slots__ = 'id', 'shortid', 'date', 'description', 'amount', 'balance'
121
122 def __init__(self, date, description, amount, balance):
123 r = repr((date, description, amount, balance))
124 self.id = b32encode(sha1(r.encode('utf8')).digest()).decode()
125 self.shortid = self.id[-14:]
126 self.date = date
127 self.description = description
128 self.amount = amount
129 self.balance = balance
130
131 @classmethod
132 def fromcsv(cls, x):
133 if x[2]:
134 amount = -float(re.sub('[^\d.]', '', x[2]))
135 if x[3]:
136 amount = +float(re.sub('[^\d.]', '', x[3]))
137 data = {'date': datetime.strptime(x[0], '%m/%d/%Y %I:%M%p'),
138 'description': normalize(x[1]),
139 'amount': amount,
140 'balance': float(re.sub('[^\d.]', '', x[4]))}
141 return cls(**data)
142
143 def __str__(self):
144 return TRANSACTION.format(self)
145
146
147 class BRINKSMoney(object):
148 def __init__(self):
149 self.session = requests.Session()
150
151 def login(self, username, password):
152 url = urljoin(URL, 'account/authenticate.m')
153
154 # acquire session id
155 r = self.session.get(url)
156 r.raise_for_status()
157
158 # credentials
159 data = { 'blackBox': '',
160 'identifier': username,
161 'secret': password,
162 'type': 'pwd',
163 'login': 'Log In' }
164 r = self.session.post(url, data=data)
165 r.raise_for_status()
166
167 def transactions(self, year, month):
168 url = urljoin(URL, 'account/acctHistory.m')
169
170 months = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
171 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
172
173 datestr = '{} {}'.format(months[month - 1], year)
174
175 params = { 'selectedAcct': 'CARD',
176 'CSV': 'true',
177 'selectedMonth': datestr }
178
179 r = self.session.get(url, params=params)
180 r.raise_for_status()
181
182 lines = r.text.splitlines()
183
184 # skip header and footer
185 lines = lines[10:-9]
186
187 rows = csv.reader(lines, doublequote=False, strict=True)
188 transactions = [ Transaction.fromcsv(row) for row in rows ]
189
190 return transactions
191
192
193 def transactions(conf):
194 db = sqlite3.connect(conf['DATABASE'])
195 db.execute('''CREATE TABLE IF NOT EXISTS transactions (id TEXT PRIMARY KEY)''')
196
197 sendmail = Mailer(host=conf['SMTPHOST'],
198 port=conf['SMTPPORT'],
199 starttls=conf['SMTPSTARTTLS'],
200 username=conf['SMTPUSER'],
201 password=conf['SMTPPASSWD']).send
202
203 encrypt = GPG(conf['GNUPGHOME']).encrypt
204
205 remote = BRINKSMoney()
206 remote.login(conf['USERNAME'], conf['PASSWORD'])
207
208 this = date.today().replace(day=1)
209 prev = prevmonth(this)
210
211 recent = ( remote.transactions(this.year, this.month) +
212 remote.transactions(prev.year, prev.month) )
213
214 recent.sort(key=lambda x: x.date)
215
216 balance = recent[-1].balance
217
218 curs = db.cursor()
219 unseen = []
220 for t in recent:
221 curs.execute('''SELECT COUNT(*) FROM transactions WHERE id = ?''', (t.id, ))
222 if not curs.fetchone()[0]:
223 # not seen before
224 unseen.append(t)
225
226 if unseen:
227 lines = []
228 lines.append(HEADER)
229 lines.append(SEP)
230 for t in unseen:
231 lines.append(str(t))
232 lines.append(SEP)
233 lines.append(FOOTER.format(balance=balance))
234
235 text = '\n'.join(lines)
236 payload = encrypt(text, conf['MAILFROM'], conf['MAILTO'])
237
238 message = MIMEText(payload, _charset='utf8 7bit')
239 message['Subject'] = 'BRINKS Money Account update'
240 message['From'] = conf['MAILFROM']
241 message['To'] = conf['MAILTO']
242 message['Date'] = format_datetime(localtime())
243
244 sendmail(message)
245
246 curs.executemany('''INSERT INTO transactions (id) VALUES (?)''', ((x.id, ) for x in unseen))
247 db.commit()
248
249
250 def loadconf(filename):
251 module = imp.new_module('conf')
252 module.__file__ = filename
253 with open(filename) as fd:
254 exec(compile(fd.read(), filename, 'exec'), module.__dict__)
255 conf = {}
256 for key in dir(module):
257 if key.isupper():
258 conf[key] = getattr(module, key)
259
260 conf['DATADIR'] = os.path.dirname(filename)
261 for key in 'DATABASE', 'GNUPGHOME':
262 # if path is not absolute, it is interpreted as relative
263 # to the location of the configuration file
264 if not os.path.isabs(conf[key]):
265 conf[key] = os.path.join(conf['DATADIR'], conf[key])
266
267 return conf
268
269
270 @click.command()
271 @click.argument('conffile')
272 @click.option('--verbose', is_flag=True, help='Verbose output.')
273 def main(conffile, verbose):
274
275 conf = loadconf(conffile)
276 if verbose:
277 conf['VERBOSE'] = True
278
279 transactions(conf)
280
281
282 if __name__ == '__main__':
283 main()