view brinksmoney.py @ 0:72fab2710469 default tip

Import
author Daniele Nicolodi <daniele@grinta.net>
date Fri, 05 Aug 2016 23:16:31 -0600
parents
children
line wrap: on
line source

import email
import imp
import itertools
import json
import os.path
import smtplib
import smtplib
import sqlite3
import sqlite3
import subprocess

import re
import csv

from contextlib import contextmanager
from datetime import datetime, date, timedelta
from email.mime.text import MIMEText
from email.utils import format_datetime, localtime, parseaddr
from base64 import b32encode
from hashlib import sha1
from urllib.parse import urljoin

import click
import requests


URL = 'https://www.brinksmoney.com/'


# transactions table row template
TRANSACTION = """{0.shortid:}  {0.date:%d-%m-%Y}  {0.description:57s}  {0.amount:+8.2f}"""

# transactions table header
HEADER = """{:14s}  {:10s}  {:57s}  {:>8s}""".format('Id', 'Date', 'Description', 'Amount')

# transactions table footer
FOOTER = """{:14s}  {:10s}  {:57s}  {{balance:8.2f}}""".format('', '', 'BALANCE')

# transactions table horizontal separator
SEP = """-""" * len(HEADER)


# GPG encrypted text is ascii and as such does not require encoding
# but its decrypted form is utf-8 and therefore the charset header
# must be set accordingly. define an appropriate charset object
email.charset.add_charset('utf8 7bit', header_enc=email.charset.SHORTEST,
                          body_enc=None, output_charset='utf-8')


def prevmonth(d):
    year = d.year
    month = d.month - 1
    if month < 1:
        year = yeat - 1
    return date(year, month, 1)


class Mailer:
    def __init__(self, host='localhost', port=25, starttls=True, username=None, password=None):
        self.host = host
        self.port = port
        self.starttls = starttls
        self.username = username
        self.password = password

    @contextmanager
    def connect(self):
        smtp = smtplib.SMTP(self.host, self.port)
        if self.starttls:
            smtp.starttls()
        if self.username:
            smtp.login(self.username, self.password)
        yield smtp
        smtp.quit()

    def send(self, message, fromaddr=None, toaddr=None):
        if not fromaddr:
            fromaddr = message['From']
        if not toaddr:
            toaddr = message['To']
        with self.connect() as conn:
            conn.sendmail(fromaddr, toaddr, str(message))


class GPG:
    def __init__(self, homedir):
        self.homedir = homedir

    def encrypt(self, message, sender, recipient):
        sender = parseaddr(sender)[1]
        recipient = parseaddr(recipient)[1]
        cmd = [ "gpg2", "--homedir", self.homedir, "--sign", "--encrypt",
                "--batch", "--no-options", "--yes", "--armor",
                "--local-user", sender, "--recipient", recipient, ]
        p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        encdata, err = p.communicate(input=message.encode('utf-8'))
        if p.returncode:
            raise RuntimeError(p.returncode, err)
        return encdata.decode('ascii')


def normalize(s):
    s = s.strip()    
    m = re.match(r'Debit: (Signature|PIN) purchase from \d*\s+(.*)', s)
    if m:
        return m.group(2)
    m = re.match(r'Debit: ATM Cash Withdrawal at \d*\s+(.*)', s)
    if m:
        return 'ATM ' + m.group(1)
    m = re.match(r'Debit: (ATM Cash Withdrawal Fee .*)', s)
    if m:
        return m.group(1).upper()
    m = re.match(r'Credit: Direct Deposit from (.+) for (.+)', s)
    if m:
        return m.group(2) + ' ' + m.group(1)
    return s


class Transaction(object):    
    __slots__ = 'id', 'shortid', 'date', 'description', 'amount', 'balance'

    def __init__(self, date, description, amount, balance):
        r = repr((date, description, amount, balance))
        self.id = b32encode(sha1(r.encode('utf8')).digest()).decode()
        self.shortid = self.id[-14:]
        self.date = date
        self.description = description
        self.amount = amount
        self.balance = balance

    @classmethod
    def fromcsv(cls, x):        
        if x[2]:
            amount = -float(re.sub('[^\d.]', '', x[2]))
        if x[3]:
            amount = +float(re.sub('[^\d.]', '', x[3]))
        data = {'date': datetime.strptime(x[0], '%m/%d/%Y %I:%M%p'),
                'description': normalize(x[1]),
                'amount': amount,
                'balance': float(re.sub('[^\d.]', '', x[4]))}
        return cls(**data)

    def __str__(self):
        return TRANSACTION.format(self)


class BRINKSMoney(object):
    def __init__(self):
        self.session = requests.Session()

    def login(self, username, password):
        url = urljoin(URL, 'account/authenticate.m')
        
        # acquire session id
        r = self.session.get(url)
        r.raise_for_status()

        # credentials
        data = { 'blackBox': '',
                 'identifier': username,
                 'secret': password,
                 'type': 'pwd',
                 'login': 'Log In' }
        r = self.session.post(url, data=data)
        r.raise_for_status()

    def transactions(self, year, month):
        url = urljoin(URL, 'account/acctHistory.m')

        months = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
                  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')

        datestr = '{} {}'.format(months[month - 1], year)

        params = { 'selectedAcct': 'CARD',
                   'CSV': 'true',
                   'selectedMonth': datestr }

        r = self.session.get(url, params=params)
        r.raise_for_status()

        lines = r.text.splitlines()

        # skip header and footer
        lines = lines[10:-9]

        rows = csv.reader(lines, doublequote=False, strict=True)
        transactions = [ Transaction.fromcsv(row) for row in rows ]

        return transactions


def transactions(conf):
    db = sqlite3.connect(conf['DATABASE'])
    db.execute('''CREATE TABLE IF NOT EXISTS transactions (id TEXT PRIMARY KEY)''')

    sendmail = Mailer(host=conf['SMTPHOST'],
                      port=conf['SMTPPORT'],
                      starttls=conf['SMTPSTARTTLS'],
                      username=conf['SMTPUSER'],
                      password=conf['SMTPPASSWD']).send

    encrypt = GPG(conf['GNUPGHOME']).encrypt

    remote = BRINKSMoney()
    remote.login(conf['USERNAME'], conf['PASSWORD'])

    this = date.today().replace(day=1)
    prev = prevmonth(this)
    
    recent = ( remote.transactions(this.year, this.month) +
               remote.transactions(prev.year, prev.month) )

    recent.sort(key=lambda x: x.date)
               
    balance = recent[-1].balance
    
    curs = db.cursor()
    unseen = []
    for t in recent:
        curs.execute('''SELECT COUNT(*) FROM transactions WHERE id = ?''', (t.id, ))
        if not curs.fetchone()[0]:
            # not seen before
            unseen.append(t)

    if unseen:
        lines = []
        lines.append(HEADER)
        lines.append(SEP)
        for t in unseen:
            lines.append(str(t))
        lines.append(SEP)
        lines.append(FOOTER.format(balance=balance))

        text = '\n'.join(lines)
        payload = encrypt(text, conf['MAILFROM'], conf['MAILTO'])

        message = MIMEText(payload, _charset='utf8 7bit')
        message['Subject'] = 'BRINKS Money Account update'
        message['From'] = conf['MAILFROM']
        message['To'] = conf['MAILTO']
        message['Date'] = format_datetime(localtime())

        sendmail(message)

    curs.executemany('''INSERT INTO transactions (id) VALUES (?)''', ((x.id, ) for x in unseen))
    db.commit()


def loadconf(filename):
    module = imp.new_module('conf')
    module.__file__ = filename
    with open(filename) as fd:
        exec(compile(fd.read(), filename, 'exec'), module.__dict__)
    conf = {}
    for key in dir(module):
        if key.isupper():
            conf[key] = getattr(module, key)

    conf['DATADIR'] = os.path.dirname(filename)
    for key in 'DATABASE', 'GNUPGHOME':
        # if path is not absolute, it is interpreted as relative
        # to the location of the configuration file
        if not os.path.isabs(conf[key]):
            conf[key] = os.path.join(conf['DATADIR'], conf[key])

    return conf

            
@click.command()
@click.argument('conffile')
@click.option('--verbose', is_flag=True, help='Verbose output.')
def main(conffile, verbose):

    conf = loadconf(conffile)
    if verbose:
        conf['VERBOSE'] = True

    transactions(conf)


if __name__ == '__main__':
    main()