diff --git a/get_some_transactions_v1.py b/get_some_transactions_v1.py new file mode 100644 index 0000000..4f1ebd0 --- /dev/null +++ b/get_some_transactions_v1.py @@ -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) diff --git a/get_some_transactions_v2.py b/get_some_transactions_v2.py new file mode 100644 index 0000000..48aa145 --- /dev/null +++ b/get_some_transactions_v2.py @@ -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 diff --git a/get_yesterdays.py b/get_yesterdays.py new file mode 100644 index 0000000..b3ef2d1 --- /dev/null +++ b/get_yesterdays.py @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..121456a --- /dev/null +++ b/run.py @@ -0,0 +1,5 @@ +from get_yesterdays import get_yesterdays_transactions +from send_summary import send_summary + + +send_summary(get_yesterdays_transactions()) \ No newline at end of file diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..9ea4b15 Binary files /dev/null and b/screenshot.png differ diff --git a/send_summary.py b/send_summary.py new file mode 100644 index 0000000..256f99c --- /dev/null +++ b/send_summary.py @@ -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) diff --git a/spending_summary.md b/spending_summary.md new file mode 100644 index 0000000..44bc7db --- /dev/null +++ b/spending_summary.md @@ -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) +``` diff --git a/spending_summary.py b/spending_summary.py new file mode 100644 index 0000000..9ca235c --- /dev/null +++ b/spending_summary.py @@ -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()) diff --git a/splurgebot.md b/splurgebot.md deleted file mode 100644 index 0393f1e..0000000 --- a/splurgebot.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/splurgebot.py b/splurgebot.py deleted file mode 100644 index 1b92a77..0000000 --- a/splurgebot.py +++ /dev/null @@ -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()