Mercurial > hg > brinksmoney
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() |