Fintech & AI

CediSmart

Offline-First, MoMo-Centric Budgeting Platform with Gemini SMS Parsing

2026 FastAPI, React Native, PostgreSQL, Gemini AI Completed

Project Overview

The Problem

Most personal finance applications assume a Western economic reality: bank-account-first transactions, constant high-speed mobile data, email addresses for identity, and free bank feeds. In Ghana, MTN MoMo and Vodafone/Telecel Cash dominate financial activity; users are on prepaid data where every kilobyte counts; network coverage is unstable; and identity is tied directly to phone numbers.

The Solution

CediSmart is a monorepo platform built from first principles for emerging markets. It pairs a high-performance FastAPI backend with a React Native (Expo) app, featuring native phone-number SMS auth, an offline SQLite caching layer with batch sync, and a Gemini 2.5 Flash-powered SMS transaction parser that turns plain-text Mobile Money notifications into structured ledger entries.

Key Metrics

  • ✨ SMS parsing to structured JSON in < 1s using Gemini 2.5 Flash
  • 📶 100% offline usability with SQLite and client-side sync tokens
  • 🛡️ Zero credentials exposure by routing all AI calls through secure proxy
  • 🚀 High deliverability SMS OTPs using Termii API

SMS Parsing Flow

How raw SMS notifications are parsed into structured ledger entries using Gemini 2.5 Flash, with a regex fallback engine.

sequenceDiagram participant User as User / SMS Inbox participant App as React Native Expo App participant API as CediSmart FastAPI Backend participant Gemini as Google Gemini API User->>App: Paste SMS message or Trigger auto-read App->>API: POST /api/v1/transactions/parse-sms (jwt auth) Note over API: Check if GEMINI_API_KEY is configured alt Gemini API Key present API->>Gemini: POST /v1beta/models/gemini-2.5-flash:generateContent Gemini-->>API: Return Structured JSON response else Key absent (Local Fallback) API->>API: Execute Rule-Based Regex Parser end API->>API: Match suggested category to DB category API-->>App: Return parsed transaction details App->>User: Pre-populate Transaction Form for validation

Database Schema

Entity-Relationship diagram showing the budgeting and transaction models.

erDiagram USERS ||--o{ FINANCIAL_ACCOUNTS : owns USERS ||--o{ TRANSACTIONS : owns USERS ||--o{ BUDGETS : sets FINANCIAL_ACCOUNTS ||--o{ TRANSACTIONS : records CATEGORIES ||--o{ TRANSACTIONS : classifies CATEGORIES ||--o{ BUDGETS : defines USERS { uuid id PK string phone UK string password_hash boolean has_premium_access timestamp created_at } FINANCIAL_ACCOUNTS { uuid id PK uuid user_id FK string name decimal balance boolean is_active timestamp created_at } TRANSACTIONS { uuid id PK uuid user_id FK uuid account_id FK uuid category_id FK decimal amount string transaction_type string description date transaction_date uuid client_id UK boolean is_deleted } CATEGORIES { uuid id PK string name string category_type boolean is_system uuid user_id FK } BUDGETS { uuid id PK uuid user_id FK uuid category_id FK decimal amount_limit int year int month }

Engineering Challenges

01

Gemini LLM Structured Parsing & Fallback

Problem

Raw transactional SMS alerts from Mobile Money are highly unstructured. Attempting to parse these with general LLM prompts can result in malformed JSON or slow response times. Furthermore, the system must remain functional even when offline or when rate limits are exceeded.

Solution

We leverage Gemini 2.5 Flash's native Structured Outputs via the FastAPI backend. By providing a strict JSON schema via responseSchema, we guarantee parseable results. If the API is offline or rate-limited, the system seamlessly falls back to a rule-based regex compiler.

# Enforcing structured schema with Gemini 2.5 Flash
generation_config = {
    "responseMimeType": "application/json",
    "responseSchema": {
        "type": "OBJECT",
        "properties": {
            "amount": {"type": "NUMBER"},
            "transaction_type": {
                "type": "STRING", 
                "enum": ["income", "expense"]
            },
            "description": {"type": "STRING"},
            "category_suggestion": {"type": "STRING"},
            "notes": {"type": "STRING"}
        },
        "required": ["amount", "transaction_type", "description"]
    }
}
02

Idempotent Bulk Synchronization

Problem

To survive low-connectivity areas, the client writes data locally to SQLite and queues it. During network recovery, sending a batch of local updates could cause double-write race conditions or duplicate transactions if the client retries a failed HTTP request.

Solution

Implemented client-side UUID generation (`client_id`) on the mobile app. The backend processes batches using a `BulkCreateRequest` schema. It preloads known `client_id`s in a single database query, processes only the non-duplicate entries, and returns a granular summary of created vs skipped items.

# Idempotent bulk check in service.py
async def bulk_create_transactions(user_id, payload, db, redis):
    incoming_ids = {
        item.client_id for item in payload.transactions 
        if item.client_id is not None
    }
    # Fetch existing client_ids in one query
    existing_result = await db.execute(
        select(Transaction.client_id).where(
            Transaction.user_id == user_id,
            Transaction.client_id.in_(incoming_ids)
        )
    )
    existing_ids = {row[0] for row in existing_result.all()}
    
    # Process batch...
    for item in payload.transactions:
        if item.client_id in existing_ids:
            skipped += 1
            continue
        # ... add transaction to database
        created += 1
03

Ghana-Native Auth & SMS Security

Problem

Email registration is uncommon in the mass Ghanaian market, and hardcoded API keys in mobile apps can be reverse-engineered from APK decompilations.

Solution

Configured passwordless mobile login using Termii SMS Gateway. All communications are securely routed through the FastAPI backend. Verification codes are validated using Redis-backed TTL caches (5 min), and successful verification generates an RS256-signed JWT token, keeping the keys safe on the server.

# SMS OTP creation route using Redis cache
async def generate_otp(phone_number: str, redis: RedisConn):
    otp_code = generate_secure_otp() # 4-digit code
    cache_key = f"otp:{phone_number}"
    
    # Set verification code with 5-minute TTL
    await redis.set(cache_key, otp_code, ex=300)
    
    # Trigger Termii API call (server-side ONLY)
    await send_termii_sms(phone_number, f"Your CediSmart code: {otp_code}")

Key Takeaways

🤖

LLM API Security is Paramount

Never place AI studio keys in front-end client environments. Proxying all requests through your own API guarantees rate-limiting, credential protection, and allows for clean fallback rules.

💾

Offline-First Demands Idempotency

Synchronization loops will inevitably retry requests under unstable connections. Without matching unique client-side UUIDs to database keys, you risk duplicating ledger transactions.

💸

Design for Local Contexts

Building applications for emerging markets means understanding user behaviors. Relying on phone numbers and mobile money is much more valuable to a local audience than a generic banking API.

Interested in the Technical Details?

Check out the full source code or reach out to discuss the architecture.