A Python pipeline that pulls bank transactions through Plaid, cleans the merchant descriptions through Google Gemini Flash (in your own voice via few-shot prompting), and writes them into the right table of the right tab in your own Google Sheet. At the start of every month it sends you an HTML email summary of the prior month with an LLM-generated commentary and a category breakdown pie chart. Runs unattended on a free-tier GitHub Actions cron.
Built around an existing personal-finance spreadsheet rather than replacing it: month tabs, structured tables, running balances, and TOTAL formulas all keep working. The pipeline is config-driven and fork-friendly - your account IDs, your tab names, and your prompt examples live in a few YAML files, not in the code.
Pipeline running in GitHub Actions - Mayo tab auto-created, carryover written, 21 transactions cleaned by Gemini and routed to the right tables.
Mayo tab in Google Sheets - placeholder, screenshot coming soon
- Bank ingest via Plaid
/transactions/sync: incremental, cursor-based fetch - each run only sees transactions that posted since the previous run. No duplicates, no manual de-dup logic. - Spanglish description cleaning via Gemini 3.1 Flash Lite: few-shot prompted with ~20 of your own real descriptions, so output stays in your voice (
"Walmart - Groceries","Gas en Chevron","Paycheck from Student Job"). Fail-soft - if Gemini is overloaded for a transaction, the raw merchant string lands in the sheet so nothing is ever lost. - Date-driven tab routing: uses
tx.date(nottoday) so a cron at 12:01 AM on June 1 still files May 30 transactions into Mayo, not Junio. - Auto-creates new month tabs from a hidden Template: duplicates the Template, makes the new tab visible, renames every table inside it to
<prefix><MonthName>for cleanliness. - Configurable balance carryover: writes a starting-balance row at the top of each new month tab - pulled either from a specific cell in the prior tab (e.g. running balance) or the prior month's table footer (e.g. credit-card total).
- Monthly summary email: on the first run of each new month, Gemini analyzes the prior month's transactions and produces a structured summary (net, top merchants, spend-by-category, observations, commentary). An HTML email with an embedded pie chart (via QuickChart.io) lands in your inbox. State persists across runs so each month is summarized exactly once, with prior-month context fed into Gemini for trend comparisons.
- Idempotent and crash-safe: Plaid cursors persist per-bank to
access_tokens.jsononly after the bank's transaction loop fully succeeds, so a mid-run failure retries cleanly on the next run. The summary state file works the same way - only marks a month as summarized after the email send succeeds. - Rate-limit resilient Sheets writes: each transaction reuses one spreadsheet metadata fetch instead of re-reading it 3+ times (~5 reads down to ~2), and every Sheets call retries with exponential backoff on a transient 429/5xx, so a quota burst during month-tab creation waits out the window instead of crashing the run.
- Optional first-sync date filter: ignore historical transactions on the very first run via
pipeline.start_dateinconfig.yaml. Doesn't affect any future run. - Single-process, no database, no server: state lives in JSON / YAML files; runs as a single Python script on a free-tier GitHub Actions cron every 3 days at 3 AM Mountain Time.
- Language: Python 3.10+ (developed on 3.14)
- Banks: Plaid Production via
plaid-python(/transactions/syncendpoint) - LLM: Google Gemini 3.1 Flash Lite Preview via
google-genaiSDK (free tier,thinking_budget=0for speed) - Spreadsheet: Google Sheets API v4 via
google-api-python-client- uses the structured Tables feature so column layouts and TOTAL formulas survive duplication - Auth: Google OAuth user-flow with combined Gmail + Sheets scopes (
google-auth-oauthlib) - Bank linking UI: one-time Flask page at
setup/link_banks.pyto run Plaid Link - Scheduler: GitHub Actions cron (free for public repos)
┌────────────────────┐
│ pipeline.py │
│ (orchestrator) │
└─────────┬──────────┘
│
┌────────────────────┬────────────┼────────────┬────────────────────┐
▼ ▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌─────┐ ┌──────────────┐ ┌──────────────────┐
│ clients/ │ │ clients/ │ │ ... │ │ clients/ │ │ clients/ │
│ plaid_client │ tx │ gemini_client│ │ │ │ sheets_writer│ │ insights │
│ sync+cursor │───>│ few-shot │->│ │->│ tabs/tables/ │ │ monthly summary │
│ │ │ + retries │ │ │ │ carryover │ │ email + chart │
└──────────────┘ └──────────────┘ └─────┘ └──────────────┘ └──────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
access_tokens.json prompts.yaml Google Sheet Gmail + Gemini +
(cursor state) (~20 examples) (your existing) summary_state.json
Per transaction the orchestrator runs: fetch → route by account_id → clean via Gemini → find/create month tab → find table by prefix → write to first empty row. The carryover step runs once at tab creation, before any transaction is written. After the bank loop, maybe_send_monthly_summary checks state and (when the prior month hasn't been summarized) sends an HTML email summary with an embedded category pie chart.
- Python 3.10+
- A Plaid Developer account (free Trial tier - 3 banks)
- A Google Cloud project with the Sheets API enabled
- A Gemini API key (free tier)
- An existing Google Sheet you'd like to write into, with at least one tab named
Templatecontaining the structured Tables you want populated
git clone https://github.com/<your-username>/Finance-Manager.git
cd Finance-Manager
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements.txtCopy the env template and fill in your keys:
cp .env.example .envEdit .env:
PLAID_CLIENT_ID=<from Plaid dashboard>
PLAID_PRODUCTION_SECRET=<from Plaid dashboard>
PLAID_SANDBOX_SECRET=<optional, for testing>
GEMINI_API_KEY=<from AI Studio>
Place your Google OAuth credentials file (downloaded from Cloud Console) at the project root as credentials.json. The first time you run anything that needs Sheets, a browser window will open for the OAuth consent flow and token.json will be written for subsequent runs.
cp config.example.yaml config.yamlEdit config.yaml:
sheet.spreadsheet_id- copy from your Sheet's URLsheet.template_tab- name of the hidden template tab (default:Template)tab_strategy.naming-spanish/english/numericaccount_routing- see step 5balance_carryover- optional; see "Balance carryover" below
config.yaml is gitignored, so your spreadsheet ID and routing stay private.
Run the one-time Plaid Link UI and follow each bank's OAuth flow:
python setup/link_banks.pyThis opens a local web page that lets you authenticate one institution at a time. Tokens get appended to access_tokens.json (gitignored). Then dump the resulting account IDs:
python setup/inspect_accounts.pyCopy each account_id you want to track into account_routing in config.yaml, mapping it to your bank nickname plus the table prefixes for income / outflow tables in your sheet.
python pipeline.pyYou should see banks sync one at a time, transactions clean through Gemini, and rows appear in the correct month tab.
Maps Plaid account_id to your bank + sheet table prefixes:
account_routing:
"PLAID_ACCOUNT_ID_HERE":
bank: "discover"
income_table_prefix: "Discover_"
outflow_table_prefix: "Discover_" # same prefix → single-table mode (sign preserved)
"PLAID_ACCOUNT_ID_HERE":
bank: "sofi"
income_table_prefix: "Sofi_Checkings_"
outflow_table_prefix: "Gastos_Checkings_" # different → two-table mode (abs amount)When income_table_prefix == outflow_table_prefix, the transaction's sign is preserved (used for credit cards). When they differ, abs(amount) is used and the transaction routes to the appropriate table by sign.
Optional. Writes a starting-balance row at the top of each new month tab on creation:
balance_carryover:
- prefix: "Sofi_Checkings_"
cell: "J21" # explicit cell to read in prior tab
description: "Cuenta Checkings"
- prefix: "Discover_"
description: "{prev_month} Expenses" # no cell → uses prior table's footer{prev_month} is substituted with the prior month's tab name. If cell is absent, the prior month's same-prefix table is found and its footer Amount cell is read. Empty list (or omitted key) disables the feature.
pipeline:
start_date: "2026-05-01" # one-time first-sync filter; ignored after cursor is set
pacing_second: 0.5 # delay between Gemini calls
max_tx_per_bank: null # null = no cap; integer caps per run for dev.
├── pipeline.py # Orchestrator (entry point)
├── config.py # Loads config.yaml at import
├── clients/
│ ├── plaid_client.py # Plaid SDK + token / cursor I/O
│ ├── gemini_client.py # Gemini wrapper with retries + fail-soft
│ ├── sheets_writer.py # Tab creation, table rename, carryover, writes
│ ├── google_auth.py # Shared OAuth helper (Gmail + Sheets scopes)
│ └── insights.py # Monthly summary: gather, Gemini, chart URL, email
├── setup/
│ ├── link_banks.py # One-time Flask page for Plaid Link
│ ├── inspect_accounts.py # Dumps account IDs per linked bank
│ └── hello_*.py # Historical API smoke tests
├── tests/
│ ├── test_routing.py # route_transaction sign handling
│ ├── test_sheets_helpers.py # _col_letter, _prev_month_name, prefix gathering
│ ├── test_sheets_throttle.py # read-quota A/B + backoff retry against a fake Sheets service
│ └── test_insights.py # summary state + categorize + email body + chart URL
├── .github/workflows/
│ └── cron.yml # Daily GitHub Actions cron + state-file caching
├── prompts.yaml # Gemini prompt templates + few-shot examples (cleaner + summary)
├── config.example.yaml # Config template for forks
├── .env.example # Env-var template for forks
├── requirements.txt
├── LICENSE
└── README.md
config.yaml, .env, credentials.json, token.json, access_tokens.json, accounts.json, and summary_state.json are all gitignored - secrets and personal IDs never get committed.
python -m unittest discover tests39 tests covering routing, sheets helpers, insights (state file round-trip, classification, chart URL, email body composition), and Sheets rate-limit handling (read-quota A/B + backoff retry against a fake service). Tests use stdlib unittest only - no extra dependency.
Currently shipped:
- Plaid → Gemini → Sheets pipeline runs end-to-end and is verified live in production
- Cursor-based incremental sync (idempotent reruns)
- Month-tab auto-creation with table rename + balance carryover
- Monthly summary email with category breakdown pie chart, structured Gemini output, and trend comparisons against prior-month history
- GitHub Actions cron deployment - runs every 3 days at 3 AM Mountain Time, fully unattended
- Cache-based state persistence so cursors + summary state survive between cron runs
- Sheets writes reuse one metadata fetch per transaction (~2 reads each) and retry with backoff on transient 429/5xx
- 39 passing unit tests
Potential future work:
- Per-transaction category labeling in the sheet itself (not just in the monthly summary)
- Batch all of a run's writes into a single Sheets call to cut the write count further
- Cross-year carryover automation
Limitations to be aware of:
- Gemini Free Tier has 30 RPM and 500 RPD limits. Typical daily volume uses far less than that, but a heavy backfill could hit the per-minute cap.
- The Sheets API has a 60-requests-per-minute-per-user quota. The pipeline keeps reads to ~2 per transaction and retries with backoff on a 429, which comfortably covers personal volumes (<100 tx/day); a very large backfill in one run would still benefit from batching the writes.
- The first call to Plaid
/transactions/syncfor a brand-new Item sometimes returnsadded=[]while Plaid does its background pull. Re-run after a minute and it catches up. - Cross-year carryover (Diciembre 2026 → Enero 2027) requires manually filling the January carryover row by hand the first time, since each year's transactions live in a separate spreadsheet.
- GitHub Actions scheduled runs can drift by up to ~30 minutes during peak load (GitHub's own caveat). Not a problem for daily personal use.
Released under the MIT License.
Adrian Ninanya
- GitHub: NinyaDev
- LinkedIn: Adrian Ninanya
