$ personal finance drill sergeant
ToughCents is a personal finance web app built around the premise that passive dashboards don't change behavior, direct accountability does. It links to real bank accounts via the Teller API, automatically imports and categorizes transactions, and runs each expense through a fine-tuned local LLM that delivers personalized spending criticism in the voice of "Sgt. Cents."
Built as a team project for CS374 at James Madison University, the app was designed end-to-end: a normalized PostgreSQL schema, a Flask REST API, SQLAlchemy ORM models, SQL analytical views, and a Next.js frontend, integrating three distinct external services for bank data, receipt parsing, and AI feedback generation.
The backend is a Flask application backed by PostgreSQL, with a Next.js frontend built to a static
out/ directory and served directly by Flask's file routing. During development,
Next.js runs on port 3000 and Flask proxies API requests via CORS; in production, Flask serves the
compiled HTML and handles all /api/ routes itself.
Bank connections use the Teller API: after a user completes the Teller Connect enrollment flow in the
browser, the frontend posts the access token to /api/save-enrollment. Flask
immediately calls Teller's /accounts and
/accounts/{id}/transactions endpoints using mutual TLS certificate authentication to
pull the full transaction history. Because different banks report transaction amounts with opposite
sign conventions, the sync pipeline samples incoming transactions to detect the bank's convention
and normalizes all amounts before inserting them.
Receipt scanning uses Google Gemini 2.0 Flash. The user uploads a photo of a receipt; the backend
sends the raw image bytes directly to Gemini's multimodal API with a structured extraction prompt,
then parses the JSON response to populate a Receipt record and its Item
line items. The system attempts to match the scanned receipt to an existing imported bank
transaction by merchant name, amount, and date within a +/- 1 day window before creating a new
transaction record.
AI feedback is generated by a custom Ollama model (toughcents:latest) fine-tuned to
adopt the Sgt. Cents persona. The tone is driven by the user's stored Personality
preference (balanced, playful, formal, or unruly), which is injected into the prompt at request
time. Feedback is persisted in the AIFeedback table and served from cache on repeat
requests unless regeneration is explicitly forced.
Normalizing transaction data across banks was the first real integration problem. The Teller API does not enforce a consistent sign convention, some institutions return debits as positive amounts, others as negative. To handle this without special-casing individual banks, the sync pipeline samples the first batch of incoming transactions: if it finds a known debit transaction type (card payment, ATM withdrawal, bank fee) with a positive amount, it flags the entire batch for sign inversion before inserting.
def detect_bank_sign_convention(transactions_data: list) -> bool:
KNOWN_DEBIT_TYPES = {
'card_payment', 'atm', 'fee', 'withdrawal', 'charge',
'bill_payment', 'ach_debit', 'pos'
}
sample_size = min(10, len(transactions_data))
for tx_data in transactions_data[:sample_size]:
tx_type = tx_data.get('type', '').lower()
amount = Decimal(tx_data.get('amount', '0'))
if tx_type in KNOWN_DEBIT_TYPES:
return amount > 0 # positive debit means we need to flip
return False
Pairing uploaded receipts to existing bank transactions required deciding what "match" means when two data sources are imprecise. The receipt OCR may not reproduce the exact merchant string stored in Teller's data. The matching logic keys on merchant name, absolute amount, and a +/- 1 day date window (a deliberate compromise that favors not missing valid matches over strict precision, since an unmatched receipt falls back to creating a new transaction rather than failing).
Building the AI feedback prompts without hardcoding behavior required a runtime injection pattern. The user's personality setting, first name, and transaction context are all interpolated into the prompt string at request time. The Sgt. Cents model responds to different framing language without needing separate model variants per tone, the fine-tuning established the persona while prompt structure controls the intensity.
ToughCents was the first end-to-end team project integrating multiple external APIs alongside a fully designed relational schema. Some key takeaways:
session.merge() for upsert behavior during
repeated transaction imports)