Unmurce Technical Documentation
Unmurce is an offline-first personal finance app. It stores transactions locally, syncs them with a cloud API, and includes Android notification parsing, AI-assisted receipt scanning, IDR/USD/EUR display, and a Neo-Brutalist interface.
Transactions are stored in SQLite before cloud synchronization. Recording an expense does not depend on an immediate network connection.
Current model IDs from this Vercel deployment. Values refresh when this page loads.
Loading...
Default model used for receipt parsing.
Loading...
Notification model; falls back to GROQ_MODEL when unset.
Full-Stack Technology Matrix
| Technology | Category | Role & Architectural Responsibility |
|---|---|---|
| Flutter (Dart ^3.11) | Mobile Client | Mobile UI and local state management. |
| Native Kotlin | Android Platform Bridge | NotificationListenerService, background event queuing, and method/event
channels. |
| SQLite (sqflite) | Embedded Database | Primary offline transactional ledger, setting cache, and pending sync FIFO queue. |
| Node.js / Express 5 | Cloud Backend | Stateless RESTful synchronization engine, currency gateway, and AI vision proxy. |
| MongoDB Atlas | Cloud Database | Mongoose schema-validated multi-tenant cloud storage with compound unique indices. |
| Firebase Authentication | Identity & Auth | JWT Bearer token verification middleware and multi-tenant user boundary enforcement. |
| Groq API | AI Parsing | Uses GROQ_MODEL for receipt parsing and
GROQ_NOTIFICATION_MODEL for notifications; the latter defaults to
GROQ_MODEL.
|
| Azure Document Intelligence | Receipt Parsing Fallback | Uses the Prebuilt Receipt analyzer when a Groq failure is eligible for fallback. |
| Cloudflare Edge | Edge DNS & Security | Full SSL/TLS termination, DDoS protection, edge caching, and global routing. |
| Vercel Serverless | Cloud Hosting | Hosts the Express API as a serverless function. |
02. Neo-Brutalism Visual Design System
The visual identity of Unmurce is anchored in modern Neo-Brutalism: unapologetic high-contrast black outlines, hard directional shadows with zero blur, deliberate asymmetry, and a signature pastel color palette.
Color Token Palette (Click any card to copy HEX)
Micro-Animations: The NeoBouncy Spring Controller
Every clickable element is wrapped with the custom Flutter widget NeoBouncy. When
pressed,
an AnimationController with Curves.easeInOutCubic scales the widget down
to
0.94 while shifting the shadow offset, providing tactile spring-loaded feedback.
Simulates Flutter
NeoBouncy
physics with scale compression and hard drop shadow offset absorption.
Design Token Specifications
| Token | CSS / Flutter Value | Visual Application |
|---|---|---|
| Border Heavy | Border.all(color: Colors.black, width: 3.5) |
Main cards, modals, app bars, and elevated surfaces. |
| Border Medium | Border.all(color: Colors.black, width: 2.5) |
Buttons, pill chips, form inputs, and inner containers. |
| Hard Shadow | BoxShadow(color: Colors.black, offset: Offset(4, 4), blurRadius: 0)
|
Signature 0-blur directional shadow that elevates neo-brutalist cards. |
| Display Font | GoogleFonts.itim() (Flutter) / Plus Jakarta Sans 800 |
Transaction amounts, category titles, and modal headers. |
| Monospace Font | Fira Code / JetBrains Mono |
Currency rates, transaction IDs, timestamps, and JSON code blocks. |
Production UI Gallery
03. Architecture & Data Flow
The app combines an offline-first Flutter client, an Express API hosted on Vercel, and MongoDB Atlas. Cloudflare provides edge routing for the production domain.
Handles user interactions, stores transactions in SQLite, and manages local state.
Background NotificationListenerService hooked into
Android
OS to intercept incoming transaction alerts.
SSL/TLS termination, DDoS mitigation, and JWT Bearer ID token cryptographic validation per request.
Serverless Node.js backend on Vercel handling multi-tenant transactional persistence in MongoDB Atlas.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ANDROID MOBILE ENVIRONMENT (DEVICE) β
β β
β ββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββ β
β β Android OS Statusbar β β Flutter UI Layer β β
β β Banking Notifications β β (hp2.dart, NeoBouncy) β β
β βββββββββββββ¬βββββββββββββ βββββββββββββββ¬βββββββββββββββ β
β β Broadcast β β
β βΌ β Local SQLite write β
β ββββββββββββββββββββββββββ EventChannel / FIFO β β
β β Kotlin NotificationSvc βββββββββββββββββββββββββββββββββββ€ β
β ββββββββββββββββββββββββββ βΌ β
β ββββββββββββββββββββββββββββββ β
β β SQLite Database (sqflite) β β
β β my_table (synced=0/1) β β
β βββββββββββββββ¬βββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββ
β Background Sync
β (Bearer JWT)
βΌ
ββββββββββββββββββββββββββββββ
β Cloudflare Edge DNS / SSL β
βββββββββββββββ¬βββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββ
β Vercel Serverless Express β
β requireAuth Firebase Admin β
βββββββββ¬ββββββββββββββ¬βββββββ
β β
Mongoose Cluster β β AI Vision OCR
βΌ βΌ
βββββββββββββββββ βββββββββββββββββββββββββ
β MongoDB Atlas β β Groq model β
β Expense Schemaβ β (Azure receipt fallback)β
βββββββββββββββββ βββββββββββββββββββββββββ
04. Automated Notification Parser
After notification access is enabled, Unmurce processes Android payment alerts through a six-stage pipeline. Cloud parsing is attempted first, with on-device parsing available as a fallback.
The 6-Stage Hybrid Processing Pipeline
NotificationListener.kt filters out ongoing foreground
services
(for example, music players and downloaders via sbn.isOngoing). It
suppresses
repeated notifications for 60 seconds using a content-based signature made from the app
package,
title, and notification text.
Rejects sensitive authentication notifications (OTP codes, verification keys, password changes, login alerts). Employs Smart Context Filtering so promotional notifications containing purchase confirmations (e.g. "QRIS Rp 35.000 sukses, dapat diskon 10%") are accurately retained.
Filters out incoming transfers and top-up confirmations (e.g., "Transfer Masuk", "Top Up Berhasil", "Dana Diterima") to ensure incoming funds are not mistakenly recorded as expenditures.
The app sends notification details to
POST /api/parse-notification.
The backend uses GROQ_NOTIFICATION_MODEL, falling back to
GROQ_MODEL
when no notification-specific model is configured.
If cloud parsing is unavailable or returns no usable result, the local Dart parser tries app-specific rules, then general financial heuristics to extract an amount and merchant.
When cloud parsing fails or an ambiguous local result requires AI, the
original
notification is stored as JSON in SQLite app_settings under the
pending_cloud_notifications key. The service retries queued items at
startup and on its retry timer.
Apps with App-Specific Notification Parsers
Other notification formats may be handled by the general parser when their transaction details are recognizable.
05. Smart Receipt Scanner & Proportional Tax Allocation
When users upload or photograph a paper receipt (Indomaret, Alfamart, Starbucks, restaurants, groceries), the backend processes the image through an automated AI vision extraction pipeline.
Receipt Parser: Groq with Azure Fallback
When a Groq API key and model are configured, receipts are sent to Groq first. Eligible retryable failures fall back to the Azure Document Intelligence Prebuilt Receipt analyzer.
Proportional Tax (PPN) & Service Charge Algorithm
Receipts typically list items without tax, appending PPN (11%) and Service Charges (5-10%) at the bottom. Unmurce applies proportional allocation across all line items so every recorded item includes its exact share of tax:
LineTotal = Ξ£ (Item.Amount)
Adjustment = ReceiptTotal - LineTotal
For each Item_i (from 1 to N-1):
ItemShare_i = Round((Adjustment * Item_i.Amount) / LineTotal)
AllocatedTotal += ItemShare_i
FinalAmount_i = Item_i.Amount + ItemShare_i
For the final Item_N:
FinalAmount_N = Item_N.Amount + (Adjustment - AllocatedTotal)
Cancellable Operation & Dynamic Quantity Multiplier
On the Flutter client, requests are managed via ReceiptScanOperation wrapping
http.Client.
If the user taps Cancel during upload, the connection is instantly aborted
(client.close()),
freeing memory. Each item in the draft list maintains an interactive quantity multiplier:
int totalItemAmount = unitPrice * selectedQuantity;
06. Multi-Currency System & Real-Time Rates
Unmurce supports displaying amounts in three currencies: IDR, USD, and EUR. To prevent cumulative rounding errors, the system implements a strict Single Base Currency Model:
Every expense is permanently stored in the database as an integer
of IDR (Indonesian Rupiah).
Conversions to foreign currencies are calculated dynamically on the presentation layer using
reactive ValueNotifier listeners.
Supported Currencies
| Currency Code | Symbol | Name | Conversion Formula |
|---|---|---|---|
IDR (Base) |
Rp | Indonesian Rupiah | Display = AmountInIDR Γ 1.0 |
USD |
$ | United States Dollar | Display = AmountInIDR Γ Rate_USD |
EUR |
β¬ | Euro | Display = AmountInIDR Γ Rate_EUR |
Offline Rate Caching in SQLite
Exchange rates fetched from GET /api/exchange-rates are immediately persisted to the
SQLite
app_settings table under the key rate_<CURRENCY>. If internet
connectivity is
unavailable in subsequent sessions, the app seamlessly reads from this local cache.
07. Financial Analytics & Dashboard Computations
The analytics screen (ExpenseSumarry.dart) processes transactional history into
structured
financial metrics with zero external analytics SDK overhead:
1. Category Spending Breakdown
Aggregates spending per category and computes proportional percentages:
double categoryTotal = expenses
.where((e) => e.category == targetCategory)
.fold(0, (sum, e) => sum + e.amount);
double percentage = (categoryTotal / totalAllExpenses) * 100.0;
2. Expected vs Unexpected Expense Ratio
Categorizes spending into expected (planned essentials: meals, rent, fuel) vs
unexpected (impulse purchases, emergency healthcare, spontaneous dining) to calculate
financial discipline index.
3. 7-Day Spending Histogram Normalization
Groups expenses by day over the past 7 days and normalizes bar heights based on peak daily spending:
final maxDailySpending = dailyTotals.reduce((a, b) => a > b ? a : b);
double computeBarHeight(double dayTotal, double maxContainerHeight) {
if (maxDailySpending == 0) return 0.0;
return (dayTotal / maxDailySpending) * maxContainerHeight;
}
08. PDF Report Engine & Android Scoped Storage Compliance
Users can export their complete transactional ledger into formatted, formal PDF reports. The
generator
is built entirely with Flutter vector drawing libraries (pdf and
printing).
Scoped Storage Compliance (Android 10 to 14+)
Traditional Android apps trigger dangerous storage permission prompts
(WRITE_EXTERNAL_STORAGE)
which are rejected on modern Android versions. Unmurce implements a modern storage pipeline:
- MediaStore API Integration: Utilizes
media_store_plusto save generated PDFs directly into the publicDownload/ExpenseAppdirectory without requiring runtime storage permissions. - FileSaver Fallback: Automatically invokes
file_saveron devices where MediaStore direct access is restricted. - Instant Document Launch: Invokes
open_filexto trigger the user's native PDF reader immediately upon file save.
09. Authentication & Multi-Tenant Data Isolation
User identification is secured end-to-end via Firebase Authentication and enforced through stateless cryptographic JWT Bearer verification:
async function requireAuth(req, res, next) {
try {
const header = req.headers.authorization || "";
if (!header.startsWith("Bearer ")) {
return res.status(401).json({ error: "Authentication required" });
}
const idToken = header.substring(7);
req.user = await admin.auth().verifyIdToken(idToken);
next();
} catch (err) {
res.status(401).json({ error: "Invalid or expired token" });
}
}
Local SQLite Data Isolation (Multi-User Device Support)
If multiple users share the same Android device, records remain isolated. Every row in
my_table
stores an ownerId column corresponding to AuthService.currentUser?.uid.
All queries
strictly include WHERE ownerId = ? to prevent data bleed.
Single-Run Notification Onboarding Gate
To prevent user fatigue while complying with Android notification listener requirements, the permission screen is governed by a persistent SQLite flag:
final shown = await DatabaseHelp.getSetting('notification_permission_onboarding_shown');
if (shown == null && !isPermissionGranted) {
await DatabaseHelp.saveSetting('notification_permission_onboarding_shown', 'true');
Navigator.push(context, SmoothPageRoute(page: const NotificationPermissionPage()));
}
10. Offline-First Synchronization & Cloud Reconciliation
Unmurce adheres strictly to the Offline-First Paradigm. The mobile app never awaits network responses before confirming user actions.
The Synced State Protocol
| Flag Value | State Definition | Lifecycle & Cloud Action |
|---|---|---|
synced = 0 |
Local Mutation Pending | Record exists only on device. Picked up by background worker
syncPendingExpenses() to push to MongoDB.
|
synced = 1 |
Reconciled & In-Sync | Record has been accepted by MongoDB Atlas and holds a valid remote
mongoId.
|
Cloud Pull Deduplication Algorithm
When a user reinstalls the app or links a second device, the "Load Online Expenses" feature pulls all cloud records and merges them into SQLite without duplicating transactions:
final localRows = await db.query('my_table', where: 'ownerId = ?', whereArgs: [uid]);
final localMongoIds = localRows.map((r) => r['mongoId']).whereType<String>().toSet();
for (final online in onlineExpenses) {
final remoteId = online['_id'] as String;
if (!localMongoIds.contains(remoteId)) {
await db.insert('my_table', {
'mongoId': remoteId,
'ownerId': uid,
'name': online['name'],
'amount': online['amount'],
'category': online['category'],
'type': online['type'],
'date': online['date'],
'synced': 1,
});
}
}
11. Primary Data Models (SQLite & MongoDB)
1. Local SQLite Schema (my_db.db β Version 6)
| Table | Column | Type | Constraints | Description |
|---|---|---|---|---|
my_table |
id |
INTEGER | PRIMARY KEY AUTOINCREMENT | Unique local transaction identifier. |
my_table |
mongoId |
TEXT | NULLABLE | Remote MongoDB ObjectId reference. |
my_table |
ownerId |
TEXT | NOT NULL | Firebase UID of the account owner. |
my_table |
name |
TEXT | NOT NULL | Title / merchant of expenditure. |
my_table |
amount |
INTEGER | NOT NULL | Monetary amount in base IDR currency. |
my_table |
date |
TEXT | NOT NULL | ISO date string (YYYY-MM-DD). |
my_table |
category |
TEXT | NOT NULL | makanan, transportasi, hiburan, etc. |
my_table |
type |
TEXT | NOT NULL | expected, unexpected, others. |
my_table |
synced |
INTEGER | DEFAULT 0 | 0 = pending push, 1 = synced to cloud. |
pending_notification_expenses |
id |
INTEGER | PRIMARY KEY AUTOINCREMENT | Unique local notification expense identifier. |
pending_notification_expenses |
ownerId |
TEXT | NOT NULL | Firebase UID of the account owner. |
pending_notification_expenses |
name |
TEXT | NOT NULL | Parsed merchant or transaction name. |
pending_notification_expenses |
amount |
INTEGER | NOT NULL | Transaction amount in IDR. |
pending_notification_expenses |
date |
TEXT | NOT NULL | ISO date string (YYYY-MM-DD). |
pending_notification_expenses |
category |
TEXT | NOT NULL | Expense category. |
pending_notification_expenses |
type |
TEXT | NOT NULL | Expense classification, such as expected or unexpected. |
pending_notification_expenses |
sourceApp |
TEXT | NOT NULL | Package name of the notification source app. |
pending_notification_expenses |
receivedAt |
TEXT | NOT NULL | Timestamp when the notification was received. |
app_settings |
key |
TEXT | PRIMARY KEY | Unique configuration identifier. |
app_settings |
value |
TEXT | NOT NULL | Text or JSON-encoded setting value; the notification retry queue is stored under
pending_cloud_notifications.
|
2. Cloud MongoDB Atlas Mongoose Schema
const ExpenseSchema = new mongoose.Schema(
{
ownerId: { type: String, required: true, index: true },
localId: { type: String, required: true },
name: { type: String, required: true },
amount: { type: Number, required: true },
category: { type: String, required: true },
type: { type: String, required: true }, // 'expected' | 'unexpected' | 'others'
date: { type: String, required: true }, // 'YYYY-MM-DD'
},
{ timestamps: true }
);
// Compound unique index guarantees upsert idempotency per local ID
ExpenseSchema.index({ ownerId: 1, localId: 1 }, { unique: true });
12. Core REST API Reference (10 Endpoints)
This section covers ten core transaction, parsing, and exchange-rate endpoints. The Flutter client
defaults to https://expense-app-personal.vercel.app/api; override it with
EXPENSE_API_BASE_URL when needed. Private endpoints require a Firebase ID token in
the Authorization header.
Fetch all expense documents belonging to the authenticated user.
[
{
"_id": "6643a7b9f1d2c3e4a5b6c7d8",
"ownerId": "firebase_uid_12345",
"localId": "42",
"name": "Kopi Kenangan QRIS",
"amount": 24000,
"category": "makanan",
"type": "expected",
"date": "2026-09-21",
"createdAt": "2026-09-21T08:30:00.000Z"
}
]
Create or upsert a new transaction using the client's localId.
{
"localId": "42",
"name": "Bensin Pertamax",
"amount": 50000,
"category": "transportasi",
"type": "expected",
"date": "2026-09-21"
}
Retrieve a single transaction document by its MongoDB ObjectId.
Update fields of an existing transaction by its MongoDB ObjectId.
Permanently remove a transaction by its MongoDB ObjectId.
Bulk delete every transaction owned by the authenticated user in a single query.
{
"message": "Deleted",
"deletedCount": 184
}
Retrieve a transaction by the SQLite device localId string.
Upload a base64-encoded receipt photo to extract line items with tax distribution.
{"type":"progress","stage":"groq","status":"processing","progress":0.4,"message":"Processing with Groq"}
{"type":"result","date":"2026-09-21","provider":"groq","items":[{"name":"Iced Caffe Latte","quantity":2,"amount":62000,"category":"makanan","type":"expected"},{"name":"Croissant Butter","quantity":1,"amount":31000,"category":"makanan","type":"expected"}]}
Parse notification text into structured expense fields using the configured Groq model.
{
"title": "Transaksi Sukses",
"packageName": "com.bca",
"message": "Pembayaran QRIS Rp 45.000 ke RM Padang Sederhana berhasil pada 21/09"
}
{
"name": "RM Padang Sederhana",
"amount": 45000,
"category": "makanan",
"type": "expected"
}
Fetch live global currency exchange rates calculated with base currency IDR = 1.0.
{
"base": "IDR",
"rates": {
"IDR": 1,
"USD": 0.000064,
"EUR": 0.000059
}
}
13. Setup & Build Guide
1. Backend Setup (Vercel & Express)
Use these names when configuring Vercel environment variables or the local environment used by Vercel CLI. Replace the placeholders and keep real credentials out of source control.
# Required; replace the placeholder values
MONGODB_URI=YOUR_MONGODB_URI
FIREBASE_SERVICE_ACCOUNT_JSON=YOUR_SERVICE_ACCOUNT_JSON
GROQ_API_KEY=YOUR_GROQ_API_KEY
GROQ_MODEL=YOUR_GROQ_MODEL_ID
# Optional: defaults to GROQ_MODEL
GROQ_NOTIFICATION_MODEL=YOUR_NOTIFICATION_MODEL_ID
AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT="https://your-resource.cognitiveservices.azure.com/"
AZURE_DOCUMENT_INTELLIGENCE_KEY="YOUR_AZURE_KEY"
# From repository root
cd backend
npm install
# Run the API locally with Vercel CLI
npx vercel dev
2. Mobile Application Build (Flutter & Android Gradle)
# Navigate to mobile Flutter project
cd frontend/expenseapp
# Pull pub packages
flutter pub get
# Run on connected Android device / emulator
flutter run
# Compile production split-per-abi release APKs
flutter build apk --split-per-abi --release