use crate::parser::{parse_upi_notification, ParsedPayment};
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct ParsedTransaction {
pub payment: ParsedPayment,
pub sender: String,
pub timestamp_ms: i64,
pub content_hash: u64,
}
pub const MAX_BODY_BYTES: usize = 16 * 1024;
pub fn parse(sms_body: &str, sender: &str, timestamp_ms: i64) -> Option<ParsedTransaction> {
if sms_body.len() > MAX_BODY_BYTES {
return None;
}
let payment = parse_upi_notification(sms_body)?;
let sender_norm = sender.trim().to_uppercase();
let content_hash = content_hash(&payment);
Some(ParsedTransaction { payment, sender: sender_norm, timestamp_ms, content_hash })
}
pub const MAX_BATCH_ITEMS: usize = 10_000;
pub fn parse_batch(items: &[(&str, &str, i64)]) -> Vec<Option<ParsedTransaction>> {
items
.iter()
.take(MAX_BATCH_ITEMS)
.map(|(body, sender, ts)| parse(body, sender, *ts))
.chain(items.iter().skip(MAX_BATCH_ITEMS).map(|_| None))
.collect()
}
fn content_hash(p: &ParsedPayment) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in p
.amount_paise
.to_le_bytes()
.into_iter()
.chain([b'|', u8::from(p.is_income)])
.chain([b'|'])
.chain(p.merchant.to_lowercase().bytes())
.chain([b'|'])
.chain(p.upi_ref.as_deref().unwrap_or("").to_lowercase().bytes())
{
h ^= u64::from(b);
h = h.wrapping_mul(0x100000001b3);
}
h
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attaches_provenance_and_stable_hash() {
let body = "₹450 paid to Swiggy using UPI UPI Ref 123456789012";
let a = parse(body, "hdfcbk", 1000).unwrap();
assert_eq!(a.sender, "HDFCBK");
assert_eq!(a.timestamp_ms, 1000);
assert_eq!(a.payment.merchant, "Swiggy");
let b = parse("INR 450.00 debited for SWIGGY. UPI Ref 123456789012", "HDFCBK", 999999).unwrap();
assert_eq!(a.content_hash, b.content_hash);
let sms_side = parse("INR 450.00 debited for SWIGGY. UPI Ref 123456789012", "VD-HDFCBK", 5).unwrap();
let push_side = parse("₹450 paid to Swiggy using UPI UPI Ref 123456789012", "com.google.android.apps.nbu.paisa.user", 9000).unwrap();
assert_eq!(sms_side.content_hash, push_side.content_hash);
let c = parse(body.replace("450", "451").as_str(), "hdfcbk", 1000).unwrap();
assert_ne!(a.content_hash, c.content_hash);
assert!(parse("OTP is 123456. Do not share.", "hdfcbk", 1000).is_none());
}
#[test]
fn batch_caps_hostile_drains() {
let mut items = vec![("₹450 paid to Swiggy", "gpay", 1)];
items.extend(vec![("hello", "x", 2); super::MAX_BATCH_ITEMS]);
let out = parse_batch(&items);
assert_eq!(out.len(), items.len());
assert!(out[0].is_some());
assert!(out.iter().skip(1).all(|o| o.is_none()));
}
#[test]
fn batch_keeps_order_and_nones() {
let out = parse_batch(&[
("₹450 paid to Swiggy using UPI", "gpay", 1),
("hello there", "friend", 2),
]);
assert_eq!(out.len(), 2);
assert!(out[0].is_some());
assert!(out[1].is_none());
}
}