Checking Your Daily Spending via SMS with Python, Plaid and Twilio

June 19, 2017
Written by
Zev Averbach
Contributor
Opinions expressed by Twilio contributors are their own

spending-header

Your bank may let you set up SMS alerts for various triggers. It might even give you the option of receiving periodic spending summaries via SMS (mine doesn’t though!). But what about a daily SMS summary of your spending across all your accounts? This summary is harder to come by, but thankfully you can roll your own by combining Plaid, an easy to use financial services API and Twilio SMS with a bit of Python 3. Let’s get going!

Setting Up

We’ll begin creating our app by lining up some of the basic building blocks. Here’s all the code in one place for those following at home and want to save some typing.

Start by nabbing a sandbox account from Plaid and putting your credentials into PLAID_CLIENT_ID, PLAID_SECRET, PLAID_PUBLIC_KEY environment variables. While you’re at it, ask for access the development API (it’ll take a few days to get approved). For now, though, create an environment variable named PLAID_ENV and set it to ‘sandbox’.  This will give us some sample data to work with.

export PLAID_CLIENT_ID='somechars1234'
export PLAID_PUBLIC_KEY='somemorechars1234'
export PLAID_SECRET='somesecretchars1234'
export PLAID_ENV='sandbox'

Next, clone the quickstart for Plaid, install from requirements.txt using the pip install -r requirements.txt command then edit the server.py code to include those environment variables.

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')

Run server.py with the python server.py command (unfortunately this part of the example code only works with Python 2 😦) and open http://127.0.0.1:5000 in your browser.

Screenshot 2017-05-31 23.07.00.png

Click “open link”, then log into Chase with the test credentials (“user_good” and “pass_good” as of 5/26/2017) and the application will print an access token to your terminal window. 

Screenshot_2017-05-31_23_28_28.png

Screenshot_2017-05-31_23_08_16.png

Grab the token 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:

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'

Finally, install a couple of project dependencies:

$ pip install python-plaid twilio

With the basic building blocks in place we can start coding our app.

Obtaining Transactions

A quick way to start working with Plaid is to grab some account transactions and explore the result.  Make a new file called get_some_transactions.py.  In that file create a plaid.Client instance and a new function named get_some_transactions which accepts an access token and the start and end dates of the range of transactions you want to get. Inside the function, call the Plaid client’s Transactions.get function with those parameters. The following code will accomplish these steps.

import os
from pprint import pprint
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)

With that code in place we can start exploring what information Plaid returns. In the same script, call get_some_transactions by adding the following two lines to the end of the file. These pass in the Chase access token and a wide date range.

some_transactions = get_some_transactions(os.getenv('CHASE_ACCESS_TOKEN'), '1972-01-01', '2017-05-26')
print(f"there are {some_transactions['total_transactions']} total transactions between those dates.”)

Run the script using the python get_some_transactions.py command.

When run this code outputs there are 338 total transactions between those dates.  How many transactions were returned from our Plaid API call?

print(f"get_some_transactions returned {len(some_transactions['transactions'])} transactions.)

Apparently, get some_transactions returned 100 transactions.  Why only 100?  It seems that’s the default value for count, an optional parameter for Transactions.get, as seen here in the Plaid API documentation.

What sort of data are in a transaction?

pprint(some_transactions['transactions'][0].keys())

This code outputs dict_keys(['account_id', 'account_owner', 'amount', 'category', 'category_id', 'date', 'location', 'name', 'payment_meta', 'pending', 'pending_transaction_id', 'transaction_id', 'transaction_type']).  For our purposes, amount seems to be all we need, but what about category?  We’re not building a full-fledged budget bot, but are there any transactions that would muddy the waters of our spending summary?

print({category
       for transaction in some_transactions['transactions'] if transaction['category'] is not None
       for category in transaction['category']})

This code gives us {'Food and Drink', 'Travel', 'Transfer', 'Airlines and Aviation Services', 'Payment', 'Credit Card', 'Coffee Shop', 'Fast Food', 'Restaurants', 'Deposit'}.  This will be useful to create a choosier get_some_transactions function.  For example, “Transfer” transactions I would argue don’t belong in our daily summaries, since they don’t qualify as spending. Before we refactor, though, let’s see what sort of accounts we’re dealing with, and whether there are any we should exclude.

pprint(some_transactions['accounts'])

Leaving out the less relevant fields, this execution results in:

{'account_id': 'qwp96Z11b5IBKVMl8XvLSkJXjgj6ZxIXX3o79'

There’s some low-hanging fruit here too: with any luck, we won’t be spending out of our savings or investment accounts — at worst, we’d be doing transfers — so let’s get refactoring!

Getting the Right Transactions

We know we want to exclude transactions with a category of “Transfer”.  “Credit Card”, “Payment” and “Deposit” aren’t going to be useful in gleaning spending activity either, so we’ll refactor our get_some_transactions function to skip transactions with those categories.  As stated earlier, we also want to skip accounts with a subtype of “savings” or “cd”.

While we’re at it, let’s also make sure to get all available transactions by using pagination, not just those default first 100, and hone in on just the transactions item, not any others. Modify get_some_transactions.py with the following code (which can also be found in get_some_transactions.py on GitHub).

import math
import os
from pprint import pprint
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", "Payment"]
OMIT_ACCOUNT_SUBTYPES = ['cd', 'savings']


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 OMIT_ACCOUNT_SUBTYPES]

    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

some_transactions = get_some_transactions(os.getenv('CHASE_ACCESS_TOKEN'), '1972-01-01', '2017-05-26')
print(f"there are {len(some_transactions)} transactions")
pprint([transaction for transaction in some_transactions if transaction['amount'] < 0])

Execute the code and it says that there are 265 transactions. Are any of them negative?

pprint([transaction for transaction in some_transactions if transaction['amount'] < 0])

Several are, in fact, negative:

{'amount': -500

Okay, that seems legit- airfare refund, I guess. All the transactions with negative amounts are similar to this, so let’s keep them in.

Pulling It All Together

Now let’s get all the transactions from yesterday, making sure to pull them from both accounts.  Create a new file named get_yesterdays.py and add this code:

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

As of 5/26/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!  Create another new file named send_summary.py and add this code to it:

import os
from typing import List

from twilio.rest import Client as TwilioClient

from get_yesterdays import get_yesterdays_transactions

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)


if __name__ == "__main__":
    send_summary(get_yesterdays_transactions())

Run the code using the python get_send_summary.py command and voila!


Wrapping It Up

We’ve now created an app that aggregates all spending across disparate credit and bank accounts, then pushes that total spend to our phone.  Here’s all the code in one place.  To deploy it, you could create a cron job somewhere to run it every day at a certain time, and never again have to deal with separate alerts/summaries from each spending account.

But that’s just the tip of the iceberg of what you could build with this spending data. I don’t know about you, but having built this proof of concept makes me want a bot that tracks my spending, pinging me to categorize items it can’t categorize itself.  Spreadsheets are a blast and all, but do I need the unpaid part-time job of maintaining one?  Ditto for the existing web apps.

A simpler extension of this app would be to set it up for your grandma and see if you can build a meaningful voice interaction into it.

Put your ideas and questions in the comments below!