Skip to main content

kharcha_core/
parser.rs

1//! UPI/bank payment parsing from notification/SMS text. Rule-based, no AI.
2//! Port of `lib/core/upi_parser.dart` (`parseUpiNotification`, `_cleanMerchant`,
3//! `encodeInboxLine`). Covers UPI apps, bank apps, and messaging apps
4//! (amount + payment keyword).
5//!
6//! Regex notes: every pattern is verbatim from the Dart source wrapped in
7//! `(?i:...)`, with mechanical `\d`→`[0-9]` / `\s`→`[ \t\n\x0B\f\r]`
8//! (see `non_transaction.rs` for why).
9
10use std::sync::LazyLock;
11
12use fancy_regex::{Captures, Regex};
13
14use crate::money::parse_amount_paise;
15use crate::non_transaction::{is_non_transaction, ASCII_CI};
16
17/// A UPI/bank payment parsed from a notification's text.
18#[derive(Debug, Clone, PartialEq, uniffi::Record)]
19pub struct ParsedPayment {
20    /// Integer paise (Dart stores a 2dp double; same value, exact).
21    pub amount_paise: i64,
22    pub merchant: String,
23    /// True when money came IN (received/credited). False for spending.
24    pub is_income: bool,
25    pub upi_ref: Option<String>,
26    /// True bank balance extracted from the message, paise.
27    pub balance_paise: Option<i64>,
28    pub account_mask: Option<String>,
29    pub bank_name: Option<String>,
30    pub needs_review: bool,
31}
32
33macro_rules! static_re {
34    ($name:ident, $pat:expr) => {
35        static $name: LazyLock<Regex> =
36            LazyLock::new(|| Regex::new(&format!("{ASCII_CI}{})", $pat)).expect("static pattern"));
37    };
38}
39
40// ponytail: one static per pattern (compiled once, shared). A single combined
41// pass if profiling ever says parse is hot — it is per-message, it won't be.
42static_re!(AMOUNT_RE, r"(?:₹|Rs\.?|INR)[ \t\n\x0B\f\r]*([0-9,]+(?:\.[0-9]{1,2})?)");
43static_re!(
44    AMOUNT_TRAILING_RE,
45    r"([0-9,]+(?:\.[0-9]{1,2})?)[ \t\n\x0B\f\r]*(?:₹|Rs\.?|INR)"
46);
47static_re!(
48    CONTEXTUAL_AMOUNT_RE,
49    r"(?:debited (?:by|for)|credited (?:with|by)|spent|paid|amount of|txn of|transfer of)[ \t\n\x0B\f\r]+(?:INR|Rs\.?|₹)?[ \t\n\x0B\f\r]*([0-9,]+(?:\.[0-9]{1,2})?)"
50);
51static_re!(
52    SPEND_RE,
53    r"\b(?:debited|paid|transferred|sent|spent|payment|txn|transaction|purchase|withdrawn|charged|deducted)\b"
54);
55static_re!(
56    RECEIVE_RE,
57    // Premortem: ATM/failed-txn reversals ("Rs 5000 reversed to your account")
58    // are real money-in that Dart drops (no revers* verb). Deliberate
59    // improvement — Kotlin's refund regex already treats them as income.
60    r"\b(?:received|credited|added to your|added in your|added to|refund|refunded|reversed|reversal|cashback|paid you|sent you|(?:sent|paid|transferred|given|credited).{0,20}to you|deposited|credited with|money received|inward)\b"
61);
62static_re!(
63    BANK_NARRATION_RE,
64    r"UPI\/(?:DR|CR|P2A|P2M|P2P|REV)\/([0-9]+)\/([A-Za-z0-9 &.\-_]+)"
65);
66static_re!(
67    GPAY_MERCHANT_RE,
68    r"·[ \t\n\x0B\f\r]*([A-Za-z0-9][A-Za-z0-9 &.\-]{1,60}?)(?=[ \t\n\x0B\f\r]*·|[ \t\n\x0B\f\r]+UPI|[ \t\n\x0B\f\r]+Ref|$)"
69);
70// Deliberate improvement over Dart (owner-approved, 2026-09-16 research):
71// Dart blocks ALL digit-start merchants (`\d` in the lookahead + `\d+` in
72// GENERIC_NAME_RE), so numeric payees (UPI Number / mobile@handle — the most
73// common P2P format) land on Unknown. Here 8–10 digit names are kept;
74// 1–7 digit fragments and 11+ digit account/refs stay blocked.
75static_re!(
76    RECIPIENT_MERCHANT_RE,
77    concat!(
78        r"(?:spent on .*? at|(?:paid|payment|transferred|sent)[ \t\n\x0B\f\r]+(?:(?:₹|rs\.?|inr)[ \t\n\x0B\f\r]*[0-9,.]+[ \t\n\x0B\f\r]+)?(?:to|at|on)|paid to|transferred to|sent to|payment to|sent .{0,12}to|done at|\bto\b|\bat\b)[ \t\n\x0B\f\r]+",
79        r"(?!(?:you|rs\.?|inr|₹)\b)(?![0-9]{1,7}\b|[0-9]{11,}\b)([A-Za-z0-9][A-Za-z0-9 &.\-@]{1,60}?)(?=,|\.|$|:|[ \t\n\x0B\f\r]+(?:of[ \t\n\x0B\f\r]*(?:₹|Rs\.?|INR|[0-9])|upi|ref|utr|trans|txn|bal|balance|on[ \t\n\x0B\f\r]+[0-9]|on[ \t\n\x0B\f\r]+[A-Za-z]|at[ \t\n\x0B\f\r]+[0-9]|via|bank|a/c|by|from|using|credited|debited|successful|is[ \t\n\x0B\f\r]+successful|was[ \t\n\x0B\f\r]+successful))",
80    )
81);
82static_re!(
83    FALLBACK_MERCHANT_RE,
84    concat!(
85        r"(?:from|towards|for|debited (?:at|from))[ \t\n\x0B\f\r]+",
86        r"(?!(?:you|rs\.?|inr|₹)\b)(?![0-9]{1,7}\b|[0-9]{11,}\b)([A-Za-z0-9][A-Za-z0-9 &.\-@]{1,60}?)(?=,|\.|$|:|[ \t\n\x0B\f\r]+(?:of[ \t\n\x0B\f\r]*(?:₹|Rs\.?|INR|[0-9])|upi|ref|utr|trans|txn|bal|balance|on[ \t\n\x0B\f\r]+[0-9]|on[ \t\n\x0B\f\r]+[A-Za-z]|at[ \t\n\x0B\f\r]+[0-9]|via|bank|a/c|by|from|using|credited|debited|successful|is[ \t\n\x0B\f\r]+successful|was[ \t\n\x0B\f\r]+successful))",
87    )
88);
89static_re!(
90    UPI_REF_RE,
91    r"(?:upi[ \t\n\x0B\f\r]*ref(?:erence)?(?:[ \t\n\x0B\f\r]*no)?|\bupi\b|utr(?:[ \t\n\x0B\f\r]*no)?|ref(?:erence)?[ \t\n\x0B\f\r]*id|ref[ \t\n\x0B\f\r]*id|ref(?:[ \t\n\x0B\f\r]*no)?|trans(?:action)?[ \t\n\x0B\f\r]*id|txn[ \t\n\x0B\f\r]*id)[ \t\n\x0B\f\r]*[:#-]?[ \t\n\x0B\f\r]*([A-Za-z0-9]{8,})"
92);
93static_re!(UPI_REF_BARE_RE, r"\b([0-9]{12})\b");
94static_re!(
95    ACCOUNT_MASK_RE,
96    r"(?:a/c|acct|account)(?:[ \t\n\x0B\f\r]*no\.?|[ \t\n\x0B\f\r]*number)?(?:[ \t\n\x0B\f\r]*ending[ \t\n\x0B\f\r]*(?:in|with))?[ \t\n\x0B\f\r]*(?:x|X|\*)*([0-9]{3,18})\b"
97);
98static_re!(
99    BANK_NAME_RE,
100    r"\b(SBI|HDFC|ICICI|Axis|Kotak|PNB|BOB|IDFC|IndusInd|Yes Bank|Canara|Union Bank|Indian Bank|State Bank of India|Bank of Baroda|Paytm Payments Bank|Airtel Payments Bank|Jio Payments Bank|Federal Bank|South Indian Bank)\b"
101);
102static_re!(
103    BALANCE_PREFIX_RE,
104    r"\b(?:bal|balance|avl[ \t\n\x0B\f\r]*bal|available[ \t\n\x0B\f\r]*(?:bal|balance)|limit|credit[ \t\n\x0B\f\r]*limit)[ \t\n\x0B\f\r:=-]*$"
105);
106static_re!(
107    ACCOUNT_PREFIX_RE,
108    r"(?:a/c|acct|account|card)(?:[ \t\n\x0B\f\r]*no\.?|[ \t\n\x0B\f\r]*number)?(?:[ \t\n\x0B\f\r]*ending[ \t\n\x0B\f\r]*(?:in|with))?[ \t\n\x0B\f\r]*(?:x|X|\*)*[ \t\n\x0B\f\r]*$"
109);
110static_re!(
111    BAL_RE,
112    // Audit: single-space `avl bal` is verbatim from Dart (both miss double-space) — parity, not a fix.
113    r"(?:bal|balance|avl bal|available balance)[^0-9]*?(?:₹|Rs\.?|INR)?[ \t\n\x0B\f\r]*([0-9,]+(?:\.[0-9]{1,2})?)"
114);
115static_re!(TRAILING_KEYWORD_RE, r"[ \t\n\x0B\f\r]+(?:via|using|on|through|in|UPI|Ref|UTR|Bank|A/c|Account|Pv|Pvt|Ltd|Limited|is|was|successful|successfully)$");
116static_re!(TRAILING_PUNCT_RE, r"[ \t\n\x0B\f\r.,:;/\-]+$");
117static_re!(
118    GENERIC_NAME_RE,
119    // Audit + UPI-Number research: all-digit names are rejected EXCEPT 8–10
120    // digits (UPI Number / mobile payee — the most common P2P format). Shorter
121    // is a fragment, longer is an account/ref.
122    r"^(?:your|your a/c|your account|account|bank|upi|self|vpa|cashback|(?:[0-9]{1,7}|[0-9]{11,})|rs\.?.*|inr.*)$"
123);
124static_re!(
125    CREDIT_TO_YOU_RE,
126    r"(?:sent|paid|transferred|given|credited).{0,20}to you"
127);
128static_re!(
129    CREDITED_TO_ACCT_RE,
130    r"(?:credited|deposited|added)[ \t\n\x0B\f\r]+(?:to|in|into)[ \t\n\x0B\f\r]+(?:your[ \t\n\x0B\f\r]+)?(?:a\/c|acct|account|wallet|balance)"
131);
132static_re!(
133    PAYMENT_RECEIVED_FROM_RE,
134    r"(?:payment|amount|money|\b)[ \t\n\x0B\f\r]*received[ \t\n\x0B\f\r]+(?:(?:(?:rs\.?|inr|₹)[ \t\n\x0B\f\r]*[0-9,.]+|[0-9,.]+)[ \t\n\x0B\f\r]+)?from\b"
135);
136static_re!(RECEIVED_BY_RE, r"received[ \t\n\x0B\f\r]+(?:by|for|towards|at)\b");
137static_re!(
138    INCOME_SENDER_RE,
139    r"^([A-Za-z0-9][A-Za-z0-9 &.\-@]{1,60}?)[ \t\n\x0B\f\r]+(?:sent|paid|transferred|given|credited)"
140);
141
142fn group(caps: &Captures<'_>, i: usize) -> Option<String> {
143    caps.get(i).map(|m| m.as_str().to_string())
144}
145
146/// Engine hiccups fail closed to None (not-a-payment); static patterns are
147/// covered by the parity suite, so a live Err means pathological input.
148fn is_match(re: &Regex, text: &str) -> bool {
149    re.is_match(text).unwrap_or(false)
150}
151fn captures<'a>(re: &Regex, text: &'a str) -> Option<Captures<'a>> {
152    re.captures(text).unwrap_or(None)
153}
154fn find_all<'a>(re: &Regex, text: &'a str) -> Vec<fancy_regex::Match<'a>> {
155    re.find_iter(text).filter_map(|r| r.ok()).collect()
156}
157fn replace_all(re: &Regex, text: &str, rep: &str) -> String {
158    re.replace_all(text, rep).into_owned()
159}
160
161/// Byte-slicing with a total guard. Match offsets into multibyte text can
162/// land on non-char-boundaries (Unicode `\b` handling) — `s[..i]` would
163/// panic across FFI. Fail-open to "" (prefix checks then simply don't match).
164fn prefix_of(s: &str, end: usize) -> &str {
165    s.get(..end).unwrap_or("")
166}
167fn span_of(s: &str, from: usize, to: usize) -> &str {
168    s.get(from..to).unwrap_or("")
169}
170
171fn capitalize_first(lower: &str) -> String {
172    let mut chars = lower.chars();
173    match chars.next() {
174        None => String::new(),
175        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
176    }
177}
178
179fn clean_merchant(raw: &str) -> String {
180    let mut name = raw.trim().to_string();
181
182    // 1. VPA handles, e.g. name@bank.
183    // (Dart uses `^paytmqr`-style regexes; `starts_with` on the lowered user
184    // is the same match with no engine — ponytail rung 3.)
185    if name.contains('@') {
186        let vpa_user = name.split('@').next().unwrap_or("").trim().to_string();
187        let lower_user = vpa_user.to_lowercase();
188        if lower_user.starts_with("paytmqr") {
189            name = "Paytm Merchant".to_string();
190        } else if lower_user.starts_with("bharatpe") {
191            name = "BharatPe Merchant".to_string();
192        } else if lower_user.starts_with("gpay") || lower_user.starts_with("googlepay") {
193            name = "Google Pay Merchant".to_string();
194        } else if lower_user.starts_with("phonepe") {
195            name = "PhonePe Merchant".to_string();
196        } else {
197            let raw_handle = vpa_user.split(['.', '_', '-']).next().unwrap_or("");
198            let cleaned = raw_handle.trim_end_matches(|c: char| c.is_ascii_digit());
199            // Audit: Dart `length` is UTF-16 units, Rust `len()` is bytes —
200            // non-ASCII VPAs took the wrong branch. Match Dart exactly.
201            if cleaned.encode_utf16().count() >= 3 {
202                name = capitalize_first(&cleaned.to_lowercase());
203            } else {
204                name = vpa_user;
205            }
206        }
207    }
208
209    // 2. Strip trailing keywords often captured in loose boundary matches.
210    name = replace_all(&TRAILING_KEYWORD_RE, &name, "");
211    // Strip trailing punctuation.
212    name = replace_all(&TRAILING_PUNCT_RE, &name, "").trim().to_string();
213    // Filter generic invalid names.
214    if is_match(&GENERIC_NAME_RE, &name) {
215        return "Unknown".to_string();
216    }
217    if !name.is_empty() && name == name.to_lowercase() {
218        name = capitalize_first(&name);
219    }
220    if name.is_empty() {
221        "Unknown".to_string()
222    } else {
223        name
224    }
225}
226
227/// Parses `text` into a payment, or None if it isn't a payment notification
228/// (spam, no amount, or no payment verb — e.g. a casual "send me ₹200" chat).
229pub fn parse_upi_notification(text: &str) -> Option<ParsedPayment> {
230    let clean = text.trim();
231    if clean.is_empty() {
232        return None;
233    }
234
235    // 0. Explicit rejection of non-transaction messages.
236    if is_non_transaction(clean) {
237        return None;
238    }
239
240    // 1. Amount extraction.
241    // First non-balance-prefixed amount wins; if every amount looks like a
242    // balance ("Avail Bal: Rs 45,000. ... Rs 150"), fall back to the first —
243    // same as Dart's `??= allAmountMatches.firstOrNull`.
244    let mut used_contextual_amount = false;
245    let group1 = |re: &Regex, text: &str| captures(re, text).and_then(|c| group(&c, 1));
246
247    let first_group_in_span = |re: &Regex, span: (usize, usize)| {
248        captures(re, span_of(clean, span.0, span.1)).and_then(|c| group(&c, 1))
249    };
250    let mut raw: Option<String> = None;
251    for m in find_all(&AMOUNT_RE, clean) {
252        if !is_match(&BALANCE_PREFIX_RE, prefix_of(clean, m.start())) {
253            raw = first_group_in_span(&AMOUNT_RE, (m.start(), m.end()));
254            break;
255        }
256    }
257    if raw.is_none() {
258        raw = group1(&AMOUNT_RE, clean);
259    }
260    if raw.as_deref().is_none_or(|s: &str| s.is_empty()) {
261        for m in find_all(&AMOUNT_TRAILING_RE, clean) {
262            if !is_match(&BALANCE_PREFIX_RE, prefix_of(clean, m.start())) {
263                raw = first_group_in_span(&AMOUNT_TRAILING_RE, (m.start(), m.end()));
264                break;
265            }
266        }
267        if raw.is_none() {
268            raw = group1(&AMOUNT_TRAILING_RE, clean);
269        }
270    }
271    if raw.as_deref().is_none_or(|s: &str| s.is_empty()) {
272        raw = group1(&CONTEXTUAL_AMOUNT_RE, clean);
273        if raw.as_deref().is_some_and(|s: &str| !s.is_empty()) {
274            used_contextual_amount = true;
275        }
276    }
277    let raw = raw.filter(|s| !s.is_empty())?;
278    let amount_paise = parse_amount_paise(Some(&raw.replace(',', "")))?;
279    if amount_paise == 0 {
280        return None;
281    }
282
283    // 2. Transaction direction (income vs spend).
284    let lower = clean.to_lowercase();
285    let has_receive = is_match(&RECEIVE_RE, clean);
286    let has_spend = is_match(&SPEND_RE, clean);
287
288    // Neither verb → casual chat or unrelated notification.
289    if !has_receive && !has_spend {
290        return None;
291    }
292
293    // Disambiguation: "debited" / "paid to" / "spent" takes priority over
294    // cashback/refund mentions unless explicitly incoming.
295    let is_income = has_receive
296        && (!has_spend
297            || lower.contains("paid you")
298            || lower.contains("sent you")
299            || is_match(&CREDIT_TO_YOU_RE, clean)
300            || is_match(&CREDITED_TO_ACCT_RE, clean)
301            || lower.contains("credited with")
302            || lower.contains("refund")
303            || is_match(&PAYMENT_RECEIVED_FROM_RE, clean)
304            || (lower.contains("received")
305                && !is_match(&RECEIVED_BY_RE, clean)
306                && !lower.contains("debited")
307                && !lower.contains("spent")
308                && !lower.contains("paid to")));
309
310    // 3. Merchant extraction.
311    let mut merchant: Option<String> = None;
312    let mut upi_ref: Option<String> = None;
313
314    // Bank narration first: "UPI/DR/123456789012/SWIGGY".
315    if let Some(caps) = captures(&BANK_NARRATION_RE, clean) {
316        upi_ref = group(&caps, 1);
317        if let Some(bm) = group(&caps, 2) {
318            if !bm.is_empty() {
319                merchant = Some(clean_merchant(&bm));
320            }
321        }
322    }
323
324    if merchant.as_deref().is_none_or(|m: &str| m == "Unknown") {
325        if let Some(cand) = group1(&GPAY_MERCHANT_RE, clean).map(|g| clean_merchant(&g)) {
326            if cand != "Unknown" {
327                merchant = Some(cand);
328            }
329        }
330    }
331
332    if merchant.as_deref().is_none_or(|m: &str| m == "Unknown") {
333        if let Some(cand) = group1(&RECIPIENT_MERCHANT_RE, clean).map(|g| clean_merchant(&g)) {
334            if cand != "Unknown" {
335                merchant = Some(cand);
336            }
337        }
338    }
339
340    let mut used_fallback_merchant = false;
341    if merchant.as_deref().is_none_or(|m: &str| m == "Unknown") {
342        if let Some(cand) = group1(&FALLBACK_MERCHANT_RE, clean).map(|g| clean_merchant(&g)) {
343            if cand != "Unknown" {
344                merchant = Some(cand);
345                used_fallback_merchant = true;
346            }
347        }
348    }
349
350    if merchant.as_deref().is_none_or(|m: &str| m == "Unknown") && is_income {
351        if let Some(sender) = group1(&INCOME_SENDER_RE, clean) {
352            let cand = clean_merchant(&sender);
353            if cand.to_lowercase() != "you" && cand.to_lowercase() != "i" {
354                merchant = Some(cand);
355            }
356        }
357    }
358    let merchant = merchant.unwrap_or_else(|| "Unknown".to_string());
359
360    // 4. UPI Ref / UTR extraction.
361    if upi_ref.is_none() {
362        upi_ref = group1(&UPI_REF_RE, clean);
363    }
364    if upi_ref.is_none() && (has_spend || has_receive) {
365        for m in find_all(&UPI_REF_BARE_RE, clean) {
366            let prefix = prefix_of(clean, m.start());
367            // Exclude 12-digit numbers preceded by account/card identifiers.
368            if is_match(&ACCOUNT_PREFIX_RE, prefix) {
369                continue;
370            }
371            upi_ref = Some(m.as_str().to_string());
372            break;
373        }
374    }
375
376    // 5. Balance extraction (e.g. "Avail Bal: Rs 10000").
377    let balance_paise = group1(&BAL_RE, clean)
378        .filter(|s| !s.is_empty())
379        .and_then(|raw_bal| parse_amount_paise(Some(&raw_bal.replace(',', ""))));
380
381    // 6. Account mask and bank name.
382    let account_mask = group1(&ACCOUNT_MASK_RE, clean);
383    let bank_name = group1(&BANK_NAME_RE, clean);
384
385    Some(ParsedPayment {
386        amount_paise,
387        merchant,
388        is_income,
389        upi_ref,
390        balance_paise,
391        account_mask,
392        bank_name,
393        needs_review: used_contextual_amount || used_fallback_merchant,
394    })
395}
396
397/// Encodes a raw capture line for the inbox JSONL file.
398pub fn encode_inbox_line(package: &str, text: &str, seen_at: &str) -> String {
399    format!(
400        "{{\"package\":\"{}\",\"text\":\"{}\",\"seenAt\":\"{}\"}}",
401        esc(package),
402        esc(text),
403        esc(seen_at)
404    )
405}
406
407fn esc(s: &str) -> String {
408    let mut o = String::with_capacity(s.len());
409    for c in s.chars() {
410        match c {
411            '"' => o.push_str("\\\""),
412            '\\' => o.push_str("\\\\"),
413            '\n' => o.push_str("\\n"),
414            '\r' => o.push_str("\\r"),
415            '\t' => o.push_str("\\t"),
416            '\u{08}' => o.push_str("\\b"),
417            '\u{0C}' => o.push_str("\\f"),
418            c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)),
419            c => o.push(c),
420        }
421    }
422    o
423}
424
425
426
427