1use 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#[derive(Debug, Clone, PartialEq, uniffi::Record)]
19pub struct ParsedPayment {
20 pub amount_paise: i64,
22 pub merchant: String,
23 pub is_income: bool,
25 pub upi_ref: Option<String>,
26 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
40static_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 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);
70static_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 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 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
146fn 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
161fn 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 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 if cleaned.encode_utf16().count() >= 3 {
202 name = capitalize_first(&cleaned.to_lowercase());
203 } else {
204 name = vpa_user;
205 }
206 }
207 }
208
209 name = replace_all(&TRAILING_KEYWORD_RE, &name, "");
211 name = replace_all(&TRAILING_PUNCT_RE, &name, "").trim().to_string();
213 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
227pub fn parse_upi_notification(text: &str) -> Option<ParsedPayment> {
230 let clean = text.trim();
231 if clean.is_empty() {
232 return None;
233 }
234
235 if is_non_transaction(clean) {
237 return None;
238 }
239
240 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 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 if !has_receive && !has_spend {
290 return None;
291 }
292
293 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 let mut merchant: Option<String> = None;
312 let mut upi_ref: Option<String> = None;
313
314 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 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 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 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 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
397pub 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