1use crate::parser::{parse_upi_notification, ParsedPayment};
11
12#[derive(Debug, Clone, PartialEq, uniffi::Record)]
15pub struct ParsedTransaction {
16 pub payment: ParsedPayment,
17 pub sender: String,
19 pub timestamp_ms: i64,
21 pub content_hash: u64,
30}
31
32pub const MAX_BODY_BYTES: usize = 16 * 1024;
36
37pub fn parse(sms_body: &str, sender: &str, timestamp_ms: i64) -> Option<ParsedTransaction> {
39 if sms_body.len() > MAX_BODY_BYTES {
40 return None;
41 }
42 let payment = parse_upi_notification(sms_body)?;
43 let sender_norm = sender.trim().to_uppercase();
44 let content_hash = content_hash(&payment);
45 Some(ParsedTransaction { payment, sender: sender_norm, timestamp_ms, content_hash })
46}
47
48pub const MAX_BATCH_ITEMS: usize = 10_000;
53
54pub fn parse_batch(items: &[(&str, &str, i64)]) -> Vec<Option<ParsedTransaction>> {
56 items
57 .iter()
58 .take(MAX_BATCH_ITEMS)
59 .map(|(body, sender, ts)| parse(body, sender, *ts))
60 .chain(items.iter().skip(MAX_BATCH_ITEMS).map(|_| None))
61 .collect()
62}
63
64fn content_hash(p: &ParsedPayment) -> u64 {
65 let mut h: u64 = 0xcbf29ce484222325;
69 for b in p
70 .amount_paise
71 .to_le_bytes()
72 .into_iter()
73 .chain([b'|', u8::from(p.is_income)])
74 .chain([b'|'])
75 .chain(p.merchant.to_lowercase().bytes())
76 .chain([b'|'])
77 .chain(p.upi_ref.as_deref().unwrap_or("").to_lowercase().bytes())
78 {
79 h ^= u64::from(b);
80 h = h.wrapping_mul(0x100000001b3);
81 }
82 h
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn attaches_provenance_and_stable_hash() {
91 let body = "₹450 paid to Swiggy using UPI UPI Ref 123456789012";
92 let a = parse(body, "hdfcbk", 1000).unwrap();
93 assert_eq!(a.sender, "HDFCBK");
94 assert_eq!(a.timestamp_ms, 1000);
95 assert_eq!(a.payment.merchant, "Swiggy");
96 let b = parse("INR 450.00 debited for SWIGGY. UPI Ref 123456789012", "HDFCBK", 999999).unwrap();
98 assert_eq!(a.content_hash, b.content_hash);
99 let sms_side = parse("INR 450.00 debited for SWIGGY. UPI Ref 123456789012", "VD-HDFCBK", 5).unwrap();
100 let push_side = parse("₹450 paid to Swiggy using UPI UPI Ref 123456789012", "com.google.android.apps.nbu.paisa.user", 9000).unwrap();
101 assert_eq!(sms_side.content_hash, push_side.content_hash);
102 let c = parse(body.replace("450", "451").as_str(), "hdfcbk", 1000).unwrap();
104 assert_ne!(a.content_hash, c.content_hash);
105 assert!(parse("OTP is 123456. Do not share.", "hdfcbk", 1000).is_none());
107 }
108
109 #[test]
110 fn batch_caps_hostile_drains() {
111 let mut items = vec![("₹450 paid to Swiggy", "gpay", 1)];
114 items.extend(vec![("hello", "x", 2); super::MAX_BATCH_ITEMS]);
115 let out = parse_batch(&items);
116 assert_eq!(out.len(), items.len());
117 assert!(out[0].is_some());
118 assert!(out.iter().skip(1).all(|o| o.is_none()));
119 }
120
121 #[test]
122 fn batch_keeps_order_and_nones() {
123 let out = parse_batch(&[
124 ("₹450 paid to Swiggy using UPI", "gpay", 1),
125 ("hello there", "friend", 2),
126 ]);
127 assert_eq!(out.len(), 2);
128 assert!(out[0].is_some());
129 assert!(out[1].is_none());
130 }
131}