Case study · 4 min read
SEFA
An AI budgeting app that proposes, and never acts without your say-so.

- My role
- Designed and built end to end: React Native app, Node/Express API and marketing website.
- Timeline
- Feb - Sep 2026
- Stack
- React Native, Expo, TypeScript, Node.js, Express, MongoDB, Redis, Bull, Azure OpenAI, LangGraph, Mono API, WebSockets
SEFA is a mobile-first personal finance app for Nigerian users: budgets, read-only bank sync, bank-statement import and an AI assistant. Every AI write and every imported row waits for the user to confirm. It was my final-year project at Bowen University and is the subject of a published preprint.
- 99.49%parser field agreement (300-transaction synthetic benchmark, preprint)
- 78.40%clear-category accuracy on the same benchmark
- 1.000duplicate-detection F1
- 29backend test files
The problem
Tracking money in Nigeria usually means scrolling bank statements and typing expenses by hand. Existing tools either ask for too much trust (full banking access) or put an AI chatbot in front of your finances with no guard rails.
I wanted to know whether an AI assistant could be genuinely useful for budgeting while staying trustworthy: it never moves money, never writes data you did not approve, and is never a black box about what it did.
Constraints
- Money is high-stakes. A wrong category is an annoyance; a wrong write is a breach of trust.
- Messy input. Statements arrive as PDFs, scans, CSV and Excel, with different layouts.
- Low-cost hosting and one developer. The API runs on Render, which sleeps when idle, and the scope had to fit a semester.
Approach
SEFA is three pieces: a React Native app (Expo, TanStack Query, Zustand), an Express/MongoDB API with Bull queues on Redis, and a Next.js marketing site. The slow work (statement extraction, bank sync, notifications, assistant replies) runs in background workers, and Azure OpenAI powers extraction and a LangGraph tool-calling assistant.
One idea runs through all of it, a review gate: the system prepares work and a person approves it.
The app never talks to Mono or the model directly; the API and its workers sit in between.
In the app
Key decisions
01The AI proposes, the user confirms
- Context
- A model that writes straight to the database can be wrong with no way back.
- What I chose
- Write tools only prepare an action. It moves through pending_fields, pending_confirmation, confirmed, then executed (or cancelled / failed), and the user confirms, edits or cancels it in the app. Stale actions expire after 24 hours.
- What I passed on
- Let the model write and offer an undo. Faster to build, but every mistake becomes a data-repair problem.
- Trade-off
- One extra tap per write. Read-only questions run without confirmation, so everyday use stays quick.
02Read-only banking, enforced in layers
- Context
- Linking a bank is where users are most nervous. "We promise not to move money" is not a design.
- What I chose
- Through Mono the app can read accounts and transactions, and disconnect. Nothing else, enforced four ways: a frozen allow-list of operations, a router guard that returns 403 for anything that looks like a transfer, withdrawal or payment, a connection model whose access mode can only be "read_only", and an append-only audit log. Tokens are encrypted at rest.
- What I passed on
- One check in the service layer, where a single forgotten route could bypass it.
- Trade-off
- Plumbing the user never sees, accepted because getting it wrong would cost the whole product.
03Statement import: extract, score, de-duplicate, then ask
- Context
- Statements range from clean PDFs to blurry scans, and they overlap with transactions already synced from the bank.
- What I chose
- Text extraction is quality-scored (accepted at 0.7 or above) with OCR fallbacks, and a separate vision path sends page images to an Azure OpenAI vision model that returns strict JSON. Rows start as "needs review" and become "ready" only at category confidence 0.6 or above. Duplicates are caught in three layers: external transaction id, a SHA-256 hash of the row, and a same-day fuzzy match against existing records.
- What I passed on
- Hand-written parsers per bank, which are precise for one bank and brittle for the next. They still exist and are tested, but the pipeline moved to model-based extraction.
- Trade-off
- Vision calls are slow and cost money, so they run in a queue with a 40-page cap. The fuzzy layer can flag a genuine repeat purchase, so it marks rows for review instead of deleting them.
04Queues for slow work, a WebSocket for the chat
- Context
- Extraction, sync and AI replies take seconds to minutes, which is too long for a request on a host that sleeps.
- What I chose
- Four Bull queues (bank sync, notifications, statement imports, assistant) with three attempts and exponential backoff; cron syncs accounts every two hours and retries failures every thirty minutes. Assistant replies stream over a token-authenticated WebSocket, replacing the earlier SSE route.
- What I passed on
- Doing the work inline, or keeping SSE only.
- Trade-off
- More moving parts (Redis, workers) to run, and two streaming transports until SSE is retired.
Every route ends at the user. Rows the system is unsure about are flagged instead of guessed.
A write only happens in the transition from confirmed to executed. A TTL index removes stale actions after 24 hours.
Challenges
- Latency and crashes. Early assistant replies were slow and one bug failed every response; both were fixed in the backend history.
- Slow vision calls. They are far slower than a 30-second client default, so they get a longer single attempt with no automatic retry that would double the wait.
- Messy data. Amount validation went through several commits before real statements stopped producing odd rows.
Results
On a 300-transaction synthetic benchmark, reported in the preprint: 99.49% parser field agreement, 78.40% clear-category accuracy and 1.000 duplicate-detection F1.
The product runs end to end: Android builds ship through Expo's build service, the website is live, and 29 Jest test files cover the assistant, bank security, insights and the statement pipeline. The part I am proudest of is the review gate: in every flow, the worst a model mistake can do is appear as a row waiting for approval.
What I would do next
- Run the benchmark in CI as accuracy regression tests; today the evaluation lives in the paper, not the build.
- Retire the SSE route and add queue monitoring and alerts.
- A Telegram-first rewrite is planned around the same review-first idea, on a Node monorepo with PostgreSQL and Prisma instead of MongoDB.