Why
Every UPI payment in India arrives as text first — an SMS, a push notification, an email receipt. Three parsers (Dart generic, Kotlin generic, a 190-bank fleet) disagreed on the same message, money floated on doubles, and duplicates slipped through reworded carrier footers.
- One engine.
engine::parse()is the only door in. SMS, notification, email body — all one string. - Triple-signal dedupe. UPI ref → content hash (footer-proof, unlike body hashing) → 300s cross-channel window.
- Exact money. i64 paise end to end. Both parents float at the edge.
- Proven, not promised. 56 tests incl. a 34-row SMS parity corpus. Zero clippy warnings.
Quickstart
ParsedTransaction { payment: ParsedPayment { amount_paise: 34000, merchant: "ZOMATO", is_income: false, upi_ref: Some("623829102812"), balance_paise: Some(1240000), account_mask: Some("1234"), bank_name: None, needs_review: false }, sender: "HDFCBK", timestamp_ms: 0, content_hash: 8485077374821435771 }
| Command | What |
|---|---|
cargo test |
Full gate: 22 unit + 34 parity rows |
cargo run --bin demo -- "<sms>" [sender] |
Parse one message, print the struct |
cargo clippy --all-targets -- -D warnings |
Lint gate: zero warnings |
cargo build --release |
cdylib (.so/.dll) for FFI consumers |
API
| Call | Replaces |
|---|---|
parse_capture / parse_captures |
Dart parseUpiNotification, Kotlin GenericUpiParser.parse |
check_capture |
insertCaptured dedupe (ref → hash → 300s window) |
categorize_merchant + normalize_merchant_text |
Categorizer |
apply_filter |
TransactionFilter.apply |
split_bill, parse_amount, is_spam, encode_inbox_line |
Same-named helpers |
Contracts: money is i64 paise, timestamps are epoch millis, parse returns null for spam/casual text by design. Full consumer guide: docs/INTEGRATION.md. Agent rules: docs/AGENT-INTEGRATION.md.
Architecture
src/
lib.rs — public API + uniffi scaffolding
engine.rs — parse(sms, sender, ts): single entry, batch API, content hash
parser.rs — UPI/bank SMS parser (Dart port, fancy-regex lookahead)
non_transaction.rs — spam rejection (recharge/OTP/loan/request/failure)
categorize.rs — merchant normalize + learned-beats-builtin rule match
dedupe.rs — ref gate + hash gate + 300s window with ref backfill
filter.rs — in-memory transaction filter
money.rs — parse_amount → i64 paise
split.rs — exact-paise bill split
ffi.rs — uniffi surface (Kotlin first); core stays FFI-agnostic
bin/demo.rs — smoke CLI
tests/parity.rs — 34-row Dart-vs-Rust SMS corpus
bindings/kotlin/ — generated uniffi Kotlin bindings (committed, never hand-edited)
docs/ — INTEGRATION.md (humans), AGENT-INTEGRATION.md (agents)
Non-goals
- No bank fleet (v1.x). 190 per-bank parsers stay out until live captures demand them; the generic path covers common formats.
- No storage, clock, or network. The core decides; callers own rows, timestamps, and sync.
- No AI. Rule maps and regex only. A PR containing "LLM" is rejected.
- No fraud detection. Parsing text is this crate's whole job; safety-switch logic lives in the app.
- No multi-currency amounts yet. Parser reads ₹/Rs/INR; AED/SGD SMS return null (v1.x).
Ecosystem
Author
Akash Priyadarshi (Patna, Bihar, India) · GitHub · Portfolio · LinkedIn · Resume
Social
X / Twitter · Threads · Instagram · Reddit
Deterministic UPI parsing in Rust — sms parser, upi expense tracker core, offline-first fintech.