mega-commit

This commit is contained in:
2017-05-24 12:14:58 -04:00
parent 4617d2d9ba
commit fe4fcc4919
10 changed files with 376 additions and 76 deletions

View File

@@ -0,0 +1,12 @@
import os
from typing import List
# twilio.rest has a Client too, so let's avoid a namespace collision
from plaid import Client as PlaidClient
plaid_client = PlaidClient(client_id=os.getenv('PLAID_CLIENT_ID'), secret=os.getenv('PLAID_SECRET'),
public_key=os.getenv('PLAID_PUBLIC_KEY'), environment=os.getenv('PLAID_ENV'))
def get_some_transactions(access_token: str, start_date: str, end_date: str) -> List[dict]:
return plaid_client.Transactions.get(access_token, start_date, end_date)

View File

@@ -0,0 +1,36 @@
import math
import os
from typing import List
# twilio.rest has a Client too, so let's avoid a namespace collision
from plaid import Client as PlaidClient
plaid_client = PlaidClient(client_id=os.getenv('PLAID_CLIENT_ID'), secret=os.getenv('PLAID_SECRET'),
public_key=os.getenv('PLAID_PUBLIC_KEY'), environment=os.getenv('PLAID_ENV'))
# https://plaid.com/docs/api/#transactions
MAX_TRANSACTIONS_PER_PAGE = 500
OMIT_CATEGORIES = ["Transfer", "Credit Card", "Deposit"]
def get_some_transactions(access_token: str, start_date: str, end_date: str) -> List[dict]:
account_ids = [account['account_id'] for account in plaid_client.Accounts.get(access_token)['accounts']
if account['subtype'] not in ['cd', 'savings']]
num_available_transactions = plaid_client.Transactions.get(access_token, start_date, end_date,
account_ids=account_ids)['total_transactions']
num_pages = math.ceil(num_available_transactions / MAX_TRANSACTIONS_PER_PAGE)
transactions = []
for page_num in range(num_pages):
transactions += [transaction
for transaction in plaid_client.Transactions.get(access_token, start_date, end_date,
account_ids=account_ids,
offset=page_num * MAX_TRANSACTIONS_PER_PAGE,
count=MAX_TRANSACTIONS_PER_PAGE)['transactions']
if transaction['category'] is None
or not any(category in OMIT_CATEGORIES
for category in transaction['category'])]
return transactions

17
get_yesterdays.py Normal file
View File

@@ -0,0 +1,17 @@
import datetime
import os
from typing import List
from get_some_transactions_v2 import get_some_transactions
def get_yesterdays_transactions() -> List[dict]:
yesterday = ('2017-05-16' if os.getenv('PLAID_ENV') == 'sandbox'
else (datetime.date.today() - datetime.timedelta(days=1)).strftime('%Y-%m-%d'))
transactions = []
for access_id in [os.getenv('CHASE_ACCESS_TOKEN'), os.getenv('BOFA_ACCESS_TOKEN')]:
transactions += get_some_transactions(access_id, yesterday, yesterday)
return transactions

5
run.py Normal file
View File

@@ -0,0 +1,5 @@
from get_yesterdays import get_yesterdays_transactions
from send_summary import send_summary
send_summary(get_yesterdays_transactions())

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

14
send_summary.py Normal file
View File

@@ -0,0 +1,14 @@
import os
from typing import List
from twilio.rest import Client as TwilioClient
twilio_client = TwilioClient(os.getenv('TWILIO_SID'), os.getenv('TWILIO_TOKEN'))
def send_summary(transactions: List[dict]) -> None:
total_spent = sum(transaction['amount'] for transaction in transactions)
message = f'You spent ${total_spent} yesterday. 💸'
twilio_client.api.account.messages.create(to=os.getenv('MY_CELL'), from_=os.getenv('MY_TWILIO_NUM'), body=message)

232
spending_summary.md Normal file
View File

@@ -0,0 +1,232 @@
Your bank lets you set up SMS alerts for various triggers; it might even give you the option of receiving periodic spending summaries (mine doesn't!).
But what about a daily SMS summary of your spending across *all* your accounts? This is harder to come by, so let's roll our own:
## Pre-work
1. [Nab a sandbox account](https://dashboard.plaid.com/signup) from Plaid, and put those credentials into `PLAID_CLIENT_ID`, `PLAID_SECRET`,
and `PLAID_PUBLIC_KEY` environment variables. While you're at it,
[ask for access](https://dashboard.plaid.com/overview/request-development) the development API (it'll take a few days).
For now, though, create an env var `PLAID_ENV` and set it to 'sandbox'.
```bash
export PLAID_CLIENT_ID='somechars1234'
export PLAID_PUBLIC_KEY='somemorechars1234'
export PLAID_SECRET='somesecretchars1234'
export PLAID_ENV='sandbox'
```
1. Clone the [quickstart for Plaid](https://github.com/plaid/quickstart) and reference those environment variables in the
Python quickstart's `server.py`.
```python
PLAID_CLIENT_ID = os.getenv('PLAID_CLIENT_ID')
PLAID_SECRET = os.getenv('PLAID_SECRET')
PLAID_PUBLIC_KEY = os.getenv('PLAID_PUBLIC_KEY')
PLAID_ENV = os.getenv('PLAID_ENV')
```
1. Run `server.py` (Python 2 only 😦) and log into Chase with the test credentials ("user_good" and
"pass_good" as of 5/24/2017). It prints the access token to your terminal: Grab that and put it into a
`CHASE_ACCESS_TOKEN` environment variable. Repeat this for Bank of America and put that access token into `BOFA_ACCESS_TOKEN`.
1. [Grab your Twilio credentials](https://www.twilio.com/console/account/settings) and
[Twilio incoming phone number](https://www.twilio.com/console/phone-numbers/incoming) make sure those are available as
environment variables too (see below).
1. Put your cell phone number in an environment variable as `MY_CELL`.
```bash
export CHASE_ACCESS_TOKEN='access-sandbox-someprettysecretchars1234'
export BOFA_ACCESS_TOKEN='access-sandbox-somemoreprettysecretchars1234'
export TWILIO_SID='somechars1234'
export TWILIO_TOKEN='somesecretchars1234'
export MY_TWILIO_NUM='+11111111111'
export MY_CELL='+12222222222'
```
1. Install just a couple of dependencies:
```bash
$ pip install python-plaid twilio
```
## Get Some Transactions
Make yourself a Plaid client instance and grab some transactions from Chase:
[get_some_transactions_v1.py](https://github.com/zevaverbach/spending_summary/blob/master/get_some_transactions_v1.py)
```python
import os
from typing import List
# twilio.rest has a Client too, so let's avoid a namespace collision
from plaid import Client as PlaidClient
plaid_client = PlaidClient(client_id=os.getenv('PLAID_CLIENT_ID'), secret=os.getenv('PLAID_SECRET'),
public_key=os.getenv('PLAID_PUBLIC_KEY'), environment=os.getenv('PLAID_ENV'))
def get_some_transactions(access_token: str, start_date: str, end_date: str) -> List[dict]:
return plaid_client.Transactions.get(access_token, start_date, end_date)
```
Inspecting the output of `get_some_transactions`, we see that there are multiple accounts, 337 transactions among them, but only
100 transactions returned from this API call. Two of these accounts are for savings, so presumably they're only
going to have transfers rather than purchases.
```python
>>> from get_some_transactions_v1 import get_some_transactions
>>> import os
>>> some_transactions = get_some_transactions(os.getenv('CHASE_ACCESS_TOKEN'), '1972-01-01', '2017-05-26'))
>>> some_transactions['total_transactions']
337
>>> from pprint import pprint
>>> pprint(some_transactions['accounts'])
[{'account_id': 'qwp96Z11b5IBKVMl8XvLSkJXjgj6ZxIXX3o79',
'name': 'Plaid Checking',
'subtype': 'checking'
...},
{'account_id': 'Kk9ZL7NN4wSX3lR9evV8f9P4GVGk3BF33QnAM',
'name': 'Plaid Saving',
'subtype': 'savings'
...},
{'account_id': 'rEy96MWWgXukrnBW4yVphv7yl3lznosBBzo6n',
'name': 'Plaid CD',
'subtype': 'cd'
...}
{'account_id': '9rNKomMMdWTvVL4X9RP6UKb4qEqng1uJJ6nQw',
'name': 'Plaid Credit Card',
'subtype': 'credit'
...}]
>>> len(some_transactions['transactions'])
100
```
## Get **The Right** Transactions
Looking at the transactions themselves, we see that there is a `category` field which sometimes has a list of values,
sometimes `None`. Among the categories there are "Transfer", "Credit Card", and "Deposit": These aren't going to be
useful in gleaning spending activity, so we'll refactor our `get_some_transactions` function to 1) skip transactions
with those categories and 2) skip accounts with a subtype of "savings" or "cd". Let's also 3) make sure to get all
available transactions by using pagination and 4) just return transactions.
```python
>>> some_transactions['transactions'].keys()
dict_keys(['account_id', 'account_owner', 'amount', 'category', 'category_id', 'date', 'location', 'name', 'payment_meta', 'pending', 'pending_transaction_id', 'transaction_id', 'transaction_type'])
>>> {category for transaction in some_transactions['transactions'] if some_transactions['category'] for category in trans['category']}
{'Airlines and Aviation Services',
'Coffee Shop',
'Credit Card',
'Deposit',
'Fast Food',
'Food and Drink',
'Payment',
'Restaurants',
'Transfer',
'Travel'}
```
[get_some_transactions_v2.py](https://github.com/zevaverbach/spending_summary/blob/master/get_some_transactions_v2.py)
```python
import math
...
# https://plaid.com/docs/api/#transactions
MAX_TRANSACTIONS_PER_PAGE = 500
OMIT_CATEGORIES = ["Transfer", "Credit Card", "Deposit"]
def get_some_transactions(access_token: str, start_date: str, end_date: str) -> List[dict]:
account_ids = [account['account_id'] for account in plaid_client.Accounts.get(access_token)['accounts']
if account['subtype'] not in ['cd', 'savings']]
num_available_transactions = plaid_client.Transactions.get(access_token, start_date, end_date,
account_ids=account_ids)['total_transactions']
num_pages = math.ceil(num_available_transactions / MAX_TRANSACTIONS_PER_PAGE)
transactions = []
for page_num in range(num_pages):
transactions += [transaction
for transaction in plaid_client.Transactions.get(access_token, start_date, end_date,
account_ids=account_ids,
offset=page_num * MAX_TRANSACTIONS_PER_PAGE,
count=MAX_TRANSACTIONS_PER_PAGE)['transactions']
if transaction['category'] is None
or not any(category in OMIT_CATEGORIES
for category in transaction['category'])]
return transactions
```
Now there are just 265 transactions. Are any of them negative?
```python
>>> pprint([transaction for transaction in some_transactions if transaction['amount'] < 0])
[{'amount': -500,
'category': ['Travel', 'Airlines and Aviation Services'],
'name': 'United Airlines',
'transaction_type': 'special',
...},
...]
```
Okay, that seems legit -- airfare refund, I guess. Let's keep negative items in.
## Pulling it All Together
Now let's get all the transactions from yesterday, making sure to pull them from both accounts.
[get_yesterdays.py](https://github.com/zevaverbach/spending_summary/blob/master/get_yesterdays.py)
```python
...
def get_yesterdays_transactions() -> List[dict]:
yesterday = ('2017-05-16' if os.getenv('PLAID_ENV') == 'sandbox'
else (datetime.date.today() - datetime.timedelta(days=1)).strftime('%Y-%m-%d'))
transactions = []
for access_id in [os.getenv('CHASE_ACCESS_TOKEN'), os.getenv('BOFA_ACCESS_TOKEN')]:
transactions += get_transactions_from_multiple_accounts(access_id, yesterday, yesterday)
return transactions
```
As of 5/24/2017, the most recent transactions available in these sandbox accounts are from 5/16/17: Hence, the hardcoded
`yesterday` value above.
Let's send an SMS to ourselves with the total spent yesterday!
[send_summary.py](https://github.com/zevaverbach/spending_summary/blob/master/send_summary.py)
```python
...
from twilio.rest import Client as TwilioClient
twilio_client = TwilioClient(os.getenv('TWILIO_SID'), os.getenv('TWILIO_TOKEN'))
def send_summary(transactions: List[dict]) -> None:
total_spent = sum(transaction['amount'] for transaction in transactions)
message = f'You spent ${total_spent} yesterday. 💸'
twilio_client.api.account.messages.create(to=os.getenv('MY_CELL'), from_=os.getenv('MY_TWILIO_NUM'), body=message)
```
Voila!
![mobile screenshot](screenshot.png)
## Representing Money as a Float?
You're gonna have a bad time, 'mkay? If you build anything significant (budget tracker bot?) with the Plaid API,
be sure to convert those transaction amounts to Decimals:
```python
import decimal
def convert_transaction_amount(transaction_amount):
return decimal.Decimal(transaction_amount)
```

60
spending_summary.py Normal file
View File

@@ -0,0 +1,60 @@
import datetime
import math
import os
from typing import List
from twilio.rest import Client as TwilioClient
from plaid import Client as PlaidClient
# these are access IDs from Plaid for a specific bank/credit account
ACCOUNT_ACCESS_IDS: List[str] = [os.getenv('PLAID_ACCESS_ID'), os.getenv('OTHER_PLAID_ACCESS_ID')]
MAX_TRANSACTIONS_PER_PAGE = 500
plaid_client = PlaidClient(client_id=os.getenv('PLAID_CLIENT_ID'), secret=os.getenv('PLAID_SECRET'),
public_key=os.getenv('PLAID_PUBLIC_KEY'), environment=os.getenv('PLAID_ENV'))
twilio_client = TwilioClient(os.getenv('TWILIO_SID'), os.getenv('TWILIO_TOKEN'))
def get_num_available_transactions(access_id, start: str, end: str) -> int:
return plaid_client.Transactions.get(access_id, start, end)['total_transactions']
def get_transactions_from_account(access_id: str, start: str, end: str) -> List[dict]:
transactions = []
num_available_transactions = get_num_available_transactions(access_id, start, end)
num_pages = math.ceil(num_available_transactions / MAX_TRANSACTIONS_PER_PAGE)
for page_num in range(num_pages):
transactions += plaid_client.Transactions.get(access_id, start, end,
offset=page_num * MAX_TRANSACTIONS_PER_PAGE,
count=MAX_TRANSACTIONS_PER_PAGE)['transactions']
return transactions
def get_transactions_from_multiple_accounts(access_ids: List[str], start: str, end: str) -> List[dict]:
transactions = []
for access_id in access_ids:
transactions += get_transactions_from_account(access_id, start, end)
return transactions
def get_yesterdays_transactions() -> List[dict]:
yesterday: str = (datetime.date.today() - datetime.timedelta(days=1)).strftime('%Y-%m-%d')
return get_transactions_from_multiple_accounts(ACCOUNT_ACCESS_IDS, yesterday, yesterday)
def send_summary(transactions: List[dict]) -> None:
total_spent = sum(transaction['amount']
for transaction in transactions
if transaction['amount'] > 0)
message = f'You spent ${total_spent} yesterday. 💸'
twilio_client.api.account.messages.create(to=os.getenv('MY_CELL'), from_=os.getenv('MY_TWILIO_NUM'), body=message)
if __name__ == "__main__":
send_summary(get_yesterdays_transactions())

View File

@@ -1,5 +0,0 @@
Budgeting. You do it, then you stop doing it. You wake up in a cold sweat and you start tracking your spending again. It sucks: You stop.
There are tools! Horrible, awful tools that are glorified spreadsheets which think all your Amazon purchases go under `books`. Much data entered, many hairs pulled out.
Surely this is fodder for a bot.

View File

@@ -1,71 +0,0 @@
import datetime
import os
import time
from typing import List
import math
from twilio.rest import Client as TwilioClient
from plaid import Client as PlaidClient
CHECK_FOR_NEW_TRANSACTIONS_EVERY_X_MINUTES = 5
ALERT_FOR_TRANSACTIONS_GTE = 500
MY_CELL = os.getenv('MY_CELL')
MY_TWILIO_NUM = os.getenv('MY_TWILIO_NUM')
PLAID_ENV = os.getenv('PLAID_ENV') or 'sandbox'
# this is an access ID from Plaid for a specific user and bank/credit account
PLAID_ACCESS_ID = os.getenv('PLAID_ACCESS_ID')
plaid_client = PlaidClient(client_id=os.getenv('PLAID_CLIENT_ID'), secret=os.getenv('PLAID_SECRET'),
public_key=os.getenv('PLAID_PUBLIC_KEY'), environment=PLAID_ENV)
twilio_client = TwilioClient(os.getenv('TWILIO_SID'), os.getenv('TWILIO_TOKEN'))
transaction_ids = set()
def get_all_transactions() -> List[dict]:
today = datetime.date.today().strftime('%Y-%m-%d')
total_transactions: int = plaid_client.Transactions.get(PLAID_ACCESS_ID, '1972-01-01', today)['total_transactions']
all_transactions = []
for page in range(math.ceil(total_transactions / 500)):
all_transactions += plaid_client.Transactions.get(PLAID_ACCESS_ID, '1972-01-01', today,
offset=page * 500, count=500)['transactions']
return all_transactions
def get_latest_transactions() -> List[dict]:
"""get 100 most recent transactions"""
today = datetime.date.today().strftime('%Y-%m-%d')
return [transaction
for transaction in
plaid_client.Transactions.get(PLAID_ACCESS_ID, '1972-01-01', today)['transactions']
if transaction['transaction_id'] not in transaction_ids]
def alert(transaction: dict) -> None:
message = (f'hey, a transaction hit your account that exceeds ${ALERT_FOR_TRANSACTIONS_GTE}: '
f'{transaction["date"]} {transaction["name"]} ${transaction["amount"]}')
twilio_client.api.account.messages.create(to=MY_CELL, from_=MY_TWILIO_NUM, body=message)
def main() -> None:
if len(transaction_ids) == 0:
transaction_ids.update(transaction['transaction_id'] for transaction in get_all_transactions())
while True:
for transaction in get_latest_transactions():
transaction_ids.add(transaction['transaction_id'])
if transaction['amount'] >= ALERT_FOR_TRANSACTIONS_GTE:
alert(transaction)
time.sleep(CHECK_FOR_NEW_TRANSACTIONS_EVERY_X_MINUTES * 60)
if __name__ == "__main__":
main()