0
|
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()
|