← Back

FinFlow

Every break has a reason. FinFlow finds it automatically.

Compares two lists of payments (say, the bank's and your own books) and explains every mismatch. Simple rule-based checks run first. Only what's left goes to an AI model running on my own computer. It also remembers past odd patterns. Built and tested on generated transactions, not real bank data.

PythonApache KafkaPostgreSQLpgvectorOllamallama3.2FastAPIStrawberry GraphQLStreamlitDocker Composesentence-transformers
GitHub →

// recon_run_2024-09-01.log

The problem

[BREAK] TXN-8821 — no ledger match

amount: $4,217.50   entity: PARTNER_C   date: 2024-08-29

analyst: is this a timing issue or a real break?

analyst: pulling ledger export...

analyst: checking VLOOKUP... sorting by date...

analyst: found it — ledger says 2024-09-01

46 more breaks in the queue.

Mismatches between two payment records are normal. A payment lands a day late, a reference has a typo, an amount is rounded differently. The cost isn't the mismatch. It's the analyst who has to rule out all the harmless explanations by hand before reaching the one that matters.

I built FinFlow to do that ruling-out automatically, with simple checks in order. Same amount and reference? Match. Dates a few days apart? Probably just timing. A reference with a small typo? Still a match. An amount off by under 1%? Probably rounding. Only what is still unexplained goes to an AI model, and that model runs on my own computer, so there is no per-use cost.

It also keeps a memory. Each odd pattern it flags is saved with a short explanation, so when something similar shows up later it can say “you've seen this before.”

What this solves

  • ✓Manual break triage — analysts spending hours on timing and reference noise
  • ✓No audit trail — match decisions were undocumented and unreplayable
  • ✓Recurring anomalies going unnoticed — no cross-run pattern memory
  • ✓AI-or-nothing tradeoffs — LLM only runs when the deterministic stack fails

Key metrics

Checks5, in order
Passes5
LLM Cost$0 (local)
Re-checksevery 60 s

Architecture

Four layers, each responsible for one concern, communicating via Kafka and PostgreSQL.

01 — Ingestion
  • ·Producer generates multi-source mock transactions with realistic drift
  • ·Consumer reads Kafka, writes to PostgreSQL with checkpoint-based replay
  • ·ON CONFLICT DO NOTHING — idempotent, safe to restart anywhere
Kafkaasyncpgconfluent-kafka
02 — Reconciliation
  • ·Engine polls every 60s, pulls unreconciled IDs from last 48h
  • ·Runs five ordered passes, each narrowing the unmatched set
  • ·Writes results and audit trail — every decision is traceable
PythonPostgreSQLLevenshtein
03 — Agent
  • ·Polls completed runs every 90s for four anomaly types
  • ·Ollama llama3.2 explains each anomaly in 2–3 sentences
  • ·sentence-transformers embeds explanation → pgvector stores it
Ollamapgvectorsentence-transformers
04 — API + Dashboard
  • ·FastAPI + Strawberry GraphQL — typed queries with playground UI
  • ·REST endpoints for stats, runs, breakdowns, anomaly resolution
  • ·Streamlit dashboard: match rate trend, anomaly cards, resolve button
FastAPIGraphQLStreamlit

// example screen: sample numbers on generated data, not a measured result

Streamlit dashboard

Auto-refreshes every 30s. Four panels: KPIs, run history, pass breakdown, anomaly queue.

◆ FinFlow — Reconciliation Dashboard↻ refreshed 3s ago

Match Rate

94.2%

last run

Matched

1,884

transactions

Breaks

116

unmatched

Open Anomaly

3

needs review

Total Runs

48

since deploy

// match rate trend — last 10 runs

98%95%90%85%Run 3941434547Run 48

// run #48 pass breakdown

Exact Match1243
Timing Tol.389
Fuzzy Ref.168
Amt Threshold71
AI Resolution13

AI used

0.7%

Token cost

$0.00

// open anomalies (3)

sorted by severity
HIGHvolume_spikeENTITY_A2 similar in history

Unmatched count 2.3× above 7-day rolling avg (116 vs avg 50). Possible feed interruption from SYSTEM_2 after 14:00 UTC.

🧠 Ollama llama3.2 — embedded to pgvector
MEDIUMentity_concentrationENTITY_B1 similar in history

ENTITY_B accounts for 38% of all breaks this run. Reference format change detected — "REF-" prefix dropped in 3 consecutive runs.

🧠 Ollama llama3.2 — embedded to pgvector
LOWsystematic_mismatchSYSTEM_33 similar in history

SYSTEM_3 contributing 17% of unmatched across last 4 runs. Consistent amount delta of $0.01–$0.03 suggests rounding mode change.

🧠 Ollama llama3.2 — embedded to pgvector

The five-pass engine

Each pass takes the transactions left unmatched by the previous one. Deterministic before probabilistic — the LLM only sees what nothing else could handle.

Pass 1 — Exact Matchconf 1.00

Entity + currency + amount + (transaction ID or reference). Fastest possible match, highest confidence. No tolerance applied.

100%
Pass 2 — Timing Toleranceconf 0.95

Same fields, but allows up to 3-day date difference. Catches settlement timing drift between source and ledger.

95%
Pass 3 — Fuzzy Referenceconf 0.85

Entity + currency + amount + Levenshtein distance ≤ 4 on reference strings. Catches typos, truncation, and format variants. Confidence decreases 0.02 per distance unit.

85%
Pass 4 — Amount Thresholdconf 0.80

Entity + currency + reference + amount within 1%. Catches rounding errors, FX conversion deltas, and small fee variances.

80%
Pass 5 — AI Resolutionconf 0.60–0.75

Batches of 10 unmatched transactions sent to Ollama llama3.2. Structured JSON response. Used only for true residual breaks that no deterministic pass could handle.

68%

AI anomaly detection

The agent runs every 90s after reconciliation completes. It detects four structural anomaly types, explains each one in plain language, embeds the explanation, and uses pgvector to find similar past events.

volume_spike

Unmatched count > 2× 7-day rolling average

Sudden surge in unmatched transactions — usually signals a feed interruption or upstream schema change.

Ollama explains in 2–3 sentences → sentence-transformers embeds → pgvector finds similar past anomalies (cosine sim > 0.85)

unusual_break_pattern

Break rate > 20% of run volume

The overall ratio of breaks to matched is unusually high for this run — not a volume spike, but a quality signal.

Ollama explains in 2–3 sentences → sentence-transformers embeds → pgvector finds similar past anomalies (cosine sim > 0.85)

entity_concentration

One entity > 40% of breaks

Breaks are clustering around a single counterparty — likely a formatting change on their side or a new data contract.

Ollama explains in 2–3 sentences → sentence-transformers embeds → pgvector finds similar past anomalies (cosine sim > 0.85)

systematic_mismatch

One source system > 15% of run unmatched

A specific source system is consistently failing to match — typically a field mapping regression or deployment artifact.

Ollama explains in 2–3 sentences → sentence-transformers embeds → pgvector finds similar past anomalies (cosine sim > 0.85)

// agent output

Anomaly resolution in practice

From raw unmatched transactions to Ollama explanation to pgvector similarity search to analyst resolution — the full loop.

step 1detection

# anomaly_agent.py polling run #48

unmatched_count = 116

rolling_7d_avg = 50.3

ratio = 2.31 # > 2.0 threshold

✔ anomaly detected

type: volume_spike

entity: ENTITY_A

severity: HIGH

step 2ollama explain

POST http://ollama:11434/api/generate

prompt →

Run #48 has 116 unmatched vs avg 50. Entity ENTITY_A is concentrated. Explain in 2-3 sentences.

llama3.2 →

ENTITY_A's transaction volume spiked 2.3× above the 7-day average in run #48, concentrated after 14:00 UTC. This pattern is consistent with a feed interruption or a schema change in the upstream SYSTEM_2 connector. Recommend checking SYSTEM_2 delivery logs for the 13:45–15:00 UTC window.

step 3pgvector + resolve

similar anomalies (cosine > 0.85)

#310.94SYSTEM_2 outage18d ago
#190.88Schema migration41d ago

analyst resolves

resolution_note:

Confirmed SYSTEM_2 connector restart at 13:52 UTC. Replay from offset 41820 initiated.

✔ Mark Resolved
Snooze 1h

Design decisions

The choices that shaped the architecture.

Plain rules first, AI last

The first four checks are plain rules with no AI, so they are fast, free and give the same answer every time. The AI model is kept for the leftovers that nothing else could explain.

Safe to stop and restart

The consumer saves its place in the stream in the database. After a restart it carries on from there, and inserting the same record twice is ignored, so it is safe to kill and restart at any point.

A memory for odd patterns

Each flagged pattern is saved in a form that lets the database find similar ones later, so the agent can ask 'have we seen something like this before?' across all history.

GraphQL API

Strawberry GraphQL at /graphql — playground UI included. Typed queries over reconciliation runs, breaks, and anomalies.

query

# open anomalies + pass breakdown for run #48

query {

anomalies(status: "open") {

id anomalyType severity

explanation similarCount resolvedAt

}

matchBreakdown(runId: 48) {

passType matchedCount confidence

}

}

response — 200 OK — 42ms

{"data": {

"anomalies": [

{

"id": "ANO-0041",

"anomalyType": "volume_spike",

"severity": "HIGH",

"explanation": "ENTITY_A volume 2.3× above 7d avg...",

"similarCount": 2,

"resolvedAt": null

} ...

],

"matchBreakdown": [

{ "passType": "exact_match", "matchedCount": 1243, "confidence": 1.0 },

{ "passType": "timing_tolerance", "matchedCount": 389, "confidence": 0.95 },

{ "passType": "ai_resolution", "matchedCount": 13, "confidence": 0.71 }

]

}}