Skip to main content

kharcha_core/
engine.rs

1//! Unified capture engine — the single entry point that replaces kharcha's
2//! three divergent parsers (Dart generic + Kotlin generic + the bank fleet,
3//! which `capture_inbox.dart` reconciles with an if/else).
4//!
5//! v0.1: sender-aware dispatch SHAPE with the generic parser as the only
6//! backend. v1.x plugs bank backends ahead of it without changing this
7//! signature (pennywise `BankParserFactory` order: specific senders first,
8//! generic fallback last).
9
10use crate::parser::{parse_upi_notification, ParsedPayment};
11
12/// A parsed capture with provenance. Mirrors pennywise `ParsedTransaction`:
13/// sender + timestamp travel WITH the payment, not in a sidecar inbox line.
14#[derive(Debug, Clone, PartialEq, uniffi::Record)]
15pub struct ParsedTransaction {
16    pub payment: ParsedPayment,
17    /// Sender ID, trimmed + uppercased (sender IDs are case-noisy).
18    pub sender: String,
19    /// Capture timestamp, epoch millis (carrier or device clock).
20    pub timestamp_ms: i64,
21    /// Deterministic content key over amount|direction|merchant|ref.
22    /// FNV-1a64, stable across restarts (unlike SipHash) — safe as a dedupe
23    /// key. Sender is DELIBERATELY excluded (audit): an SMS sender
24    /// (`HDFCBK`) and a push package (`com.gpay`) for one payment would
25    /// otherwise never hash equal, killing the cross-channel gate. Refs are
26    /// unique per payment, so same-body collisions across senders need an
27    /// identical ref too — except ref-less duplicates far apart in time,
28    /// which merge (accepted, documented).
29    pub content_hash: u64,
30}
31
32/// Hard input cap. Real SMS/notifications are <2 KB; anything past 16 KB is a
33/// paste-attack or a corrupt read — regexing megabytes burns CPU for nothing.
34/// (Premortem: FFI callers pass arbitrary strings; the core must not spin.)
35pub const MAX_BODY_BYTES: usize = 16 * 1024;
36
37/// Sender-aware parse. Today every sender routes to the generic backend.
38pub 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
48/// Max batch size. Per-item bytes are already capped by MAX_BODY_BYTES; this
49/// caps the count so a hostile 100k-item drain can't balloon RAM/CPU.
50/// Real backlogs are hundreds — chunk larger drains caller-side; items past
51/// the cap come back None (documented, not silent: caller sees the tail).
52pub const MAX_BATCH_ITEMS: usize = 10_000;
53
54/// Batch drain for SMS backlogs: one call, compiled-once regexes, order kept.
55pub 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    // FNV-1a 64: deterministic, no dep (ponytail rung 3 — no md5 crate for one hash).
66    // Merchant AND ref are lowercased: "Swiggy" (push) vs "SWIGGY" (SMS),
67    // `T2408…` vs `t2408…`, is one payment (audit).
68    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        // Same payment, other channel's casing AND sender, later clock → same hash.
97        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        // Different amount → different hash.
103        let c = parse(body.replace("450", "451").as_str(), "hdfcbk", 1000).unwrap();
104        assert_ne!(a.content_hash, c.content_hash);
105        // Spam → None, same as the bare parser.
106        assert!(parse("OTP is 123456. Do not share.", "hdfcbk", 1000).is_none());
107    }
108
109    #[test]
110    fn batch_caps_hostile_drains() {
111        // Audit: unbounded batch × 16KB items = RAM/CPU DoS. Tail past
112        // MAX_BATCH_ITEMS comes back None (documented), head still parses.
113        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}