Compare commits

...

4 Commits

Author SHA1 Message Date
Sara Montecino
d986461019 Add a bunch of fixes, categories
- Go through and update database
2022-10-31 00:57:57 -07:00
Sara Montecino
9e544ec64a Add frontend for updating transactions 2022-10-30 20:15:48 -07:00
Sara Montecino
12e7dbcc93 Delete database, update with July first half 2022-10-30 02:58:43 -07:00
Sara Montecino
001d589a1e Fix default categories 2022-10-30 02:58:27 -07:00
9 changed files with 752 additions and 47 deletions

23
.vscode/launch.json vendored
View File

@ -11,7 +11,28 @@
"program": "main.py", "program": "main.py",
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": true, "justMyCode": true,
"args": ["C:\\Users\\saram\\Downloads\\Statement_082022_4653.pdf"] "args": [
// "C:\\Users\\saram\\Downloads\\Statement_072022_4653.pdf",
// "C:\\Users\\saram\\Downloads\\Statement_082022_4653.pdf",
"C:\\Users\\saram\\Downloads\\Statement_092022_4653.pdf",
"C:\\Users\\saram\\Downloads\\Statement_102022_4653.pdf",
]
},
{
"name": "Create Database",
"type": "python",
"request": "launch",
"program": "database.py",
"console": "integratedTerminal",
"justMyCode": true
},
{
"name": "Flask",
"type": "python",
"request": "launch",
"program": "app.py",
"console": "integratedTerminal",
"justMyCode": true
} }
] ]
} }

144
app.py Executable file
View File

@ -0,0 +1,144 @@
from datetime import datetime
from flask import Flask, render_template, request, abort
import peewee
from playhouse.shortcuts import model_to_dict
import database
app = Flask(__name__)
app.config["DEBUG"] = True
@app.before_request
def before_request():
database.instance.connect()
@app.after_request
def after_request(response):
database.instance.close()
return response
@app.route("/")
def home():
puppies = ['hollie', 'grace', 'loki']
return render_template('index.html', puppies=puppies)
@app.route("/users")
def users():
args = request.args.to_dict()
if 'username' not in args:
abort(400)
username = args['username'].strip()
try:
return model_to_dict(database.User.get(database.User.username == username))
except peewee.DoesNotExist:
abort(404)
except:
abort(500)
@app.route("/transactions", methods=["GET", "POST"])
def transactions():
if request.method == "GET":
args = request.args.to_dict()
if 'user_id' not in args:
abort(400)
user_id = args['user_id'].strip()
try:
july_2022 = datetime(2022, 7, 1, 0)
transactions = database.Transaction.select().where(
(database.Transaction.user == user_id) &
(database.Transaction.transaction_date > july_2022)
)
return [model_to_dict(t) for t in transactions]
except peewee.DoesNotExist:
abort(404)
except:
abort(500)
if request.method == "POST":
body = request.get_json()
try:
user = database.User.get(database.User.uuid == body['user_id'])
category = database.TransactionCategory.get(database.TransactionCategory.name == body['category'])
database.Transaction.create(
user=user,
subcategory=category,
transaction_date=body['transaction_date'],
description=body['description'],
amount=body['amount'],
type=body['type'],
notes=body['notes']
)
return ('', 200)
except peewee.DoesNotExist:
abort(404)
except KeyError:
abort(400)
# Default if no method is hit
abort(400)
@app.route("/transactions/<uuid>", methods=["PUT"])
def transaction(uuid=None):
if request.method == "PUT":
if not uuid:
abort(400)
body = request.get_json()
if 'category' not in body or 'notes' not in body:
abort(400)
try:
category = database.TransactionCategory.get(database.TransactionCategory.name == body['category'])
transaction = database.Transaction.get(database.Transaction.primary_key == uuid)
transaction.subcategory = category
transaction.notes = body['notes']
transaction.save()
return ('', 200)
except peewee.DoesNotExist:
abort(404)
except:
abort(500)
# Default if no method is hit
abort(400)
@app.route("/categories", methods=["GET", "POST"])
def categories():
if request.method == "GET":
try:
raw_categories = database.TransactionCategory.select()
categories = {}
for category in raw_categories:
if not category.parent:
categories[category.name] = []
for category in raw_categories:
if category.parent:
categories[category.parent.name].append(category.name)
return categories
except peewee.DoesNotExist:
abort(404)
except:
abort(500)
if request.method == "POST":
body = request.get_json()
try:
if 'parent' in body:
parent = database.TransactionCategory.get(database.TransactionCategory.name == body['parent'])
database.TransactionCategory.create(name=body['name'], parent=parent)
else:
database.TransactionCategory.create(name=body['name'])
return ('', 200)
except peewee.DoesNotExist:
abort(404)
except:
abort(500)
# Should never get here.
abort(500)
app.run()

BIN
budget.db

Binary file not shown.

View File

@ -53,12 +53,15 @@ class Transactions(Regex):
date = result[0] date = result[0]
description = " ".join(result[1].split()) description = " ".join(result[1].split())
is_payment = '-' in result[2] is_credit = '-' in result[2]
amount = float(result[3].replace(',', '')) amount = float(result[3].replace(',', ''))
if is_payment: if is_credit:
print(f"Skipping payment: {amount}") if "AUTOPAY" in description:
continue print(f"Skipping payment: {amount}")
continue
amount *= -1
transactions.append([date, description, amount]) transactions.append([date, description, amount])

View File

@ -1,4 +1,5 @@
import datetime import datetime
import uuid
from peewee import * from peewee import *
DATABASE = 'budget.db' DATABASE = 'budget.db'
@ -7,59 +8,67 @@ instance = SqliteDatabase(DATABASE, pragmas=[('foreign_keys', 'on')])
def create_tables(): def create_tables():
"""Helper function to create database tables. Should be called manually.""" """Helper function to create database tables. Should be called manually."""
with instance: with instance:
instance.create_tables([User, TransactionCategory, Transaction, Source]) instance.create_tables([User, TransactionCategory, Transaction])
def add_user():
# Make my user. # Make my user.
instance.connect() instance.connect()
User.create(username='ciphercules') User.create(username='ciphercules')
instance.close() instance.close()
def add_default_categories(): def add_default_categories():
"""Helper function to add default categories. Should be called manually."""
categories = [ categories = [
{"food": ["snacks", "fast_food", "groceries", "restaurant"]}, ("food", ["snacks", "fast_food", "groceries", "restaurant"]),
{"clothing": []}, ("clothing", []),
{"event": ["event_food", "birthday", "movie_theater"]}, ("event", ["event_food", "birthday", "movie_theater"]),
{"finance": ["interest"]}, ("finance", ["interest"]),
{"hobby": ["gym", "game_development", "projects", "education"]}, ("hobby", ["gym", "game_development", "projects", "education"]),
{"home_improvement": []}, ("home_improvement", []),
{"pet": ["health", "dog_food"]}, ("pet", ["dog_health", "dog_food"]),
{"media": ["book", "music", "television", "video_game"]}, ("media", ["book", "music", "television", "video_game"]),
{"health": ["medicine"]}, ("health", ["medicine"]),
{"transit": ["car_insurance", "car_registration", "gas", "parking", "taxi", "car_maintenance"]}, ("transit", ["car_insurance", "car_registration", "gas", "parking", "taxi", "car_maintenance"]),
{"utilities": ["electricity", "gas", "laundry", "cell_phone", "trash", "water"]}, ("utilities", ["electricity", "natural_gas", "laundry", "cell_phone", "trash", "water"]),
{"rent": []}, ("rent", []),
] ]
instance.connect() instance.connect()
with instance.atomic(): with instance.atomic():
for parent, children in categories.items(): for category in categories:
parent, children = category
parent_db = TransactionCategory.create(name=parent) parent_db = TransactionCategory.create(name=parent)
for child in children: for child in children:
TransactionCategory.create(name=child, parent=parent_db) TransactionCategory.create(name=child, parent=parent_db)
def delete_transactions():
instance.connect()
# august_2022 = datetime.datetime(2022, 8, 18, 0)
# delete_query = Transaction.delete().where(Transaction.transaction_date > august_2022)
delete_query = Transaction.delete().where(Transaction.primary_key == 83)
delete_query.execute()
instance.close()
class BaseModel(Model): class BaseModel(Model):
class Meta: class Meta:
database = instance database = instance
class User(BaseModel): class User(BaseModel):
username = CharField(unique=True, primary_key=True) uuid = UUIDField(unique=True, primary_key=True, default=uuid.uuid4())
username = CharField(unique=True)
class TransactionCategory(BaseModel): class TransactionCategory(BaseModel):
primary_key = AutoField(primary_key=True) primary_key = AutoField(primary_key=True)
name = CharField(unique=True) name = CharField(unique=True)
parent = ForeignKeyField('self', null=True, backref='children') parent = ForeignKeyField('self', null=True, backref='children')
class Source(BaseModel):
filename=CharField(unique=True, primary_key=True)
type = IntegerField()
created_date = DateTimeField(default=datetime.datetime.now)
user = ForeignKeyField(User, backref='transactions')
class Transaction(BaseModel): class Transaction(BaseModel):
# Metadata # Metadata
primary_key = AutoField(primary_key=True) primary_key = AutoField(primary_key=True)
source = ForeignKeyField(Source, backref='transactions')
created_date = DateTimeField(default=datetime.datetime.now) created_date = DateTimeField(default=datetime.datetime.now)
source_file=CharField(null=True)
type = IntegerField()
user = ForeignKeyField(User, backref='transactions') user = ForeignKeyField(User, backref='transactions')
# Real data # Real data
@ -67,3 +76,9 @@ class Transaction(BaseModel):
description = CharField() description = CharField()
amount = FloatField() amount = FloatField()
subcategory = ForeignKeyField(TransactionCategory, backref='+', null=True) subcategory = ForeignKeyField(TransactionCategory, backref='+', null=True)
notes = CharField(null=True)
if __name__ == "__main__":
create_tables()
add_user()
add_default_categories()

18
main.py
View File

@ -9,10 +9,14 @@ parser = argparse.ArgumentParser(prog="BudgetBear", description="Calculate a bud
parser.add_argument('files', type=str, nargs='+', help='File to parse transactions from.') parser.add_argument('files', type=str, nargs='+', help='File to parse transactions from.')
args = parser.parse_args() args = parser.parse_args()
username = 'ciphercules' # Get my user.
database.instance.connect()
user = database.User.select().where(database.User.username == 'ciphercules').get()
database.instance.close()
file_parsers = [capital_one.Parser()] file_parsers = [capital_one.Parser()]
for f in args.files: for f in args.files:
filename=os.path.basename(f),
for file_parser in file_parsers: for file_parser in file_parsers:
# Use the first successful parser. # Use the first successful parser.
transactions = file_parser.parse(f) transactions = file_parser.parse(f)
@ -22,22 +26,16 @@ for f in args.files:
# Add to database # Add to database
database.instance.connect() database.instance.connect()
with database.instance.atomic(): with database.instance.atomic():
# Add source file first.
source = database.Source.create(
filename=os.path.basename(f),
type=file_parser.source,
user=username
)
# Add each transaction # Add each transaction
for transaction in transactions: for transaction in transactions:
date, description, amount = transaction date, description, amount = transaction
database.Transaction.create( database.Transaction.create(
user=username, user=user,
transaction_date=date, transaction_date=date,
description=description, description=description,
amount=amount, amount=amount,
source=source source_file=filename,
type=file_parser.source
) )
database.instance.close() database.instance.close()

View File

@ -12,3 +12,5 @@ class BaseParser:
class TransactionSource: class TransactionSource:
"""Enum of possible transaction sources""" """Enum of possible transaction sources"""
CAPITAL_ONE = 1 CAPITAL_ONE = 1
SPLITWISE = 2
COMPANY = 3

522
templates/index.html Executable file
View File

@ -0,0 +1,522 @@
<!doctype html>
<html>
<head>
<title>BudgetBear</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {font-family: Arial, Helvetica, sans-serif;}
/* The Modal (background) */
.modal {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 1; /* Sit on top */
padding-top: 50px; /* Location of the box */
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgb(0,0,0); /* Fallback color */
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
}
/* Modal Content */
.modal-content {
background-color: #fefefe;
margin: auto;
padding: 30px;
border: 1px solid #888;
width: 40%;
}
/* The Close Button */
.close {
float: left;
margin: auto;
}
/* The Next Button */
.next {
float: right;
margin: auto;
}
</style>
</head>
<body>
<h1>BudgetBear</h1>
<p>The app for bears on a budget</p>
<div id="login_form">
<label for="login_input">Login: </label>
<input id="login_input" name="login">
<input id="login_submit" type="submit" value="Submit">
</div>
<div id="login_success" style="display:none">
<p>Logged in as: <span id="login_username"></span></p>
</div>
<div id="login_error" style="display:none">
<p style="color:red">Failed to login</p>
</div>
<h2>Transactions</h2>
<input id="transactions_refresh" type="submit" value="Refresh" disabled>
<!-- Transaction Modal -->
<button id="transaction_modal_btn">Let's Process!</button>
<div id="transaction_error" style="display:none">
<p style="color:blue">No transactions to process!</p>
</div>
<div id="transaction_modal" class="modal">
<div class="modal-content">
<h3>Transaction ID <span id="transaction_id"></span></h3>
<p><b>Remaining:</b> <span id="transactions_unprocessed"></span></p>
<p><b>Transaction date:</b> <span id="transaction_date"></span></p>
<p><b>Description:</b> <span id="transaction_description"></span></p>
<p><b>Amount:</b> $<span id="transaction_amount"></span></p>
<label for="transaction_category">Category</label>
<select id="transaction_category"></select>
<label for="transaction_subcategory">Subcategory</label>
<select id="transaction_subcategory" disabled></select>
<br><br>
<label for="transaction_notes">Notes:</label>
<textarea id="transaction_notes" rows="4" cols="50"></textarea>
<br><br>
<label for="transaction_split">Split with others?</label>
<input type="checkbox" id="transaction_split" value="true">
<br><br>
<div id="transaction_split_content" style="display:none">
<h3>Split Transaction</h3>
<label for="transaction_split_amount">Amount reimbursed:</label>
<input type="number" id="transaction_split_amount">
<br><br>
<label for="transaction_split_type">Type: </label>
<select id="transaction_split_type">
<option value="2">Splitwise</option>
<option value="3">SpaceX</option>
</select>
<br><br>
<label for="transaction_split_notes">Notes: </label>
<textarea id="transaction_split_notes" rows="4" cols="50"></textarea>
<br><br>
</div>
<button id="modal_close" class="close">Exit</button>
<button id="modal_next" class="next">Submit</button>
</div>
</div>
<h2>Add category</h2>
<label for="category_parent">Parent: </label>
<select id="category_parent"></select>
<label for="category_name">Name: </label>
<input id="category_name">
<button id="category_add">Add</button>
<!-- Transaction Modal End -->
</body>
<script>
// Login HTML elements.
const login =
{
form : document.getElementById('login_form'),
input : document.getElementById('login_input'),
submit : document.getElementById('login_submit'),
success : document.getElementById('login_success'),
error : document.getElementById('login_error'),
username : document.getElementById('login_username'),
};
// Transaction HTML elements.
const x_action =
{
refresh : document.getElementById('transactions_refresh'),
modal_btn : document.getElementById('transaction_modal_btn'),
error : document.getElementById('transaction_error'),
modal : document.getElementById('transaction_modal'),
id : document.getElementById('transaction_id'),
exit: document.getElementById('modal_close'),
submit: document.getElementById('modal_next'),
unprocessed : document.getElementById('transactions_unprocessed'),
date: document.getElementById('transaction_date'),
description: document.getElementById('transaction_description'),
amount: document.getElementById('transaction_amount'),
category: document.getElementById('transaction_category'),
subcategory: document.getElementById('transaction_subcategory'),
notes: document.getElementById('transaction_notes'),
split:
{
enabled: document.getElementById('transaction_split'),
container: document.getElementById('transaction_split_content'),
notes: document.getElementById('transaction_split_notes'),
amount: document.getElementById('transaction_split_amount'),
type: document.getElementById('transaction_split_type'),
},
};
const category =
{
name_input: document.getElementById('category_name'),
parent_select: document.getElementById('category_parent'),
add_button: document.getElementById('category_add'),
};
// Global variables.
const global_data =
{
current_user: null,
transaction:
{
unprocessed_indices: [],
list: [],
process_index: 0,
unprocessed_cnt: function()
{
const diff = this.unprocessed_indices.length - 1 - this.process_index
return diff >= 0 ? diff : 0;
},
get_current: function()
{
if (this.process_index < this.unprocessed_indices.length)
{
return this.list[this.unprocessed_indices[this.process_index]]
}
return null
}
},
categories: {},
update: function()
{
let new_indices = []
for (let i = 0; i < this.transaction.list.length; i++)
{
if (this.transaction.list[i].subcategory === null)
{
// Save index of first unprocessed transaction.
new_indices.push(i);
}
}
this.transaction.unprocessed_indices = new_indices;
}
};
login.input.onkeypress = function(event) {
if (event.key === "Enter")
{
event.preventDefault();
login.submit.click();
}
};
login.submit.onclick = function(_) {
const username = login.input.value;
const request_url = "/users?username=" + username;
const handler =
{
success: function (data) {
// Cache data in browser.
global_data.current_user = data;
// Retrieve transactions.
get_transactions(global_data.current_user.uuid);
// Update UI.
login.form.style.display = 'none';
login.error.style.display = 'none';
login.username.innerText = data.username;
login.success.style.display = 'block';
},
error: function () {
// Update UI.
login.error.style.display = 'block';
}
};
http_request(request_url, handler);
};
x_action.refresh.onclick = function() {
get_transactions(global_data.current_user.uuid);
}
x_action.modal_btn.onclick = function() {
if (global_data.transaction.unprocessed_cnt() === 0)
{
x_action.error.style.display = "block";
}
else
{
update_transaction_ui(global_data.transaction.get_current());
x_action.error.style.display = "none";
x_action.modal.style.display = "block";
}
}
x_action.exit.onclick = function()
{
reset_transaction_ui();
}
x_action.submit.onclick = function()
{
const body = {}
if (x_action.subcategory.value !== "")
{
body.category = x_action.subcategory.value;
}
else
{
body.category = x_action.category.value;
}
body.notes = x_action.notes.value;
const transaction = global_data.transaction.get_current()
const url = "/transactions/" + transaction.primary_key;
const handler =
{
success: function() {
console.log("Updated transaction successfully!");
},
error: function() {
alert("Failed to update transaction");
}
}
http_request(url, handler, "PUT", body);
const is_split = x_action.split.enabled.checked;
if (is_split)
{
const split_body = {}
split_body.category = body.category;
split_body.notes = x_action.split.notes.value;
split_body.amount = -1 * parseFloat(x_action.split.amount.value);
split_body.type = parseInt(x_action.split.type.value);
split_body.transaction_date = transaction.transaction_date;
split_body.description = transaction.description;
split_body.user_id = global_data.current_user.uuid;
const url = "/transactions";
const handler =
{
success: function() {
},
error: function() {
alert("Failed to add split transaction");
}
}
http_request(url, handler, "POST", split_body);
}
global_data.transaction.process_index++;
if (global_data.transaction.get_current() !== null)
{
update_transaction_ui(global_data.transaction.get_current());
}
else
{
reset_transaction_ui();
}
}
x_action.split.enabled.onclick = function()
{
if (x_action.split.enabled.checked == true)
{
x_action.split.container.style.display = 'block';
const transaction = global_data.transaction.get_current();
x_action.split.type.onchange();
}
else
{
x_action.split.container.style.display = 'none';
}
}
x_action.split.type.onchange = function()
{
const selected = x_action.split.type.value;
const transaction = global_data.transaction.get_current();
if (selected == 2) // Splitwise
{
x_action.split.amount.value = transaction.amount / 2;
}
else if (selected == 3) //SpaceX
{
x_action.split.amount.value = transaction.amount;
}
}
x_action.category.onchange = function()
{
x_action.subcategory.innerHTML = '';
const selected = x_action.category.value;
const subcategories = global_data.categories[selected]
if (subcategories.length == 0)
{
x_action.subcategory.disabled = true
}
else
{
x_action.subcategory.disabled = undefined
for (let i = 0; i < subcategories.length; i++)
{
const opt = document.createElement("option");
opt.value = subcategories[i];
opt.innerText = subcategories[i];
x_action.subcategory.appendChild(opt);
}
}
}
category.add_button.onclick = function()
{
const body = {};
body.name = category.name_input.value;
const selected = category.parent_select.value;
if (selected != "none" )
{
body.parent = selected;
}
const url = "/categories";
const handler =
{
success: function()
{
get_categories();
},
error: function()
{
alert("Failed to add category");
}
}
http_request(url, handler, "POST", body);
category.parent_select.value = "none";
category.name_input.value = "";
}
category.name_input.onkeypress = function(event) {
if (event.key === "Enter")
{
event.preventDefault();
category.add_button.click();
}
};
function reset_transaction_ui(close_modal=true)
{
// Reset all stateful inputs.
if (close_modal)
{
x_action.modal.style.display = "none";
}
x_action.split.container.style.display = 'none';
x_action.split.enabled.checked = false;
x_action.split.notes.value = "";
x_action.category.innerHTML = "";
x_action.subcategory.innerHTML = "";
x_action.subcategory.disabled = "true";
x_action.notes.value = "";
}
function update_transaction_ui(transaction)
{
// Reset old state.
reset_transaction_ui(false);
// Global updates first.
x_action.unprocessed.innerText = global_data.transaction.unprocessed_cnt();
for (key in global_data.categories)
{
const opt = document.createElement("option");
opt.value = key;
opt.innerText = key;
x_action.category.appendChild(opt);
}
// Transaction specific updates.
x_action.id.innerText = transaction.primary_key;
x_action.date.innerText = transaction.transaction_date;
x_action.description.innerText = transaction.description;
x_action.amount.innerText = transaction.amount;
}
function get_transactions(uuid) {
const request_url = "/transactions?user_id=" + uuid;
const handler =
{
success: function(data) {
global_data.transaction.list = data;
x_action.refresh.disabled = undefined;
},
error: function() {
console.error("Failed to get transactions");
}
}
http_request(request_url, handler);
}
function get_categories() {
const request_url = "/categories";
const handler =
{
success: function(data) {
global_data.categories = data
update_category_ui();
},
error: function() {
console.error("Failed to get categories");
}
}
http_request(request_url, handler);
}
function update_category_ui()
{
function add_opt(key)
{
const opt = document.createElement("option");
opt.value = key;
opt.innerText = key;
category.parent_select.appendChild(opt);
}
category.parent_select.innerHTML = "";
add_opt("none");
for (key in global_data.categories) add_opt(key);
}
function http_request(url, response_handler, method="GET", body=null)
{
let request = new XMLHttpRequest();
request.addEventListener("load", function(){
if (request.status !== 200)
{
response_handler.error();
}
else
{
let response_body = ""
if (request.responseText !== "")
{
response_body = JSON.parse(request.responseText);
}
response_handler.success(response_body);
global_data.update();
}
})
const async = true;
request.open(method, url, async);
if (method == "POST" || method == "PUT")
{
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
request.send(JSON.stringify(body));
}
else
{
request.send(null);
}
}
// Do every page load.
get_categories();
</script>
</html>

View File

@ -4,11 +4,11 @@ Every week, I need to:
2. Update SplitWise for shared purchases 2. Update SplitWise for shared purchases
# Tasks # Tasks
xParse capital one statement
## Parse capital one statement x Given a PDF bank statement from capital one, extract the transaction date, description, and amount from each transaction
x Given a PDF bank statement from capital one, extract the transaction date, description, and amount from each transaction xStore the data in a SQL lite database
Store the data in a SQL lite database x Create database schema
x Create database schema x Automatically add capital one transactions to database
x Automatically add capital one transactions to database xAdd web page to categorize transactions
Add web page to add categories xAdd web page to add categories
Add web page to categorize transactions Visualize spend for each month