toughcents

$ personal finance drill sergeant

overview

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.

tech stack

  • Python (Flask, Flask-AppBuilder, SQLAlchemy, Flask-JWT-Extended)
  • PostgreSQL (13-table normalized schema with 4 analytical SQL views)
  • Next.js (static export served by Flask)
  • Teller API (bank account linking and real-time transaction sync via mutual TLS)
  • Google Gemini 2.0 Flash (multimodal receipt OCR and structured data extraction)
  • Ollama (locally-run fine-tuned LLM for AI spending feedback)

links

showcase

ToughCents spending dashboard with category breakdown
ToughCents wireframe 1 ToughCents wireframe 2

how it works

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.

features

challenges

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.

bank sign convention detection

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.

takeaways

ToughCents was the first end-to-end team project integrating multiple external APIs alongside a fully designed relational schema. Some key takeaways:

  • Teller API integration, mutual TLS certificate authentication for production bank data access
  • Multimodal LLM use, sending raw image bytes to Gemini for structured JSON extraction from photos
  • Local LLM fine-tuning, training a custom Ollama model and controlling persona through prompt injection at runtime
  • Idempotent sync patterns (SQLAlchemy's session.merge() for upsert behavior during repeated transaction imports)
  • Relational schema design, 13-table normalized PostgreSQL schema with analytical SQL views for reporting queries